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
+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 != "" {