feat(api): add Lovart-style project and agent thread endpoints

Add canva/canda-compatible routes for lightweight project open/save and
agent thread history:
- POST /canva/project/queryProject, saveProject
- POST /canva/agent/queryAgentLastThread, agentThreadList
- GET /canda/chat-history, /canda/thread/status

Canvas save now accepts a compressed SHAKKERDATA:// snapshot payload,
preserves connections, and returns a SaveProject response; blank
successful image nodes are backfilled from generated artifacts. Frontend
designGateway adopts the new save/query flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:10:08 +08:00
parent ada29cee12
commit 263748af4a
27 changed files with 2045 additions and 114 deletions
@@ -0,0 +1,93 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type AgentThreadListLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAgentThreadListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AgentThreadListLogic {
return &AgentThreadListLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AgentThreadListLogic) AgentThreadList(req *types.AgentThreadListRequest) (resp *types.LovartAgentThreadListResponse, err error) {
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
threads, err := l.svcCtx.DesignService.AgentThreads(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 100
}
if pageSize > 100 {
pageSize = 100
}
total := int64(len(threads))
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]types.LovartAgentThreadSummary, 0, end-start)
for index, thread := range threads[start:end] {
var cover *string
if thread.CoverImageURL != "" {
value := thread.CoverImageURL
cover = &value
}
items = append(items, types.LovartAgentThreadSummary{
Id: start + int64(index) + 1,
UserId: project.UserID,
ProjectId: req.ProjectId,
AgentThreadId: thread.AgentThreadID,
ThreadIdType: thread.ThreadIDType,
Title: "",
Text: thread.Text,
CoverImageUrl: cover,
FileType: nil,
UpdateTimestamp: thread.UpdateTimestamp,
Source: 1,
})
}
return &types.LovartAgentThreadListResponse{
Code: 0,
Msg: nil,
Data: types.LovartAgentThreadListData{
Page: page,
Total: total,
PageSize: pageSize,
Data: items,
UserIp: "",
TraceId: "",
HasMore: end < total,
},
}, nil
}
@@ -0,0 +1,51 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CandaChatHistoryLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCandaChatHistoryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CandaChatHistoryLogic {
return &CandaChatHistoryLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CandaChatHistoryLogic) CandaChatHistory(req *types.CandaChatHistoryRequest) (resp *types.CandaChatHistoryResponse, err error) {
replay, err := l.svcCtx.DesignService.ReplayHistory(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
items := make([]types.CandaChatHistoryItem, 0, len(replay.Messages))
for _, message := range replay.Messages {
threadID := message.ThreadID
if threadID == "" {
threadID = req.ThreadId
}
if req.ThreadId != "" && threadID != req.ThreadId {
continue
}
items = append(items, toCandaChatHistoryItem(message, req.ProjectId, threadID))
}
return &types.CandaChatHistoryResponse{
Code: 0,
Message: "Successfully got history view for " + req.ThreadId,
Data: items,
}, nil
}
@@ -0,0 +1,44 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CandaThreadStatusLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCandaThreadStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CandaThreadStatusLogic {
return &CandaThreadStatusLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CandaThreadStatusLogic) CandaThreadStatus(req *types.CandaThreadStatusRequest) (resp *types.CandaThreadStatusResponse, err error) {
status := "done"
if req.ProjectId != "" {
thread, err := l.svcCtx.DesignService.AgentThreadStatus(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
status = thread.Status
}
return &types.CandaThreadStatusResponse{
Code: 0,
Msg: "success",
Data: types.CandaThreadStatusData{Status: status},
}, nil
}
@@ -0,0 +1,78 @@
package logic
import (
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/types"
)
func latestLovartThreadID(project design.Project) string {
if threadID := strings.TrimSpace(project.LastThreadID); threadID != "" {
return threadID
}
for index := len(project.Messages) - 1; index >= 0; index-- {
message := project.Messages[index]
if isHiddenAgentMessage(message) {
continue
}
if threadID := strings.TrimSpace(message.ThreadID); threadID != "" {
return threadID
}
}
return ""
}
func toCandaChatHistoryItem(message design.Message, projectID string, threadID string) types.CandaChatHistoryItem {
messageType := strings.TrimSpace(message.Type)
if messageType == "" {
messageType = strings.TrimSpace(message.Role)
}
if messageType == "tool" {
messageType = "tool_use"
}
item := types.CandaChatHistoryItem{
Id: message.ID,
Timestamp: formatCandaTimestamp(message.CreatedAt),
Type: messageType,
ActionId: nonEmpty(message.ActionID, threadID),
StepId: nonEmpty(message.StepID, message.ID),
Status: message.Status,
ToolCallId: message.ToolCallID,
Name: message.Name,
ThreadMeta: types.CandaThreadMeta{
ActionId: nonEmpty(message.ActionID, threadID),
SessionId: threadID + "_main",
StepId: nonEmpty(message.StepID, message.ID),
Depth: 0,
AgentName: "next_agent",
ProjectId: projectID,
},
}
if message.Role == "user" {
item.Text = message.Content
} else {
item.Content = message.Content
if item.Name == "" {
item.Name = message.Title
}
}
return item
}
func nonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func formatCandaTimestamp(value time.Time) string {
if value.IsZero() {
value = time.Now()
}
return value.UTC().Format(time.RFC3339Nano)
}
@@ -0,0 +1,40 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type QueryAgentLastThreadLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewQueryAgentLastThreadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryAgentLastThreadLogic {
return &QueryAgentLastThreadLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryAgentLastThreadLogic) QueryAgentLastThread(req *types.QueryAgentLastThreadRequest) (resp *types.QueryAgentLastThreadResponse, err error) {
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
return &types.QueryAgentLastThreadResponse{
Code: 0,
Msg: nil,
Data: latestLovartThreadID(project),
}, nil
}
@@ -0,0 +1,114 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
const lovartProjectType int64 = 6
type QueryProjectLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewQueryProjectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryProjectLogic {
return &QueryProjectLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryProjectLogic) QueryProject(req *types.QueryProjectRequest) (resp *types.QueryProjectResponse, err error) {
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
if err != nil {
return nil, err
}
return toQueryProjectResponse(project), nil
}
func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse {
covers := queryProjectCoverList(project)
updatedAt := formatLovartTime(project.UpdatedAt)
return &types.QueryProjectResponse{
Code: 0,
Msg: nil,
Data: types.QueryProjectData{
AssetMeta: types.QueryProjectAssetMeta{
AssetId: project.ID,
ProjectId: project.ID,
Name: project.Title,
ProjectType: lovartProjectType,
ContentType: "PROJECT_LOVART_V2",
OwnerType: "USER",
OwnerId: project.UserID,
Extra: types.QueryProjectExtra{
PicCount: int64(len(covers)),
ProjectCoverList: covers,
},
Meta: types.QueryProjectMeta{
UniqId: project.SnapshotID,
ParentUniqId: "",
},
EffectivePermission: "OWNER",
CopyTaskStatus: nil,
Visibility: "PRIVATE",
CreateTime: updatedAt,
UpdateTime: updatedAt,
},
UserId: project.UserID,
ProjectName: project.Title,
ProjectId: nil,
Canvas: project.Canvas,
ValidProjectId: true,
ProjectType: lovartProjectType,
Version: project.Version,
SnapshotId: nil,
ItemId: nil,
Permission: "OWNER",
},
}
}
func queryProjectCoverList(project design.Project) []string {
seen := make(map[string]bool)
covers := make([]string, 0, 4)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "blob:") || seen[value] {
return
}
seen[value] = true
covers = append(covers, value)
}
add(project.Thumbnail)
for _, node := range project.Nodes {
if len(covers) >= 12 {
break
}
if (node.Type == design.NodeTypeImage || node.Type == design.NodeTypeFrame) && node.Status == "success" {
add(node.Content)
}
}
return covers
}
func formatLovartTime(value time.Time) string {
if value.IsZero() {
value = time.Now()
}
return value.Format("2006-01-02T15:04:05")
}
+16 -2
View File
@@ -6,6 +6,7 @@ package logic
import (
"context"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -26,7 +27,7 @@ func NewSaveCanvasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveCa
}
}
func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.ProjectResponse, err error) {
func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.SaveProjectResponse, err error) {
project, err := l.svcCtx.DesignService.SaveCanvas(
l.ctx,
req.Id,
@@ -41,5 +42,18 @@ func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.
return nil, err
}
return toProjectResponse(project), nil
return saveProjectResponse(project), nil
}
func saveProjectResponse(project design.Project) *types.SaveProjectResponse {
return &types.SaveProjectResponse{
Code: 0,
Msg: nil,
Data: types.SaveProjectData{
ProjectId: project.ID,
NewCreated: false,
ValidProjectId: true,
Version: project.Version,
},
}
}
@@ -0,0 +1,87 @@
package logic
import (
"context"
"testing"
"time"
"img_infinite_canvas/internal/application"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/infrastructure/memory"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func TestSaveCanvasReturnsSaveProjectMetadata(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := application.NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-save-metadata",
Title: "Save metadata",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{K: 1},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
logic := NewSaveCanvasLogic(ctx, &svc.ServiceContext{DesignService: service})
resp, err := logic.SaveCanvas(&types.SaveCanvasRequest{
Id: project.ID,
Version: project.Version,
Viewport: types.CanvasViewport{
K: 1,
},
})
if err != nil {
t.Fatal(err)
}
if resp.Code != 0 || resp.Msg != nil {
t.Fatalf("unexpected wrapper: %#v", resp)
}
if resp.Data.ProjectId != project.ID || resp.Data.NewCreated || !resp.Data.ValidProjectId || resp.Data.Version == "" {
t.Fatalf("unexpected save project data: %#v", resp.Data)
}
}
func TestSaveProjectReturnsSaveProjectMetadata(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := application.NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-lovart-save",
Title: "Lovart save",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{K: 1},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
logic := NewSaveProjectLogic(ctx, &svc.ServiceContext{DesignService: service})
resp, err := logic.SaveProject(&types.SaveProjectRequest{
ProjectId: project.ID,
Version: project.Version,
Viewport: types.CanvasViewport{
K: 1,
},
})
if err != nil {
t.Fatal(err)
}
if resp.Code != 0 || resp.Msg != nil {
t.Fatalf("unexpected wrapper: %#v", resp)
}
if resp.Data.ProjectId != project.ID || resp.Data.NewCreated || !resp.Data.ValidProjectId || resp.Data.Version == "" {
t.Fatalf("unexpected save project data: %#v", resp.Data)
}
}
@@ -0,0 +1,52 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"fmt"
"strings"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type SaveProjectLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSaveProjectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveProjectLogic {
return &SaveProjectLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *SaveProjectLogic) SaveProject(req *types.SaveProjectRequest) (resp *types.SaveProjectResponse, err error) {
projectID := strings.TrimSpace(req.ProjectId)
if projectID == "" {
return nil, fmt.Errorf("%w: projectId is required", design.ErrInvalidInput)
}
project, err := l.svcCtx.DesignService.SaveCanvas(
l.ctx,
projectID,
toDomainView(req.Viewport),
toDomainNodes(req.Nodes),
toDomainConnections(req.Connections),
req.Version,
req.SnapshotId,
req.Canvas,
)
if err != nil {
return nil, err
}
return saveProjectResponse(project), nil
}