44406b72db
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>
929 lines
28 KiB
Go
929 lines
28 KiB
Go
package piagent
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"unicode/utf8"
|
||
|
||
"img_infinite_canvas/internal/domain/design"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/sky-valley/pi/agent"
|
||
"github.com/sky-valley/pi/ai"
|
||
)
|
||
|
||
const (
|
||
imageGenerationPromptMaxRunes = 900
|
||
imageGenerationFallbackMaxRunes = 650
|
||
imageGenerationPromptTooLargeRunes = 1300
|
||
)
|
||
|
||
type DesignAgent struct {
|
||
base design.AgentRunner
|
||
imageAgent ImageGenerator
|
||
textAgent design.CreativeAgent
|
||
tools []agent.AgentTool
|
||
}
|
||
|
||
func NewDesignAgent(base design.AgentRunner, imageAgent ImageGenerator, textAgents ...design.CreativeAgent) *DesignAgent {
|
||
var textAgent design.CreativeAgent
|
||
if len(textAgents) > 0 {
|
||
textAgent = textAgents[0]
|
||
}
|
||
return &DesignAgent{
|
||
base: base,
|
||
imageAgent: imageAgent,
|
||
textAgent: textAgent,
|
||
tools: designTools(),
|
||
}
|
||
}
|
||
|
||
func (a *DesignAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
|
||
plan, err := a.base.Plan(ctx, req)
|
||
if err != nil {
|
||
return design.AgentPlan{}, err
|
||
}
|
||
|
||
plan.Message.Title = "Pi Agent 设计任务完成"
|
||
|
||
if shouldGenerateImage(req) {
|
||
if a.imageAgent == nil {
|
||
return design.AgentPlan{}, fmt.Errorf("gpt image 2 is not configured")
|
||
}
|
||
generationReq := req
|
||
generationReq.ImageSize = resolveImageSizeForGeneration(req.ImageSize, req.Prompt)
|
||
imagePrompts := a.buildImagePrompts(ctx, generationReq)
|
||
imageURLs, err := a.generatePlannedImages(ctx, imagePrompts, generationReq)
|
||
if err != nil {
|
||
return design.AgentPlan{}, fmt.Errorf("gpt image 2 generation failed: %w", err)
|
||
}
|
||
applyPlannedGeneratedImages(&plan, generationReq, imageURLs, imagePrompts)
|
||
title := ""
|
||
if len(imagePrompts) > 0 {
|
||
title = imagePrompts[0].Title
|
||
}
|
||
plan.Message.Title = generatedMessageTitle(title, req.Prompt)
|
||
plan.Message.Content = a.imageGenerationSummary(ctx, req, imageURLs[0])
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func (a *DesignAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) {
|
||
plan, err := a.base.Plan(ctx, req)
|
||
if err != nil {
|
||
return design.AgentPlan{}, err
|
||
}
|
||
|
||
plan.Message.Title = "Pi Agent 设计任务完成"
|
||
|
||
if !shouldGenerateImage(req) {
|
||
return plan, nil
|
||
}
|
||
if a.imageAgent == nil {
|
||
return design.AgentPlan{}, fmt.Errorf("gpt image 2 is not configured")
|
||
}
|
||
|
||
generationReq := req
|
||
generationReq.ImageSize = resolveImageSizeForGeneration(req.ImageSize, req.Prompt)
|
||
imagePrompts := a.buildImagePrompts(ctx, generationReq)
|
||
nodes, err := a.generatePlannedImagesIncremental(ctx, imagePrompts, generationReq, plan.GeneratedNodes, onResult)
|
||
if err != nil {
|
||
if len(nodes) > 0 {
|
||
plan.GeneratedNodes = nodes
|
||
plan.Connections = nil
|
||
}
|
||
return plan, fmt.Errorf("gpt image 2 generation failed: %w", err)
|
||
}
|
||
|
||
plan.GeneratedNodes = nodes
|
||
plan.Connections = nil
|
||
title := ""
|
||
if len(imagePrompts) > 0 {
|
||
title = imagePrompts[0].Title
|
||
}
|
||
plan.Message.Title = generatedMessageTitle(title, req.Prompt)
|
||
if len(nodes) > 0 {
|
||
plan.Message.Content = a.imageGenerationSummary(ctx, req, nodes[0].Content)
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func (a *DesignAgent) buildImagePrompts(ctx context.Context, req design.AgentRequest) []design.AgentImagePrompt {
|
||
count := plannedGenerationCount(req)
|
||
prompts := make([]design.AgentImagePrompt, 0, count)
|
||
for index := 0; index < count; index++ {
|
||
taskReq := req
|
||
if index < len(req.TaskPlan.ImageTasks) {
|
||
task := req.TaskPlan.ImageTasks[index]
|
||
if strings.TrimSpace(task.Brief) != "" {
|
||
taskReq.Prompt = strings.TrimSpace(task.Brief)
|
||
}
|
||
taskReq.TaskPlan.ImageTasks = []design.AgentImageTask{task}
|
||
taskReq.TaskPlan.ImageCount = 1
|
||
}
|
||
imagePrompt := a.buildImagePrompt(ctx, taskReq)
|
||
imagePrompt.Prompt = prepareGPTImagePrompt(imagePrompt.Prompt, imageGenerationPromptMaxRunes)
|
||
if imagePrompt.Title == "" && index < len(req.TaskPlan.ImageTasks) {
|
||
imagePrompt.Title = strings.TrimSpace(req.TaskPlan.ImageTasks[index].Title)
|
||
}
|
||
prompts = append(prompts, imagePrompt)
|
||
}
|
||
return prompts
|
||
}
|
||
|
||
func plannedGenerationCount(req design.AgentRequest) int {
|
||
count := req.TaskPlan.ImageCount
|
||
if count <= 0 && len(req.TaskPlan.ImageTasks) > 0 {
|
||
count = len(req.TaskPlan.ImageTasks)
|
||
}
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
if count > 10 {
|
||
count = 10
|
||
}
|
||
return count
|
||
}
|
||
|
||
func (a *DesignAgent) generatePlannedImages(ctx context.Context, prompts []design.AgentImagePrompt, req design.AgentRequest) ([]string, error) {
|
||
if len(prompts) == 0 {
|
||
prompts = []design.AgentImagePrompt{{Title: normalizeGeneratedTitle("", req.Prompt), Prompt: prepareGPTImagePrompt(fallbackDirectImagePrompt(req), imageGenerationFallbackMaxRunes)}}
|
||
}
|
||
if len(prompts) == 1 {
|
||
image, err := a.generateImageWithFallback(ctx, req, prompts[0].Prompt, fallbackImagePrompt(req, 0), 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
urls := generatedImageURLs(image)
|
||
if len(urls) == 0 {
|
||
return nil, fmt.Errorf("image generation returned empty image")
|
||
}
|
||
return urls[:1], nil
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
urls := make([]string, len(prompts))
|
||
errs := make(chan error, len(prompts))
|
||
var wg sync.WaitGroup
|
||
for index := range prompts {
|
||
index := index
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
image, err := a.generateImageWithFallback(ctx, req, prompts[index].Prompt, fallbackImagePrompt(req, index), index)
|
||
if err != nil {
|
||
errs <- err
|
||
cancel()
|
||
return
|
||
}
|
||
imageURLs := generatedImageURLs(image)
|
||
if len(imageURLs) == 0 {
|
||
errs <- fmt.Errorf("image generation returned empty image")
|
||
cancel()
|
||
return
|
||
}
|
||
urls[index] = imageURLs[0]
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
close(errs)
|
||
if err := <-errs; err != nil {
|
||
return nil, err
|
||
}
|
||
return urls, nil
|
||
}
|
||
|
||
type plannedImageResult struct {
|
||
index int
|
||
node design.Node
|
||
err error
|
||
}
|
||
|
||
func (a *DesignAgent) generatePlannedImagesIncremental(ctx context.Context, prompts []design.AgentImagePrompt, req design.AgentRequest, fallback []design.Node, onResult func(design.AgentImageGenerationResult) error) ([]design.Node, error) {
|
||
if len(prompts) == 0 {
|
||
prompts = []design.AgentImagePrompt{{Title: normalizeGeneratedTitle("", req.Prompt), Prompt: prepareGPTImagePrompt(fallbackDirectImagePrompt(req), imageGenerationFallbackMaxRunes)}}
|
||
}
|
||
|
||
slots := generatedImageSlots(req, fallback, len(prompts))
|
||
results := make(chan plannedImageResult, len(prompts))
|
||
var wg sync.WaitGroup
|
||
for index := range prompts {
|
||
index := index
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
image, err := a.generateImageWithFallback(ctx, req, prompts[index].Prompt, fallbackImagePrompt(req, index), index)
|
||
if err != nil {
|
||
results <- plannedImageResult{index: index, err: err}
|
||
return
|
||
}
|
||
imageURLs := generatedImageURLs(image)
|
||
if len(imageURLs) == 0 {
|
||
results <- plannedImageResult{index: index, err: fmt.Errorf("image generation returned empty image")}
|
||
return
|
||
}
|
||
title := ""
|
||
if index < len(prompts) {
|
||
title = prompts[index].Title
|
||
}
|
||
results <- plannedImageResult{
|
||
index: index,
|
||
node: generatedImageNodeFromSlot(slots[index], req, imageURLs[0], title, index, len(prompts)),
|
||
}
|
||
}()
|
||
}
|
||
go func() {
|
||
wg.Wait()
|
||
close(results)
|
||
}()
|
||
|
||
successes := make([]plannedImageResult, 0, len(prompts))
|
||
var firstErr error
|
||
for result := range results {
|
||
if result.err != nil {
|
||
if firstErr == nil {
|
||
firstErr = result.err
|
||
}
|
||
continue
|
||
}
|
||
if onResult != nil {
|
||
if err := onResult(design.AgentImageGenerationResult{Index: result.index, Node: result.node}); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
continue
|
||
}
|
||
}
|
||
successes = append(successes, result)
|
||
}
|
||
if len(successes) == 0 {
|
||
if firstErr != nil {
|
||
return nil, firstErr
|
||
}
|
||
return nil, fmt.Errorf("image generation returned empty image")
|
||
}
|
||
|
||
sort.SliceStable(successes, func(i, j int) bool {
|
||
return successes[i].index < successes[j].index
|
||
})
|
||
nodes := make([]design.Node, 0, len(successes))
|
||
for _, result := range successes {
|
||
nodes = append(nodes, result.node)
|
||
}
|
||
if firstErr != nil {
|
||
return nodes, fmt.Errorf("only %d of %d planned images returned: %w", len(successes), len(prompts), firstErr)
|
||
}
|
||
return nodes, nil
|
||
}
|
||
|
||
func (a *DesignAgent) generateImageWithFallback(ctx context.Context, req design.AgentRequest, prompt string, fallback string, index int) (GeneratedImage, error) {
|
||
prompt = prepareGPTImagePrompt(prompt, imageGenerationPromptMaxRunes)
|
||
fallback = prepareGPTImagePrompt(fallback, imageGenerationFallbackMaxRunes)
|
||
if imagePromptTooLarge(prompt) && strings.TrimSpace(fallback) != "" {
|
||
prompt = fallback
|
||
fallback = ""
|
||
}
|
||
image, err := a.imageAgent.Generate(ctx, prompt, ImageRequestOptions{
|
||
Model: req.ImageModel,
|
||
Size: req.ImageSize,
|
||
Count: 1,
|
||
IdempotencyKey: imageGenerationIdempotencyKey(req, index, prompt, false),
|
||
})
|
||
if err == nil {
|
||
return image, nil
|
||
}
|
||
if ctx.Err() != nil || isImageGenerationTerminalError(err) {
|
||
return GeneratedImage{}, err
|
||
}
|
||
if strings.TrimSpace(fallback) == "" || strings.TrimSpace(fallback) == strings.TrimSpace(prompt) {
|
||
return GeneratedImage{}, err
|
||
}
|
||
fallbackImage, fallbackErr := a.imageAgent.Generate(ctx, fallback, ImageRequestOptions{
|
||
Model: req.ImageModel,
|
||
Size: req.ImageSize,
|
||
Count: 1,
|
||
IdempotencyKey: imageGenerationIdempotencyKey(req, index, fallback, true),
|
||
})
|
||
if fallbackErr == nil {
|
||
return fallbackImage, nil
|
||
}
|
||
return GeneratedImage{}, fmt.Errorf("%w; fallback direct prompt also failed: %v", err, fallbackErr)
|
||
}
|
||
|
||
func imageGenerationIdempotencyKey(req design.AgentRequest, index int, prompt string, fallback bool) string {
|
||
kind := "primary"
|
||
if fallback {
|
||
kind = "fallback"
|
||
}
|
||
seed := strings.Join([]string{
|
||
strings.TrimSpace(req.Project.ID),
|
||
strings.TrimSpace(req.Mode),
|
||
strings.TrimSpace(req.Prompt),
|
||
strings.TrimSpace(req.ImageModel),
|
||
strings.TrimSpace(req.ImageSize),
|
||
strconv.Itoa(index),
|
||
kind,
|
||
strings.TrimSpace(prompt),
|
||
}, "\n")
|
||
sum := sha256.Sum256([]byte(seed))
|
||
return "img-gen-" + kind + "-" + hex.EncodeToString(sum[:16])
|
||
}
|
||
|
||
func imagePromptTooLarge(prompt string) bool {
|
||
return utf8.RuneCountInString(strings.TrimSpace(prompt)) > imageGenerationPromptTooLargeRunes
|
||
}
|
||
|
||
func fallbackImagePrompt(req design.AgentRequest, index int) string {
|
||
if index >= 0 && index < len(req.TaskPlan.ImageTasks) {
|
||
task := req.TaskPlan.ImageTasks[index]
|
||
if brief := strings.TrimSpace(task.Brief); brief != "" {
|
||
if title := strings.TrimSpace(task.Title); title != "" && !strings.Contains(brief, title) {
|
||
return title + ": " + brief
|
||
}
|
||
return brief
|
||
}
|
||
}
|
||
return fallbackDirectImagePrompt(req)
|
||
}
|
||
|
||
func prepareGPTImagePrompt(prompt string, maxRunes int) string {
|
||
prompt = compactImageGenerationPrompt(prompt, maxRunes)
|
||
if prompt == "" {
|
||
return ""
|
||
}
|
||
return prompt
|
||
}
|
||
|
||
func compactImageGenerationPrompt(prompt string, maxRunes int) string {
|
||
prompt = strings.TrimSpace(prompt)
|
||
prompt = strings.ReplaceAll(prompt, fullFrameSafetyInstruction, "")
|
||
prompt = strings.ReplaceAll(prompt, "Composition safety:", "")
|
||
prompt = imagePromptURLPattern.ReplaceAllString(prompt, "[参考图]")
|
||
lines := strings.Split(prompt, "\n")
|
||
compacted := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ")
|
||
if line != "" {
|
||
compacted = append(compacted, line)
|
||
}
|
||
}
|
||
prompt = strings.Join(compacted, "\n")
|
||
return truncateRunes(prompt, maxRunes)
|
||
}
|
||
|
||
func truncateRunes(value string, maxRunes int) string {
|
||
value = strings.TrimSpace(value)
|
||
if maxRunes <= 0 || utf8.RuneCountInString(value) <= maxRunes {
|
||
return value
|
||
}
|
||
runes := []rune(value)
|
||
value = strings.TrimSpace(string(runes[:maxRunes]))
|
||
if cut := strings.LastIndexAny(value, " ,,.。;;::\n\t"); cut > maxRunes*3/4 {
|
||
value = strings.TrimSpace(value[:cut])
|
||
}
|
||
return value
|
||
}
|
||
|
||
func variantImagePrompt(prompt string, index int, count int) string {
|
||
if count <= 1 {
|
||
return prompt
|
||
}
|
||
return fmt.Sprintf("%s\n\nCreate option %d of %d. Keep the same brief, but make this option visually distinct in composition, rhythm, and detail choices.", prompt, index, count)
|
||
}
|
||
|
||
const fullFrameSafetyInstruction = "Composition safety: keep important subjects, products, text, logos, buttons, and decorative elements fully inside the visible image with safe margins. Do not crop edges unless requested. Do not add pixel size, aspect ratio, or resolution unless specified."
|
||
|
||
var explicitPixelSizePattern = regexp.MustCompile(`(?i)([1-9]\d{2,4})\s*(?:x|×|\*)\s*([1-9]\d{2,4})`)
|
||
|
||
var explicitAspectRatioPattern = regexp.MustCompile(`(?i)(?:^|[^\d])((?:1|2|3|4|9|16)\s*:\s*(?:1|2|3|4|9|16))(?:[^\d]|$)`)
|
||
|
||
var imagePromptURLPattern = regexp.MustCompile(`https?://\S+`)
|
||
|
||
func fullFrameImagePrompt(prompt string) string {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" {
|
||
return fullFrameSafetyInstruction
|
||
}
|
||
if strings.Contains(prompt, fullFrameSafetyInstruction) || strings.Contains(strings.ToLower(prompt), "composition safety:") {
|
||
return prompt
|
||
}
|
||
return prompt + "\n\n" + fullFrameSafetyInstruction
|
||
}
|
||
|
||
func resolveImageSizeForGeneration(requestedSize string, prompt string) string {
|
||
requestedSize = normalizeImageSize(requestedSize)
|
||
if requestedSize != "" {
|
||
return requestedSize
|
||
}
|
||
if explicitSize := explicitImageSizeFromPrompt(prompt); explicitSize != "" {
|
||
return explicitSize
|
||
}
|
||
return design.DefaultGeneratedImageSize
|
||
}
|
||
|
||
func explicitImageSizeFromPrompt(prompt string) string {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" {
|
||
return ""
|
||
}
|
||
if match := explicitPixelSizePattern.FindStringSubmatch(prompt); len(match) == 3 {
|
||
width, widthErr := strconv.Atoi(match[1])
|
||
height, heightErr := strconv.Atoi(match[2])
|
||
if widthErr == nil && heightErr == nil && width >= 256 && height >= 256 {
|
||
return fmt.Sprintf("%dx%d", width, height)
|
||
}
|
||
}
|
||
if match := explicitAspectRatioPattern.FindStringSubmatch(prompt); len(match) >= 2 {
|
||
switch strings.ReplaceAll(match[1], " ", "") {
|
||
case "1:1":
|
||
return "1024x1024"
|
||
case "3:2":
|
||
return "1536x1024"
|
||
case "2:3":
|
||
return "1024x1536"
|
||
case "4:3":
|
||
return "1365x1024"
|
||
case "3:4":
|
||
return "1024x1365"
|
||
case "16:9":
|
||
return "2048x1152"
|
||
case "9:16":
|
||
return "1152x2048"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeImageSize(size string) string {
|
||
width, height, ok := imageSizeDimensions(size)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("%.0fx%.0f", width, height)
|
||
}
|
||
|
||
func (a *DesignAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) {
|
||
replay, err := a.base.Replay(ctx, project)
|
||
if err != nil {
|
||
return design.AgentReplay{}, err
|
||
}
|
||
return replay, nil
|
||
}
|
||
|
||
func designTools() []agent.AgentTool {
|
||
return []agent.AgentTool{
|
||
{
|
||
Name: "collect_visual_references",
|
||
Label: "Collect visual references",
|
||
Description: "Collect Moteva-style visual references for a design request without launching a local desktop agent.",
|
||
Parameters: ai.Object(
|
||
ai.Prop("prompt", ai.String("User design request")),
|
||
ai.Prop("category", ai.String("Design category such as ecommerce, ui, brand, campaign")),
|
||
),
|
||
PromptGuidelines: []string{
|
||
"Use web and inspiration references to ground the visual direction.",
|
||
"Do not call video generation tools.",
|
||
},
|
||
},
|
||
{
|
||
Name: "compose_canvas_plan",
|
||
Label: "Compose canvas plan",
|
||
Description: "Turn a design request into visual canvas artboards and assistant narration.",
|
||
Parameters: ai.Object(
|
||
ai.Prop("prompt", ai.String("User design request")),
|
||
ai.Prop("project_id", ai.String("Current canvas project id")),
|
||
),
|
||
PromptGuidelines: []string{
|
||
"Return actionable nodes that can be replayed from chat history.",
|
||
"Keep handlers thin; persist canvas state through the project service.",
|
||
},
|
||
},
|
||
{
|
||
Name: "generate_image_with_gpt_image_2",
|
||
Label: "Generate image with GPT Image 2",
|
||
Description: "Generate an image through the configured OpenAI-compatible GPT Image 2 endpoint and place it on the infinite canvas.",
|
||
Parameters: ai.Object(
|
||
ai.Prop("prompt", ai.String("Image generation prompt")),
|
||
ai.Prop("size", ai.String("Requested output size")),
|
||
),
|
||
PromptGuidelines: []string{
|
||
"Use this tool for image generation requests.",
|
||
"Return the generated image as a canvas image node.",
|
||
},
|
||
},
|
||
{
|
||
Name: "prepare_image_asset_upload",
|
||
Label: "Prepare image asset upload",
|
||
Description: "Prepare an object-storage upload target for generated or referenced images.",
|
||
Parameters: ai.Object(
|
||
ai.Prop("file_name", ai.String("Original file name")),
|
||
ai.Prop("content_type", ai.String("Image MIME type")),
|
||
),
|
||
PromptGuidelines: []string{
|
||
"Use configured OSS/R2/AWS/MinIO storage for image assets.",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func shouldGenerateImage(req design.AgentRequest) bool {
|
||
intent := strings.ToLower(strings.TrimSpace(req.TaskPlan.Intent))
|
||
switch intent {
|
||
case "image", "generate_image", "edit_image", "visual", "design":
|
||
return true
|
||
case "chat", "conversation", "research":
|
||
return false
|
||
}
|
||
if req.TaskPlan.ImageCount > 0 || len(req.TaskPlan.ImageTasks) > 0 {
|
||
return true
|
||
}
|
||
|
||
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
||
if strings.Contains(mode, "image") || strings.Contains(mode, "design") {
|
||
return true
|
||
}
|
||
|
||
prompt := strings.ToLower(req.Prompt)
|
||
keywords := []string{"图", "图片", "海报", "视觉", "官网", "页面", "image", "poster", "website", "visual"}
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(prompt, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func applyGeneratedImage(plan *design.AgentPlan, req design.AgentRequest, imageURL string, title string) {
|
||
applyGeneratedImages(plan, req, []string{imageURL}, title, 1)
|
||
}
|
||
|
||
func applyGeneratedImages(plan *design.AgentPlan, req design.AgentRequest, imageURLs []string, title string, requestedCount int) {
|
||
if requestedCount < 1 {
|
||
requestedCount = 1
|
||
}
|
||
if len(imageURLs) < requestedCount {
|
||
requestedCount = len(imageURLs)
|
||
}
|
||
slots := generatedImageSlots(req, plan.GeneratedNodes, requestedCount)
|
||
nodes := make([]design.Node, 0, requestedCount)
|
||
for index := 0; index < requestedCount; index++ {
|
||
node := slots[index]
|
||
node.Type = design.NodeTypeImage
|
||
node.Title = generatedNodeTitle(title, req.Prompt, index, requestedCount)
|
||
node.Content = imageURLs[index]
|
||
node.Status = "success"
|
||
if node.ID == "" {
|
||
node.ID = uuid.NewString()
|
||
}
|
||
if width, height, ok := imageSizeDimensions(req.ImageSize); ok {
|
||
node.Width = width
|
||
node.Height = height
|
||
}
|
||
if node.Width <= 0 {
|
||
node.Width = design.DefaultGeneratedImageWidth
|
||
}
|
||
if node.Height <= 0 {
|
||
node.Height = design.DefaultGeneratedImageHeight
|
||
}
|
||
if node.Tone == "" {
|
||
node.Tone = "visual"
|
||
}
|
||
nodes = append(nodes, node)
|
||
}
|
||
plan.GeneratedNodes = nodes
|
||
plan.Connections = nil
|
||
}
|
||
|
||
func applyPlannedGeneratedImages(plan *design.AgentPlan, req design.AgentRequest, imageURLs []string, prompts []design.AgentImagePrompt) {
|
||
requestedCount := len(imageURLs)
|
||
if requestedCount < 1 {
|
||
requestedCount = 1
|
||
}
|
||
slots := generatedImageSlots(req, plan.GeneratedNodes, requestedCount)
|
||
nodes := make([]design.Node, 0, requestedCount)
|
||
for index := 0; index < requestedCount; index++ {
|
||
title := ""
|
||
if index < len(prompts) {
|
||
title = prompts[index].Title
|
||
}
|
||
nodes = append(nodes, generatedImageNodeFromSlot(slots[index], req, imageURLs[index], title, index, requestedCount))
|
||
}
|
||
plan.GeneratedNodes = nodes
|
||
plan.Connections = nil
|
||
}
|
||
|
||
func generatedImageNodeFromSlot(slot design.Node, req design.AgentRequest, imageURL string, title string, index int, requestedCount int) design.Node {
|
||
node := slot
|
||
node.Type = design.NodeTypeImage
|
||
node.Title = generatedNodeTitle(title, req.Prompt, index, requestedCount)
|
||
node.Content = imageURL
|
||
node.Status = "success"
|
||
if node.ID == "" {
|
||
node.ID = uuid.NewString()
|
||
}
|
||
if width, height, ok := imageSizeDimensions(req.ImageSize); ok {
|
||
node.Width = width
|
||
node.Height = height
|
||
}
|
||
if node.Width <= 0 {
|
||
node.Width = design.DefaultGeneratedImageWidth
|
||
}
|
||
if node.Height <= 0 {
|
||
node.Height = design.DefaultGeneratedImageHeight
|
||
}
|
||
if node.Tone == "" {
|
||
node.Tone = "visual"
|
||
}
|
||
return node
|
||
}
|
||
|
||
func generatedImageURLs(image GeneratedImage) []string {
|
||
if len(image.URLs) > 0 {
|
||
return image.URLs
|
||
}
|
||
if strings.TrimSpace(image.URL) == "" {
|
||
return nil
|
||
}
|
||
return []string{image.URL}
|
||
}
|
||
|
||
func generatedNodeTitle(title string, prompt string, index int, count int) string {
|
||
title = normalizeGeneratedTitle(title, prompt)
|
||
if count <= 1 {
|
||
return title
|
||
}
|
||
if prefersEnglish(prompt + title) {
|
||
return fmt.Sprintf("%s Option %d", title, index+1)
|
||
}
|
||
return fmt.Sprintf("%s 方案 %d", title, index+1)
|
||
}
|
||
|
||
func (a *DesignAgent) buildImagePrompt(ctx context.Context, req design.AgentRequest) design.AgentImagePrompt {
|
||
if a.textAgent != nil {
|
||
prompt, err := a.textAgent.BuildImagePrompt(ctx, design.AgentImagePromptRequest{
|
||
Project: req.Project,
|
||
Prompt: req.Prompt,
|
||
Mode: req.Mode,
|
||
Messages: req.Messages,
|
||
Memory: req.Memory,
|
||
TaskPlan: req.TaskPlan,
|
||
})
|
||
if err == nil && strings.TrimSpace(prompt.Prompt) != "" {
|
||
prompt.Title = normalizeGeneratedTitle(prompt.Title, req.Prompt)
|
||
return prompt
|
||
}
|
||
}
|
||
return design.AgentImagePrompt{
|
||
Title: normalizeGeneratedTitle("", req.Prompt),
|
||
Prompt: fallbackDirectImagePrompt(req),
|
||
}
|
||
}
|
||
|
||
func normalizeGeneratedTitle(title string, prompt string) string {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" || strings.EqualFold(title, "GPT Image 2") || strings.Contains(strings.ToLower(title), "gpt image") {
|
||
title = inferGeneratedTitle(prompt)
|
||
}
|
||
title = strings.Trim(title, "「」[]【】 ")
|
||
if len([]rune(title)) > 24 {
|
||
runes := []rune(title)
|
||
title = string(runes[:24])
|
||
}
|
||
return title
|
||
}
|
||
|
||
func generatedMessageTitle(title string, prompt string) string {
|
||
title = normalizeGeneratedTitle(title, prompt)
|
||
if title == "" {
|
||
if prefersEnglish(prompt) {
|
||
return "Image generated"
|
||
}
|
||
return "图片生成完成"
|
||
}
|
||
if prefersEnglish(prompt + title) {
|
||
return title + " generated"
|
||
}
|
||
return title + " 已生成"
|
||
}
|
||
|
||
func inferGeneratedTitle(prompt string) string {
|
||
lower := strings.ToLower(prompt)
|
||
if prefersEnglish(prompt) {
|
||
switch {
|
||
case strings.Contains(lower, "pdp") || strings.Contains(lower, "detail page"):
|
||
return "Product detail page"
|
||
case strings.Contains(lower, "landing") || strings.Contains(lower, "homepage") || strings.Contains(lower, "website"):
|
||
return "Homepage visual"
|
||
case strings.Contains(lower, "poster"):
|
||
return "Poster visual"
|
||
case strings.Contains(lower, "main image") || strings.Contains(lower, "product image"):
|
||
return "Product hero image"
|
||
case strings.Contains(lower, "banner"):
|
||
return "Banner visual"
|
||
case strings.Contains(lower, "logo"):
|
||
return "Logo visual"
|
||
default:
|
||
return "Generated image"
|
||
}
|
||
}
|
||
switch {
|
||
case strings.Contains(prompt, "详情页") || strings.Contains(lower, "pdp"):
|
||
return "电商详情页"
|
||
case strings.Contains(prompt, "首页") || strings.Contains(prompt, "官网") || strings.Contains(lower, "landing"):
|
||
return "官网首页视觉"
|
||
case strings.Contains(prompt, "海报") || strings.Contains(lower, "poster"):
|
||
return "海报视觉"
|
||
case strings.Contains(prompt, "主图") || strings.Contains(prompt, "产品图"):
|
||
return "产品主图"
|
||
case strings.Contains(prompt, "banner"):
|
||
return "Banner 视觉"
|
||
case strings.Contains(prompt, "logo"):
|
||
return "Logo 视觉"
|
||
default:
|
||
return "生成图片"
|
||
}
|
||
}
|
||
|
||
func fallbackDirectImagePrompt(req design.AgentRequest) string {
|
||
var lines []string
|
||
lines = append(lines, "Create one polished ecommerce/design image from this concise brief.")
|
||
if title := strings.TrimSpace(req.Project.Title); title != "" {
|
||
lines = append(lines, "Project: "+truncateRunes(title, 80))
|
||
}
|
||
if brief := strings.TrimSpace(req.Project.Brief); brief != "" {
|
||
lines = append(lines, "Project brief: "+truncateRunes(brief, 180))
|
||
}
|
||
if memory := compactFallbackMemory(req.Memory); memory != "" {
|
||
lines = append(lines, "Relevant memory: "+memory)
|
||
}
|
||
if task := compactFallbackTaskPlan(req.TaskPlan); task != "" {
|
||
lines = append(lines, "Task: "+task)
|
||
}
|
||
if prompt := strings.TrimSpace(req.Prompt); prompt != "" {
|
||
lines = append(lines, "User request: "+truncateRunes(prompt, 360))
|
||
}
|
||
if notes := compactFallbackInlineNotes(req.Messages); notes != "" {
|
||
lines = append(lines, "Inline notes: "+notes)
|
||
}
|
||
lines = append(lines, "Use strong commercial composition, clear hierarchy, refined typography when text is needed, coherent color and lighting, realistic safe margins, and no cropped important subjects.")
|
||
return compactImageGenerationPrompt(strings.Join(lines, "\n"), imageGenerationFallbackMaxRunes)
|
||
}
|
||
|
||
func compactFallbackMemory(memory design.AgentMemory) string {
|
||
if memory.IsZero() {
|
||
return ""
|
||
}
|
||
var parts []string
|
||
if value := strings.TrimSpace(memory.ShortTerm); value != "" {
|
||
parts = append(parts, truncateRunes(value, 180))
|
||
}
|
||
if value := strings.TrimSpace(memory.LongTerm); value != "" {
|
||
parts = append(parts, truncateRunes(value, 180))
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
func compactFallbackTaskPlan(plan design.AgentTaskPlan) string {
|
||
if len(plan.ImageTasks) == 0 {
|
||
return truncateRunes(strings.TrimSpace(plan.UserFacingResponse), 180)
|
||
}
|
||
var parts []string
|
||
for _, task := range plan.ImageTasks {
|
||
title := strings.TrimSpace(task.Title)
|
||
brief := strings.TrimSpace(task.Brief)
|
||
if title == "" && brief == "" {
|
||
continue
|
||
}
|
||
item := strings.TrimSpace(title + " " + brief)
|
||
parts = append(parts, truncateRunes(item, 160))
|
||
if len(parts) >= 3 {
|
||
break
|
||
}
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
func compactFallbackInlineNotes(messages []design.AgentChatMessage) string {
|
||
var parts []string
|
||
for _, message := range messages {
|
||
for _, content := range message.Contents {
|
||
switch strings.ToLower(strings.TrimSpace(content.Type)) {
|
||
case "text":
|
||
if text := strings.TrimSpace(content.Text); text != "" {
|
||
parts = append(parts, truncateRunes(text, 160))
|
||
}
|
||
case "image":
|
||
if strings.TrimSpace(content.ImageURL) != "" {
|
||
parts = append(parts, "selected reference image")
|
||
}
|
||
}
|
||
if len(parts) >= 4 {
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
}
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
func generatedImageSlot(req design.AgentRequest, fallback []design.Node) design.Node {
|
||
slots := generatedImageSlots(req, fallback, 1)
|
||
if len(slots) > 0 {
|
||
return slots[0]
|
||
}
|
||
return design.Node{
|
||
ID: uuid.NewString(),
|
||
Type: design.NodeTypeImage,
|
||
Width: design.DefaultGeneratedImageWidth,
|
||
Height: design.DefaultGeneratedImageHeight,
|
||
}
|
||
}
|
||
|
||
func generatedImageSlots(req design.AgentRequest, fallback []design.Node, count int) []design.Node {
|
||
if count < 1 {
|
||
count = 1
|
||
}
|
||
slots := make([]design.Node, 0, count)
|
||
for index := len(req.Project.Nodes) - 1; index >= 0; index-- {
|
||
node := req.Project.Nodes[index]
|
||
if isImageGenerationSlot(node) {
|
||
slots = append(slots, node)
|
||
}
|
||
}
|
||
reverseNodes(slots)
|
||
if len(slots) >= count {
|
||
return slots[:count]
|
||
}
|
||
for _, node := range fallback {
|
||
if node.Type == design.NodeTypeImage || node.Type == design.NodeTypeFrame {
|
||
node.Width = design.DefaultGeneratedImageWidth
|
||
node.Height = design.DefaultGeneratedImageHeight
|
||
slots = append(slots, node)
|
||
if len(slots) >= count {
|
||
return slots[:count]
|
||
}
|
||
}
|
||
}
|
||
for len(slots) < count {
|
||
slots = append(slots, design.Node{
|
||
ID: uuid.NewString(),
|
||
Type: design.NodeTypeImage,
|
||
Width: design.DefaultGeneratedImageWidth,
|
||
Height: design.DefaultGeneratedImageHeight,
|
||
})
|
||
}
|
||
return slots
|
||
}
|
||
|
||
func isImageGenerationSlot(node design.Node) bool {
|
||
if node.Type != design.NodeTypeImage || node.Status != "generating" {
|
||
return false
|
||
}
|
||
role := strings.TrimSpace(node.LayerRole)
|
||
return role == "" || role == "image-generator"
|
||
}
|
||
|
||
func reverseNodes(nodes []design.Node) {
|
||
for left, right := 0, len(nodes)-1; left < right; left, right = left+1, right-1 {
|
||
nodes[left], nodes[right] = nodes[right], nodes[left]
|
||
}
|
||
}
|
||
|
||
func imageSizeDimensions(size string) (float64, float64, bool) {
|
||
return design.ParseImageSize(size)
|
||
}
|
||
|
||
func (a *DesignAgent) imageGenerationSummary(ctx context.Context, req design.AgentRequest, imageURL string) string {
|
||
if a.textAgent != nil {
|
||
content, err := a.textAgent.SummarizeImage(ctx, design.AgentImageSummaryRequest{
|
||
Project: req.Project,
|
||
Prompt: req.Prompt,
|
||
Mode: req.Mode,
|
||
ImageURL: imageURL,
|
||
Memory: req.Memory,
|
||
})
|
||
if err == nil && strings.TrimSpace(content) != "" {
|
||
return content
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func prefersEnglish(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if r >= 0x4E00 && r <= 0x9FFF {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|