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:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user