Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user