Files
moteva/server/internal/infrastructure/piagent/design_agent_test.go
T
root 2f5291a0f9 feat(image): pass reference images to GPT image model for edits
Server: forward canvas/reference images to the image model so extract,
continue, and edit requests stay faithful to the source product.
- Add ImageRequestOptions.Images and route requests with inputs to the
  /v1/images/edits endpoint, via multipart upload (default) or JSON
  image_url payload, selectable through Agent.Image.InputImageTransport.
- Extract reference URLs from prompt directives, inline @image tokens,
  and chat message image/mask contents; fold them into the idempotency
  key and prepend a reference-fidelity instruction to the prompt.

Frontend:
- Shrink CanvasToast to a compact top pill.
- Paste an internal transparent clipboard node instead of re-uploading
  when it matches the clipboard image.
- Keep agent-project polling alive on stream error while streaming.
2026-07-08 01:19:20 +08:00

493 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package piagent
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"testing"
"unicode/utf8"
"img_infinite_canvas/internal/domain/design"
)
type fakeRunner struct{}
func (fakeRunner) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
return design.AgentPlan{
Message: design.Message{ID: "message-1", Role: "assistant", Title: "done", Content: "done"},
GeneratedNodes: []design.Node{
{
ID: "node-1",
Type: design.NodeTypeFrame,
Title: "Website Hero",
Width: 520,
Height: 300,
Content: req.Prompt,
},
{
ID: "mock-image-1",
Type: design.NodeTypeImage,
Title: "Mood x Color",
Width: 520,
Height: 640,
Content: req.Prompt,
},
{
ID: "mock-image-2",
Type: design.NodeTypeImage,
Title: "Mobile Page",
Width: 260,
Height: 460,
Content: req.Prompt,
},
},
}, nil
}
func (fakeRunner) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) {
return design.AgentReplay{ProjectID: project.ID, Messages: project.Messages}, nil
}
type fakeImageGenerator struct {
mu sync.Mutex
called bool
prompt string
opts ImageRequestOptions
calls int
options []ImageRequestOptions
}
type failingImageGenerator struct {
calls int
err error
}
type partialFailImageGenerator struct {
mu sync.Mutex
calls int
}
func (g *failingImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.calls++
return GeneratedImage{}, g.err
}
func (g *partialFailImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.mu.Lock()
g.calls++
call := g.calls
g.mu.Unlock()
if call == 1 {
return GeneratedImage{URL: "https://example.com/generated-partial.png", URLs: []string{"https://example.com/generated-partial.png"}}, nil
}
return GeneratedImage{}, errors.New("upstream response did not return image")
}
func (g *fakeImageGenerator) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
g.mu.Lock()
defer g.mu.Unlock()
g.called = true
g.prompt = prompt
g.opts = opts
g.calls++
callIndex := imageOptionIndex(prompt, g.calls)
g.options = append(g.options, opts)
count := opts.Count
if count < 1 {
count = 1
}
urls := make([]string, 0, count)
for index := 0; index < count; index++ {
urls = append(urls, "https://example.com/generated-"+strconv.Itoa(callIndex+index)+".png")
}
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
func imageOptionIndex(prompt string, fallback int) int {
for index := 1; index <= 10; index++ {
if strings.Contains(prompt, "option "+strconv.Itoa(index)+" of") {
return index
}
}
return fallback
}
func (g *fakeImageGenerator) callCount() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.calls
}
type fakeCreativeAgent struct{}
func (fakeCreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
return design.AgentDecision{}, nil
}
func (fakeCreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return "", nil
}
func (fakeCreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
return "这张图已经生成。建议下一步检查首屏构图、商品露出和转化按钮层级。", nil
}
func (fakeCreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
return design.AgentImagePrompt{
Title: "Still Day 首页升级",
Prompt: "基于 still day 现有首页方向,保留高饱和撞色,升级电商首屏、商品网格和品牌页脚。",
}, nil
}
type longCreativeAgent struct{}
func (longCreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
return design.AgentDecision{}, nil
}
func (longCreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return "", nil
}
func (longCreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
return "", nil
}
func (longCreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
return design.AgentImagePrompt{
Title: "精简生图指令",
Prompt: strings.Repeat("大量上下文不应该直接进入 gpt-image-2。", 240),
}, nil
}
func TestDesignAgentGeneratesImageThroughPiAgent(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "image",
ImageModel: "gpt-image-2",
ImageSize: "1536x1024",
})
if err != nil {
t.Fatal(err)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if !containsAll(imageGenerator.prompt, []string{"基于 still day 现有首页方向", "高饱和撞色"}) {
t.Fatalf("unexpected prompt: %s", imageGenerator.prompt)
}
if imageGenerator.opts.Model != "gpt-image-2" || imageGenerator.opts.Size != "1536x1024" {
t.Fatalf("unexpected image options: %#v", imageGenerator.opts)
}
if imageGenerator.opts.Count != 1 {
t.Fatalf("expected one image request, got %d", imageGenerator.opts.Count)
}
if len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one node, got %d", len(plan.GeneratedNodes))
}
node := plan.GeneratedNodes[0]
if node.Type != design.NodeTypeImage {
t.Fatalf("expected image node, got %s", node.Type)
}
if node.Content != "https://example.com/generated-1.png" {
t.Fatalf("expected generated image url, got %s", node.Content)
}
if node.Title != "Still Day 首页升级" {
t.Fatalf("expected generated image title, got %s", node.Title)
}
if node.Width != 1536 || node.Height != 1024 {
t.Fatalf("expected node size from request image size, got %.0fx%.0f", node.Width, node.Height)
}
if plan.Message.Title != "Still Day 首页升级 已生成" {
t.Fatalf("expected contextual completion title, got %s", plan.Message.Title)
}
if plan.Message.Content == "" {
t.Fatal("expected image generation summary")
}
if !containsAll(plan.Message.Content, []string{"首屏构图", "商品露出", "转化按钮"}) {
t.Fatalf("expected creative agent summary, got %s", plan.Message.Content)
}
}
func TestDesignAgentPassesInlineReferenceImagesToImageGenerator(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "把参考图里的灰裤子拆成单件商品图",
Mode: "image",
ImageModel: "gpt-image-2",
Messages: []design.AgentChatMessage{
{
Role: "user",
Contents: []design.AgentContent{
{Type: "image", ImageURL: "https://example.com/pants-rack.png", ImageWidth: 1200, ImageHeight: 900},
{Type: "text", Text: "把参考图里的灰裤子拆成单件商品图", TextSource: "input"},
},
},
},
})
if err != nil {
t.Fatal(err)
}
if len(imageGenerator.opts.Images) != 1 || imageGenerator.opts.Images[0] != "https://example.com/pants-rack.png" {
t.Fatalf("expected reference image to be passed to GPT Image 2, got %#v", imageGenerator.opts.Images)
}
if !strings.Contains(imageGenerator.prompt, "参考图") {
t.Fatalf("expected prompt to tell image model to use attached reference, got %s", imageGenerator.prompt)
}
}
func TestDesignAgentPassesPromptDirectiveReferenceImagesToImageGenerator(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "参考图 1image):https://example.com/pants-rack.png\n灰裤子拆成单件衣服,为了后面电商配图。",
Mode: "image",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if len(imageGenerator.opts.Images) != 1 || imageGenerator.opts.Images[0] != "https://example.com/pants-rack.png" {
t.Fatalf("expected directive reference image to be passed to GPT Image 2, got %#v", imageGenerator.opts.Images)
}
}
func TestDesignAgentUsesDefaultImageSizeWithoutExplicitRequest(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "design",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.opts.Size != "1024x1024" {
t.Fatalf("expected default image size, got %q", imageGenerator.opts.Size)
}
if len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one generated node, got %d", len(plan.GeneratedNodes))
}
if plan.GeneratedNodes[0].Width != 1024 || plan.GeneratedNodes[0].Height != 1024 {
t.Fatalf("expected default generated image size, got %.0fx%.0f", plan.GeneratedNodes[0].Width, plan.GeneratedNodes[0].Height)
}
}
func TestDesignAgentUsesExplicitPromptImageRatio(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 16:9 的 still day 官网首页图片",
Mode: "design",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.opts.Size != "2048x1152" {
t.Fatalf("expected explicit 16:9 image size, got %q", imageGenerator.opts.Size)
}
if len(plan.GeneratedNodes) != 1 || plan.GeneratedNodes[0].Width != 2048 || plan.GeneratedNodes[0].Height != 1152 {
t.Fatalf("expected node size from explicit ratio, got %#v", plan.GeneratedNodes)
}
}
func TestDesignAgentGeneratesModelPlannedImageTasks(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "设计二次元水墨人物形象,输出角色正面、侧面、细节和色彩方案。",
Mode: "image",
ImageModel: "gpt-image-2",
ImageSize: "1024x1024",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 3,
ImageTasks: []design.AgentImageTask{
{Title: "水墨角色_正面", Brief: "正面全身立绘"},
{Title: "水墨角色_侧面", Brief: "侧面全身立绘"},
{Title: "水墨角色_细节", Brief: "局部细节拆解"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating", X: 10},
{ID: "slot-2", Type: design.NodeTypeImage, Status: "generating", X: 20},
{ID: "slot-3", Type: design.NodeTypeImage, Status: "generating", X: 30},
},
},
})
if err != nil {
t.Fatal(err)
}
if imageGenerator.callCount() != 3 {
t.Fatalf("expected 3 image calls, got %d", imageGenerator.callCount())
}
idempotencyKeys := make(map[string]bool)
for _, opts := range imageGenerator.options {
if opts.Count != 1 {
t.Fatalf("expected individual image request count 1, got %#v", imageGenerator.options)
}
if opts.IdempotencyKey == "" {
t.Fatalf("expected idempotency key for image request, got %#v", imageGenerator.options)
}
if idempotencyKeys[opts.IdempotencyKey] {
t.Fatalf("expected unique idempotency keys, got %#v", imageGenerator.options)
}
idempotencyKeys[opts.IdempotencyKey] = true
}
if len(plan.GeneratedNodes) != 3 {
t.Fatalf("expected 3 generated nodes, got %#v", plan.GeneratedNodes)
}
for index, node := range plan.GeneratedNodes {
wantID := "slot-" + strconv.Itoa(index+1)
if node.ID != wantID || node.Content == "" || node.Status != "success" {
t.Fatalf("unexpected generated node %d: %#v", index, node)
}
}
}
func TestDesignAgentGeneratesWhenTaskPlanIntentIsImageEvenInAgentMode(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Prompt: "来一版",
Mode: "agent",
ImageModel: "gpt-image-2",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 1,
ImageTasks: []design.AgentImageTask{
{Title: "画面方案", Brief: "生成一个高级电商主视觉,强调产品层次和留白。"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating", X: 10},
},
},
}, nil)
if err != nil {
t.Fatal(err)
}
if imageGenerator.callCount() != 1 {
t.Fatalf("expected image generation to run from task plan intent, got %d calls", imageGenerator.callCount())
}
if len(plan.GeneratedNodes) != 1 || plan.GeneratedNodes[0].Content == "" {
t.Fatalf("expected generated image node, got %#v", plan.GeneratedNodes)
}
}
func TestDesignAgentReturnsPartialFailureWhenPlannedImageIsMissing(t *testing.T) {
imageGenerator := &partialFailImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
var emitted []design.Node
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Prompt: "生成两张视觉",
Mode: "image",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 2,
ImageTasks: []design.AgentImageTask{
{Title: "方案一", Brief: "生成第一张视觉"},
{Title: "方案二", Brief: "生成第二张视觉"},
},
},
Project: design.Project{
Nodes: []design.Node{
{ID: "slot-1", Type: design.NodeTypeImage, Status: "generating"},
{ID: "slot-2", Type: design.NodeTypeImage, Status: "generating"},
},
},
}, func(result design.AgentImageGenerationResult) error {
emitted = append(emitted, result.Node)
return nil
})
if err == nil {
t.Fatal("expected partial generation failure")
}
if !strings.Contains(err.Error(), "only 1 of 2 planned images returned") {
t.Fatalf("expected partial failure count, got %v", err)
}
if len(emitted) != 1 || len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one successful node to be preserved, emitted=%#v plan=%#v", emitted, plan.GeneratedNodes)
}
}
func TestGeneratedImageSlotsIgnoreCanvasActionPlaceholders(t *testing.T) {
slots := generatedImageSlots(design.AgentRequest{
Project: design.Project{
Nodes: []design.Node{
{ID: "background-placeholder", Type: design.NodeTypeImage, Status: "generating", LayerRole: "background-removed"},
{ID: "agent-placeholder", Type: design.NodeTypeImage, Status: "generating"},
},
},
}, nil, 1)
if len(slots) != 1 || slots[0].ID != "agent-placeholder" {
t.Fatalf("expected agent placeholder slot, got %#v", slots)
}
}
func TestDesignAgentDoesNotRunFallbackAfterImageTimeout(t *testing.T) {
imageGenerator := &failingImageGenerator{err: context.DeadlineExceeded}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "image",
ImageModel: "gpt-image-2",
})
if err == nil {
t.Fatal("expected image timeout error")
}
if imageGenerator.calls != 1 {
t.Fatalf("expected timeout to skip fallback generation, got %d calls", imageGenerator.calls)
}
}
func TestDesignAgentCompactsPromptBeforeGPTImage(t *testing.T) {
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, longCreativeAgent{})
_, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "根据当前画布生成一张电商主视觉",
Mode: "image",
ImageModel: "gpt-image-2",
})
if err != nil {
t.Fatal(err)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if utf8.RuneCountInString(imageGenerator.prompt) > imageGenerationPromptTooLargeRunes {
t.Fatalf("expected compact image prompt, got %d runes", utf8.RuneCountInString(imageGenerator.prompt))
}
if containsAll(imageGenerator.prompt, []string{"任务类型", "主体描述", "风格定义", "技术参数", "输出规格"}) {
t.Fatalf("expected compact prompt without forced framework, got %s", imageGenerator.prompt)
}
}
func containsAll(value string, needles []string) bool {
for _, needle := range needles {
if !strings.Contains(value, needle) {
return false
}
}
return true
}