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
@@ -122,6 +122,66 @@ func TestSaveCanvasQueuesDeletedImageAsset(t *testing.T) {
}
}
func TestSaveCanvasAcceptsCompressedSnapshotPayload(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
service.now = func() time.Time {
return time.Unix(101, 0).UTC()
}
project := design.Project{
ID: "project-compressed-save",
Title: "Compressed save",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{X: 1, Y: 2, K: 1},
Nodes: []design.Node{
{ID: "old-image", Type: design.NodeTypeImage, Title: "Old", Content: "http://localhost:19000/canvas-assets/old.png", Status: "success"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
snapshotProject := project
snapshotProject.Version = project.Version
snapshotProject.SnapshotID = "snapshot-client"
snapshotProject.Viewport = design.Viewport{X: 12, Y: 34, K: 0.8}
snapshotProject.Nodes = []design.Node{
{ID: "new-image", Type: design.NodeTypeImage, Title: "New", Content: "http://localhost:19000/canvas-assets/new.png", Status: "success"},
}
snapshotProject.Connections = []design.Connection{
{ID: "connection-1", FromNodeID: "old-image", ToNodeID: "new-image"},
}
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
if err != nil {
t.Fatal(err)
}
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{}, nil, nil, "", "", canvas)
if err != nil {
t.Fatal(err)
}
if saved.Version != "101000" {
t.Fatalf("expected refreshed version, got %s", saved.Version)
}
if saved.SnapshotID != "snapshot-client" {
t.Fatalf("expected client snapshot id, got %s", saved.SnapshotID)
}
if saved.Viewport != snapshotProject.Viewport {
t.Fatalf("unexpected viewport: %#v", saved.Viewport)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "new-image" {
t.Fatalf("unexpected saved nodes: %#v", saved.Nodes)
}
if len(saved.Connections) != 1 || saved.Connections[0].ID != "connection-1" {
t.Fatalf("unexpected saved connections: %#v", saved.Connections)
}
}
func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
+69 -21
View File
@@ -1054,7 +1054,7 @@ func (s *DesignService) DeleteAsset(ctx context.Context, publicURL string) error
return nil
}
func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewport design.Viewport, nodes []design.Node, _ []design.Connection, clientVersion string, snapshotID string, canvas string) (design.Project, error) {
func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewport design.Viewport, nodes []design.Node, connections []design.Connection, clientVersion string, snapshotID string, canvas string) (design.Project, error) {
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
if err != nil {
return design.Project{}, err
@@ -1064,19 +1064,24 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
project = normalizedProject
}
if strings.TrimSpace(canvas) != "" {
if snapshot, err := design.DecodeCanvasSnapshot(canvas); err == nil {
if len(nodes) == 0 {
nodes = snapshot.Nodes
}
if viewport.K == 0 {
viewport = snapshot.Viewport
}
if snapshotID == "" {
snapshotID = snapshot.SnapshotID
}
if clientVersion == "" {
clientVersion = snapshot.Version
}
snapshot, err := design.DecodeCanvasSnapshot(canvas)
if err != nil {
return design.Project{}, fmt.Errorf("%w: invalid canvas snapshot", design.ErrInvalidInput)
}
if len(nodes) == 0 {
nodes = snapshot.Nodes
}
if len(connections) == 0 {
connections = snapshot.Connections
}
if viewport.K == 0 {
viewport = snapshot.Viewport
}
if snapshotID == "" {
snapshotID = snapshot.SnapshotID
}
if clientVersion == "" {
clientVersion = snapshot.Version
}
}
nodes, err = s.persistNodeImageAssets(ctx, project.ID, nodes)
@@ -1090,7 +1095,7 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
deletedAssetURLs := deletedCanvasAssetURLs(project.Nodes, nodes, project.Messages)
project.Viewport = viewport
project.Nodes = nodes
project.Connections = nil
project.Connections = append([]design.Connection(nil), connections...)
project.UpdatedAt = now
if strings.TrimSpace(snapshotID) != "" && strings.TrimSpace(snapshotID) != project.SnapshotID {
project.SnapshotID = strings.TrimSpace(snapshotID)
@@ -4042,15 +4047,25 @@ func (s *DesignService) clearStaleGeneratingPlaceholders(project design.Project,
}
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
artifactsByID := generatedImageArtifactsByID(project.Messages)
if len(artifactsByID) == 0 {
artifacts := generatedImageArtifacts(project.Messages)
if len(artifacts) == 0 {
return project, false
}
artifactsByID := generatedImageArtifactsByID(project.Messages)
usedArtifacts := make(map[string]bool)
next := make([]design.Node, 0, len(project.Nodes))
changed := false
for _, node := range project.Nodes {
if artifact, ok := artifactsByID[node.ID]; ok && node.Status == "generating" {
node = restoreNodeFromGeneratedArtifact(node, artifact)
usedArtifacts[artifactKey(artifact)] = true
changed = true
}
if blankSuccessfulGeneratedImageNode(node) {
if artifact, ok := firstUnusedGeneratedArtifact(artifacts, usedArtifacts); ok {
node = restoreNodeFromGeneratedArtifact(node, artifact)
usedArtifacts[artifactKey(artifact)] = true
}
changed = true
}
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
@@ -4068,23 +4083,56 @@ func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Projec
return project, true
}
func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact {
artifactsByID := make(map[string]design.GeneratorTaskArtifact)
func generatedImageArtifacts(messages []design.Message) []design.GeneratorTaskArtifact {
artifacts := make([]design.GeneratorTaskArtifact, 0)
for _, message := range messages {
if !isToolArtifactsMessage(message) || !strings.EqualFold(strings.TrimSpace(message.Status), "success") {
continue
}
for _, artifact := range parseGeneratorArtifacts(message.Content) {
id := strings.TrimSpace(artifact.Metadata.ArtifactID)
if id == "" || strings.TrimSpace(artifact.Content) == "" {
if strings.TrimSpace(artifact.Content) == "" {
continue
}
artifacts = append(artifacts, artifact)
}
}
return artifacts
}
func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact {
artifactsByID := make(map[string]design.GeneratorTaskArtifact)
for _, artifact := range generatedImageArtifacts(messages) {
id := strings.TrimSpace(artifact.Metadata.ArtifactID)
if id != "" {
artifactsByID[id] = artifact
}
}
return artifactsByID
}
func blankSuccessfulGeneratedImageNode(node design.Node) bool {
return node.Type == design.NodeTypeImage && strings.TrimSpace(node.Content) == "" && strings.EqualFold(strings.TrimSpace(node.Status), "success")
}
func firstUnusedGeneratedArtifact(artifacts []design.GeneratorTaskArtifact, used map[string]bool) (design.GeneratorTaskArtifact, bool) {
for index := len(artifacts) - 1; index >= 0; index-- {
artifact := artifacts[index]
key := artifactKey(artifact)
if key == "" || used[key] {
continue
}
return artifact, true
}
return design.GeneratorTaskArtifact{}, false
}
func artifactKey(artifact design.GeneratorTaskArtifact) string {
if id := strings.TrimSpace(artifact.Metadata.ArtifactID); id != "" {
return id
}
return strings.TrimSpace(artifact.Content)
}
func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.GeneratorTaskArtifact) design.Node {
node.Type = design.NodeTypeImage
if title := strings.TrimSpace(artifact.Metadata.NodeName); title != "" {
@@ -119,6 +119,48 @@ func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing
}
}
func TestRestoreFinishedGeneratedImageNodesRepairsBlankSuccessfulImageNode(t *testing.T) {
now := time.Now()
project := design.Project{
ID: "project-blank-generated-node",
Status: design.StatusReady,
Nodes: []design.Node{
{
ID: "blank-node",
Type: design.NodeTypeImage,
Status: "success",
Width: 320,
Height: 240,
},
},
Messages: []design.Message{
toolArtifactsMessage("thread-image", "pants", []design.Node{
{
ID: "artifact-node",
Type: design.NodeTypeImage,
Title: "浅麻灰长裤抠图",
Content: "http://localhost:19000/canvas-assets/pants.png",
Status: "success",
Width: 1024,
Height: 1024,
},
}, now),
},
}
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
if !changed {
t.Fatal("expected blank generated node to be repaired")
}
if len(restored.Nodes) != 1 {
t.Fatalf("expected one repaired node, got %#v", restored.Nodes)
}
node := restored.Nodes[0]
if node.ID != "blank-node" || node.Content != "http://localhost:19000/canvas-assets/pants.png" || node.Title != "浅麻灰长裤抠图" || node.Width != 1024 || node.Height != 1024 {
t.Fatalf("expected blank node repaired from artifact, got %#v", node)
}
}
func TestMergeSavedCanvasWorkStateDoesNotReapplyStaleGeneratingNodes(t *testing.T) {
existing := []design.Node{
{ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "http://localhost:19000/canvas-assets/generated.png", Status: "success"},