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