diff --git a/server/internal/application/design_service.go b/server/internal/application/design_service.go index 2e968f1..6279aea 100644 --- a/server/internal/application/design_service.go +++ b/server/internal/application/design_service.go @@ -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 { diff --git a/server/internal/application/image_generator_target_test.go b/server/internal/application/image_generator_target_test.go index b175fd4..966bf9b 100644 --- a/server/internal/application/image_generator_target_test.go +++ b/server/internal/application/image_generator_target_test.go @@ -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{