From d87cb46a9e073c2ec8190e80666e8f6ff4ff926f Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Jul 2026 09:06:02 +0800 Subject: [PATCH] feat(server): place generated images beside the reference anchor node Read a "reference anchor node:" agent directive and lay newly generated image nodes immediately to the right of that node, inheriting its row and falling back to its size, for planned, follow-up, and incremental flows. Generalize the directive parser shared with the image-generator target. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/internal/application/design_service.go | 47 ++++++++++++++-- .../application/generation_failure_test.go | 56 +++++++++++++++++++ .../image_generator_target_test.go | 46 +++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/server/internal/application/design_service.go b/server/internal/application/design_service.go index 447fc5d..cec2229 100644 --- a/server/internal/application/design_service.go +++ b/server/internal/application/design_service.go @@ -696,6 +696,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des now := s.now() actionID := newID() targetNodeID := imageGeneratorTargetNodeID(req.Messages) + referenceAnchorID := referenceAnchorNodeID(req.Messages) isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID) directImageMode := isDirectImageGenerationMode(mode, isolatedImageGenerator) enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig) @@ -792,7 +793,8 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des if targetNodeID != "" { 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)...) + placeholders := newGeneratingImageNodesForPlan(prompt, len(project.Nodes), taskPlan, req.ImageSize) + project.Nodes = append(project.Nodes, positionGeneratedNodesBesideReference(project.Nodes, referenceAnchorID, placeholders)...) } } project.Connections = nil @@ -3114,6 +3116,7 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI var plan design.AgentPlan var incremental bool targetNodeID := imageGeneratorTargetNodeID(messages) + referenceAnchorID := referenceAnchorNodeID(messages) if isolated { plan, incremental, err = s.planWithIncrementalImages(ctx, projectID, threadID, prompt, targetNodeID, false, request) } else { @@ -3139,6 +3142,7 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI } return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err) } + plan.GeneratedNodes = positionGeneratedNodesBesideReference(project.Nodes, referenceAnchorID, plan.GeneratedNodes) } else if err := incompleteIncrementalGenerationError(plan, taskPlan); err != nil { if isolated { return s.markIsolatedGenerationFailed(detachedUserContext(ctx), projectID, targetNodeID, err) @@ -3241,6 +3245,10 @@ func (s *DesignService) planWithIncrementalImages(ctx context.Context, projectID return fmt.Errorf("image generation returned empty image") } node := nodes[0] + positioned := positionGeneratedNodesBesideReference(req.Project.Nodes, referenceAnchorNodeID(req.Messages), append(persistedNodes, node)) + if len(positioned) > 0 { + node = positioned[len(positioned)-1] + } persistedNodes = append(persistedNodes, node) return s.applyIncrementalGeneratedNode(ctx, projectID, threadID, prompt, targetNodeID, node, result.Index, appendArtifacts) }) @@ -4710,7 +4718,15 @@ func isImageGeneratorRequest(mode string, targetNodeID string) bool { } func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string { - const targetPrefix = "image generator target node:" + return agentDirectiveNodeID(messages, "image generator target node:") +} + +func referenceAnchorNodeID(messages []design.AgentChatMessage) string { + return agentDirectiveNodeID(messages, "reference anchor node:") +} + +func agentDirectiveNodeID(messages []design.AgentChatMessage, prefix string) string { + prefix = strings.ToLower(strings.TrimSpace(prefix)) for _, message := range messages { for _, content := range message.Contents { if strings.ToLower(strings.TrimSpace(content.Type)) != "text" { @@ -4718,8 +4734,8 @@ func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string { } for _, line := range strings.Split(content.Text, "\n") { line = strings.TrimSpace(line) - if strings.HasPrefix(strings.ToLower(line), targetPrefix) { - return strings.TrimSpace(line[len(targetPrefix):]) + if strings.HasPrefix(strings.ToLower(line), prefix) { + return strings.TrimSpace(line[len(prefix):]) } } } @@ -4727,6 +4743,29 @@ func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string { return "" } +func positionGeneratedNodesBesideReference(nodes []design.Node, referenceNodeID string, generated []design.Node) []design.Node { + positioned := append([]design.Node(nil), generated...) + referenceIndex := findNodeIndex(nodes, strings.TrimSpace(referenceNodeID)) + if referenceIndex < 0 || len(positioned) == 0 { + return positioned + } + reference := nodes[referenceIndex] + gap := math.Max(48, math.Min(120, reference.Width*0.08)) + nextX := reference.X + reference.Width + gap + for index := range positioned { + if positioned[index].Width <= 0 { + positioned[index].Width = reference.Width + } + if positioned[index].Height <= 0 { + positioned[index].Height = reference.Height + } + positioned[index].X = nextX + positioned[index].Y = reference.Y + nextX += positioned[index].Width + gap + } + return positioned +} + func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node { if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 { return append(nodes, completedImageGeneratorNodes(generated)...) diff --git a/server/internal/application/generation_failure_test.go b/server/internal/application/generation_failure_test.go index 6348c79..c2852dc 100644 --- a/server/internal/application/generation_failure_test.go +++ b/server/internal/application/generation_failure_test.go @@ -623,6 +623,62 @@ func TestAgentChatImageModeBypassesPlannerAndHidesSettings(t *testing.T) { } } +func TestAgentChatPlacesReferenceAnchoredPlaceholdersBesideLastCapsule(t *testing.T) { + ctx := context.Background() + repo := memory.NewProjectRepository() + service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ + decision: design.AgentDecision{Intent: "image"}, + }) + service.SetJobQueue(&fakeJobQueue{}) + project := design.Project{ + ID: "project-reference-anchor", + Title: "Reference anchor", + Status: design.StatusReady, + Nodes: []design.Node{ + {ID: "reference-1", Type: design.NodeTypeImage, X: 40, Y: 80, Width: 400, Height: 300, Content: "https://example.com/reference-1.webp", Status: "success"}, + {ID: "reference-2", Type: design.NodeTypeImage, X: 620, Y: 160, Width: 520, Height: 360, Content: "https://example.com/reference-2.webp", Status: "success"}, + }, + UpdatedAt: time.Now(), + } + if err := repo.Save(ctx, project); err != nil { + t.Fatal(err) + } + + thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ + Mode: "image", + ImageSize: "1024x1024", + ImageCount: 2, + Messages: []design.AgentChatMessage{ + { + Role: "user", + Contents: []design.AgentContent{ + {Type: "image", ImageURL: project.Nodes[0].Content, ImageName: "first"}, + {Type: "image", ImageURL: project.Nodes[1].Content, ImageName: "last"}, + {Type: "text", Text: "把最后一张参考图转换成立面效果", TextSource: "input"}, + {Type: "text", Text: "Reference anchor node: reference-2", TextSource: "settings"}, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(thread.Project.Nodes) != 4 { + t.Fatalf("expected two references and two placeholders, got %#v", thread.Project.Nodes) + } + if thread.Project.Nodes[1].Content != project.Nodes[1].Content || thread.Project.Nodes[1].X != project.Nodes[1].X { + t.Fatalf("expected last reference to remain unchanged, got %#v", thread.Project.Nodes[1]) + } + first := thread.Project.Nodes[2] + second := thread.Project.Nodes[3] + if first.X <= project.Nodes[1].X+project.Nodes[1].Width || first.Y != project.Nodes[1].Y { + t.Fatalf("expected first placeholder beside last capsule, got %#v", first) + } + if second.X <= first.X+first.Width || second.Y != project.Nodes[1].Y { + t.Fatalf("expected second placeholder after first placeholder, got %#v", second) + } +} + func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() diff --git a/server/internal/application/image_generator_target_test.go b/server/internal/application/image_generator_target_test.go index 966bf9b..dbb470c 100644 --- a/server/internal/application/image_generator_target_test.go +++ b/server/internal/application/image_generator_target_test.go @@ -196,6 +196,52 @@ func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T } } +func TestReferenceAnchorNodeIDParsesDirectiveCaseInsensitively(t *testing.T) { + anchorID := referenceAnchorNodeID([]design.AgentChatMessage{ + { + Role: "user", + Contents: []design.AgentContent{ + {Type: "text", Text: "REFERENCE ANCHOR NODE: image-2", TextSource: "settings"}, + }, + }, + }) + if anchorID != "image-2" { + t.Fatalf("expected reference anchor id, got %q", anchorID) + } +} + +func TestPositionGeneratedNodesBesideReferenceKeepsAnchorAndOrdersResults(t *testing.T) { + nodes := []design.Node{ + {ID: "reference-1", Type: design.NodeTypeImage, X: 100, Y: 80, Width: 500, Height: 360, Content: "https://example.com/reference.webp", Status: "success"}, + } + generated := []design.Node{ + {ID: "generated-1", Type: design.NodeTypeImage, Width: 400, Height: 300}, + {ID: "generated-2", Type: design.NodeTypeImage, Width: 320, Height: 240}, + } + + positioned := positionGeneratedNodesBesideReference(nodes, "reference-1", generated) + if nodes[0].X != 100 || nodes[0].Content != "https://example.com/reference.webp" { + t.Fatalf("expected reference node to remain unchanged, got %#v", nodes[0]) + } + if positioned[0].X <= nodes[0].X+nodes[0].Width || positioned[0].Y != nodes[0].Y { + t.Fatalf("expected first result beside reference, got %#v", positioned[0]) + } + if positioned[1].X <= positioned[0].X+positioned[0].Width || positioned[1].Y != nodes[0].Y { + t.Fatalf("expected second result after first result, got %#v", positioned[1]) + } +} + +func TestPositionGeneratedNodesBesideReferenceKeepsDefaultPlacementWithoutAnchor(t *testing.T) { + generated := []design.Node{ + {ID: "generated-1", Type: design.NodeTypeImage, X: 720, Y: 360, Width: 400, Height: 300}, + } + + positioned := positionGeneratedNodesBesideReference(nil, "missing-reference", generated) + if positioned[0].X != generated[0].X || positioned[0].Y != generated[0].Y { + t.Fatalf("expected default placement without a matching anchor, got %#v", positioned[0]) + } +} + func TestMarkImageGeneratorTargetGeneratingCopiesPlaceholdersForRequestedCount(t *testing.T) { nodes := []design.Node{ {