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 {
@@ -88,6 +88,100 @@ func TestMergeGeneratedNodesIntoCompletedImageGeneratorTargetDoesNotReappend(t *
}
}
func TestMergeGeneratedNodesIntoImageGeneratorTargetPlacesExtraImagesBesideTarget(t *testing.T) {
nodes := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "图像生成器",
X: 100,
Y: 80,
Width: 360,
Height: 240,
Status: "generating",
LayerRole: "image-generator",
},
}
generated := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "商品主图 1",
Content: "https://example.com/generated-1.png",
Tone: "visual",
Status: "success",
Width: 360,
Height: 240,
},
{
ID: "generated-2",
Type: design.NodeTypeImage,
Title: "商品主图 2",
Content: "https://example.com/generated-2.png",
Tone: "visual",
Status: "success",
Width: 360,
Height: 240,
},
}
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
if len(merged) != 2 {
t.Fatalf("expected target plus sibling image, got %#v", merged)
}
if merged[0].Content != "https://example.com/generated-1.png" || merged[0].LayerRole != "" {
t.Fatalf("expected first image to replace target, got %#v", merged[0])
}
if merged[1].Content != "https://example.com/generated-2.png" || merged[1].X <= merged[0].X+merged[0].Width || merged[1].Y != merged[0].Y {
t.Fatalf("expected second image beside target, got %#v after %#v", merged[1], merged[0])
}
mergedAgain := mergeGeneratedNodesIntoTarget(merged, generated, "generator-1")
if len(mergedAgain) != 2 {
t.Fatalf("expected repeated merge to update sibling without duplicating, got %#v", mergedAgain)
}
}
func TestMergeGeneratedNodeIntoCompletedImageGeneratorTargetAppendsSibling(t *testing.T) {
nodes := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "商品主图 1",
Content: "https://example.com/generated-1.png",
Tone: "visual",
Status: "success",
X: 100,
Y: 80,
Width: 360,
Height: 240,
},
}
generated := []design.Node{
{
ID: "generated-2",
Type: design.NodeTypeImage,
Title: "商品主图 2",
Content: "https://example.com/generated-2.png",
Tone: "visual",
Status: "success",
Width: 360,
Height: 240,
},
}
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
if len(merged) != 2 {
t.Fatalf("expected completed target plus sibling image, got %#v", merged)
}
if merged[0].Content != "https://example.com/generated-1.png" {
t.Fatalf("expected completed target to stay intact, got %#v", merged[0])
}
if merged[1].Content != "https://example.com/generated-2.png" || merged[1].X <= merged[0].X+merged[0].Width {
t.Fatalf("expected sibling appended beside completed target, got %#v", merged[1])
}
}
func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T) {
targetID := imageGeneratorTargetNodeID([]design.AgentChatMessage{
{
@@ -102,6 +196,59 @@ func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T
}
}
func TestMarkImageGeneratorTargetGeneratingCopiesPlaceholdersForRequestedCount(t *testing.T) {
nodes := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "图像生成器",
X: 100,
Y: 80,
Width: 360,
Height: 240,
Status: "draft",
LayerRole: "image-generator",
},
{
ID: "regular-image",
Type: design.NodeTypeImage,
Content: "https://example.com/keep.png",
Status: "success",
},
}
marked := markImageGeneratorTargetGenerating(nodes, "generator-1", "生成五张商品主图", "1024x1024", 5)
if len(marked) != 6 {
t.Fatalf("expected target, four copied placeholders, and existing image, got %#v", marked)
}
if marked[0].ID != "generator-1" || marked[0].Status != "generating" || marked[0].Content != "生成五张商品主图" {
t.Fatalf("expected original generator to become first placeholder, got %#v", marked[0])
}
for index := 1; index <= 4; index++ {
node := marked[index]
if node.ID == "" || node.ID == "generator-1" || node.ParentID != "generator-1" || node.LayerRole != "image-generator" || node.Status != "generating" {
t.Fatalf("expected copied image generator placeholder at index %d, got %#v", index, node)
}
if node.X <= marked[index-1].X+marked[index-1].Width || node.Y != marked[0].Y {
t.Fatalf("expected copied placeholder %d beside previous placeholder, got %#v", index, node)
}
}
if marked[5].ID != "regular-image" {
t.Fatalf("expected unrelated image to stay after placeholders, got %#v", marked[5])
}
remarked := markImageGeneratorTargetGenerating(marked, "generator-1", "生成两张商品主图", "1024x1024", 2)
if len(remarked) != 3 {
t.Fatalf("expected stale copied placeholders to be replaced for new count, got %#v", remarked)
}
if remarked[1].ParentID != "generator-1" || remarked[1].Status != "generating" {
t.Fatalf("expected one fresh copied placeholder, got %#v", remarked[1])
}
if remarked[2].ID != "regular-image" {
t.Fatalf("expected unrelated image to remain, got %#v", remarked[2])
}
}
func TestRestoreFinishedGeneratedImageNodesConvertsCompletedImageGeneratorNode(t *testing.T) {
now := time.Now()
project := design.Project{