feat(piagent): bypass planner for direct image generation modes
For image / pure-image / image-generator / image-action modes, skip the task-planning round trip and hand the prompt straight to the image model, deriving the requested image count from the composer settings. Also normalize completed image-generator nodes back into regular image nodes (clearing the image-generator layer role and defaulting Tone to "visual") when merging generated results and when restoring finished nodes, so a finished generator node is not re-appended as a duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -547,11 +547,12 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
|
||||
actionID := newID()
|
||||
targetNodeID := imageGeneratorTargetNodeID(req.Messages)
|
||||
isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID)
|
||||
directImageMode := isDirectImageGenerationMode(mode, isolatedImageGenerator)
|
||||
enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig)
|
||||
memory := buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||||
if !isolatedImageGenerator {
|
||||
project.Messages = append(project.Messages,
|
||||
design.Message{
|
||||
nextMessages := []design.Message{
|
||||
{
|
||||
ID: newID(),
|
||||
Role: "user",
|
||||
Type: "user",
|
||||
@@ -562,8 +563,11 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
|
||||
StepID: newID(),
|
||||
CreatedAt: now,
|
||||
},
|
||||
agentPlanningMessage(threadID, actionID, prompt, enableWebSearch, now.Add(time.Millisecond)),
|
||||
)
|
||||
}
|
||||
if !directImageMode {
|
||||
nextMessages = append(nextMessages, agentPlanningMessage(threadID, actionID, prompt, enableWebSearch, now.Add(time.Millisecond)))
|
||||
}
|
||||
project.Messages = append(project.Messages, nextMessages...)
|
||||
project.Status = design.StatusExploring
|
||||
project.LastThreadID = threadID
|
||||
project.UpdatedAt = now
|
||||
@@ -571,31 +575,38 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
|
||||
if err := s.repo.Save(ctx, project); err != nil {
|
||||
return design.AgentThread{}, err
|
||||
}
|
||||
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
|
||||
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
|
||||
Prompt: prompt,
|
||||
Mode: mode,
|
||||
Messages: req.Messages,
|
||||
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
|
||||
WebSearchEnabled: enableWebSearch,
|
||||
})
|
||||
project, err = s.repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
return design.AgentThread{}, err
|
||||
}
|
||||
memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||||
return design.AgentThread{}, err
|
||||
} else if cancelled {
|
||||
return design.AgentThread{
|
||||
ThreadID: threadID,
|
||||
Status: "cancelled",
|
||||
ThreadIDType: threadIDType,
|
||||
Project: project,
|
||||
}, nil
|
||||
if !directImageMode {
|
||||
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
|
||||
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
|
||||
Prompt: prompt,
|
||||
Mode: mode,
|
||||
Messages: req.Messages,
|
||||
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
|
||||
WebSearchEnabled: enableWebSearch,
|
||||
})
|
||||
project, err = s.repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
return design.AgentThread{}, err
|
||||
}
|
||||
memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||||
return design.AgentThread{}, err
|
||||
} else if cancelled {
|
||||
return design.AgentThread{
|
||||
ThreadID: threadID,
|
||||
Status: "cancelled",
|
||||
ThreadIDType: threadIDType,
|
||||
Project: project,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
taskPlan := s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory)
|
||||
var taskPlan design.AgentTaskPlan
|
||||
if directImageMode {
|
||||
taskPlan = directImageTaskPlan(prompt, req.Messages)
|
||||
} else {
|
||||
taskPlan = s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory)
|
||||
}
|
||||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||||
return design.AgentThread{}, err
|
||||
} else if cancelled {
|
||||
@@ -606,7 +617,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
|
||||
Project: project,
|
||||
}, nil
|
||||
}
|
||||
if !isolatedImageGenerator {
|
||||
if !isolatedImageGenerator && !directImageMode {
|
||||
if err := s.maybeAddPlannedResearchStep(ctx, project.ID, threadID, projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages, enableWebSearch, taskPlan); err != nil {
|
||||
return design.AgentThread{}, err
|
||||
}
|
||||
@@ -647,7 +658,9 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
|
||||
assistantMessageID := ""
|
||||
if !isolatedImageGenerator {
|
||||
if shouldGenerateImage {
|
||||
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
|
||||
if !directImageMode {
|
||||
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
|
||||
}
|
||||
} else {
|
||||
assistantMessageID = preparePlannerForChatMessage(&project, threadID, actionID, prompt, now.Add(time.Millisecond))
|
||||
}
|
||||
@@ -3468,6 +3481,84 @@ func shouldGenerateForAgentPrompt(prompt string, mode string, messages []design.
|
||||
return false
|
||||
}
|
||||
|
||||
func isDirectImageGenerationMode(mode string, isolated bool) bool {
|
||||
if isolated {
|
||||
return true
|
||||
}
|
||||
modeText := strings.ToLower(strings.TrimSpace(mode))
|
||||
return modeText == "image" || modeText == "pure-image" || modeText == "image-generator" || strings.Contains(modeText, "image-action")
|
||||
}
|
||||
|
||||
func directImageTaskPlan(prompt string, messages []design.AgentChatMessage) design.AgentTaskPlan {
|
||||
count := imageModeSettingsCount(messages)
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
if count > 10 {
|
||||
count = 10
|
||||
}
|
||||
tasks := make([]design.AgentImageTask, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
tasks = append(tasks, design.AgentImageTask{
|
||||
Title: generatedTaskFallbackTitle(prompt, index, count),
|
||||
Brief: prompt,
|
||||
})
|
||||
}
|
||||
return design.AgentTaskPlan{
|
||||
Intent: "image",
|
||||
Reason: "direct image mode",
|
||||
ImageCount: count,
|
||||
ImageTasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
func imageModeSettingsCount(messages []design.AgentChatMessage) int {
|
||||
for _, message := range messages {
|
||||
for _, content := range message.Contents {
|
||||
if !strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
||||
continue
|
||||
}
|
||||
if count := imageModeSettingsCountFromText(content.Text); count > 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func imageModeSettingsCountFromText(text string) int {
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range []string{"数量", "count"} {
|
||||
index := strings.Index(lower, marker)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
if count := firstPositiveInt(lower[index+len(marker):]); count > 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstPositiveInt(text string) int {
|
||||
value := 0
|
||||
seen := false
|
||||
for _, char := range text {
|
||||
if char < '0' || char > '9' {
|
||||
if seen {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
value = value*10 + int(char-'0')
|
||||
if value > 10 {
|
||||
return 10
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func shouldForceImageMode(mode string) bool {
|
||||
modeText := strings.ToLower(strings.TrimSpace(mode))
|
||||
return strings.Contains(modeText, "image-action") || modeText == "image" || modeText == "pure-image"
|
||||
@@ -4048,9 +4139,6 @@ func (s *DesignService) clearStaleGeneratingPlaceholders(project design.Project,
|
||||
|
||||
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
|
||||
artifacts := generatedImageArtifacts(project.Messages)
|
||||
if len(artifacts) == 0 {
|
||||
return project, false
|
||||
}
|
||||
artifactsByID := generatedImageArtifactsByID(project.Messages)
|
||||
usedArtifacts := make(map[string]bool)
|
||||
next := make([]design.Node, 0, len(project.Nodes))
|
||||
@@ -4068,6 +4156,10 @@ func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Projec
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if normalized, ok := completedImageGeneratorNode(node); ok {
|
||||
node = normalized
|
||||
changed = true
|
||||
}
|
||||
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
|
||||
changed = true
|
||||
continue
|
||||
@@ -4155,6 +4247,17 @@ func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.Generato
|
||||
return node
|
||||
}
|
||||
|
||||
func completedImageGeneratorNode(node design.Node) (design.Node, bool) {
|
||||
if node.Type != design.NodeTypeImage || node.LayerRole != "image-generator" || node.Status != "success" || !isGeneratedImageContent(node.Content) {
|
||||
return node, false
|
||||
}
|
||||
node.LayerRole = ""
|
||||
if strings.TrimSpace(node.Tone) == "" || node.Tone == "image-generator" {
|
||||
node.Tone = "visual"
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
func generationPlaceholdersAreStale(project design.Project, now time.Time) bool {
|
||||
if !hasGeneratingCanvasWork(project.Nodes) {
|
||||
return false
|
||||
@@ -4361,20 +4464,24 @@ func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string {
|
||||
|
||||
func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node {
|
||||
if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 {
|
||||
return append(nodes, generated...)
|
||||
return append(nodes, completedImageGeneratorNodes(generated)...)
|
||||
}
|
||||
targetNodeID = strings.TrimSpace(targetNodeID)
|
||||
targetIndex := -1
|
||||
for index, node := range nodes {
|
||||
if node.ID == targetNodeID && node.Type == design.NodeTypeImage && node.LayerRole == "image-generator" {
|
||||
if node.ID == targetNodeID && node.Type == design.NodeTypeImage {
|
||||
targetIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetIndex < 0 {
|
||||
return append(nodes, generated...)
|
||||
return append(nodes, completedImageGeneratorNodes(generated)...)
|
||||
}
|
||||
|
||||
primary := generated[0]
|
||||
if normalized, ok := completedImageGeneratorNode(primary); ok {
|
||||
primary = normalized
|
||||
}
|
||||
target := nodes[targetIndex]
|
||||
centerX := target.X + target.Width/2
|
||||
centerY := target.Y + target.Height/2
|
||||
@@ -4383,6 +4490,9 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
|
||||
target.Tone = primary.Tone
|
||||
target.Status = primary.Status
|
||||
target.LayerRole = ""
|
||||
if strings.TrimSpace(target.Tone) == "" || target.Tone == "image-generator" {
|
||||
target.Tone = "visual"
|
||||
}
|
||||
if primary.Width > 0 {
|
||||
target.Width = primary.Width
|
||||
target.X = centerX - target.Width/2
|
||||
@@ -4393,11 +4503,23 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
|
||||
}
|
||||
nodes[targetIndex] = target
|
||||
if len(generated) > 1 {
|
||||
return append(nodes, generated[1:]...)
|
||||
return append(nodes, completedImageGeneratorNodes(generated[1:])...)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func completedImageGeneratorNodes(nodes []design.Node) []design.Node {
|
||||
next := make([]design.Node, len(nodes))
|
||||
for index, node := range nodes {
|
||||
if normalized, ok := completedImageGeneratorNode(node); ok {
|
||||
next[index] = normalized
|
||||
continue
|
||||
}
|
||||
next[index] = node
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func findNodeIndex(nodes []design.Node, nodeID string) int {
|
||||
for index, node := range nodes {
|
||||
if node.ID == nodeID {
|
||||
|
||||
@@ -554,6 +554,75 @@ func TestAgentChatRequiresTextPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatImageModeBypassesPlannerAndHidesSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
planner := &recordingTaskPlanner{
|
||||
fakeCreativeChatAgent: fakeCreativeChatAgent{
|
||||
decision: design.AgentDecision{Intent: "image"},
|
||||
},
|
||||
}
|
||||
service := NewDesignService(repo, nil, nil, nil, nil, planner)
|
||||
queue := &fakeJobQueue{}
|
||||
service.SetJobQueue(queue)
|
||||
project := design.Project{
|
||||
ID: "project-direct-image",
|
||||
Title: "Direct image",
|
||||
Status: design.StatusReady,
|
||||
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: "1536x1024",
|
||||
Messages: []design.AgentChatMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Contents: []design.AgentContent{
|
||||
{Type: "text", Text: "a cute bird flatten ui", TextSource: "input"},
|
||||
{Type: "text", Text: "图片生成参数:质量 自动,尺寸 1536x1024,比例 3:2,数量 3 张。纯生图模式,请直接围绕用户输入生成图片,不展开额外聊天。", TextSource: "settings"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if thread.Status != "running" {
|
||||
t.Fatalf("expected running thread, got %s", thread.Status)
|
||||
}
|
||||
if planner.called != 0 {
|
||||
t.Fatalf("expected image mode to bypass planner, got %d calls", planner.called)
|
||||
}
|
||||
if len(queue.jobs) != 1 {
|
||||
t.Fatalf("expected one image job, got %#v", queue.jobs)
|
||||
}
|
||||
job := queue.jobs[0]
|
||||
if job.TaskPlan.ImageCount != 3 || len(job.TaskPlan.ImageTasks) != 3 {
|
||||
t.Fatalf("expected direct image count from settings, got %#v", job.TaskPlan)
|
||||
}
|
||||
if job.ImageSize != "1536x1024" {
|
||||
t.Fatalf("expected image size to be preserved, got %s", job.ImageSize)
|
||||
}
|
||||
|
||||
saved, err := repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(saved.Messages) != 1 {
|
||||
t.Fatalf("expected only the user message before generation starts, got %#v", saved.Messages)
|
||||
}
|
||||
if saved.Messages[0].Content != "a cute bird flatten ui" {
|
||||
t.Fatalf("expected settings to stay out of visible messages, got %q", saved.Messages[0].Content)
|
||||
}
|
||||
if len(saved.Nodes) != 3 {
|
||||
t.Fatalf("expected three direct image placeholders, got %#v", saved.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
|
||||
@@ -50,6 +50,44 @@ func TestMergeGeneratedNodesIntoImageGeneratorTargetUsesGeneratedSizeAndKeepsCen
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGeneratedNodesIntoCompletedImageGeneratorTargetDoesNotReappend(t *testing.T) {
|
||||
nodes := []design.Node{
|
||||
{
|
||||
ID: "generator-1",
|
||||
Type: design.NodeTypeImage,
|
||||
Title: "旧图",
|
||||
Content: "https://example.com/old.png",
|
||||
Tone: "visual",
|
||||
Status: "success",
|
||||
LayerRole: "",
|
||||
Width: 360,
|
||||
Height: 240,
|
||||
},
|
||||
}
|
||||
generated := []design.Node{
|
||||
{
|
||||
ID: "generator-1",
|
||||
Type: design.NodeTypeImage,
|
||||
Title: "新图",
|
||||
Content: "https://example.com/new.png",
|
||||
Tone: "image-generator",
|
||||
Status: "success",
|
||||
LayerRole: "image-generator",
|
||||
Width: 1024,
|
||||
Height: 1024,
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected existing target to be updated in place, got %#v", merged)
|
||||
}
|
||||
node := merged[0]
|
||||
if node.Content != "https://example.com/new.png" || node.LayerRole != "" || node.Tone != "visual" {
|
||||
t.Fatalf("expected completed image generator to become regular image, got %#v", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T) {
|
||||
targetID := imageGeneratorTargetNodeID([]design.AgentChatMessage{
|
||||
{
|
||||
@@ -64,6 +102,36 @@ func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFinishedGeneratedImageNodesConvertsCompletedImageGeneratorNode(t *testing.T) {
|
||||
now := time.Now()
|
||||
project := design.Project{
|
||||
ID: "project-completed-generator",
|
||||
Status: design.StatusReady,
|
||||
Nodes: []design.Node{
|
||||
{
|
||||
ID: "generator-1",
|
||||
Type: design.NodeTypeImage,
|
||||
Title: "生成图片",
|
||||
Content: "http://localhost:19000/canvas-assets/cat.png",
|
||||
Tone: "image-generator",
|
||||
Status: "success",
|
||||
LayerRole: "image-generator",
|
||||
Width: 1024,
|
||||
Height: 1024,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
|
||||
if !changed {
|
||||
t.Fatal("expected completed image generator node to be normalized")
|
||||
}
|
||||
node := restored.Nodes[0]
|
||||
if node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" || node.Content != "http://localhost:19000/canvas-assets/cat.png" {
|
||||
t.Fatalf("expected regular completed image node, got %#v", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing.T) {
|
||||
now := time.Now()
|
||||
project := design.Project{
|
||||
|
||||
Reference in New Issue
Block a user