Files
moteva/server/internal/infrastructure/piagent/creative_agent.go
T
root a339e09859 feat(agent): guide creative agent to use purposeful emoji in replies
Add prompt instructions for emoji-led list formatting in visible
responses, plan narration, and image summaries, with guardrails against
hyphen chaining and empty numbered choices. Tighten the planner's
userFacingResponse description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:33:16 +08:00

1021 lines
45 KiB
Go

package piagent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
)
type CreativeAgentOptions struct {
BaseURL string
APIKey string
Model string
TimeoutSeconds int
}
type CreativeAgent struct {
baseURL string
apiKey string
model string
client *http.Client
}
func NewCreativeAgent(opts CreativeAgentOptions) *CreativeAgent {
if strings.TrimSpace(opts.BaseURL) == "" || strings.TrimSpace(opts.APIKey) == "" {
return nil
}
timeout := time.Duration(opts.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 300 * time.Second
}
model := strings.TrimSpace(opts.Model)
if model == "" {
model = "gpt-5.5"
}
return &CreativeAgent{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
apiKey: opts.APIKey,
model: model,
client: &http.Client{Timeout: timeout},
}
}
func (c *CreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are the routing brain of a Moteva-style creative production agent.",
"Decide whether the user wants the agent to call the image generation/editing pipeline or just answer in chat.",
"Return strict JSON only: {\"intent\":\"image\"|\"chat\",\"reason\":\"short reason\"}.",
"Use image only for explicit visual production, image editing, ecommerce design output, page/banner/poster/product/mockup generation, or iterative visual changes.",
"Use chat when the user asks what the agent can do, when critical brand/design requirements are missing, or when a brief needs 2-4 targeted clarification questions before generating.",
"Use chat for greetings, thanks, capability questions, vague conversation, or clarification without a concrete visual action.",
}, " "),
},
{
"role": "user",
"content": buildDecisionPrompt(req),
},
}, 220, 0.1, true)
if err != nil {
return design.AgentDecision{}, err
}
var parsed struct {
Intent string `json:"intent"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(extractJSONObject(content)), &parsed); err != nil {
return design.AgentDecision{}, err
}
return design.AgentDecision{Intent: parsed.Intent, Reason: parsed.Reason}, nil
}
func (c *CreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are a top-tier ecommerce art director and Moteva-style creative production agent.",
"Answer naturally in the user's language and locale, like a senior creative partner who understands image generation, ecommerce pages, product visuals, and canvas workflows.",
"Use short memory for the current turn and canvas state; use long memory for durable project preferences and prior decisions.",
"Do not use canned templates. Do not claim you generated an image unless an image tool actually ran.",
"Do not mention unsupported video, audio, or 3D model features. This product supports image generation, image editing, and infinite-canvas design workflows only.",
"For greetings, reply briefly and invite a concrete next step. For capability questions, answer with image-only capabilities: brand/site visuals, ecommerce product images, posters/banners/social images, reference-based generation, image edits, background removal, vectorization, mockup placement, and multi-round canvas iteration.",
"When a current project exists, connect capabilities to the canvas instead of giving a generic product brochure.",
"When the task plan indicates missing requirements or contains user response questions, ask those questions directly and stop; do not pretend generation has started.",
"Use a few purposeful emoji in visible replies to make scanning warmer: put a relevant emoji at the start of capability or next-step bullets, for example 🎨 design direction, 📦 packaging, 🖼️ canvas. Keep the tone professional and avoid empty bullet labels such as '• :'.",
"Format emoji lists as short separate lines: intro sentence, then one emoji-led item per line, for example 🎭角色设定、\\n📦系列包装、\\n🧩展示台/贴纸、\\n📣发售海报。 Do not put a hyphen after the emoji. Do not chain items like 🎨-设计...-生成..., do not start continuation lines with bare hyphens, and never end with empty choices such as 1. 2. 3.",
}, " "),
},
{
"role": "user",
"content": buildConversationPrompt(req),
},
}, 900, 0.65, false)
}
func (c *CreativeAgent) StreamRespond(ctx context.Context, req design.AgentConversationRequest, onDelta func(delta string) error) (string, error) {
return c.chatStream(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are a top-tier ecommerce art director and Moteva-style creative production agent.",
"Answer naturally in the user's language and locale, like a senior creative partner who understands image generation, ecommerce pages, product visuals, and canvas workflows.",
"Use short memory for the current turn and canvas state; use long memory for durable project preferences and prior decisions.",
"Do not use canned templates. Do not claim you generated an image unless an image tool actually ran.",
"Do not mention unsupported video, audio, or 3D model features. This product supports image generation, image editing, and infinite-canvas design workflows only.",
"For greetings, reply briefly and invite a concrete next step. For capability questions, answer with image-only capabilities: brand/site visuals, ecommerce product images, posters/banners/social images, reference-based generation, image edits, background removal, vectorization, mockup placement, and multi-round canvas iteration.",
"When a current project exists, connect capabilities to the canvas instead of giving a generic product brochure.",
"When the task plan indicates missing requirements or contains user response questions, ask those questions directly and stop; do not pretend generation has started.",
"Use a few purposeful emoji in visible replies to make scanning warmer: put a relevant emoji at the start of capability or next-step bullets, for example 🎨 design direction, 📦 packaging, 🖼️ canvas. Keep the tone professional and avoid empty bullet labels such as '• :'.",
"Format emoji lists as short separate lines: intro sentence, then one emoji-led item per line, for example 🎭角色设定、\\n📦系列包装、\\n🧩展示台/贴纸、\\n📣发售海报。 Do not put a hyphen after the emoji. Do not chain items like 🎨-设计...-生成..., do not start continuation lines with bare hyphens, and never end with empty choices such as 1. 2. 3.",
}, " "),
},
{
"role": "user",
"content": buildConversationPrompt(req),
},
}, 900, 0.65, onDelta)
}
func (c *CreativeAgent) StreamPlanNarration(ctx context.Context, req design.AgentTaskPlanRequest, onDelta func(delta string) error) (string, error) {
return c.chatStream(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You write the visible live thinking text for a Moteva-style creative agent.",
"This is not hidden chain-of-thought. Do not reveal private reasoning, step-by-step deliberation, policies, or tool internals.",
"Write one concise localized sentence that tells the user what the agent is preparing to do next.",
"If web research results are present, mention that references have been collected and how they will guide the visual direction.",
"Do not use markdown, numbering, or generic canned wording. Use the user's language and locale automatically.",
"A single fitting emoji at the beginning is welcome when it improves the live status; do not overuse emoji.",
}, " "),
},
{
"role": "user",
"content": strings.Join([]string{
buildAgentTaskPlanPrompt(req),
"",
"Write only the live text shown below the “Thinking...” label.",
}, "\n"),
},
}, 260, 0.45, onDelta)
}
func (c *CreativeAgent) PlanAgentTask(ctx context.Context, req design.AgentTaskPlanRequest) (design.AgentTaskPlan, error) {
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are the automatic planner for a Moteva-style creative agent.",
"Read the user's request, canvas, inline references, short memory, and long memory, then decide the next workflow.",
"Return strict JSON only: {\"intent\":\"image\"|\"chat\",\"reason\":\"short internal reason\",\"userFacingResponse\":\"short localized visible response explaining what you will do\",\"shouldSearch\":true|false,\"searchQuery\":\"query or empty\",\"searchReason\":\"short reason\",\"imageCount\":0-10,\"imageTasks\":[{\"title\":\"localized short title\",\"brief\":\"specific output brief\"}]}",
"Intent image means the user wants visual production, image editing, ecommerce/page/poster/product/character/illustration/output generation, or concrete canvas creation.",
"Intent chat means greetings, thanks, vague discussion, capability questions, or missing critical information.",
"For open-ended brand, ecommerce, homepage, logo, or campaign requests with missing essentials, choose intent chat and ask 2-4 targeted questions before generation. Essentials can include brand/logo assets, visual direction, product/category scope, audience, key sections, slogan/copy, deliverable format, and must-keep constraints.",
"For homepage or website requests, treat static visual drafts as image tasks and runnable HTML/code as out of scope. If the user asks for HTML/code, clarify whether they want a static visual draft or image assets instead.",
"If the user asks for visible page modules, slogans, navigation labels, button copy, or other text but does not provide the content, choose intent chat unless they explicitly authorize you to decide.",
"For multiple uploaded reference images with no stated roles, choose intent chat and ask which images are product/source, logo, style, color, or composition references. Do not infer roles just by looking at the images.",
"When the user asks for multiple images of the same new person, character, model, or product and no consistency anchor exists, choose intent chat and explain that an anchor/reference must be confirmed first. Multi-panel sheets inside one image do not need separate image count.",
"If the user already supplied enough signal or explicitly asks to proceed, choose image and make reasonable art-direction decisions.",
"When the user gives vague taste feedback such as not stylish enough, more premium, more big-company, treat it as feedback rather than an edit instruction. Ask one focused clarification question with likely directions unless the user explicitly authorizes your judgment or provides a reference image/brand.",
"If the user explicitly authorizes your judgment, keep durable constraints from memory and translate vague feedback into concrete visual actions in imageTasks: composition, color, typography, product/category structure, spacing, and ecommerce hierarchy.",
"If web search is disabled, shouldSearch must be false. If enabled, choose search only when external references will improve the work: public brand/product facts, current trends, competitor/style references, niche cultural or visual references, or explicit research/search requests.",
"When shouldSearch is true, keep the user's named brands, artists, products, and style terms intact. Add only minimal retrieval terms such as case study, visual identity, moodboard, official site, or lookbook; do not replace the user's wording with your own aesthetic interpretation.",
"For logo tasks, avoid logo-maker/tool results: include terms like logo design case study, brand identity, visual identity, design process, moodboard, and negative terms such as -maker -generator -free -template when useful.",
"When research is used, treat it as reference collection. Do not turn unconfirmed visual inferences from search images into prompt text; use researched facts and user-confirmed references only.",
"Image count must match explicit deliverables, not just numeric counts. Examples: front view + side view + details + color scheme = 4 images; three poster options = 3; one pure edit = 1.",
"Count final independent image files, not internal panels, grid cells, or edit actions. A nine-grid character sheet, website long-page visual draft, or front-side-back board can still be one image if the user asked for one composed artifact.",
"Image tasks must be concrete, ordered, and aligned with the count. For character/design work, separate views, detail sheets, palette/style sheets, and final composite boards when requested.",
"Do not expose hidden chain-of-thought. The userFacingResponse is a short planning summary, not a long explanation.",
"If intent is chat for clarification, userFacingResponse should be the exact concise questions to ask and imageCount must be 0.",
"For userFacingResponse, use a few purposeful emoji in visible questions or summaries, especially as list markers. Use newline-separated emoji items, for example 🎭角色设定、\\n📦系列包装、\\n🧩展示台/贴纸、\\n📣发售海报。 Do not put a hyphen after the emoji, do not chain items like 🎨-设计...-生成..., do not use bare continuation hyphens, and never output empty numbered choices such as 1. 2. 3. Do not put emoji in image task titles or briefs.",
"Use the user's language and locale automatically.",
}, " "),
},
{
"role": "user",
"content": buildAgentTaskPlanPrompt(req),
},
}, 900, 0.2, true)
if err != nil {
return design.AgentTaskPlan{}, err
}
var parsed struct {
Intent string `json:"intent"`
Reason string `json:"reason"`
UserFacingResponse string `json:"userFacingResponse"`
ShouldSearch bool `json:"shouldSearch"`
SearchQuery string `json:"searchQuery"`
SearchReason string `json:"searchReason"`
ImageCount int `json:"imageCount"`
ImageTasks []struct {
Title string `json:"title"`
Brief string `json:"brief"`
} `json:"imageTasks"`
}
if err := json.Unmarshal([]byte(extractJSONObject(content)), &parsed); err != nil {
return design.AgentTaskPlan{}, err
}
tasks := make([]design.AgentImageTask, 0, len(parsed.ImageTasks))
for _, task := range parsed.ImageTasks {
title := strings.TrimSpace(task.Title)
brief := strings.TrimSpace(task.Brief)
if title == "" && brief == "" {
continue
}
tasks = append(tasks, design.AgentImageTask{Title: title, Brief: brief})
}
return design.AgentTaskPlan{
Intent: strings.TrimSpace(parsed.Intent),
Reason: strings.TrimSpace(parsed.Reason),
UserFacingResponse: strings.TrimSpace(parsed.UserFacingResponse),
ShouldSearch: parsed.ShouldSearch,
SearchQuery: strings.TrimSpace(parsed.SearchQuery),
SearchReason: strings.TrimSpace(parsed.SearchReason),
ImageCount: parsed.ImageCount,
ImageTasks: tasks,
}, nil
}
func (c *CreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
return c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are a senior ecommerce creative director reviewing a just-generated image result.",
"Write a concise, non-generic response in the user's language and locale.",
"Never use a fixed version phrase like 'v5 出来了' unless the user explicitly asked for that version.",
"Do not invent exact visual details you cannot see. Tie the response to the prompt and canvas goal.",
"When the user authorized your judgment or asked for an iteration, state the main change you attempted and what constraint it preserves. Mention a next step only when it is useful or required by the workflow.",
"Sound like a professional art director, not a template.",
"Use one fitting emoji or a short emoji-led phrase when it improves the completion message; keep it sparse.",
}, " "),
},
{
"role": "user",
"content": buildImageSummaryPrompt(req),
},
}, 900, 0.7, false)
}
func (c *CreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are the harness planner for a Moteva-style ecommerce image agent.",
"First distill the user request, current canvas context, recent conversation, memory, web research, and reference images into a compact visual intent summary; then write the exact standalone image-generation brief for GPT Image 2.",
"Use short memory for the current working state and latest turn; use long memory for durable brand, style, product, and user preference decisions.",
"When short memory conflicts with long memory, the latest explicit user instruction wins.",
"Treat durable brand constraints as binding: product/category scope, mood/style preferences, logo/brand assets, color decisions, and future expansion requirements must carry into the brief unless the latest user instruction changes them.",
"Preserve the user's own wording as the core of the final prompt. Add only requirements grounded in explicit user text, confirmed memory, current canvas artifacts, image generation settings, or tool constraints.",
"Return strict JSON only: {\"title\":\"short localized artifact title\",\"prompt\":\"detailed image generation prompt\"}.",
"The title must be content-specific, not a model name. Use the user's language and locale. Keep it under 18 CJK characters or 48 Latin characters.",
"Use this proven prompt framework as a thinking guide, not a rigid template: task type, subject description, style definition, technical parameters, output specification.",
"Only format the final prompt with explicit labels such as 任务类型/主体描述/风格定义/技术参数/输出规格 when that improves clarity. Do not force empty or irrelevant fields.",
"Write directly in the user's language; Chinese prompts are preferred when the user writes Chinese. Do not translate Chinese requests into English by default.",
"If the image must contain visible text, include the exact text content in 主体描述.",
"Use 风格定义 to anchor references such as 参考 XX 风格 only when the user gave that style reference or confirmed a searched reference.",
"For multiple reference images, preserve the roles stated by the user such as product/source, logo, style, color, or composition. If roles were not stated, do not invent them in the prompt.",
"For website/homepage visual drafts, include page modules, slogans, navigation labels, button copy, and body copy only if the user provided them or explicitly authorized you to decide. Do not silently add nav/hero/category/footer/copy just because they are common.",
"When the user says previous version, last version, v2, v3, or similar, use only clear recent conversation, canvas title/order, or explicit user references to decide the source image. If the source is ambiguous, the planner should have asked instead of generating.",
"If user feedback recognizes different versions by dimension, preserve those dimensions separately: for example, use v2 for overall direction and v3 for color only when the user said so.",
"If the task edits an existing image, write the prompt as concrete change actions plus what must remain unchanged. If the task only uses a reference for style/color/composition, write it as a new generation task using the reference role from the user's words.",
"The prompt must preserve only the relevant distilled context from previous canvas work, current brand/product/page direction, referenced images, and requested iteration.",
"If web research results are available, use factual context such as official brand/product/category facts. Do not describe visual traits from unconfirmed search images as if the user requested them.",
"Never copy raw conversation history, raw canvas dumps, image URLs, storage URLs, JSON, IDs, coordinates, or internal event text into the final prompt.",
"The final prompt sent to GPT Image 2 must be self-contained and compact; it should read like an art-director brief, not like application context.",
"Do not add generic beautifiers, lens words, color guesses, or premium-style cliches merely to improve the image. Ask for clarification in the planning step when those choices are subjective and not authorized.",
"Keep important subjects, products, furniture, people, text, logos, buttons, and decorative elements fully inside the visible frame with safe margins; avoid accidental edge cropping.",
"Include ratio/resolution such as 3:4, 9:16, or 4K only when the user explicitly requested it. Otherwise do not invent output specs.",
"Keep the final prompt generation-friendly: under 700 CJK characters or 1100 Latin characters, with one clear composition and no over-constrained checklist.",
"Be concrete about composition, ecommerce hierarchy, typography, product treatment, colors, and what to avoid only when those choices are grounded in the request, memory, canvas, or confirmed references. Do not mention internal tools.",
}, " "),
},
{
"role": "user",
"content": buildImagePromptPlannerInput(req),
},
}, 1300, 0.55, true)
if err != nil {
return design.AgentImagePrompt{}, err
}
var parsed struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(extractJSONObject(content)), &parsed); err != nil {
return design.AgentImagePrompt{}, err
}
parsed.Title = strings.TrimSpace(parsed.Title)
parsed.Prompt = compactImageGenerationPrompt(parsed.Prompt, imageGenerationPromptMaxRunes)
if parsed.Prompt == "" {
return design.AgentImagePrompt{}, fmt.Errorf("creative agent returned empty image prompt")
}
return design.AgentImagePrompt{Title: parsed.Title, Prompt: parsed.Prompt}, nil
}
func (c *CreativeAgent) VectorizeImage(ctx context.Context, req design.ImageVectorizationRequest) (design.ImageVectorization, error) {
imageDataURL := strings.TrimSpace(req.ImageDataURL)
if imageDataURL == "" {
return design.ImageVectorization{}, fmt.Errorf("vectorize image is required")
}
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are an expert SVG vectorization engine for a design canvas.",
"Look at the supplied image and recreate it as compact, clean SVG code.",
"Return strict JSON only: {\"svg\":\"<svg ...>...</svg>\",\"width\":1024,\"height\":1024}.",
"The SVG must be self-contained and scalable: use <path>, <rect>, <circle>, <ellipse>, <polygon>, <line>, <polyline>, <text>, gradients, masks, and groups when useful.",
"Do not embed raster images. Do not use <image>, base64 data, external URLs, scripts, foreignObject, CSS imports, or HTML.",
"Keep transparent backgrounds transparent unless the source has an intentional filled background.",
"Preserve the visible subject silhouette, logo/text layout, color blocks, strokes, and readable details. Favor crisp geometry over photorealistic texture.",
"Set xmlns, width, height, and viewBox. Use width/height matching the requested dimensions when provided.",
"Keep the SVG reasonably compact while still faithful enough for download and canvas display.",
}, " "),
},
{
"role": "user",
"content": []map[string]any{
{
"type": "text",
"text": buildVectorizePrompt(req),
},
{
"type": "image_url",
"image_url": map[string]any{
"url": imageDataURL,
},
},
},
},
}, 16000, 0.1, true)
if err != nil {
return design.ImageVectorization{}, err
}
return parseVectorizeResponse(content, req)
}
func (c *CreativeAgent) AnalyzeMockupModel(ctx context.Context, req design.MockupModelAnalysisRequest) (design.MockupModelAnalysis, error) {
if strings.TrimSpace(req.ImageDataURL) == "" {
return design.MockupModelAnalysis{}, fmt.Errorf("mockup model image is required")
}
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You are a visual mockup geometry model generator.",
"Analyze the image and return the raw numeric model parameters that the canvas renderer will use to place a print/design onto a real surface.",
"Return strict JSON only. Do not return markdown. Do not invent blob URLs, dataId values, or binary files.",
"All of these fields must come from your visual estimate of the image: depthMap, shadeMap, segmentationMap, xOffsetMap, yOffsetMap, and cameraIntrinsics.",
"Use compact raw arrays so the response can be sent directly to the frontend: depthMap, shadeMap, xOffsetMap, yOffsetMap must be 16x16; segmentationMap must be 32x32.",
"Array lengths are strict: every 16x16 map must contain exactly 256 numeric values and segmentationMap must contain exactly 1024 numeric values. Never use ellipses, omitted ranges, comments, or shortened arrays.",
"depthMap and shadeMap data values are floats 0..1. segmentationMap data values are 0 outside and 255 on the printable surface, with soft edge values allowed. xOffsetMap and yOffsetMap values are floats -1..1.",
"Pick the usable printable/decal surface, not the existing artwork bounds: for apparel this is the main front print zone below neckline/drawstrings and above pocket/hem, large enough for portrait or landscape artwork. Do not choose only the current logo/text letters, the whole hoodie, sleeve, pocket, model body, or background. For bags/packages/screens choose the actual flat design panel, not the whole product silhouette.",
"placement, surface.polygon, segmentationMap, depthMap, shadeMap, xOffsetMap, and yOffsetMap must refer to the same normalized target-image coordinate system. cameraIntrinsics principal point cx/cy must be normalized 0..1, usually 0.5.",
"Return shape: {\"placement\":{\"x\":0,\"y\":0,\"width\":0,\"height\":0},\"surface\":{\"targetId\":\"\",\"polygon\":[{\"x\":0,\"y\":0}],\"mesh\":{\"columns\":5,\"rows\":5,\"points\":[{\"x\":0,\"y\":0}]}},\"model\":{\"version\":2,\"depthMap\":{\"width\":16,\"height\":16,\"data\":[...]},\"depthScaleMin\":0,\"depthScaleMax\":1,\"shadeMap\":{\"width\":16,\"height\":16,\"data\":[...]},\"segmentationMap\":{\"width\":32,\"height\":32,\"data\":[...]},\"xOffsetMap\":{\"width\":16,\"height\":16,\"data\":[...]},\"yOffsetMap\":{\"width\":16,\"height\":16,\"data\":[...]},\"cameraIntrinsics\":{\"matrix\":[fx,0,cx,0,fy,cy,0,0,1]},\"colorCorrection\":\"limited_brightness\",\"foregroundImage\":\"\"}}.",
}, " "),
},
{
"role": "user",
"content": []map[string]any{
{
"type": "text",
"text": buildMockupModelPrompt(req),
},
{
"type": "image_url",
"image_url": map[string]any{
"url": req.ImageDataURL,
},
},
},
},
}, 16000, 0.15, true)
if err != nil {
return design.MockupModelAnalysis{}, err
}
var analysis design.MockupModelAnalysis
if err := json.Unmarshal([]byte(extractJSONObject(content)), &analysis); err != nil {
return design.MockupModelAnalysis{}, err
}
return analysis, nil
}
func (c *CreativeAgent) DecideResearch(ctx context.Context, req design.AgentResearchDecisionRequest) (design.AgentResearchDecision, error) {
content, err := c.chat(ctx, []map[string]any{
{
"role": "system",
"content": strings.Join([]string{
"You decide whether a Moteva-style creative agent should use web search.",
"Web search is available because the user enabled web reference, but it is not mandatory.",
"Return strict JSON only: {\"shouldSearch\":true|false,\"query\":\"search query or empty\",\"reason\":\"short reason\"}.",
"Use search only when the task materially depends on current facts, public brand/product/site details, competitors, trends, market references, unfamiliar proper nouns, or an explicit request to research the web.",
"Do not search for greetings, capability questions, pure canvas operations, ordinary image edits, or style refinements that can be handled from the canvas and conversation context.",
"If searching, write one focused keyword query in the user's language when suitable; use English only when it will likely find stronger global results.",
"Keep named references and user style terms intact. Add only minimal retrieval terms such as official site, case study, visual identity, moodboard, lookbook, competitors, or style system; do not replace the user's words with your own aesthetic interpretation.",
"For logo tasks, avoid logo-maker/tool results: include logo design case study, brand identity, visual identity, design process, moodboard, and negative terms such as -maker -generator -free -template when useful.",
}, " "),
},
{
"role": "user",
"content": buildResearchDecisionPrompt(req),
},
}, 360, 0.15, true)
if err != nil {
return design.AgentResearchDecision{}, err
}
var parsed struct {
ShouldSearch bool `json:"shouldSearch"`
Query string `json:"query"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(extractJSONObject(content)), &parsed); err != nil {
return design.AgentResearchDecision{}, err
}
return design.AgentResearchDecision{
ShouldSearch: parsed.ShouldSearch,
Query: strings.TrimSpace(parsed.Query),
Reason: strings.TrimSpace(parsed.Reason),
}, nil
}
func (c *CreativeAgent) chat(ctx context.Context, messages []map[string]any, maxTokens int, temperature float64, jsonMode bool) (string, error) {
body := map[string]any{
"model": c.model,
"messages": messages,
"max_tokens": maxTokens,
"temperature": temperature,
}
if jsonMode {
body["response_format"] = map[string]any{"type": "json_object"}
}
rawBody, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(rawBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("creative agent failed: %s: %s", resp.Status, trimErrorBody(respBody))
}
var payload struct {
Choices []struct {
Message struct {
Content json.RawMessage `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", err
}
if len(payload.Choices) == 0 {
return "", fmt.Errorf("creative agent returned no choices")
}
content := strings.TrimSpace(decodeChatContent(payload.Choices[0].Message.Content))
if content == "" {
return "", fmt.Errorf("creative agent returned empty content")
}
return content, nil
}
func (c *CreativeAgent) chatStream(ctx context.Context, messages []map[string]any, maxTokens int, temperature float64, onDelta func(delta string) error) (string, error) {
body := map[string]any{
"model": c.model,
"messages": messages,
"max_tokens": maxTokens,
"temperature": temperature,
"stream": true,
}
rawBody, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(rawBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if readErr != nil {
return "", readErr
}
return "", fmt.Errorf("creative agent stream failed: %s: %s", resp.Status, trimErrorBody(respBody))
}
var builder strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
delta := decodeChatStreamDelta([]byte(data))
if delta == "" {
continue
}
builder.WriteString(delta)
if onDelta != nil {
if err := onDelta(delta); err != nil {
return builder.String(), err
}
}
}
if err := scanner.Err(); err != nil {
return builder.String(), err
}
if strings.TrimSpace(builder.String()) == "" {
return "", fmt.Errorf("creative agent stream returned empty content")
}
return builder.String(), nil
}
func buildDecisionPrompt(req design.AgentDecisionRequest) string {
return strings.Join([]string{
"Project context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"Mode: " + req.Mode,
"User message:",
req.Prompt,
"",
"Input summary:",
agentMessageContentSummary(req.Messages),
}, "\n")
}
func buildResearchDecisionPrompt(req design.AgentResearchDecisionRequest) string {
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"Visible canvas assets:",
canvasAssetContext(req.Project.Nodes),
"",
"Recent conversation:",
recentConversationContext(req.Project.Messages),
"",
"User request and inline references:",
req.Prompt,
agentMessageContentSummary(req.Messages),
"",
"Mode: " + req.Mode,
}, "\n")
}
func buildAgentTaskPlanPrompt(req design.AgentTaskPlanRequest) string {
searchState := "disabled"
if req.WebSearchEnabled {
searchState = "enabled"
}
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"Visible canvas assets:",
canvasAssetContext(req.Project.Nodes),
"",
"Recent conversation:",
recentConversationContext(req.Project.Messages),
"",
"Inline references and user inputs:",
req.Prompt,
agentMessageContentSummary(req.Messages),
"",
"Mode: " + req.Mode,
"Web search: " + searchState,
"",
"Plan the workflow before any tool runs. Decide intent, whether research is useful, how many output images/artboards are actually needed, and the concrete image tasks.",
}, "\n")
}
func buildConversationPrompt(req design.AgentConversationRequest) string {
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"Recent conversation:",
recentConversationContext(req.Project.Messages),
"",
"Agent task plan:",
taskPlanContext(req.TaskPlan),
"",
"Inline references:",
agentMessageContentSummary(req.Messages),
"",
"User message:",
req.Prompt,
"",
"Respond as the creative production agent. If the user is asking what you can do, be specific to image/ecommerce/canvas work. If they are just greeting, keep it short.",
}, "\n")
}
func buildImageSummaryPrompt(req design.AgentImageSummaryRequest) string {
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"User image task:",
req.Prompt,
"",
"Generated image URL:",
req.ImageURL,
"",
"Write the message that should appear after GPT Image 2 finishes.",
}, "\n")
}
func buildImagePromptPlannerInput(req design.AgentImagePromptRequest) string {
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Harness memory:",
memoryContext(req.Memory),
"",
"Visible canvas assets:",
canvasAssetContext(req.Project.Nodes),
"",
"Recent conversation:",
recentConversationContext(req.Project.Messages),
"",
"Agent task plan:",
taskPlanContext(req.TaskPlan),
"",
"Web research context:",
researchContext(req.Project.Messages),
"",
"User request and inline references:",
req.Prompt,
agentMessageContentSummary(req.Messages),
"",
"Mode: " + req.Mode,
}, "\n")
}
func buildMockupModelPrompt(req design.MockupModelAnalysisRequest) string {
return strings.Join([]string{
"Current canvas context:",
projectBriefContext(req.Project),
"",
"Selected target node:",
fmt.Sprintf("- title: %s", strings.TrimSpace(req.Node.Title)),
fmt.Sprintf("- canvas size: %.0fx%.0f", req.Node.Width, req.Node.Height),
fmt.Sprintf("- source image pixels: %dx%d", req.ImageWidth, req.ImageHeight),
"",
"Generate a Moteva-style mockup model for this selected image.",
"The placement and polygon must identify the printable area. The raw map arrays must describe the same area and surface deformation.",
"Return only the JSON object requested by the system message.",
}, "\n")
}
func buildVectorizePrompt(req design.ImageVectorizationRequest) string {
width := req.Width
height := req.Height
if width <= 0 {
width = 1024
}
if height <= 0 {
height = 1024
}
lines := []string{
"Vectorize the supplied image into editable SVG markup.",
fmt.Sprintf("Target SVG dimensions: %dx%d.", width, height),
"Use the image itself as the visual source of truth.",
"Return only the JSON object requested by the system message.",
}
if title := strings.TrimSpace(req.Title); title != "" {
lines = append(lines, "Canvas node title: "+title)
}
if prompt := strings.TrimSpace(req.Prompt); prompt != "" {
lines = append(lines, "User instruction: "+prompt)
}
return strings.Join(lines, "\n")
}
func parseVectorizeResponse(content string, req design.ImageVectorizationRequest) (design.ImageVectorization, error) {
var parsed struct {
SVG string `json:"svg"`
Width int64 `json:"width"`
Height int64 `json:"height"`
}
source := content
if err := json.Unmarshal([]byte(extractJSONObject(content)), &parsed); err == nil && strings.TrimSpace(parsed.SVG) != "" {
source = parsed.SVG
}
svg := extractSVGMarkup(source)
if svg == "" {
return design.ImageVectorization{}, fmt.Errorf("creative agent returned no SVG markup")
}
width := parsed.Width
height := parsed.Height
if width <= 0 {
width = req.Width
}
if height <= 0 {
height = req.Height
}
return design.ImageVectorization{
SVG: svg,
Width: width,
Height: height,
}, nil
}
func extractSVGMarkup(content string) string {
content = strings.TrimSpace(content)
if strings.HasPrefix(content, "```") {
content = strings.Trim(content, "`")
content = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), "svg"))
}
lower := strings.ToLower(content)
start := strings.Index(lower, "<svg")
end := strings.LastIndex(lower, "</svg>")
if start < 0 || end < start {
return ""
}
return strings.TrimSpace(content[start : end+len("</svg>")])
}
func taskPlanContext(plan design.AgentTaskPlan) string {
if strings.TrimSpace(plan.Intent) == "" && len(plan.ImageTasks) == 0 && strings.TrimSpace(plan.UserFacingResponse) == "" {
return "none"
}
var lines []string
if strings.TrimSpace(plan.Intent) != "" {
lines = append(lines, "intent: "+strings.TrimSpace(plan.Intent))
}
if strings.TrimSpace(plan.UserFacingResponse) != "" {
lines = append(lines, "user response: "+strings.TrimSpace(plan.UserFacingResponse))
}
if plan.ImageCount > 0 {
lines = append(lines, fmt.Sprintf("image count: %d", plan.ImageCount))
}
if plan.ShouldSearch {
lines = append(lines, "web search: true")
if strings.TrimSpace(plan.SearchQuery) != "" {
lines = append(lines, "search query: "+strings.TrimSpace(plan.SearchQuery))
}
}
for index, task := range plan.ImageTasks {
title := strings.TrimSpace(task.Title)
brief := strings.TrimSpace(task.Brief)
if title == "" && brief == "" {
continue
}
lines = append(lines, fmt.Sprintf("task %d: %s - %s", index+1, title, brief))
}
return strings.Join(lines, "\n")
}
func projectBriefContext(project design.Project) string {
var builder strings.Builder
if strings.TrimSpace(project.Title) != "" {
builder.WriteString("Title: ")
builder.WriteString(project.Title)
builder.WriteString("\n")
}
if strings.TrimSpace(project.Brief) != "" {
builder.WriteString("Brief: ")
builder.WriteString(project.Brief)
builder.WriteString("\n")
}
builder.WriteString(fmt.Sprintf("Canvas nodes: %d", len(project.Nodes)))
return builder.String()
}
func memoryContext(memory design.AgentMemory) string {
if memory.IsZero() {
return "none"
}
var sections []string
if strings.TrimSpace(memory.ShortTerm) != "" {
sections = append(sections, "Short memory:\n"+strings.TrimSpace(memory.ShortTerm))
}
if strings.TrimSpace(memory.LongTerm) != "" {
sections = append(sections, "Long memory:\n"+strings.TrimSpace(memory.LongTerm))
}
return strings.Join(sections, "\n")
}
func canvasAssetContext(nodes []design.Node) string {
if len(nodes) == 0 {
return "none"
}
start := 0
if len(nodes) > 8 {
start = len(nodes) - 8
}
var lines []string
for _, node := range nodes[start:] {
if node.Status == "generating" || node.LayerRole == "pen-stroke" {
continue
}
content := strings.TrimSpace(node.Content)
if strings.HasPrefix(content, "http") || strings.HasPrefix(content, "/canvas-assets/") {
content = "asset reference"
} else {
content = truncateRunes(content, 120)
}
lines = append(lines, fmt.Sprintf("- %s | %s | %.0fx%.0f | %s", node.Type, node.Title, node.Width, node.Height, content))
}
if len(lines) == 0 {
return "none"
}
return strings.Join(lines, "\n")
}
func recentConversationContext(messages []design.Message) string {
if len(messages) == 0 {
return "none"
}
start := 0
if len(messages) > 6 {
start = len(messages) - 6
}
var lines []string
for _, message := range messages[start:] {
content := strings.TrimSpace(message.Content)
if content == "" || message.Role == "tool" || message.Role == "error" {
continue
}
content = truncateRunes(content, 220)
lines = append(lines, fmt.Sprintf("- %s: %s", message.Role, content))
}
if len(lines) == 0 {
return "none"
}
return strings.Join(lines, "\n")
}
func researchContext(messages []design.Message) string {
if len(messages) == 0 {
return "none"
}
start := 0
if len(messages) > 12 {
start = len(messages) - 12
}
var lines []string
for _, message := range messages[start:] {
if message.Role != "tool" || message.Title != "Synthesize Skills" {
continue
}
var report struct {
Query string `json:"query"`
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Content string `json:"content"`
} `json:"results"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(message.Content), &report); err != nil {
continue
}
if strings.TrimSpace(report.Query) != "" {
lines = append(lines, "Query: "+strings.TrimSpace(report.Query))
}
for _, result := range report.Results {
title := strings.TrimSpace(result.Title)
url := strings.TrimSpace(result.URL)
snippet := strings.TrimSpace(result.Snippet)
content := strings.TrimSpace(result.Content)
if title == "" && snippet == "" && content == "" {
continue
}
title = truncateRunes(title, 120)
snippet = truncateRunes(snippet, 160)
content = truncateRunes(content, 1800)
line := "- " + title
if url != "" {
line += " (" + url + ")"
}
if snippet != "" {
line += ": " + snippet
}
lines = append(lines, line)
if content != "" {
lines = append(lines, " page content: "+content)
}
}
if report.Error != "" {
lines = append(lines, "Search error: "+report.Error)
}
}
if len(lines) == 0 {
return "none"
}
return strings.Join(lines, "\n")
}
func agentMessageContentSummary(messages []design.AgentChatMessage) string {
var items []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 != "" {
items = append(items, "text: "+truncateRunes(text, 300))
}
case "image":
if strings.TrimSpace(content.ImageURL) != "" {
size := ""
if content.ImageWidth > 0 && content.ImageHeight > 0 {
size = fmt.Sprintf(" %.0fx%.0f", float64(content.ImageWidth), float64(content.ImageHeight))
}
items = append(items, "image reference selected"+size)
}
}
}
}
if len(items) == 0 {
return "none"
}
return strings.Join(items, "\n")
}
func decodeChatStreamDelta(data []byte) string {
var payload struct {
Choices []struct {
Delta struct {
Content json.RawMessage `json:"content"`
} `json:"delta"`
Message struct {
Content json.RawMessage `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &payload); err != nil || len(payload.Choices) == 0 {
return ""
}
content := decodeChatContent(payload.Choices[0].Delta.Content)
if strings.TrimSpace(content) == "" {
content = decodeChatContent(payload.Choices[0].Message.Content)
}
return content
}
func extractJSONObject(content string) string {
content = strings.TrimSpace(content)
if strings.HasPrefix(content, "```") {
content = strings.Trim(content, "`")
content = strings.TrimSpace(strings.TrimPrefix(content, "json"))
}
start := strings.Index(content, "{")
end := strings.LastIndex(content, "}")
if start >= 0 && end >= start {
return content[start : end+1]
}
return content
}