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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user