feat(canvas): clone generator placeholders and place batch results as siblings

When an image-generator target requests multiple images, duplicate the
target placeholder into N generating clones and lay them out beside the
target. Incremental results after the first are appended as siblings
rather than merged into the target, and placeholder cleanup now clears
the target's generating clones on failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:05:33 +08:00
parent cc30ae4284
commit 3eae2beb12
2 changed files with 278 additions and 16 deletions
+131 -16
View File
@@ -640,7 +640,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
shouldGenerateImage := isolatedImageGenerator || taskPlan.Intent == "image"
if shouldGenerateImage {
if targetNodeID != "" {
project.Nodes = markImageGeneratorTargetGenerating(project.Nodes, targetNodeID, prompt, req.ImageSize)
project.Nodes = markImageGeneratorTargetGenerating(project.Nodes, targetNodeID, prompt, req.ImageSize, plannedImageCount(taskPlan))
} else {
project.Nodes = append(project.Nodes, newGeneratingImageNodesForPlan(prompt, len(project.Nodes), taskPlan, req.ImageSize)...)
}
@@ -3059,7 +3059,7 @@ func (s *DesignService) planWithIncrementalImages(ctx context.Context, projectID
}
node := nodes[0]
persistedNodes = append(persistedNodes, node)
return s.applyIncrementalGeneratedNode(ctx, projectID, threadID, prompt, targetNodeID, node, appendArtifacts)
return s.applyIncrementalGeneratedNode(ctx, projectID, threadID, prompt, targetNodeID, node, result.Index, appendArtifacts)
})
if len(persistedNodes) > 0 {
plan.GeneratedNodes = append([]design.Node(nil), persistedNodes...)
@@ -3133,7 +3133,7 @@ func plannedGenerationResultCapacity(req design.AgentRequest) int {
return count
}
func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, projectID string, threadID string, prompt string, targetNodeID string, node design.Node, appendArtifact bool) error {
func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, projectID string, threadID string, prompt string, targetNodeID string, node design.Node, resultIndex int, appendArtifact bool) error {
project, err := s.repo.Get(ctx, projectID)
if err != nil {
return err
@@ -3142,7 +3142,12 @@ func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, proje
return context.Canceled
}
if strings.TrimSpace(targetNodeID) != "" {
project.Nodes = mergeGeneratedNodesIntoTarget(nonGeneratingNodesExceptTarget(project.Nodes, targetNodeID), []design.Node{node}, targetNodeID)
baseNodes := nonGeneratingNodesExceptTarget(project.Nodes, targetNodeID)
if resultIndex > 0 {
project.Nodes = appendGeneratedNodeBesideTarget(baseNodes, targetNodeID, node)
} else {
project.Nodes = mergeGeneratedNodesIntoTarget(baseNodes, []design.Node{node}, targetNodeID)
}
} else {
project.Nodes = upsertGeneratedNode(project.Nodes, node)
}
@@ -4429,23 +4434,89 @@ func failRemainingAgentGenerationPlaceholders(nodes []design.Node, targetNodeID
func shouldClearRemainingGenerationPlaceholder(node design.Node, targetNodeID string) bool {
if targetNodeID != "" {
return node.ID == targetNodeID
return node.ID == targetNodeID || (node.ParentID == targetNodeID && node.LayerRole == "image-generator")
}
return node.LayerRole != "image-generator"
}
func markImageGeneratorTargetGenerating(nodes []design.Node, targetNodeID string, prompt string, imageSize string) []design.Node {
next := append([]design.Node(nil), nodes...)
for index := range next {
if next[index].ID == targetNodeID && next[index].Type == design.NodeTypeImage && next[index].LayerRole == "image-generator" {
next[index].Title = generationPlaceholderTitle(prompt)
next[index].Content = prompt
next[index].Tone = "image-generator"
next[index].Status = "generating"
return next
func markImageGeneratorTargetGenerating(nodes []design.Node, targetNodeID string, prompt string, imageSize string, count int) []design.Node {
count = normalizeImageGeneratorTargetCount(count)
targetNodeID = strings.TrimSpace(targetNodeID)
next := make([]design.Node, 0, len(nodes)+count-1)
targetIndex := -1
var target design.Node
for _, node := range nodes {
if isImageGeneratorTargetClone(node, targetNodeID) {
continue
}
if node.ID == targetNodeID && node.Type == design.NodeTypeImage && node.LayerRole == "image-generator" {
node.Title = generationPlaceholderTitle(prompt)
node.Content = prompt
node.Tone = "image-generator"
node.Status = "generating"
targetIndex = len(next)
target = node
}
next = append(next, node)
}
return append(next, newGeneratingImageNode(prompt, len(next), imageSize))
if targetIndex < 0 {
return append(next, newGeneratingImageNodes(prompt, len(next), count, imageSize)...)
}
clones := imageGeneratorTargetCloneNodes(target, prompt, imageSize, count-1)
if len(clones) == 0 {
return next
}
next = append(next, make([]design.Node, len(clones))...)
copy(next[targetIndex+1+len(clones):], next[targetIndex+1:])
copy(next[targetIndex+1:], clones)
return next
}
func normalizeImageGeneratorTargetCount(count int) int {
if count < 1 {
return 1
}
if count > 10 {
return 10
}
return count
}
func isImageGeneratorTargetClone(node design.Node, targetNodeID string) bool {
return targetNodeID != "" && node.ParentID == targetNodeID && node.Type == design.NodeTypeImage && node.LayerRole == "image-generator" && node.Status == "generating"
}
func imageGeneratorTargetCloneNodes(target design.Node, prompt string, imageSize string, count int) []design.Node {
if count <= 0 {
return nil
}
width, height := target.Width, target.Height
if width <= 0 || height <= 0 {
width, height = design.GeneratedImageDimensions(imageSize)
}
if width <= 0 {
width = design.DefaultGeneratedImageWidth
}
if height <= 0 {
height = design.DefaultGeneratedImageHeight
}
clones := make([]design.Node, 0, count)
for index := 0; index < count; index++ {
clone := target
clone.ID = newID()
clone.ParentID = target.ID
clone.Title = generationPlaceholderTitle(prompt)
clone.Content = prompt
clone.Tone = "image-generator"
clone.Status = "generating"
clone.LayerRole = "image-generator"
clone.Width = width
clone.Height = height
clone.X = positionGeneratedSiblingNextToTarget(target, clone, index).X
clone.Y = target.Y
clones = append(clones, clone)
}
return clones
}
func isImageGeneratorRequest(mode string, targetNodeID string) bool {
@@ -4491,6 +4562,9 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
primary = normalized
}
target := nodes[targetIndex]
if len(generated) == 1 && primary.ID != targetNodeID && !isActiveImageGeneratorTarget(target) {
return appendGeneratedSiblingsNextToTarget(nodes, target, []design.Node{primary})
}
centerX := target.X + target.Width/2
centerY := target.Y + target.Height/2
target.Title = primary.Title
@@ -4511,11 +4585,52 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
}
nodes[targetIndex] = target
if len(generated) > 1 {
return append(nodes, completedImageGeneratorNodes(generated[1:])...)
return appendGeneratedSiblingsNextToTarget(nodes, target, generated[1:])
}
return nodes
}
func isActiveImageGeneratorTarget(node design.Node) bool {
if node.Type != design.NodeTypeImage {
return false
}
return node.Status == "generating" || node.LayerRole == "image-generator" || strings.TrimSpace(node.Content) == ""
}
func appendGeneratedSiblingsNextToTarget(nodes []design.Node, target design.Node, generated []design.Node) []design.Node {
siblings := completedImageGeneratorNodes(generated)
for index, sibling := range siblings {
sibling = positionGeneratedSiblingNextToTarget(target, sibling, index)
if existingIndex := findNodeIndex(nodes, sibling.ID); existingIndex >= 0 {
nodes[existingIndex] = sibling
continue
}
nodes = append(nodes, sibling)
}
return nodes
}
func appendGeneratedNodeBesideTarget(nodes []design.Node, targetNodeID string, generated design.Node) []design.Node {
targetIndex := findNodeIndex(nodes, strings.TrimSpace(targetNodeID))
if targetIndex < 0 {
return append(nodes, completedImageGeneratorNodes([]design.Node{generated})...)
}
return appendGeneratedSiblingsNextToTarget(nodes, nodes[targetIndex], []design.Node{generated})
}
func positionGeneratedSiblingNextToTarget(target design.Node, sibling design.Node, index int) design.Node {
if sibling.Width <= 0 {
sibling.Width = target.Width
}
if sibling.Height <= 0 {
sibling.Height = target.Height
}
gap := math.Max(48, math.Min(120, target.Width*0.08))
sibling.X = target.X + target.Width + gap + float64(index)*(sibling.Width+gap)
sibling.Y = target.Y
return sibling
}
func completedImageGeneratorNodes(nodes []design.Node) []design.Node {
next := make([]design.Node, len(nodes))
for index, node := range nodes {