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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)...)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user