6069 lines
197 KiB
Go
6069 lines
197 KiB
Go
package application
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/draw"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
"image/png"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"img_infinite_canvas/internal/domain/design"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/zeromicro/go-zero/core/logx"
|
||
)
|
||
|
||
var (
|
||
ErrMissingDataSource = errors.New("postgres storage requires Storage.DataSource")
|
||
ErrUnsupportedStorage = errors.New("unsupported storage driver")
|
||
ErrUnsupportedAgent = errors.New("unsupported agent driver")
|
||
ErrUnsupportedObjectStorage = errors.New("unsupported object storage driver")
|
||
ErrUnsupportedResearch = errors.New("unsupported research driver")
|
||
)
|
||
|
||
const generationBudget = 15 * time.Minute
|
||
const agentConversationBudget = 15 * time.Minute
|
||
const generationHeartbeatInterval = 20 * time.Second
|
||
const generationRecoveryAfter = 90 * time.Second
|
||
const generationRecoveryMaxAttempts = 3
|
||
const defaultProjectPageSize int64 = 20
|
||
const maxProjectPageSize int64 = 50
|
||
const textExtractionResultMessageType = "text_extraction_result"
|
||
const textExtractionStaleAfter = generationBudget + 60*time.Second
|
||
const nodeActionResultNodeIDOption = "_resultNodeId"
|
||
const generationRecoveryToolHint = "generation_recovery"
|
||
|
||
type DesignService struct {
|
||
repo design.Repository
|
||
agent design.AgentRunner
|
||
assets design.AssetStorage
|
||
assetCleanup *assetCleanupQueue
|
||
jobs design.JobQueue
|
||
search design.Researcher
|
||
textExtractor design.TextExtractor
|
||
objectRecognizer design.ObjectRecognizer
|
||
imageVectorizer design.ImageVectorizer
|
||
backgroundRemover design.BackgroundRemover
|
||
mockupAnalyzer design.MockupModelAnalyzer
|
||
textExtractionCache textExtractionCacheStore
|
||
textExtractionCacheTTL time.Duration
|
||
creativeAgent design.CreativeAgent
|
||
cancelledAgentTasks sync.Map
|
||
agentTaskCancels sync.Map
|
||
generatorTaskMu sync.RWMutex
|
||
generatorTasks map[string]design.GeneratorTask
|
||
now func() time.Time
|
||
}
|
||
|
||
type GenerateResult struct {
|
||
Project design.Project
|
||
Message design.Message
|
||
GeneratedNodes []design.Node
|
||
}
|
||
|
||
type ProjectPage struct {
|
||
Projects []design.ProjectSummary
|
||
NextOffset int64
|
||
HasMore bool
|
||
}
|
||
|
||
type textExtractionCacheStore interface {
|
||
Get(ctx context.Context, key string, target any) (bool, error)
|
||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||
Delete(ctx context.Context, key string) error
|
||
}
|
||
|
||
func NewDesignService(repo design.Repository, agent design.AgentRunner, assets design.AssetStorage, search design.Researcher, textExtractor design.TextExtractor, creativeAgents ...design.CreativeAgent) *DesignService {
|
||
if agent == nil {
|
||
agent = NewDeterministicAgent()
|
||
}
|
||
var creativeAgent design.CreativeAgent
|
||
if len(creativeAgents) > 0 {
|
||
creativeAgent = creativeAgents[0]
|
||
}
|
||
|
||
return &DesignService{
|
||
repo: repo,
|
||
agent: agent,
|
||
assets: assets,
|
||
assetCleanup: newAssetCleanupQueue(assets),
|
||
search: search,
|
||
textExtractor: textExtractor,
|
||
generatorTasks: make(map[string]design.GeneratorTask),
|
||
imageVectorizer: imageVectorizerFromCreativeAgent(creativeAgent),
|
||
creativeAgent: creativeAgent,
|
||
now: time.Now,
|
||
}
|
||
}
|
||
|
||
func imageVectorizerFromCreativeAgent(agent design.CreativeAgent) design.ImageVectorizer {
|
||
if vectorizer, ok := agent.(design.ImageVectorizer); ok {
|
||
return vectorizer
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *DesignService) SetTextExtractionCache(store textExtractionCacheStore, ttl time.Duration) {
|
||
s.textExtractionCache = store
|
||
if ttl <= 0 {
|
||
ttl = 10 * time.Minute
|
||
}
|
||
s.textExtractionCacheTTL = ttl
|
||
}
|
||
|
||
func (s *DesignService) SetBackgroundRemover(remover design.BackgroundRemover) {
|
||
s.backgroundRemover = remover
|
||
}
|
||
|
||
func (s *DesignService) SetObjectRecognizer(recognizer design.ObjectRecognizer) {
|
||
s.objectRecognizer = recognizer
|
||
}
|
||
|
||
func (s *DesignService) SetImageVectorizer(vectorizer design.ImageVectorizer) {
|
||
s.imageVectorizer = vectorizer
|
||
}
|
||
|
||
func (s *DesignService) SetJobQueue(queue design.JobQueue) {
|
||
s.jobs = queue
|
||
}
|
||
|
||
func (s *DesignService) SetMockupModelAnalyzer(analyzer design.MockupModelAnalyzer) {
|
||
s.mockupAnalyzer = analyzer
|
||
}
|
||
|
||
func (s *DesignService) EnsureSeedData(ctx context.Context) error {
|
||
items, err := s.repo.List(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(items) > 0 {
|
||
return nil
|
||
}
|
||
|
||
for _, project := range seedProjects(s.now()) {
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *DesignService) Dashboard(ctx context.Context) (int64, []design.QuickAction, []design.ProjectSummary, []design.InspirationItem, error) {
|
||
projects, err := s.repo.ListPage(ctx, 3, 0)
|
||
if err != nil {
|
||
return 0, nil, nil, nil, err
|
||
}
|
||
design.SortSummaries(projects)
|
||
|
||
return 30, quickActions(), projects, inspirationItems(), nil
|
||
}
|
||
|
||
func (s *DesignService) ListProjects(ctx context.Context, limit int64, offset int64) (ProjectPage, error) {
|
||
limit = normalizeProjectLimit(limit)
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
projects, err := s.repo.ListPage(ctx, limit+1, offset)
|
||
if err != nil {
|
||
return ProjectPage{}, err
|
||
}
|
||
design.SortSummaries(projects)
|
||
hasMore := int64(len(projects)) > limit
|
||
if hasMore {
|
||
projects = projects[:limit]
|
||
}
|
||
return ProjectPage{
|
||
Projects: projects,
|
||
NextOffset: offset + int64(len(projects)),
|
||
HasMore: hasMore,
|
||
}, nil
|
||
}
|
||
|
||
func normalizeProjectLimit(limit int64) int64 {
|
||
if limit <= 0 {
|
||
return defaultProjectPageSize
|
||
}
|
||
if limit > maxProjectPageSize {
|
||
return maxProjectPageSize
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func (s *DesignService) GetProject(ctx context.Context, id string) (design.Project, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(id))
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
if nodes, changed := clearLegacyMockCanvasNodes(project.Nodes); changed {
|
||
project.Nodes = nodes
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
if nextProject, changed := s.restoreFinishedGeneratedImageNodes(project, s.now()); changed {
|
||
project = s.refreshCanvasSnapshot(nextProject, nextProject.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
if nextProject, changed := s.recoverOrphanedAgentGeneration(ctx, project, s.now()); changed {
|
||
project = nextProject
|
||
}
|
||
if nextProject, changed := s.clearStaleGeneratingPlaceholders(project, s.now()); changed {
|
||
project = s.refreshCanvasSnapshot(nextProject, nextProject.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
if project.Status == design.StatusFailed {
|
||
if nodes, changed := clearFailedCanvasPlaceholders(project.Nodes); changed {
|
||
project.Nodes = nodes
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
}
|
||
if strings.TrimSpace(project.Version) == "" || strings.TrimSpace(project.Canvas) == "" || strings.TrimSpace(project.SnapshotID) == "" {
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
if nextProject, changed := s.restoreFinishedTextExtractionNodes(project, s.now()); changed {
|
||
project = s.refreshCanvasSnapshot(nextProject, nextProject.UpdatedAt)
|
||
_ = s.repo.Save(ctx, project)
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) UpdateProjectTitle(ctx context.Context, id string, title string) (design.Project, error) {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" {
|
||
return design.Project{}, fmt.Errorf("%w: title is required", design.ErrInvalidInput)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(id))
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
project.Title = title
|
||
project.UpdatedAt = s.now()
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) DeleteProject(ctx context.Context, id string) error {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(id))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
assetURLs := projectAssetURLs(project)
|
||
if err := s.repo.Delete(ctx, project.ID); err != nil {
|
||
return err
|
||
}
|
||
s.enqueueAssetDeletion(assetURLs)
|
||
return nil
|
||
}
|
||
|
||
func (s *DesignService) DeleteProjects(ctx context.Context, ids []string) (int64, error) {
|
||
seen := make(map[string]struct{})
|
||
var deleted int64
|
||
var queuedAssets []string
|
||
for _, id := range ids {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
project, err := s.repo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, design.ErrNotFound) {
|
||
continue
|
||
}
|
||
return deleted, err
|
||
}
|
||
if err := s.repo.Delete(ctx, project.ID); err != nil {
|
||
if errors.Is(err, design.ErrNotFound) {
|
||
continue
|
||
}
|
||
return deleted, err
|
||
}
|
||
queuedAssets = append(queuedAssets, projectAssetURLs(project)...)
|
||
deleted++
|
||
}
|
||
s.enqueueAssetDeletion(queuedAssets)
|
||
return deleted, nil
|
||
}
|
||
|
||
func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) {
|
||
prompt = design.NormalizePrompt(prompt)
|
||
if prompt == "" {
|
||
project, _ := s.newProjectShell(ctx, title, prompt)
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
now := s.now()
|
||
project := design.Project{
|
||
ID: newID(),
|
||
UserID: design.UserIDFromContext(ctx),
|
||
Title: design.NormalizeTitle(title, inferTitle(prompt)),
|
||
Brief: prompt,
|
||
Status: design.StatusExploring,
|
||
Thumbnail: inferThumbnail(prompt),
|
||
UpdatedAt: now,
|
||
Viewport: design.Viewport{X: 420, Y: 250, K: 0.7},
|
||
Messages: []design.Message{
|
||
{
|
||
ID: newID(),
|
||
Role: "user",
|
||
Type: "user",
|
||
Title: "新的设计需求",
|
||
Content: prompt,
|
||
CreatedAt: now,
|
||
},
|
||
},
|
||
}
|
||
|
||
agentReq := design.AgentRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: "design",
|
||
ImageModel: strings.TrimSpace(imageModel),
|
||
ImageSize: strings.TrimSpace(imageSize),
|
||
Messages: []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: prompt, TextSource: "input"},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
if enableWebSearch {
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
if err := s.maybeAddResearchStep(ctx, project.ID, "", project, prompt, "design", agentReq.Messages, true); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
if nextProject, err := s.repo.Get(ctx, project.ID); err == nil {
|
||
project = nextProject
|
||
agentReq.Project = project
|
||
}
|
||
}
|
||
agentReq.Memory = buildAgentMemory(project, prompt, "design", agentReq.Messages)
|
||
plan, err := s.agent.Plan(ctx, agentReq)
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
|
||
generatedNodes, err := s.persistNodeImageAssets(ctx, project.ID, plan.GeneratedNodes)
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
|
||
message := plan.Message
|
||
message.CreatedAt = now.Add(time.Millisecond)
|
||
if message.Type == "" {
|
||
message.Type = "assistant"
|
||
}
|
||
project.Nodes = generatedNodes
|
||
project.Connections = nil
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
project.Messages = append(project.Messages, message)
|
||
project.Status = design.StatusReady
|
||
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) {
|
||
project, prompt := s.newProjectShell(ctx, title, prompt)
|
||
if prompt == "" {
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
threadID := newID()
|
||
project.LastThreadID = threadID
|
||
if len(project.Messages) > 0 {
|
||
project.Messages[0].Type = "user"
|
||
project.Messages[0].ThreadID = threadID
|
||
project.Messages[0].ActionID = threadID
|
||
project.Messages[0].StepID = newID()
|
||
}
|
||
project.Messages = append(project.Messages, agentPlanningMessage(threadID, threadID, prompt, enableWebSearch, project.UpdatedAt.Add(time.Millisecond)))
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
|
||
s.startCreateProjectGeneration(project.ID, project.UserID, threadID, prompt, imageModel, imageSize, enableWebSearch, design.AgentTaskPlan{})
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) Generate(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string) (GenerateResult, error) {
|
||
prompt = design.NormalizePrompt(prompt)
|
||
if prompt == "" {
|
||
return GenerateResult{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return GenerateResult{}, err
|
||
}
|
||
|
||
agentReq := design.AgentRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
ImageModel: strings.TrimSpace(imageModel),
|
||
ImageSize: strings.TrimSpace(imageSize),
|
||
Messages: []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: prompt, TextSource: "input"},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
agentReq.Memory = buildAgentMemory(project, prompt, mode, agentReq.Messages)
|
||
plan, err := s.agent.Plan(ctx, agentReq)
|
||
if err != nil {
|
||
return GenerateResult{}, err
|
||
}
|
||
|
||
generated := plan.GeneratedNodes
|
||
project.Nodes = append(project.Nodes, generated...)
|
||
project.Connections = nil
|
||
project.Brief = prompt
|
||
project.Status = design.StatusReady
|
||
project.Thumbnail = inferThumbnail(prompt)
|
||
project.UpdatedAt = s.now()
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
|
||
message := plan.Message
|
||
message.CreatedAt = project.UpdatedAt
|
||
if message.Type == "" {
|
||
message.Type = "assistant"
|
||
}
|
||
project.Messages = append(project.Messages,
|
||
design.Message{
|
||
ID: newID(),
|
||
Role: "user",
|
||
Type: "user",
|
||
Title: "新的设计需求",
|
||
Content: prompt,
|
||
CreatedAt: project.UpdatedAt.Add(-time.Millisecond),
|
||
},
|
||
message,
|
||
)
|
||
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return GenerateResult{}, err
|
||
}
|
||
|
||
return GenerateResult{Project: project, Message: message, GeneratedNodes: generated}, nil
|
||
}
|
||
|
||
func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string) (design.Project, error) {
|
||
thread, err := s.AgentChat(ctx, projectID, design.AgentChatRequest{
|
||
Messages: []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: prompt, TextSource: "input"},
|
||
},
|
||
},
|
||
},
|
||
ThreadIDType: 9,
|
||
ImageModel: imageModel,
|
||
ImageSize: imageSize,
|
||
Mode: mode,
|
||
})
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return thread.Project, nil
|
||
}
|
||
|
||
func (s *DesignService) AgentChat(ctx context.Context, projectID string, req design.AgentChatRequest) (design.AgentThread, error) {
|
||
if !hasUserPromptTextInAgentMessages(req.Messages) {
|
||
return design.AgentThread{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
|
||
}
|
||
prompt := design.NormalizePrompt(promptFromAgentMessages(req.Messages))
|
||
if prompt == "" {
|
||
return design.AgentThread{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
|
||
}
|
||
|
||
mode := strings.TrimSpace(req.Mode)
|
||
if mode == "" {
|
||
mode = "design"
|
||
}
|
||
threadID := strings.TrimSpace(req.ThreadID)
|
||
if threadID == "" {
|
||
threadID = newID()
|
||
}
|
||
threadIDType := req.ThreadIDType
|
||
if threadIDType == 0 {
|
||
threadIDType = 9
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(project.ID, threadID) {
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "cancelled",
|
||
ThreadIDType: threadIDType,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
|
||
now := s.now()
|
||
actionID := newID()
|
||
targetNodeID := imageGeneratorTargetNodeID(req.Messages)
|
||
isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID)
|
||
enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig)
|
||
memory := buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||
if !isolatedImageGenerator {
|
||
project.Messages = append(project.Messages,
|
||
design.Message{
|
||
ID: newID(),
|
||
Role: "user",
|
||
Type: "user",
|
||
Title: "新的设计需求",
|
||
Content: prompt,
|
||
ThreadID: threadID,
|
||
ActionID: actionID,
|
||
StepID: newID(),
|
||
CreatedAt: now,
|
||
},
|
||
agentPlanningMessage(threadID, actionID, prompt, enableWebSearch, now.Add(time.Millisecond)),
|
||
)
|
||
project.Status = design.StatusExploring
|
||
project.LastThreadID = threadID
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
|
||
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: req.Messages,
|
||
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
|
||
WebSearchEnabled: enableWebSearch,
|
||
})
|
||
project, err = s.repo.Get(ctx, project.ID)
|
||
if err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||
return design.AgentThread{}, err
|
||
} else if cancelled {
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "cancelled",
|
||
ThreadIDType: threadIDType,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
}
|
||
taskPlan := s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory)
|
||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||
return design.AgentThread{}, err
|
||
} else if cancelled {
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "cancelled",
|
||
ThreadIDType: threadIDType,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
if !isolatedImageGenerator {
|
||
if err := s.maybeAddPlannedResearchStep(ctx, project.ID, threadID, projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages, enableWebSearch, taskPlan); err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
project, err = s.repo.Get(ctx, project.ID)
|
||
if err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
|
||
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
|
||
return design.AgentThread{}, err
|
||
} else if cancelled {
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "cancelled",
|
||
ThreadIDType: threadIDType,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
}
|
||
shouldGenerateImage := isolatedImageGenerator || taskPlan.Intent == "image"
|
||
if shouldGenerateImage {
|
||
if targetNodeID != "" {
|
||
project.Nodes = markImageGeneratorTargetGenerating(project.Nodes, targetNodeID, prompt, req.ImageSize)
|
||
} else {
|
||
project.Nodes = append(project.Nodes, newGeneratingImageNodesForPlan(prompt, len(project.Nodes), taskPlan, req.ImageSize)...)
|
||
}
|
||
}
|
||
project.Connections = nil
|
||
if shouldGenerateImage {
|
||
project.Brief = prompt
|
||
project.Status = design.StatusExploring
|
||
project.Thumbnail = inferThumbnail(prompt)
|
||
} else {
|
||
project.Status = design.StatusExploring
|
||
}
|
||
project.UpdatedAt = now
|
||
project.LastThreadID = threadID
|
||
assistantMessageID := ""
|
||
if !isolatedImageGenerator {
|
||
if shouldGenerateImage {
|
||
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
|
||
} else {
|
||
assistantMessageID = preparePlannerForChatMessage(&project, threadID, actionID, prompt, now.Add(time.Millisecond))
|
||
}
|
||
}
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
|
||
if shouldGenerateImage {
|
||
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, mode, req.ImageModel, req.ImageSize, req.Messages, enableWebSearch, isolatedImageGenerator, taskPlan)
|
||
} else {
|
||
if assistantMessageID == "" && len(project.Messages) > 0 {
|
||
assistantMessageID = project.Messages[len(project.Messages)-1].ID
|
||
}
|
||
s.startAgentConversation(project.ID, project.UserID, threadID, assistantMessageID, design.AgentConversationRequest{
|
||
Project: projectScopedToAgentThread(project, threadID),
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: req.Messages,
|
||
Memory: memory,
|
||
TaskPlan: taskPlan,
|
||
})
|
||
}
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "running",
|
||
ThreadIDType: threadIDType,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
|
||
func (s *DesignService) StopAgentTask(ctx context.Context, projectID string, threadID string) (design.Project, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" {
|
||
threadID = project.LastThreadID
|
||
}
|
||
s.cancelAgentTask(projectID, threadID)
|
||
now := s.now()
|
||
project.Nodes = clearRemainingAgentGenerationPlaceholders(project.Nodes, "")
|
||
alreadyCancelled := threadIsCancelled(project.Messages, threadID)
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "cancelled")
|
||
if !alreadyCancelled {
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: stoppedTaskTitle(project.Brief),
|
||
Content: stoppedTaskContent(project.Brief),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: "cancelled",
|
||
CreatedAt: now,
|
||
})
|
||
}
|
||
if threadID != "" {
|
||
project.LastThreadID = threadID
|
||
}
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) AgentThreadStatus(ctx context.Context, projectID string) (design.AgentThread, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
|
||
status := "done"
|
||
if project.Status == design.StatusExploring {
|
||
status = "running"
|
||
}
|
||
if project.Status == design.StatusFailed {
|
||
status = "failed"
|
||
}
|
||
|
||
return design.AgentThread{
|
||
ThreadID: project.LastThreadID,
|
||
Status: status,
|
||
ThreadIDType: 9,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
|
||
func (s *DesignService) AgentThreads(ctx context.Context, projectID string) ([]design.AgentThreadSummary, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
type accumulator struct {
|
||
threadID string
|
||
text string
|
||
hasAgent bool
|
||
blocked bool
|
||
updated time.Time
|
||
}
|
||
threads := make(map[string]*accumulator)
|
||
order := make([]string, 0)
|
||
for _, message := range project.Messages {
|
||
threadID := strings.TrimSpace(message.ThreadID)
|
||
if threadID == "" {
|
||
threadID = project.LastThreadID
|
||
}
|
||
if threadID == "" {
|
||
continue
|
||
}
|
||
item := threads[threadID]
|
||
if item == nil {
|
||
item = &accumulator{threadID: threadID}
|
||
threads[threadID] = item
|
||
order = append(order, threadID)
|
||
}
|
||
if isNonAgentConversationMessage(message) {
|
||
item.blocked = true
|
||
continue
|
||
}
|
||
if message.Role == "user" && item.text == "" {
|
||
item.text = agentThreadSummaryText(message.Content)
|
||
}
|
||
if message.Role == "user" {
|
||
item.hasAgent = true
|
||
}
|
||
if message.CreatedAt.After(item.updated) {
|
||
item.updated = message.CreatedAt
|
||
}
|
||
}
|
||
|
||
cover := firstCompletedImageURL(project.Nodes)
|
||
items := make([]design.AgentThreadSummary, 0, len(order))
|
||
for _, threadID := range order {
|
||
item := threads[threadID]
|
||
if item == nil || item.blocked || !item.hasAgent {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(item.text) == "" {
|
||
continue
|
||
}
|
||
items = append(items, design.AgentThreadSummary{
|
||
AgentThreadID: item.threadID,
|
||
CoverImageURL: cover,
|
||
Text: item.text,
|
||
ThreadIDType: 9,
|
||
UpdateTimestamp: item.updated.UnixMilli(),
|
||
})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func isNonAgentConversationMessage(message design.Message) bool {
|
||
name := strings.TrimSpace(message.Name)
|
||
toolHint := strings.TrimSpace(message.ToolHint)
|
||
if name == "canvas_action" || toolHint == "canvas_action" || name == "mockup_model" || toolHint == "mockup_model" {
|
||
return true
|
||
}
|
||
if name == "text_extraction" || toolHint == "text_extraction" || message.Type == textExtractionResultMessageType {
|
||
return true
|
||
}
|
||
return strings.Contains(strings.ToLower(message.Content), "image generator target node:")
|
||
}
|
||
|
||
func agentThreadSummaryText(content string) string {
|
||
content = stripPromptReferenceDirectives(content)
|
||
lines := strings.Split(content, "\n")
|
||
kept := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
lower := strings.ToLower(line)
|
||
if line == "" || strings.HasPrefix(lower, "image generator target node:") || strings.HasPrefix(lower, "selected model:") || strings.HasPrefix(line, "模型选择") {
|
||
continue
|
||
}
|
||
kept = append(kept, line)
|
||
}
|
||
if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" {
|
||
return text
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func projectScopedToAgentThread(project design.Project, threadID string) design.Project {
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" {
|
||
return project
|
||
}
|
||
scoped := project
|
||
scoped.Messages = make([]design.Message, 0, len(project.Messages))
|
||
for _, message := range project.Messages {
|
||
if strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if isNonAgentConversationMessage(message) {
|
||
continue
|
||
}
|
||
scoped.Messages = append(scoped.Messages, message)
|
||
}
|
||
return scoped
|
||
}
|
||
|
||
func (s *DesignService) RunNodeActionAsync(ctx context.Context, projectID string, nodeID string, req design.NodeActionRequest) (design.Project, error) {
|
||
req.Action = normalizeNodeAction(req.Action)
|
||
if req.Action == "" {
|
||
return design.Project{}, fmt.Errorf("%w: action is required", design.ErrInvalidInput)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
|
||
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
||
if nodeIndex < 0 {
|
||
return design.Project{}, design.ErrNotFound
|
||
}
|
||
if project.Nodes[nodeIndex].Type != design.NodeTypeImage && project.Nodes[nodeIndex].Type != design.NodeTypeFrame {
|
||
return design.Project{}, fmt.Errorf("%w: node action requires an image node", design.ErrInvalidInput)
|
||
}
|
||
|
||
now := s.now()
|
||
threadID := newID()
|
||
target := project.Nodes[nodeIndex]
|
||
if req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" {
|
||
resultNode := newBackgroundRemovalPlaceholder(target)
|
||
if req.Action == "vectorize" {
|
||
resultNode = newVectorizePlaceholder(target)
|
||
} else if req.Action == "move-object" {
|
||
resultNode = newMoveObjectPlaceholder(target)
|
||
}
|
||
if req.Options == nil {
|
||
req.Options = make(map[string]string)
|
||
}
|
||
req.Options[nodeActionResultNodeIDOption] = resultNode.ID
|
||
project.Nodes = append(project.Nodes, resultNode)
|
||
} else {
|
||
project.Nodes[nodeIndex].Status = "generating"
|
||
project.Nodes[nodeIndex].Title = nodeActionWorkingTitle(req.Action)
|
||
}
|
||
project.Connections = nil
|
||
project.Status = design.StatusExploring
|
||
project.UpdatedAt = now
|
||
project.LastThreadID = threadID
|
||
inputArgs := generatorTaskInputArgsForNodeAction(target, req)
|
||
generatorName := generatorTaskNameForNodeAction(req)
|
||
price := generatorTaskPrice(inputArgs, generatorName)
|
||
project.Messages = append(project.Messages,
|
||
generatorTaskMetadataMessage(threadID, generatorName, inputArgs, price, now.Add(-time.Millisecond)),
|
||
design.Message{
|
||
ID: newID(),
|
||
Role: "user",
|
||
Type: "user",
|
||
Title: nodeActionUserTitle(req.Action),
|
||
Content: nodeActionUserContent(req, target),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "running",
|
||
CreatedAt: now,
|
||
},
|
||
design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: "Agent 正在处理图片",
|
||
Content: nodeActionThinkingContent(req.Action),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "running",
|
||
CreatedAt: now.Add(time.Millisecond),
|
||
},
|
||
)
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
|
||
s.startNodeActionGeneration(project.ID, project.UserID, threadID, target, req)
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) ExtractNodeText(ctx context.Context, projectID string, nodeID string, prompt string) (design.TextExtraction, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.TextExtraction{}, err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
||
if nodeIndex < 0 {
|
||
return design.TextExtraction{}, design.ErrNotFound
|
||
}
|
||
target := project.Nodes[nodeIndex]
|
||
if target.Type != design.NodeTypeImage && target.Type != design.NodeTypeFrame {
|
||
return design.TextExtraction{}, fmt.Errorf("%w: text extraction requires an image node", design.ErrInvalidInput)
|
||
}
|
||
return s.extractTextExtraction(ctx, target, design.NodeActionRequest{
|
||
Action: "edit-text",
|
||
Prompt: strings.TrimSpace(prompt),
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) ExtractNodeTextAsync(ctx context.Context, projectID string, nodeID string, feature string) (design.AgentThread, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
||
if nodeIndex < 0 {
|
||
return design.AgentThread{}, design.ErrNotFound
|
||
}
|
||
target := project.Nodes[nodeIndex]
|
||
if target.Type != design.NodeTypeImage && target.Type != design.NodeTypeFrame {
|
||
return design.AgentThread{}, fmt.Errorf("%w: text extraction requires an image node", design.ErrInvalidInput)
|
||
}
|
||
|
||
now := s.now()
|
||
threadID := newID()
|
||
project.Nodes[nodeIndex].Status = "text-extracting"
|
||
project.UpdatedAt = now
|
||
project.LastThreadID = threadID
|
||
inputArgs := design.GeneratorTaskInputArgs{
|
||
AspectRatio: aspectRatioLabel(int64(math.Round(target.Width)), int64(math.Round(target.Height))),
|
||
Image: []string{target.Content},
|
||
Prompt: "Extract editable text layers",
|
||
Resolution: "1K",
|
||
}
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "tool",
|
||
Type: "tool",
|
||
Title: "提取文字",
|
||
Content: "正在识别图片中的文字和位置。",
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "text_extraction",
|
||
Status: "running",
|
||
CreatedAt: now,
|
||
}, generatorTaskMetadataMessage(threadID, "gpt-5.4-mini", inputArgs, generatorTaskPrice(inputArgs, "gpt-5.4-mini"), now.Add(time.Millisecond)))
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.AgentThread{}, err
|
||
}
|
||
|
||
s.startTextExtraction(project.ID, project.UserID, threadID, target, normalizeTextExtractionFeature(feature))
|
||
return design.AgentThread{
|
||
ThreadID: threadID,
|
||
Status: "running",
|
||
ThreadIDType: 9,
|
||
Project: project,
|
||
}, nil
|
||
}
|
||
|
||
func (s *DesignService) ReplayHistory(ctx context.Context, projectID string) (design.AgentReplay, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.AgentReplay{}, err
|
||
}
|
||
return s.agent.Replay(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) CreateAssetUpload(ctx context.Context, req design.AssetUploadRequest) (design.AssetUpload, error) {
|
||
if s.assets == nil {
|
||
return design.AssetUpload{}, fmt.Errorf("%w: asset storage is not configured", design.ErrInvalidInput)
|
||
}
|
||
req.FileName = strings.TrimSpace(req.FileName)
|
||
req.ContentType = strings.TrimSpace(req.ContentType)
|
||
if req.FileName == "" || req.ContentType == "" {
|
||
return design.AssetUpload{}, fmt.Errorf("%w: fileName and contentType are required", design.ErrInvalidInput)
|
||
}
|
||
return s.assets.CreateUpload(ctx, req)
|
||
}
|
||
|
||
func (s *DesignService) DeleteAsset(ctx context.Context, publicURL string) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if s.assets == nil {
|
||
return fmt.Errorf("%w: asset storage is not configured", design.ErrInvalidInput)
|
||
}
|
||
publicURL = strings.TrimSpace(publicURL)
|
||
if publicURL == "" {
|
||
return fmt.Errorf("%w: publicUrl is required", design.ErrInvalidInput)
|
||
}
|
||
if referenced, err := s.assetReferencedByProjects(ctx, publicURL); err != nil {
|
||
return err
|
||
} else if referenced {
|
||
return nil
|
||
}
|
||
s.enqueueAssetDeletion([]string{publicURL})
|
||
return nil
|
||
}
|
||
|
||
func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewport design.Viewport, nodes []design.Node, _ []design.Connection, clientVersion string, snapshotID string, canvas string) (design.Project, error) {
|
||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
now := s.now()
|
||
if normalizedProject, changed := s.restoreFinishedTextExtractionNodes(project, now); changed {
|
||
project = normalizedProject
|
||
}
|
||
if strings.TrimSpace(canvas) != "" {
|
||
if snapshot, err := design.DecodeCanvasSnapshot(canvas); err == nil {
|
||
if len(nodes) == 0 {
|
||
nodes = snapshot.Nodes
|
||
}
|
||
if viewport.K == 0 {
|
||
viewport = snapshot.Viewport
|
||
}
|
||
if snapshotID == "" {
|
||
snapshotID = snapshot.SnapshotID
|
||
}
|
||
if clientVersion == "" {
|
||
clientVersion = snapshot.Version
|
||
}
|
||
}
|
||
}
|
||
nodes, err = s.persistNodeImageAssets(ctx, project.ID, nodes)
|
||
if err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
if cleaned, changed := clearLegacyMockCanvasNodes(nodes); changed {
|
||
nodes = cleaned
|
||
}
|
||
nodes = mergeSavedCanvasWorkState(project.Nodes, nodes)
|
||
deletedAssetURLs := deletedCanvasAssetURLs(project.Nodes, nodes, project.Messages)
|
||
project.Viewport = viewport
|
||
project.Nodes = nodes
|
||
project.Connections = nil
|
||
project.UpdatedAt = now
|
||
if strings.TrimSpace(snapshotID) != "" && strings.TrimSpace(snapshotID) != project.SnapshotID {
|
||
project.SnapshotID = strings.TrimSpace(snapshotID)
|
||
} else {
|
||
project.SnapshotID = newID()
|
||
}
|
||
baseVersion := strings.TrimSpace(clientVersion)
|
||
currentVersion := project.Version
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
if canvasRepo, ok := s.repo.(design.CanvasRepository); ok {
|
||
if err := canvasRepo.SaveCanvas(ctx, project, baseVersion); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
} else {
|
||
if baseVersion != "" && currentVersion != "" && baseVersion != currentVersion {
|
||
return design.Project{}, design.ErrConflict
|
||
}
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return design.Project{}, err
|
||
}
|
||
}
|
||
s.enqueueAssetDeletion(deletedAssetURLs)
|
||
return project, nil
|
||
}
|
||
|
||
func (s *DesignService) newProjectShell(ctx context.Context, title string, prompt string) (design.Project, string) {
|
||
prompt = design.NormalizePrompt(prompt)
|
||
now := s.now()
|
||
status := design.StatusReady
|
||
messages := []design.Message(nil)
|
||
if prompt != "" {
|
||
status = design.StatusExploring
|
||
messages = append(messages, design.Message{
|
||
ID: newID(),
|
||
Role: "user",
|
||
Type: "user",
|
||
Title: "新的设计需求",
|
||
Content: prompt,
|
||
CreatedAt: now,
|
||
})
|
||
}
|
||
return design.Project{
|
||
ID: newID(),
|
||
UserID: design.UserIDFromContext(ctx),
|
||
Title: design.NormalizeTitle(title, inferTitle(prompt)),
|
||
Brief: prompt,
|
||
Status: status,
|
||
Thumbnail: inferThumbnail(prompt),
|
||
UpdatedAt: now,
|
||
Viewport: design.Viewport{X: 420, Y: 250, K: 0.7},
|
||
Connections: nil,
|
||
Messages: messages,
|
||
}, prompt
|
||
}
|
||
|
||
func newGeneratingImageNode(prompt string, offset int, imageSize string) design.Node {
|
||
width, height := design.GeneratedImageDimensions(imageSize)
|
||
column := offset % 3
|
||
row := offset / 3
|
||
columnSpacing := math.Max(width+80, 600)
|
||
rowSpacing := math.Max(height+100, 620)
|
||
return design.Node{
|
||
ID: newID(),
|
||
Type: design.NodeTypeImage,
|
||
Title: generationPlaceholderTitle(prompt),
|
||
X: float64(column)*columnSpacing - 240,
|
||
Y: float64(row)*rowSpacing - 120,
|
||
Width: width,
|
||
Height: height,
|
||
Content: prompt,
|
||
Tone: inferTone(prompt),
|
||
Status: "generating",
|
||
}
|
||
}
|
||
|
||
func newGeneratingImageNodes(prompt string, startOffset int, count int, imageSize string) []design.Node {
|
||
if count < 1 {
|
||
count = 1
|
||
}
|
||
nodes := make([]design.Node, 0, count)
|
||
for index := 0; index < count; index++ {
|
||
nodes = append(nodes, newGeneratingImageNode(prompt, startOffset+index, imageSize))
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
func newGeneratingImageNodesForPlan(prompt string, startOffset int, plan design.AgentTaskPlan, imageSize string) []design.Node {
|
||
count := plannedImageCount(plan)
|
||
nodes := newGeneratingImageNodes(prompt, startOffset, count, imageSize)
|
||
for index := range nodes {
|
||
if index >= len(plan.ImageTasks) {
|
||
continue
|
||
}
|
||
task := plan.ImageTasks[index]
|
||
if title := strings.TrimSpace(task.Title); title != "" {
|
||
nodes[index].Title = title
|
||
}
|
||
if brief := strings.TrimSpace(task.Brief); brief != "" {
|
||
nodes[index].Content = brief
|
||
}
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
func plannedImageCount(plan design.AgentTaskPlan) int {
|
||
count := plan.ImageCount
|
||
if count <= 0 && len(plan.ImageTasks) > 0 {
|
||
count = len(plan.ImageTasks)
|
||
}
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
if count > 10 {
|
||
count = 10
|
||
}
|
||
return count
|
||
}
|
||
|
||
func agentKickoffMessages(threadID string, actionID string, prompt string, plan design.AgentTaskPlan, createdAt time.Time) []design.Message {
|
||
response := strings.TrimSpace(plan.UserFacingResponse)
|
||
if response == "" {
|
||
response = "我会先理解需求和画布上下文,再把任务拆成合适的图像输出。"
|
||
}
|
||
return []design.Message{
|
||
{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: planningStepDoneTitle(prompt),
|
||
Content: response,
|
||
ThreadID: threadID,
|
||
ActionID: actionID,
|
||
StepID: newID(),
|
||
Name: "agent_planner",
|
||
ToolHint: "agent_planner",
|
||
Status: "success",
|
||
CreatedAt: createdAt,
|
||
},
|
||
}
|
||
}
|
||
|
||
func agentPlanningMessage(threadID string, actionID string, prompt string, webSearchEnabled bool, createdAt time.Time) design.Message {
|
||
return design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: planningStepTitle(prompt),
|
||
Content: planningStepPendingContent(prompt, webSearchEnabled),
|
||
ThreadID: threadID,
|
||
ActionID: actionID,
|
||
StepID: newID(),
|
||
Name: "agent_planner",
|
||
ToolHint: agentPlannerToolHint(webSearchEnabled),
|
||
Status: "running",
|
||
CreatedAt: createdAt,
|
||
}
|
||
}
|
||
|
||
func agentPlannerToolHint(webSearchEnabled bool) string {
|
||
if webSearchEnabled {
|
||
return "agent_planner:web_search_enabled"
|
||
}
|
||
return "agent_planner"
|
||
}
|
||
|
||
func planningStepTitle(prompt string) string {
|
||
if prefersEnglishText(prompt) {
|
||
return "Thinking..."
|
||
}
|
||
return "思考中..."
|
||
}
|
||
|
||
func planningStepDoneTitle(prompt string) string {
|
||
if prefersEnglishText(prompt) {
|
||
return "Thought complete"
|
||
}
|
||
return "思考完成"
|
||
}
|
||
|
||
func planningStepPendingContent(prompt string, webSearchEnabled bool) string {
|
||
if prefersEnglishText(prompt) {
|
||
if webSearchEnabled {
|
||
return "Understanding the request, reference order, and current canvas context. Web search is enabled; I will decide whether real research is needed."
|
||
}
|
||
return "Understanding the request, reference order, and current canvas context."
|
||
}
|
||
if webSearchEnabled {
|
||
return "正在理解需求、参考图顺序和当前画布上下文。联网搜索已启用,我会判断是否需要真实检索。"
|
||
}
|
||
return "正在理解需求、参考图顺序和当前画布上下文。"
|
||
}
|
||
|
||
func stoppedTaskTitle(prompt string) string {
|
||
if prefersEnglishText(prompt) {
|
||
return "Stopped"
|
||
}
|
||
return "已停止"
|
||
}
|
||
|
||
func stoppedTaskContent(prompt string) string {
|
||
if prefersEnglishText(prompt) {
|
||
return "The current task has been stopped. Finished images already on the canvas are kept."
|
||
}
|
||
return "当前任务已停止,已经生成到画布里的图片会保留。"
|
||
}
|
||
|
||
func generationStepContent(plan design.AgentTaskPlan) string {
|
||
count := plannedImageCount(plan)
|
||
if count > 1 {
|
||
return fmt.Sprintf("正在并行生成 %d 张图像,完成后会自动替换画布占位。", count)
|
||
}
|
||
return "正在生成图片,完成后会自动替换画布占位。"
|
||
}
|
||
|
||
func postResearchThoughtContent(plan design.AgentTaskPlan) string {
|
||
count := plannedImageCount(plan)
|
||
if count > 1 {
|
||
return fmt.Sprintf("参考资料已收集完成,现在开始并行生成 %d 张设计图。", count)
|
||
}
|
||
return "参考资料已收集完成,现在开始生成设计图。"
|
||
}
|
||
|
||
func (s *DesignService) decideAgentIntent(ctx context.Context, req design.AgentDecisionRequest) design.AgentDecision {
|
||
if s.creativeAgent != nil {
|
||
if decision, err := s.creativeAgent.Decide(ctx, req); err == nil && strings.TrimSpace(decision.Intent) != "" {
|
||
decision.Intent = normalizeAgentIntent(decision.Intent)
|
||
return decision
|
||
}
|
||
}
|
||
if shouldGenerateForAgentPrompt(req.Prompt, req.Mode, req.Messages) {
|
||
return design.AgentDecision{Intent: "image", Reason: "fallback visual generation intent"}
|
||
}
|
||
return design.AgentDecision{Intent: "chat", Reason: "fallback conversational intent"}
|
||
}
|
||
|
||
func (s *DesignService) planAgentTask(ctx context.Context, project design.Project, prompt string, mode string, messages []design.AgentChatMessage, webSearchEnabled bool, forceImage bool, memory design.AgentMemory) design.AgentTaskPlan {
|
||
if memory.IsZero() {
|
||
memory = buildAgentMemory(project, prompt, mode, messages)
|
||
}
|
||
if planner, ok := s.creativeAgent.(design.AgentTaskPlanner); ok {
|
||
if plan, err := planner.PlanAgentTask(ctx, design.AgentTaskPlanRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: messages,
|
||
Memory: memory,
|
||
WebSearchEnabled: webSearchEnabled,
|
||
}); err == nil {
|
||
return normalizeAgentTaskPlan(plan, prompt, mode, messages, webSearchEnabled, forceImage)
|
||
}
|
||
}
|
||
decision := s.decideAgentIntent(ctx, design.AgentDecisionRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: messages,
|
||
Memory: memory,
|
||
})
|
||
plan := design.AgentTaskPlan{
|
||
Intent: decision.Intent,
|
||
Reason: decision.Reason,
|
||
UserFacingResponse: fallbackPlanningResponse(prompt, decision.Intent),
|
||
ImageCount: 1,
|
||
ImageTasks: []design.AgentImageTask{
|
||
{Title: generationPlaceholderTitle(prompt), Brief: prompt},
|
||
},
|
||
}
|
||
if webSearchEnabled {
|
||
research := s.decideResearch(ctx, design.AgentResearchDecisionRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: messages,
|
||
Memory: memory,
|
||
})
|
||
plan.ShouldSearch = research.ShouldSearch
|
||
plan.SearchQuery = research.Query
|
||
plan.SearchReason = research.Reason
|
||
}
|
||
return normalizeAgentTaskPlan(plan, prompt, mode, messages, webSearchEnabled, forceImage)
|
||
}
|
||
|
||
func normalizeAgentTaskPlan(plan design.AgentTaskPlan, prompt string, mode string, messages []design.AgentChatMessage, webSearchEnabled bool, forceImage bool) design.AgentTaskPlan {
|
||
rawIntent := strings.TrimSpace(plan.Intent)
|
||
plan.Intent = normalizeAgentIntent(rawIntent)
|
||
if forceImage || shouldForceImageMode(mode) {
|
||
plan.Intent = "image"
|
||
}
|
||
if plan.Intent == "" {
|
||
if shouldGenerateForAgentPrompt(prompt, mode, messages) {
|
||
plan.Intent = "image"
|
||
} else {
|
||
plan.Intent = "chat"
|
||
}
|
||
}
|
||
if !webSearchEnabled {
|
||
plan.ShouldSearch = false
|
||
plan.SearchQuery = ""
|
||
}
|
||
plan.UserFacingResponse = strings.TrimSpace(plan.UserFacingResponse)
|
||
if plan.UserFacingResponse == "" {
|
||
plan.UserFacingResponse = fallbackPlanningResponse(prompt, plan.Intent)
|
||
}
|
||
tasks := make([]design.AgentImageTask, 0, len(plan.ImageTasks))
|
||
for _, task := range plan.ImageTasks {
|
||
title := strings.TrimSpace(task.Title)
|
||
brief := strings.TrimSpace(task.Brief)
|
||
if title == "" && brief == "" {
|
||
continue
|
||
}
|
||
if brief == "" {
|
||
brief = prompt
|
||
}
|
||
tasks = append(tasks, design.AgentImageTask{Title: title, Brief: brief})
|
||
}
|
||
if plan.Intent == "image" && len(tasks) == 0 {
|
||
tasks = append(tasks, design.AgentImageTask{Title: generationPlaceholderTitle(prompt), Brief: prompt})
|
||
}
|
||
plan.ImageTasks = tasks
|
||
if plan.Intent == "image" {
|
||
if plan.ImageCount <= 0 {
|
||
plan.ImageCount = len(plan.ImageTasks)
|
||
}
|
||
if plan.ImageCount <= 0 {
|
||
plan.ImageCount = 1
|
||
}
|
||
if plan.ImageCount > 10 {
|
||
plan.ImageCount = 10
|
||
}
|
||
if len(plan.ImageTasks) > plan.ImageCount {
|
||
plan.ImageTasks = plan.ImageTasks[:plan.ImageCount]
|
||
}
|
||
for len(plan.ImageTasks) < plan.ImageCount {
|
||
plan.ImageTasks = append(plan.ImageTasks, design.AgentImageTask{Title: generatedTaskFallbackTitle(prompt, len(plan.ImageTasks), plan.ImageCount), Brief: prompt})
|
||
}
|
||
} else {
|
||
plan.ImageCount = 0
|
||
plan.ImageTasks = nil
|
||
}
|
||
return plan
|
||
}
|
||
|
||
func fallbackPlanningResponse(prompt string, intent string) string {
|
||
if normalizeAgentIntent(intent) == "image" {
|
||
if prefersEnglishText(prompt) {
|
||
return "I will turn this into concrete image deliverables and generate them on the canvas."
|
||
}
|
||
return "我会先把需求拆成具体图片交付项,再在画布中生成。"
|
||
}
|
||
if prefersEnglishText(prompt) {
|
||
return "I will answer based on the current canvas context."
|
||
}
|
||
return "我会结合当前画布上下文回答。"
|
||
}
|
||
|
||
func generatedTaskFallbackTitle(prompt string, index int, count int) string {
|
||
base := generationPlaceholderTitle(prompt)
|
||
if count <= 1 {
|
||
return base
|
||
}
|
||
if prefersEnglishText(prompt) {
|
||
return fmt.Sprintf("%s %d", base, index+1)
|
||
}
|
||
return fmt.Sprintf("%s %d", base, index+1)
|
||
}
|
||
|
||
func agentChatPlaceholderMessage(threadID string, actionID string, createdAt time.Time) design.Message {
|
||
return design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: "思考完成",
|
||
ThreadID: threadID,
|
||
ActionID: actionID,
|
||
StepID: newID(),
|
||
Status: "streaming",
|
||
CreatedAt: createdAt,
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) startAgentConversation(projectID string, userID string, threadID string, messageID string, req design.AgentConversationRequest) {
|
||
s.enqueueOrRunJob(design.Job{
|
||
Kind: design.JobAgentConversation,
|
||
UserID: userID,
|
||
ProjectID: projectID,
|
||
ThreadID: threadID,
|
||
MessageID: messageID,
|
||
Conversation: req,
|
||
}, func(ctx context.Context) error {
|
||
return s.completeAgentConversation(ctx, projectID, threadID, messageID, req)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) completeAgentConversation(ctx context.Context, projectID string, threadID string, messageID string, req design.AgentConversationRequest) error {
|
||
ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID)
|
||
defer cleanup()
|
||
if err := ctx.Err(); err != nil {
|
||
return nil
|
||
}
|
||
if cancelled, err := s.projectThreadCancelled(ctx, projectID, threadID); err != nil {
|
||
return err
|
||
} else if cancelled {
|
||
return nil
|
||
}
|
||
var content string
|
||
var err error
|
||
if streamer, ok := s.creativeAgent.(design.StreamingCreativeAgent); ok {
|
||
content, err = s.streamAgentChatResponse(ctx, projectID, messageID, req, streamer)
|
||
} else {
|
||
content, err = s.createAgentChatResponse(ctx, req)
|
||
}
|
||
if err != nil || strings.TrimSpace(content) == "" {
|
||
content = fallbackAgentChatResponse(req.Prompt, req.Project)
|
||
}
|
||
if cancelled, err := s.projectThreadCancelled(ctx, projectID, threadID); err != nil {
|
||
return err
|
||
} else if cancelled {
|
||
return nil
|
||
}
|
||
return s.updateAgentMessage(ctx, projectID, messageID, content, "success", design.StatusReady)
|
||
}
|
||
|
||
func (s *DesignService) streamAgentChatResponse(ctx context.Context, projectID string, messageID string, req design.AgentConversationRequest, streamer design.StreamingCreativeAgent) (string, error) {
|
||
var builder strings.Builder
|
||
lastFlush := time.Now()
|
||
lastFlushedLen := 0
|
||
flush := func(force bool) error {
|
||
if !force && time.Since(lastFlush) < 120*time.Millisecond && builder.Len()-lastFlushedLen < 24 {
|
||
return nil
|
||
}
|
||
lastFlush = time.Now()
|
||
lastFlushedLen = builder.Len()
|
||
return s.updateAgentMessage(ctx, projectID, messageID, builder.String(), "streaming", design.StatusExploring)
|
||
}
|
||
content, err := streamer.StreamRespond(ctx, req, func(delta string) error {
|
||
if strings.TrimSpace(delta) == "" {
|
||
builder.WriteString(delta)
|
||
return nil
|
||
}
|
||
builder.WriteString(delta)
|
||
return flush(false)
|
||
})
|
||
if builder.Len() > 0 {
|
||
_ = flush(true)
|
||
}
|
||
if err != nil {
|
||
return builder.String(), err
|
||
}
|
||
if strings.TrimSpace(content) != "" {
|
||
return content, nil
|
||
}
|
||
return builder.String(), nil
|
||
}
|
||
|
||
func (s *DesignService) createAgentChatResponse(ctx context.Context, req design.AgentConversationRequest) (string, error) {
|
||
if s.creativeAgent != nil {
|
||
content, err := s.creativeAgent.Respond(ctx, req)
|
||
if err == nil && strings.TrimSpace(content) != "" {
|
||
return content, nil
|
||
}
|
||
}
|
||
return fallbackAgentChatResponse(req.Prompt, req.Project), nil
|
||
}
|
||
|
||
func (s *DesignService) updateAgentMessage(ctx context.Context, projectID string, messageID string, content string, status string, projectStatus design.ProjectStatus) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for index := range project.Messages {
|
||
if project.Messages[index].ID == messageID {
|
||
project.Messages[index].Content = content
|
||
project.Messages[index].Status = status
|
||
break
|
||
}
|
||
}
|
||
project.Status = projectStatus
|
||
project.UpdatedAt = s.now()
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func fallbackAgentChatResponse(prompt string, project design.Project) string {
|
||
if isCapabilityPrompt(strings.ToLower(strings.TrimSpace(prompt))) {
|
||
if prefersEnglishText(prompt) {
|
||
return "I focus on image generation and image editing: brand/site visuals, ecommerce product images, posters, banners, social graphics, reference-based generation, background removal, vectorization, mockup placement, and multi-round canvas iteration. Give me a clear visual task and I will generate or edit images on the canvas."
|
||
}
|
||
return "我专注图片生成和图片编辑:品牌/官网视觉、电商产品图、海报 Banner、社媒图、参考图生图、去背景、矢量化、Mockup 贴图,以及在无限画布里多轮迭代。你给我明确的图片任务后,我会进入生图或修图链路。"
|
||
}
|
||
if strings.TrimSpace(project.Brief) != "" {
|
||
if prefersEnglishText(prompt) {
|
||
return fmt.Sprintf("I'm here. The current canvas is \"%s\". Give me a specific change or ask me to generate a new visual direction.", project.Brief)
|
||
}
|
||
return fmt.Sprintf("我在。当前画布是「%s」。你可以继续给我一个具体修改点,或者让我生成新的视觉方向。", project.Brief)
|
||
}
|
||
if prefersEnglishText(prompt) {
|
||
return "I'm here. Describe the image you want to generate or edit; if this is just a chat, I will answer first and will not generate automatically."
|
||
}
|
||
return "我在。你可以直接描述要生成或编辑的图片;如果只是聊天,我会先回答,不会自动生图。"
|
||
}
|
||
|
||
func normalizeAgentIntent(intent string) string {
|
||
intent = strings.ToLower(strings.TrimSpace(intent))
|
||
switch intent {
|
||
case "image", "generate_image", "edit_image", "visual", "design":
|
||
return "image"
|
||
case "":
|
||
return ""
|
||
case "research", "clarify", "brief", "briefing", "question", "questions", "chat", "conversation":
|
||
return "chat"
|
||
default:
|
||
return "chat"
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) startNodeActionGeneration(projectID string, userID string, threadID string, target design.Node, req design.NodeActionRequest) {
|
||
s.enqueueOrRunJob(design.Job{
|
||
Kind: design.JobNodeActionGeneration,
|
||
UserID: userID,
|
||
ProjectID: projectID,
|
||
ThreadID: threadID,
|
||
Target: target,
|
||
Request: req,
|
||
}, func(ctx context.Context) error {
|
||
return s.completeNodeActionGeneration(ctx, projectID, threadID, target, req)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) ProcessJob(ctx context.Context, job design.Job) error {
|
||
ctx = design.ContextWithUserID(ctx, job.UserID)
|
||
switch job.Kind {
|
||
case design.JobCreateProjectGeneration:
|
||
return s.finishGenerationJob(job, s.completeCreateProjectGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.ImageModel, job.ImageSize, job.EnableWebSearch, job.TaskPlan))
|
||
case design.JobFollowupGeneration:
|
||
return s.finishGenerationJob(job, s.completeFollowupGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.Mode, job.ImageModel, job.ImageSize, job.Messages, job.EnableWebSearch, job.Isolated, job.TaskPlan))
|
||
case design.JobNodeActionGeneration:
|
||
return s.completeNodeActionGeneration(ctx, job.ProjectID, job.ThreadID, job.Target, job.Request)
|
||
case design.JobAgentConversation:
|
||
return ignoreCancelledJob(s.completeAgentConversation(ctx, job.ProjectID, job.ThreadID, job.MessageID, job.Conversation))
|
||
case design.JobTextExtraction:
|
||
return s.completeTextExtraction(ctx, job.ProjectID, job.ThreadID, job.Target, job.Feature)
|
||
case design.JobMockupModelGeneration:
|
||
if err := s.completeMockupModelGeneration(ctx, job.ProjectID, job.ThreadID, job.Target); err != nil {
|
||
return s.markMockupModelFailed(detachedUserContext(ctx), job.ProjectID, job.ThreadID, job.Target, err)
|
||
}
|
||
return nil
|
||
case design.JobLayerMergeGeneration:
|
||
if err := s.completeLayerMergeGeneration(ctx, job.ProjectID, job.ThreadID, job.PlaceholderID, job.SourceNodes, job.Title); err != nil {
|
||
return s.markLayerMergeFailed(detachedUserContext(ctx), job.ProjectID, job.ThreadID, job.PlaceholderID, job.SourceNodes, err)
|
||
}
|
||
return nil
|
||
case design.JobAssetCleanup:
|
||
return s.deleteAssetURLsWithRetry(job.PublicURLs)
|
||
default:
|
||
return fmt.Errorf("%w: unsupported job kind %s", design.ErrInvalidInput, job.Kind)
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) HandleJobFailure(ctx context.Context, job design.Job, failure error) error {
|
||
if failure == nil {
|
||
return nil
|
||
}
|
||
ctx = design.ContextWithUserID(ctx, job.UserID)
|
||
switch job.Kind {
|
||
case design.JobCreateProjectGeneration, design.JobFollowupGeneration:
|
||
return s.finishGenerationJob(job, failure)
|
||
case design.JobNodeActionGeneration:
|
||
return s.markNodeActionFailed(ctx, job.ProjectID, job.ThreadID, nodeActionResultNodeID(job.Target, job.Request), failure)
|
||
case design.JobMockupModelGeneration:
|
||
return s.markMockupModelFailed(ctx, job.ProjectID, job.ThreadID, job.Target, failure)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) finishGenerationJob(job design.Job, err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
ctx := design.ContextWithUserID(context.Background(), job.UserID)
|
||
if s.isUserCancelledGeneration(ctx, job.ProjectID, job.ThreadID, err) {
|
||
return nil
|
||
}
|
||
logx.Errorf("generation job failed kind=%s project=%s thread=%s err=%v", job.Kind, job.ProjectID, job.ThreadID, err)
|
||
if job.Kind == design.JobFollowupGeneration && job.Isolated {
|
||
if markErr := s.markIsolatedGenerationFailed(ctx, job.ProjectID, imageGeneratorTargetNodeID(job.Messages), err); markErr != nil {
|
||
return markErr
|
||
}
|
||
return nil
|
||
}
|
||
if markErr := s.markAgentGenerationFailed(ctx, job.ProjectID, job.ThreadID, err); markErr != nil {
|
||
return markErr
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *DesignService) isUserCancelledGeneration(ctx context.Context, projectID string, threadID string, err error) bool {
|
||
if err == nil || !errors.Is(err, context.Canceled) {
|
||
return false
|
||
}
|
||
if s.isAgentTaskCancelled(projectID, threadID) {
|
||
return true
|
||
}
|
||
project, getErr := s.repo.Get(detachedUserContext(ctx), projectID)
|
||
if getErr != nil {
|
||
return false
|
||
}
|
||
return threadIsCancelled(project.Messages, threadID)
|
||
}
|
||
|
||
func detachedUserContext(ctx context.Context) context.Context {
|
||
return design.ContextWithUserID(context.Background(), design.UserIDFromContext(ctx))
|
||
}
|
||
|
||
func ignoreCancelledJob(err error) error {
|
||
if errors.Is(err, context.Canceled) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
|
||
func (s *DesignService) enqueueOrRunJob(job design.Job, fallback func(context.Context) error) {
|
||
job.UserID = design.NormalizeUserID(job.UserID)
|
||
if s.jobs != nil {
|
||
if err := s.jobs.Enqueue(context.Background(), job); err == nil {
|
||
return
|
||
} else {
|
||
logx.Errorf("enqueue job %s failed: %v", job.Kind, err)
|
||
}
|
||
}
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), generationBudget)
|
||
defer cancel()
|
||
ctx = design.ContextWithUserID(ctx, job.UserID)
|
||
_ = fallback(ctx)
|
||
}()
|
||
}
|
||
|
||
func (s *DesignService) withAgentTaskCancellation(ctx context.Context, projectID string, threadID string) (context.Context, func()) {
|
||
key := agentTaskKey(projectID, threadID)
|
||
if key == "" {
|
||
return ctx, func() {}
|
||
}
|
||
taskCtx, cancel := context.WithCancel(ctx)
|
||
s.agentTaskCancels.Store(key, context.CancelFunc(cancel))
|
||
if s.isAgentTaskCancelled(projectID, threadID) {
|
||
cancel()
|
||
}
|
||
return taskCtx, func() {
|
||
cancel()
|
||
s.agentTaskCancels.Delete(key)
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) cancelAgentTask(projectID string, threadID string) {
|
||
key := agentTaskKey(projectID, threadID)
|
||
if key == "" {
|
||
return
|
||
}
|
||
s.cancelledAgentTasks.Store(key, s.now())
|
||
if cancel, ok := s.agentTaskCancels.Load(key); ok {
|
||
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
|
||
cancelFunc()
|
||
}
|
||
}
|
||
time.AfterFunc(10*time.Minute, func() {
|
||
s.cancelledAgentTasks.Delete(key)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) isAgentTaskCancelled(projectID string, threadID string) bool {
|
||
key := agentTaskKey(projectID, threadID)
|
||
if key == "" {
|
||
return false
|
||
}
|
||
_, ok := s.cancelledAgentTasks.Load(key)
|
||
return ok
|
||
}
|
||
|
||
func (s *DesignService) projectThreadCancelled(ctx context.Context, projectID string, threadID string) (bool, error) {
|
||
if s.isAgentTaskCancelled(projectID, threadID) {
|
||
return true, nil
|
||
}
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return threadIsCancelled(project.Messages, threadID), nil
|
||
}
|
||
|
||
func agentTaskKey(projectID string, threadID string) string {
|
||
projectID = strings.TrimSpace(projectID)
|
||
threadID = strings.TrimSpace(threadID)
|
||
if projectID == "" || threadID == "" {
|
||
return ""
|
||
}
|
||
return projectID + ":" + threadID
|
||
}
|
||
|
||
func (s *DesignService) completeNodeActionGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||
resultNodeID := nodeActionResultNodeID(target, req)
|
||
toolTitle := "Pi Agent Image"
|
||
if req.Action == "remove-background" {
|
||
toolTitle = "RMBG 1.4"
|
||
} else if req.Action == "vectorize" {
|
||
toolTitle = "GPT 5.5 Vectorizer"
|
||
}
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", toolTitle, nodeActionToolContent(req.Action)); err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, nodeActionResultNodeID(target, req), err)
|
||
}
|
||
if req.Action == "remove-background" {
|
||
return s.completeBackgroundRemoval(ctx, projectID, threadID, target, req)
|
||
}
|
||
if req.Action == "vectorize" {
|
||
return s.completeVectorizeGeneration(ctx, projectID, threadID, target, req)
|
||
}
|
||
if req.Action == "edit-elements" || req.Action == "edit-text" {
|
||
return s.completeEditableLayerGeneration(ctx, projectID, threadID, target, req)
|
||
}
|
||
editToolContent := "正在根据选中图片执行模型编辑,完成后会替换画布中的图片节点。"
|
||
if req.Action == "move-object" {
|
||
editToolContent = "正在基于旁侧副本移动选中对象,完成后会保留原图并更新副本。"
|
||
}
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "GPT Image 2", editToolContent); err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, nodeActionResultNodeID(target, req), err)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
actionPrompt := buildNodeActionPrompt(req, target)
|
||
actionMessages := []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: req.Prompt, TextSource: "input"},
|
||
{Type: "image", ImageURL: target.Content, ImageWidth: int64(target.Width), ImageHeight: int64(target.Height)},
|
||
},
|
||
},
|
||
}
|
||
projectForPlan := projectWithoutGeneratingNodes(project)
|
||
plan, err := s.planWithHeartbeat(ctx, projectID, threadID, design.AgentRequest{
|
||
Project: projectForPlan,
|
||
Prompt: actionPrompt,
|
||
Mode: "image-action:" + req.Action,
|
||
ImageModel: strings.TrimSpace(req.ImageModel),
|
||
ImageSize: strings.TrimSpace(req.ImageSize),
|
||
Messages: actionMessages,
|
||
Memory: buildAgentMemory(projectForPlan, actionPrompt, "image-action:"+req.Action, actionMessages),
|
||
})
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
plan.GeneratedNodes, err = s.persistNodeImageAssets(ctx, projectID, plan.GeneratedNodes)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
|
||
generatedURL := firstGeneratedImageURL(plan.GeneratedNodes)
|
||
if generatedURL == "" {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, fmt.Errorf("image action returned no image"))
|
||
}
|
||
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
|
||
if nodeIndex < 0 {
|
||
if req.Action != "move-object" {
|
||
return design.ErrNotFound
|
||
}
|
||
project.Nodes = append(project.Nodes, newMoveObjectPlaceholder(target))
|
||
nodeIndex = len(project.Nodes) - 1
|
||
project.Nodes[nodeIndex].ID = resultNodeID
|
||
}
|
||
|
||
now := s.now()
|
||
project.Nodes[nodeIndex].Title = nodeActionResultTitle(req.Action)
|
||
project.Nodes[nodeIndex].Content = generatedURL
|
||
if req.Action == "expand" {
|
||
applyExpandResultGeometry(&project.Nodes[nodeIndex], req)
|
||
}
|
||
project.Nodes[nodeIndex].Status = "success"
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
message := plan.Message
|
||
message.CreatedAt = now
|
||
message.Title = nodeActionDoneTitle(req.Action)
|
||
message.Content = nodeActionDoneContent(req.Action, req.Prompt)
|
||
message.ThreadID = threadID
|
||
message.ActionID = threadID
|
||
message.StepID = newID()
|
||
message.Name = "canvas_action"
|
||
message.ToolHint = "canvas_action"
|
||
message.Status = "success"
|
||
if message.Type == "" {
|
||
message.Type = "assistant"
|
||
}
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, req.Prompt, []design.Node{project.Nodes[nodeIndex]}, now.Add(-time.Millisecond)))
|
||
project.Messages = append(project.Messages, message)
|
||
project.Connections = nil
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) completeBackgroundRemoval(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||
resultNodeID := nodeActionResultNodeID(target, req)
|
||
if s.backgroundRemover == nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, fmt.Errorf("background remover is not configured"))
|
||
}
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "ONNX Runtime", "正在用 rmbg1.4.onnx 执行异步主体分割,优先使用 GPU,失败时自动退回 CPU。"); err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
|
||
data, contentType, err := loadImageContent(ctx, target.Content)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
inputSize, _ := positiveIntOption(req.Options, "inputSize")
|
||
imageWidth := int(math.Round(target.Width))
|
||
imageHeight := int(math.Round(target.Height))
|
||
if width, ok := positiveIntOption(req.Options, "imageWidth"); ok {
|
||
imageWidth = width
|
||
}
|
||
if height, ok := positiveIntOption(req.Options, "imageHeight"); ok {
|
||
imageHeight = height
|
||
}
|
||
removed, err := s.backgroundRemover.RemoveBackground(ctx, design.BackgroundRemovalRequest{
|
||
Image: data,
|
||
ContentType: contentType,
|
||
Width: imageWidth,
|
||
Height: imageHeight,
|
||
InputSize: inputSize,
|
||
})
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
generatedURL, err := s.persistBackgroundRemovalResult(ctx, projectID, target, removed)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
|
||
if nodeIndex < 0 {
|
||
project.Nodes = append(project.Nodes, newBackgroundRemovalPlaceholder(target))
|
||
nodeIndex = len(project.Nodes) - 1
|
||
project.Nodes[nodeIndex].ID = resultNodeID
|
||
}
|
||
|
||
now := s.now()
|
||
project.Nodes[nodeIndex].Title = nodeActionResultTitle(req.Action)
|
||
project.Nodes[nodeIndex].Content = generatedURL
|
||
project.Nodes[nodeIndex].Status = "success"
|
||
project.Nodes[nodeIndex].Type = design.NodeTypeImage
|
||
project.Nodes[nodeIndex].Tone = "transparent-image"
|
||
project.Nodes[nodeIndex].LayerRole = "background-removed"
|
||
project.Nodes[nodeIndex].ParentID = target.ID
|
||
if removed.Width > 0 && removed.Height > 0 && target.Width <= 0 && target.Height <= 0 {
|
||
project.Nodes[nodeIndex].Width = float64(removed.Width)
|
||
project.Nodes[nodeIndex].Height = float64(removed.Height)
|
||
}
|
||
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, nodeActionDoneContent(req.Action, req.Prompt), []design.Node{project.Nodes[nodeIndex]}, now.Add(-time.Millisecond)))
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: nodeActionDoneTitle(req.Action),
|
||
Content: nodeActionDoneContent(req.Action, req.Prompt),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "success",
|
||
CreatedAt: now,
|
||
})
|
||
project.Connections = nil
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) completeVectorizeGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||
resultNodeID := nodeActionResultNodeID(target, req)
|
||
if s.imageVectorizer == nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, fmt.Errorf("image vectorizer is not configured"))
|
||
}
|
||
imageDataURL, err := nodeImageDataURL(ctx, target)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
result, err := s.imageVectorizer.VectorizeImage(ctx, design.ImageVectorizationRequest{
|
||
ImageDataURL: imageDataURL,
|
||
Prompt: strings.TrimSpace(req.Prompt),
|
||
Title: strings.TrimSpace(target.Title),
|
||
Width: int64(math.Round(target.Width)),
|
||
Height: int64(math.Round(target.Height)),
|
||
})
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
svg := strings.TrimSpace(result.SVG)
|
||
if svg == "" {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, fmt.Errorf("image vectorizer returned no SVG"))
|
||
}
|
||
generatedURL, err := s.persistVectorizedSVG(ctx, projectID, resultNodeID, svg)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
|
||
if nodeIndex < 0 {
|
||
project.Nodes = append(project.Nodes, newVectorizePlaceholder(target))
|
||
nodeIndex = len(project.Nodes) - 1
|
||
project.Nodes[nodeIndex].ID = resultNodeID
|
||
}
|
||
|
||
now := s.now()
|
||
project.Nodes[nodeIndex].Title = vectorizedNodeTitle(target)
|
||
project.Nodes[nodeIndex].Content = generatedURL
|
||
project.Nodes[nodeIndex].Status = "success"
|
||
project.Nodes[nodeIndex].Type = design.NodeTypeImage
|
||
project.Nodes[nodeIndex].LayerRole = "vectorized-image"
|
||
project.Nodes[nodeIndex].Tone = "vector-image"
|
||
if project.Nodes[nodeIndex].Width <= 0 && result.Width > 0 {
|
||
project.Nodes[nodeIndex].Width = float64(result.Width)
|
||
}
|
||
if project.Nodes[nodeIndex].Height <= 0 && result.Height > 0 {
|
||
project.Nodes[nodeIndex].Height = float64(result.Height)
|
||
}
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, nodeActionDoneContent(req.Action, req.Prompt), []design.Node{project.Nodes[nodeIndex]}, now.Add(-time.Millisecond)))
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: nodeActionDoneTitle(req.Action),
|
||
Content: nodeActionDoneContent(req.Action, req.Prompt),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "success",
|
||
CreatedAt: now,
|
||
})
|
||
project.Connections = nil
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func nodeImageDataURL(ctx context.Context, node design.Node) (string, error) {
|
||
content := strings.TrimSpace(node.Content)
|
||
if strings.HasPrefix(content, "data:image/") {
|
||
return content, nil
|
||
}
|
||
data, contentType, err := loadImageContent(ctx, content)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
contentType = strings.TrimSpace(contentType)
|
||
if contentType == "" || !strings.HasPrefix(contentType, "image/") {
|
||
contentType = http.DetectContentType(data)
|
||
}
|
||
if !strings.HasPrefix(contentType, "image/") {
|
||
return "", fmt.Errorf("%w: vectorize requires an image", design.ErrInvalidInput)
|
||
}
|
||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||
}
|
||
|
||
func (s *DesignService) persistVectorizedSVG(ctx context.Context, projectID string, nodeID string, svg string) (string, error) {
|
||
svg = normalizeSVGMarkup(svg)
|
||
if svg == "" {
|
||
return "", fmt.Errorf("vectorizer returned empty SVG")
|
||
}
|
||
if s.assets == nil {
|
||
return "data:image/svg+xml;charset=utf-8," + url.QueryEscape(svg), nil
|
||
}
|
||
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
|
||
FileName: fmt.Sprintf("%s-%s-vectorized.svg", projectID, nodeID),
|
||
ContentType: "image/svg+xml",
|
||
Data: []byte(svg),
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if strings.TrimSpace(object.PublicURL) == "" {
|
||
return "", fmt.Errorf("vectorized SVG upload returned empty public URL")
|
||
}
|
||
return object.PublicURL, nil
|
||
}
|
||
|
||
func normalizeSVGMarkup(svg string) string {
|
||
svg = strings.TrimSpace(svg)
|
||
if strings.HasPrefix(svg, "```") {
|
||
svg = strings.Trim(svg, "`")
|
||
svg = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(svg), "svg"))
|
||
}
|
||
start := strings.Index(strings.ToLower(svg), "<svg")
|
||
end := strings.LastIndex(strings.ToLower(svg), "</svg>")
|
||
if start < 0 || end < start {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(svg[start : end+len("</svg>")])
|
||
}
|
||
|
||
func (s *DesignService) persistBackgroundRemovalResult(ctx context.Context, projectID string, target design.Node, result design.BackgroundRemoval) (string, error) {
|
||
if len(result.Image) == 0 {
|
||
return "", fmt.Errorf("background removal returned no image")
|
||
}
|
||
contentType := strings.TrimSpace(result.ContentType)
|
||
if contentType == "" {
|
||
contentType = "image/png"
|
||
}
|
||
if s.assets == nil {
|
||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(result.Image), nil
|
||
}
|
||
data, assetContentType, err := encodeRasterAssetAsWebP(result.Image, contentType)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
|
||
FileName: fmt.Sprintf("%s-%s-remove-background.webp", projectID, target.ID),
|
||
ContentType: assetContentType,
|
||
Data: data,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if strings.TrimSpace(object.PublicURL) == "" {
|
||
return "", fmt.Errorf("background removal upload returned empty public URL")
|
||
}
|
||
return object.PublicURL, nil
|
||
}
|
||
|
||
func (s *DesignService) completeEditableLayerGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "OCR 文字定位", "正在用 gpt-5.4-mini 识别原图文字、位置和大致排版。"); err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, target.ID, err)
|
||
}
|
||
|
||
extraction, err := s.extractTextExtraction(ctx, target, req)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, target.ID, fmt.Errorf("OCR 文字定位未完成: %w", err))
|
||
}
|
||
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "生成无文字底图", "正在只擦除 OCR 命中的文字区域,保留原图构图、主体和像素细节。"); err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, target.ID, err)
|
||
}
|
||
|
||
cleanImageURL, err := s.createTextRemovedImage(ctx, projectID, target, extraction)
|
||
if err != nil {
|
||
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, target.ID, fmt.Errorf("生成无文字底图失败: %w", err))
|
||
}
|
||
editableTextNodes := newEditableTextLayers(target, extraction)
|
||
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, target.ID)
|
||
if nodeIndex < 0 {
|
||
return design.ErrNotFound
|
||
}
|
||
|
||
now := s.now()
|
||
project.Nodes[nodeIndex].Title = nodeActionResultTitle(req.Action)
|
||
project.Nodes[nodeIndex].Content = cleanImageURL
|
||
project.Nodes[nodeIndex].Status = "success"
|
||
project.Nodes[nodeIndex].LayerRole = "clean-image"
|
||
project.Nodes = append(project.Nodes, editableTextNodes...)
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, req.Prompt, []design.Node{project.Nodes[nodeIndex]}, now.Add(-time.Millisecond)))
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "assistant",
|
||
Type: "assistant",
|
||
Title: nodeActionDoneTitle(req.Action),
|
||
Content: nodeActionDoneContent(req.Action, req.Prompt),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "success",
|
||
CreatedAt: now,
|
||
})
|
||
project.Connections = nil
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) startTextExtraction(projectID string, userID string, threadID string, target design.Node, feature string) {
|
||
s.enqueueOrRunJob(design.Job{
|
||
Kind: design.JobTextExtraction,
|
||
UserID: userID,
|
||
ProjectID: projectID,
|
||
ThreadID: threadID,
|
||
Target: target,
|
||
Feature: feature,
|
||
}, func(ctx context.Context) error {
|
||
return s.completeTextExtraction(ctx, projectID, threadID, target, feature)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) completeTextExtraction(ctx context.Context, projectID string, threadID string, target design.Node, feature string) error {
|
||
extraction, err := s.extractTextExtraction(ctx, target, design.NodeActionRequest{
|
||
Action: "edit-text",
|
||
Prompt: textExtractionPromptForFeature(feature),
|
||
})
|
||
if err != nil {
|
||
return s.markTextExtractionFailed(detachedUserContext(ctx), projectID, threadID, target, err)
|
||
}
|
||
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, target.ID)
|
||
if nodeIndex < 0 {
|
||
return design.ErrNotFound
|
||
}
|
||
|
||
now := s.now()
|
||
project.Nodes[nodeIndex].Status = restoredNodeStatus(target.Status)
|
||
project.Nodes[nodeIndex].Title = target.Title
|
||
resultJSON, err := textExtractionResultJSON(extraction)
|
||
if err != nil {
|
||
return s.markTextExtractionFailed(detachedUserContext(ctx), projectID, threadID, target, err)
|
||
}
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "tool",
|
||
Type: textExtractionResultMessageType,
|
||
Title: "文字提取完成",
|
||
Content: resultJSON,
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "text_extraction",
|
||
Status: "success",
|
||
CreatedAt: now,
|
||
})
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) markTextExtractionFailed(ctx context.Context, projectID string, threadID string, target design.Node, failure error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeIndex := findNodeIndex(project.Nodes, target.ID)
|
||
if nodeIndex >= 0 {
|
||
project.Nodes[nodeIndex].Status = restoredNodeStatus(target.Status)
|
||
project.Nodes[nodeIndex].Title = target.Title
|
||
}
|
||
now := s.now()
|
||
project.UpdatedAt = now
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "文字提取失败",
|
||
Content: failure.Error(),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func normalizeTextExtractionFeature(feature string) string {
|
||
feature = strings.ToLower(strings.TrimSpace(feature))
|
||
feature = strings.ReplaceAll(feature, "_", "-")
|
||
switch feature {
|
||
case "", "eitdortxt", "editortxt", "editor-txt", "edit-text", "text-edit":
|
||
return "edit-text"
|
||
default:
|
||
return feature
|
||
}
|
||
}
|
||
|
||
func textExtractionPromptForFeature(feature string) string {
|
||
switch normalizeTextExtractionFeature(feature) {
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func restoredNodeStatus(status string) string {
|
||
status = strings.TrimSpace(status)
|
||
if status == "" || status == "generating" || status == "text-extracting" || status == "mockup-modeling" {
|
||
return "success"
|
||
}
|
||
return status
|
||
}
|
||
|
||
func (s *DesignService) restoreFinishedTextExtractionNodes(project design.Project, now time.Time) (design.Project, bool) {
|
||
if now.IsZero() {
|
||
now = s.now()
|
||
}
|
||
hasTextExtractingNode := false
|
||
for _, node := range project.Nodes {
|
||
if node.Status == "text-extracting" {
|
||
hasTextExtractingNode = true
|
||
break
|
||
}
|
||
}
|
||
if !hasTextExtractingNode {
|
||
return project, false
|
||
}
|
||
|
||
active, staleThreads := textExtractionActivity(project.Messages, now)
|
||
if active {
|
||
return project, false
|
||
}
|
||
|
||
for index := range project.Nodes {
|
||
if project.Nodes[index].Status == "text-extracting" {
|
||
project.Nodes[index].Status = restoredNodeStatus(project.Nodes[index].Status)
|
||
}
|
||
}
|
||
for _, threadID := range staleThreads {
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "文字提取超时",
|
||
Content: "文字提取任务超时,已恢复图片状态,请重试。",
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "text_extraction",
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
}
|
||
project.UpdatedAt = now
|
||
return project, true
|
||
}
|
||
|
||
func textExtractionActivity(messages []design.Message, now time.Time) (active bool, staleThreads []string) {
|
||
running := make(map[string]design.Message)
|
||
finished := make(map[string]bool)
|
||
for _, message := range messages {
|
||
if strings.TrimSpace(message.ThreadID) == "" {
|
||
continue
|
||
}
|
||
if message.Type == textExtractionResultMessageType || (message.Name == "text_extraction" && message.Status == "success") {
|
||
finished[message.ThreadID] = true
|
||
continue
|
||
}
|
||
if (message.Name == "text_extraction" && message.Status == "failed") || (message.Role == "error" && message.Name == "text_extraction") {
|
||
finished[message.ThreadID] = true
|
||
continue
|
||
}
|
||
if message.Name == "text_extraction" && message.Status == "running" {
|
||
running[message.ThreadID] = message
|
||
}
|
||
}
|
||
for threadID, message := range running {
|
||
if finished[threadID] {
|
||
continue
|
||
}
|
||
if message.CreatedAt.IsZero() || now.Sub(message.CreatedAt) <= textExtractionStaleAfter {
|
||
active = true
|
||
continue
|
||
}
|
||
staleThreads = append(staleThreads, threadID)
|
||
}
|
||
return active, staleThreads
|
||
}
|
||
|
||
func mergeSavedCanvasWorkState(existing []design.Node, incoming []design.Node) []design.Node {
|
||
if len(incoming) == 0 {
|
||
return incoming
|
||
}
|
||
existingByID := make(map[string]design.Node, len(existing))
|
||
for _, node := range existing {
|
||
existingByID[node.ID] = node
|
||
}
|
||
next := make([]design.Node, 0, len(incoming))
|
||
for _, node := range incoming {
|
||
if node.Status == "generating" {
|
||
if current, ok := existingByID[node.ID]; ok && current.Status != "generating" {
|
||
next = append(next, current)
|
||
continue
|
||
}
|
||
if _, ok := existingByID[node.ID]; !ok && node.LayerRole != "image-generator" {
|
||
continue
|
||
}
|
||
}
|
||
if node.Status != "text-extracting" {
|
||
next = append(next, node)
|
||
continue
|
||
}
|
||
if current, ok := existingByID[node.ID]; ok && current.Status != "text-extracting" {
|
||
node.Status = restoredNodeStatus(current.Status)
|
||
next = append(next, node)
|
||
continue
|
||
}
|
||
if _, ok := existingByID[node.ID]; !ok {
|
||
node.Status = restoredNodeStatus(node.Status)
|
||
}
|
||
next = append(next, node)
|
||
}
|
||
return next
|
||
}
|
||
|
||
func (s *DesignService) startCreateProjectGeneration(projectID string, userID string, threadID string, prompt string, imageModel string, imageSize string, enableWebSearch bool, taskPlan design.AgentTaskPlan) {
|
||
s.enqueueOrRunJob(design.Job{
|
||
Kind: design.JobCreateProjectGeneration,
|
||
UserID: userID,
|
||
ProjectID: projectID,
|
||
ThreadID: threadID,
|
||
Prompt: prompt,
|
||
ImageModel: imageModel,
|
||
ImageSize: imageSize,
|
||
TaskPlan: taskPlan,
|
||
EnableWebSearch: enableWebSearch,
|
||
}, func(ctx context.Context) error {
|
||
return s.completeCreateProjectGeneration(ctx, projectID, threadID, prompt, imageModel, imageSize, enableWebSearch, taskPlan)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, projectID string, threadID string, prompt string, imageModel string, imageSize string, enableWebSearch bool, taskPlan design.AgentTaskPlan) error {
|
||
ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID)
|
||
defer cleanup()
|
||
if err := ctx.Err(); err != nil {
|
||
if s.isUserCancelledGeneration(ctx, projectID, threadID, err) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return nil
|
||
}
|
||
messages := []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: prompt, TextSource: "input"},
|
||
},
|
||
},
|
||
}
|
||
s.streamAgentPlanningMessage(ctx, projectID, threadID, design.AgentTaskPlanRequest{
|
||
Project: projectWithoutGeneratingNodes(project),
|
||
Prompt: prompt,
|
||
Mode: "design",
|
||
Messages: messages,
|
||
Memory: buildAgentMemory(project, prompt, "design", messages),
|
||
WebSearchEnabled: enableWebSearch,
|
||
})
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
memory := buildAgentMemory(project, prompt, "design", messages)
|
||
if agentTaskPlanIsEmpty(taskPlan) {
|
||
taskPlan = s.planAgentTask(ctx, projectWithoutGeneratingNodes(project), prompt, "design", messages, enableWebSearch, false, memory)
|
||
} else {
|
||
taskPlan = normalizeAgentTaskPlan(taskPlan, prompt, "design", messages, enableWebSearch, false)
|
||
}
|
||
if cancelled, err := s.projectThreadCancelled(ctx, projectID, threadID); err != nil {
|
||
return err
|
||
} else if cancelled {
|
||
return nil
|
||
}
|
||
if err := s.maybeAddPlannedResearchStep(ctx, projectID, threadID, project, prompt, "design", messages, enableWebSearch, taskPlan); err != nil {
|
||
return err
|
||
}
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if taskPlan.Intent != "image" {
|
||
messageID, err := s.prepareCreateProjectConversation(ctx, projectID, threadID, prompt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
reqProject, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.completeAgentConversation(ctx, projectID, threadID, messageID, design.AgentConversationRequest{
|
||
Project: reqProject,
|
||
Prompt: prompt,
|
||
Mode: "design",
|
||
Messages: messages,
|
||
Memory: buildAgentMemory(reqProject, prompt, "design", messages),
|
||
TaskPlan: taskPlan,
|
||
})
|
||
}
|
||
if err := s.applyCreateProjectTaskPlan(ctx, projectID, threadID, prompt, imageSize, taskPlan); err != nil {
|
||
return err
|
||
}
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "GPT Image 2", generationStepContent(taskPlan)); err != nil {
|
||
return err
|
||
}
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
plan, incremental, err := s.planWithIncrementalImages(ctx, projectID, threadID, prompt, "", true, design.AgentRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: "design",
|
||
ImageModel: strings.TrimSpace(imageModel),
|
||
ImageSize: strings.TrimSpace(imageSize),
|
||
Messages: messages,
|
||
Memory: buildAgentMemory(project, prompt, "design", messages),
|
||
TaskPlan: taskPlan,
|
||
})
|
||
if err != nil {
|
||
if s.isUserCancelledGeneration(ctx, projectID, threadID, err) {
|
||
return nil
|
||
}
|
||
if incremental && len(completedGeneratedImageNodes(plan.GeneratedNodes)) > 0 {
|
||
return s.markAgentGenerationPartiallyFailed(detachedUserContext(ctx), projectID, threadID, "", plan.GeneratedNodes, err)
|
||
}
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
if !incremental {
|
||
plan.GeneratedNodes, err = s.persistNodeImageAssets(ctx, projectID, plan.GeneratedNodes)
|
||
if err != nil {
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
} else if err := incompleteIncrementalGenerationError(plan, taskPlan); err != nil {
|
||
if len(completedGeneratedImageNodes(plan.GeneratedNodes)) > 0 {
|
||
return s.markAgentGenerationPartiallyFailed(detachedUserContext(ctx), projectID, threadID, "", plan.GeneratedNodes, err)
|
||
}
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return nil
|
||
}
|
||
|
||
now := s.now()
|
||
message := plan.Message
|
||
message.CreatedAt = now
|
||
message.ThreadID = threadID
|
||
message.ActionID = threadID
|
||
message.StepID = newID()
|
||
message.Status = "success"
|
||
if message.Type == "" {
|
||
message.Type = "assistant"
|
||
}
|
||
if incremental {
|
||
project.Nodes = mergeCompletedIncrementalGeneratedNodes(project.Nodes, plan.GeneratedNodes, "")
|
||
project.Nodes = clearRemainingAgentGenerationPlaceholders(project.Nodes, "")
|
||
project.Messages = appendMissingIncrementalArtifactMessages(project.Messages, threadID, prompt, plan.GeneratedNodes, now.Add(-time.Millisecond))
|
||
} else {
|
||
project.Nodes = mergeGeneratedNodesIntoTarget(nonGeneratingNodes(project.Nodes), plan.GeneratedNodes, "")
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, prompt, plan.GeneratedNodes, now.Add(-time.Millisecond)))
|
||
}
|
||
project.Connections = nil
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "success")
|
||
project.Messages = append(project.Messages, message)
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func agentTaskPlanIsEmpty(plan design.AgentTaskPlan) bool {
|
||
return strings.TrimSpace(plan.Intent) == "" &&
|
||
strings.TrimSpace(plan.Reason) == "" &&
|
||
strings.TrimSpace(plan.UserFacingResponse) == "" &&
|
||
!plan.ShouldSearch &&
|
||
strings.TrimSpace(plan.SearchQuery) == "" &&
|
||
strings.TrimSpace(plan.SearchReason) == "" &&
|
||
plan.ImageCount == 0 &&
|
||
len(plan.ImageTasks) == 0
|
||
}
|
||
|
||
func (s *DesignService) applyCreateProjectTaskPlan(ctx context.Context, projectID string, threadID string, prompt string, imageSize string, taskPlan design.AgentTaskPlan) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
updatedPlanningMessage := false
|
||
for index := range project.Messages {
|
||
if project.Messages[index].ThreadID != threadID {
|
||
continue
|
||
}
|
||
if project.Messages[index].Name != "agent_planner" && project.Messages[index].ToolHint != "agent_planner" {
|
||
continue
|
||
}
|
||
project.Messages[index].Title = planningStepDoneTitle(prompt)
|
||
existingContent := strings.TrimSpace(project.Messages[index].Content)
|
||
project.Messages[index].Content = strings.TrimSpace(taskPlan.UserFacingResponse)
|
||
if project.Messages[index].Content == "" {
|
||
project.Messages[index].Content = existingContent
|
||
}
|
||
if project.Messages[index].Content == "" {
|
||
project.Messages[index].Content = fallbackPlanningResponse(prompt, taskPlan.Intent)
|
||
}
|
||
project.Messages[index].Status = "success"
|
||
updatedPlanningMessage = true
|
||
break
|
||
}
|
||
if !updatedPlanningMessage {
|
||
project.Messages = append(project.Messages, agentKickoffMessages(threadID, threadID, prompt, taskPlan, now)...)
|
||
}
|
||
if !hasPlannedGeneratingNodes(project.Nodes, prompt, taskPlan) && !hasActiveAgentGenerationPlaceholder(project, threadID) {
|
||
project.Nodes = append(project.Nodes, newGeneratingImageNodesForPlan(prompt, len(project.Nodes), taskPlan, imageSize)...)
|
||
}
|
||
project.Brief = prompt
|
||
project.Status = design.StatusExploring
|
||
project.Thumbnail = inferThumbnail(prompt)
|
||
project.Connections = nil
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func hasPlannedGeneratingNodes(nodes []design.Node, prompt string, taskPlan design.AgentTaskPlan) bool {
|
||
plannedContent := map[string]bool{strings.TrimSpace(prompt): true}
|
||
for _, task := range taskPlan.ImageTasks {
|
||
if brief := strings.TrimSpace(task.Brief); brief != "" {
|
||
plannedContent[brief] = true
|
||
}
|
||
}
|
||
for _, node := range nodes {
|
||
if node.Type != design.NodeTypeImage || node.Status != "generating" || node.LayerRole == "image-generator" {
|
||
continue
|
||
}
|
||
if plannedContent[strings.TrimSpace(node.Content)] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasActiveAgentGenerationPlaceholder(project design.Project, threadID string) bool {
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" || strings.TrimSpace(project.LastThreadID) != threadID {
|
||
return false
|
||
}
|
||
for _, node := range project.Nodes {
|
||
if node.Type == design.NodeTypeImage && node.Status == "generating" && node.LayerRole != "image-generator" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *DesignService) prepareCreateProjectConversation(ctx context.Context, projectID string, threadID string, prompt string) (string, error) {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
now := s.now()
|
||
for index := range project.Messages {
|
||
if project.Messages[index].ThreadID != threadID {
|
||
continue
|
||
}
|
||
if project.Messages[index].Name != "agent_planner" && project.Messages[index].ToolHint != "agent_planner" {
|
||
continue
|
||
}
|
||
project.Messages[index].Title = planningStepDoneTitle(prompt)
|
||
project.Messages[index].Content = ""
|
||
project.Messages[index].Status = "streaming"
|
||
project.Messages[index].Name = ""
|
||
project.Messages[index].ToolHint = ""
|
||
project.UpdatedAt = now
|
||
project.Status = design.StatusExploring
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return "", err
|
||
}
|
||
return project.Messages[index].ID, nil
|
||
}
|
||
|
||
message := agentChatPlaceholderMessage(threadID, threadID, now)
|
||
project.Messages = append(project.Messages, message)
|
||
project.UpdatedAt = now
|
||
project.Status = design.StatusExploring
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return "", err
|
||
}
|
||
return message.ID, nil
|
||
}
|
||
|
||
func (s *DesignService) streamAgentPlanningMessage(ctx context.Context, projectID string, threadID string, req design.AgentTaskPlanRequest) {
|
||
narrator, ok := s.creativeAgent.(design.AgentPlanningNarrator)
|
||
if !ok || narrator == nil {
|
||
return
|
||
}
|
||
var builder strings.Builder
|
||
lastFlush := time.Now()
|
||
lastFlushedLen := 0
|
||
flush := func(force bool) error {
|
||
if !force && time.Since(lastFlush) < 160*time.Millisecond && builder.Len()-lastFlushedLen < 18 {
|
||
return nil
|
||
}
|
||
lastFlush = time.Now()
|
||
lastFlushedLen = builder.Len()
|
||
return s.updateAgentPlannerMessage(ctx, projectID, threadID, builder.String(), "running")
|
||
}
|
||
content, err := narrator.StreamPlanNarration(ctx, req, func(delta string) error {
|
||
builder.WriteString(delta)
|
||
return flush(false)
|
||
})
|
||
if builder.Len() > 0 {
|
||
_ = flush(true)
|
||
}
|
||
if err == nil && strings.TrimSpace(content) != "" && strings.TrimSpace(content) != strings.TrimSpace(builder.String()) {
|
||
_ = s.updateAgentPlannerMessage(ctx, projectID, threadID, content, "running")
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) updateAgentPlannerMessage(ctx context.Context, projectID string, threadID string, content string, status string) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
index := findPlannerMessageIndex(project.Messages, threadID)
|
||
if index < 0 {
|
||
return nil
|
||
}
|
||
project.Messages[index].Content = strings.TrimSpace(content)
|
||
if strings.TrimSpace(status) != "" {
|
||
project.Messages[index].Status = status
|
||
}
|
||
project.Messages[index].Name = "agent_planner"
|
||
if strings.TrimSpace(project.Messages[index].ToolHint) == "" {
|
||
project.Messages[index].ToolHint = "agent_planner"
|
||
}
|
||
project.UpdatedAt = s.now()
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func applyPlannerTaskPlanMessage(project *design.Project, threadID string, prompt string, plan design.AgentTaskPlan) {
|
||
if project == nil {
|
||
return
|
||
}
|
||
index := findPlannerMessageIndex(project.Messages, threadID)
|
||
if index < 0 {
|
||
return
|
||
}
|
||
existingContent := strings.TrimSpace(project.Messages[index].Content)
|
||
content := strings.TrimSpace(plan.UserFacingResponse)
|
||
if content == "" {
|
||
content = existingContent
|
||
}
|
||
if content == "" {
|
||
content = fallbackPlanningResponse(prompt, plan.Intent)
|
||
}
|
||
project.Messages[index].Title = planningStepDoneTitle(prompt)
|
||
project.Messages[index].Content = content
|
||
project.Messages[index].Status = "success"
|
||
project.Messages[index].Name = "agent_planner"
|
||
if strings.TrimSpace(project.Messages[index].ToolHint) == "" {
|
||
project.Messages[index].ToolHint = "agent_planner"
|
||
}
|
||
}
|
||
|
||
func preparePlannerForChatMessage(project *design.Project, threadID string, actionID string, prompt string, now time.Time) string {
|
||
if project == nil {
|
||
return ""
|
||
}
|
||
index := findPlannerMessageIndex(project.Messages, threadID)
|
||
if index >= 0 {
|
||
project.Messages[index].Title = planningStepDoneTitle(prompt)
|
||
project.Messages[index].Content = ""
|
||
project.Messages[index].Status = "streaming"
|
||
project.Messages[index].Name = ""
|
||
project.Messages[index].ToolHint = ""
|
||
return project.Messages[index].ID
|
||
}
|
||
message := agentChatPlaceholderMessage(threadID, actionID, now)
|
||
project.Messages = append(project.Messages, message)
|
||
return message.ID
|
||
}
|
||
|
||
func findPlannerMessageIndex(messages []design.Message, threadID string) int {
|
||
threadID = strings.TrimSpace(threadID)
|
||
for index := len(messages) - 1; index >= 0; index-- {
|
||
message := messages[index]
|
||
if threadID != "" && strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if message.Name == "agent_planner" || strings.HasPrefix(message.ToolHint, "agent_planner") {
|
||
return index
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (s *DesignService) startFollowupGeneration(projectID string, userID string, threadID string, prompt string, mode string, imageModel string, imageSize string, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) {
|
||
s.enqueueOrRunJob(design.Job{
|
||
Kind: design.JobFollowupGeneration,
|
||
UserID: userID,
|
||
ProjectID: projectID,
|
||
ThreadID: threadID,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
ImageModel: imageModel,
|
||
ImageSize: imageSize,
|
||
TaskPlan: taskPlan,
|
||
Messages: messages,
|
||
EnableWebSearch: enableWebSearch,
|
||
Isolated: isolated,
|
||
}, func(ctx context.Context) error {
|
||
return s.completeFollowupGeneration(ctx, projectID, threadID, prompt, mode, imageModel, imageSize, messages, enableWebSearch, isolated, taskPlan)
|
||
})
|
||
}
|
||
|
||
func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectID string, threadID string, prompt string, mode string, imageModel string, imageSize string, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) error {
|
||
ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID)
|
||
defer cleanup()
|
||
if err := ctx.Err(); err != nil {
|
||
if s.isUserCancelledGeneration(ctx, projectID, threadID, err) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return nil
|
||
}
|
||
scopedProject := projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID)
|
||
memory := buildAgentMemory(scopedProject, prompt, mode, messages)
|
||
taskPlan = normalizeAgentTaskPlan(taskPlan, prompt, mode, messages, enableWebSearch, isolated)
|
||
if agentTaskPlanIsEmpty(taskPlan) && !isolated {
|
||
taskPlan = s.planAgentTask(ctx, scopedProject, prompt, mode, messages, enableWebSearch, false, memory)
|
||
}
|
||
if cancelled, err := s.projectThreadCancelled(ctx, projectID, threadID); err != nil {
|
||
return err
|
||
} else if cancelled {
|
||
return nil
|
||
}
|
||
if !isolated {
|
||
if !hasRunningGenerationToolMessage(project.Messages, threadID, "GPT Image 2") {
|
||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "GPT Image 2", generationStepContent(taskPlan)); err != nil {
|
||
return err
|
||
}
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
request := design.AgentRequest{
|
||
Project: projectScopedToAgentThread(project, threadID),
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
ImageModel: strings.TrimSpace(imageModel),
|
||
ImageSize: strings.TrimSpace(imageSize),
|
||
Messages: messages,
|
||
Memory: buildAgentMemory(projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, messages),
|
||
TaskPlan: taskPlan,
|
||
}
|
||
var plan design.AgentPlan
|
||
var incremental bool
|
||
targetNodeID := imageGeneratorTargetNodeID(messages)
|
||
if isolated {
|
||
plan, incremental, err = s.planWithIncrementalImages(ctx, projectID, threadID, prompt, targetNodeID, false, request)
|
||
} else {
|
||
plan, incremental, err = s.planWithIncrementalImages(ctx, projectID, threadID, prompt, targetNodeID, true, request)
|
||
}
|
||
if err != nil {
|
||
if s.isUserCancelledGeneration(ctx, projectID, threadID, err) {
|
||
return nil
|
||
}
|
||
if isolated {
|
||
return s.markIsolatedGenerationFailed(detachedUserContext(ctx), projectID, targetNodeID, err)
|
||
}
|
||
if incremental && len(completedGeneratedImageNodes(plan.GeneratedNodes)) > 0 {
|
||
return s.markAgentGenerationPartiallyFailed(detachedUserContext(ctx), projectID, threadID, targetNodeID, plan.GeneratedNodes, err)
|
||
}
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
if !incremental {
|
||
plan.GeneratedNodes, err = s.persistNodeImageAssets(ctx, projectID, plan.GeneratedNodes)
|
||
if err != nil {
|
||
if isolated {
|
||
return s.markIsolatedGenerationFailed(detachedUserContext(ctx), projectID, targetNodeID, err)
|
||
}
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
} else if err := incompleteIncrementalGenerationError(plan, taskPlan); err != nil {
|
||
if isolated {
|
||
return s.markIsolatedGenerationFailed(detachedUserContext(ctx), projectID, targetNodeID, err)
|
||
}
|
||
if len(completedGeneratedImageNodes(plan.GeneratedNodes)) > 0 {
|
||
return s.markAgentGenerationPartiallyFailed(detachedUserContext(ctx), projectID, threadID, targetNodeID, plan.GeneratedNodes, err)
|
||
}
|
||
return s.markAgentGenerationFailed(detachedUserContext(ctx), projectID, threadID, err)
|
||
}
|
||
|
||
project, err = s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return nil
|
||
}
|
||
|
||
now := s.now()
|
||
message := plan.Message
|
||
message.CreatedAt = now
|
||
message.ThreadID = threadID
|
||
message.ActionID = threadID
|
||
message.StepID = newID()
|
||
message.Status = "success"
|
||
if message.Type == "" {
|
||
message.Type = "assistant"
|
||
}
|
||
if incremental {
|
||
project.Nodes = mergeCompletedIncrementalGeneratedNodes(project.Nodes, plan.GeneratedNodes, targetNodeID)
|
||
project.Nodes = clearRemainingAgentGenerationPlaceholders(project.Nodes, targetNodeID)
|
||
if !isolated {
|
||
project.Messages = appendMissingIncrementalArtifactMessages(project.Messages, threadID, prompt, plan.GeneratedNodes, now.Add(-time.Millisecond))
|
||
}
|
||
} else {
|
||
baseNodes := nonGeneratingNodes(project.Nodes)
|
||
if targetNodeID != "" {
|
||
baseNodes = nonGeneratingNodesExceptTarget(project.Nodes, targetNodeID)
|
||
}
|
||
project.Nodes = mergeGeneratedNodesIntoTarget(baseNodes, plan.GeneratedNodes, targetNodeID)
|
||
}
|
||
project.Connections = nil
|
||
if !isolated && !incremental {
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, prompt, plan.GeneratedNodes, now.Add(-time.Millisecond)))
|
||
}
|
||
if !isolated {
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "success")
|
||
project.Messages = append(project.Messages, message)
|
||
}
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) planWithHeartbeat(ctx context.Context, projectID string, threadID string, req design.AgentRequest) (design.AgentPlan, error) {
|
||
if req.Memory.IsZero() {
|
||
req.Memory = buildAgentMemory(req.Project, req.Prompt, req.Mode, req.Messages)
|
||
}
|
||
done := make(chan struct{})
|
||
go s.emitGenerationHeartbeat(ctx, projectID, threadID, req.Prompt, done)
|
||
plan, err := s.agent.Plan(ctx, req)
|
||
close(done)
|
||
return plan, err
|
||
}
|
||
|
||
func (s *DesignService) planWithIncrementalImages(ctx context.Context, projectID string, threadID string, prompt string, targetNodeID string, appendArtifacts bool, req design.AgentRequest) (design.AgentPlan, bool, error) {
|
||
runner, ok := s.agent.(design.IncrementalAgentRunner)
|
||
if !ok {
|
||
plan, err := s.planWithHeartbeat(ctx, projectID, threadID, req)
|
||
return plan, false, err
|
||
}
|
||
if req.Memory.IsZero() {
|
||
req.Memory = buildAgentMemory(req.Project, req.Prompt, req.Mode, req.Messages)
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
if appendArtifacts {
|
||
heartbeatStopped := make(chan struct{})
|
||
go func() {
|
||
defer close(heartbeatStopped)
|
||
s.emitGenerationHeartbeat(ctx, projectID, threadID, req.Prompt, done)
|
||
}()
|
||
defer func() {
|
||
close(done)
|
||
<-heartbeatStopped
|
||
}()
|
||
}
|
||
var mu sync.Mutex
|
||
persistedNodes := make([]design.Node, 0, plannedGenerationResultCapacity(req))
|
||
plan, err := runner.PlanIncremental(ctx, req, func(result design.AgentImageGenerationResult) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
nodes, err := s.persistNodeImageAssets(ctx, projectID, []design.Node{result.Node})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(nodes) == 0 {
|
||
return fmt.Errorf("image generation returned empty image")
|
||
}
|
||
node := nodes[0]
|
||
persistedNodes = append(persistedNodes, node)
|
||
return s.applyIncrementalGeneratedNode(ctx, projectID, threadID, prompt, targetNodeID, node, appendArtifacts)
|
||
})
|
||
if len(persistedNodes) > 0 {
|
||
plan.GeneratedNodes = append([]design.Node(nil), persistedNodes...)
|
||
}
|
||
return plan, true, err
|
||
}
|
||
|
||
func incompleteIncrementalGenerationError(plan design.AgentPlan, taskPlan design.AgentTaskPlan) error {
|
||
expected := plannedImageCount(taskPlan)
|
||
actual := len(completedGeneratedImageNodes(plan.GeneratedNodes))
|
||
if actual >= expected {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("only %d of %d planned images returned", actual, expected)
|
||
}
|
||
|
||
func completedGeneratedImageNodes(nodes []design.Node) []design.Node {
|
||
completed := make([]design.Node, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame {
|
||
continue
|
||
}
|
||
if !isGeneratedImageContent(node.Content) {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(node.Status), "generating") {
|
||
continue
|
||
}
|
||
completed = append(completed, node)
|
||
}
|
||
return completed
|
||
}
|
||
|
||
func mergeCompletedIncrementalGeneratedNodes(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node {
|
||
completed := completedGeneratedImageNodes(generated)
|
||
if len(completed) == 0 {
|
||
return nodes
|
||
}
|
||
if strings.TrimSpace(targetNodeID) != "" {
|
||
return mergeGeneratedNodesIntoTarget(nodes, completed, targetNodeID)
|
||
}
|
||
for _, node := range completed {
|
||
nodes = upsertGeneratedNode(nodes, node)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
func appendMissingIncrementalArtifactMessages(messages []design.Message, threadID string, prompt string, nodes []design.Node, createdAt time.Time) []design.Message {
|
||
existing := generatedImageArtifactsByID(messages)
|
||
for _, node := range completedGeneratedImageNodes(nodes) {
|
||
if _, ok := existing[node.ID]; ok {
|
||
continue
|
||
}
|
||
messages = append(messages, toolArtifactsMessage(threadID, prompt, []design.Node{node}, createdAt))
|
||
existing[node.ID] = design.GeneratorTaskArtifact{}
|
||
}
|
||
return messages
|
||
}
|
||
|
||
func plannedGenerationResultCapacity(req design.AgentRequest) int {
|
||
count := req.TaskPlan.ImageCount
|
||
if count <= 0 {
|
||
count = len(req.TaskPlan.ImageTasks)
|
||
}
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
if count > 10 {
|
||
count = 10
|
||
}
|
||
return count
|
||
}
|
||
|
||
func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, projectID string, threadID string, prompt string, targetNodeID string, node design.Node, appendArtifact bool) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return context.Canceled
|
||
}
|
||
if strings.TrimSpace(targetNodeID) != "" {
|
||
project.Nodes = mergeGeneratedNodesIntoTarget(nonGeneratingNodesExceptTarget(project.Nodes, targetNodeID), []design.Node{node}, targetNodeID)
|
||
} else {
|
||
project.Nodes = upsertGeneratedNode(project.Nodes, node)
|
||
}
|
||
now := s.now()
|
||
if appendArtifact {
|
||
project.Messages = append(project.Messages, toolArtifactsMessage(threadID, prompt, []design.Node{node}, now))
|
||
}
|
||
project.Connections = nil
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func threadIsCancelled(messages []design.Message, threadID string) bool {
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" {
|
||
return false
|
||
}
|
||
for _, message := range messages {
|
||
if strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(message.Status), "cancelled") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *DesignService) emitGenerationHeartbeat(ctx context.Context, projectID string, threadID string, prompt string, done <-chan struct{}) {
|
||
startedAt := s.now()
|
||
timer := time.NewTimer(generationHeartbeatInterval)
|
||
defer timer.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-done:
|
||
return
|
||
case <-timer.C:
|
||
elapsed := s.now().Sub(startedAt)
|
||
_ = s.updateLatestGenerationStep(ctx, projectID, threadID, generationHeartbeatContent(prompt, elapsed), "running")
|
||
timer.Reset(generationHeartbeatInterval)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) addGenerationStep(ctx context.Context, projectID string, threadID string, role string, title string, content string) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return context.Canceled
|
||
}
|
||
now := s.now()
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: role,
|
||
Type: role,
|
||
Title: title,
|
||
Content: content,
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: generationStepStatus(role, title),
|
||
CreatedAt: now,
|
||
})
|
||
project.UpdatedAt = now
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func generationStepStatus(role string, title string) string {
|
||
if !strings.EqualFold(strings.TrimSpace(role), "tool") {
|
||
return ""
|
||
}
|
||
switch strings.TrimSpace(title) {
|
||
case "GPT Image 2", "Pi Agent Image", "OCR 文字定位", "生成无文字底图":
|
||
return "running"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) updateLatestGenerationStep(ctx context.Context, projectID string, threadID string, content string, status string) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
index := latestGenerationToolMessageIndex(project.Messages, threadID, "GPT Image 2")
|
||
if index < 0 {
|
||
return nil
|
||
}
|
||
project.Messages[index].Content = content
|
||
project.Messages[index].Status = status
|
||
project.UpdatedAt = s.now()
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func latestGenerationToolMessageIndex(messages []design.Message, threadID string, title string) int {
|
||
threadID = strings.TrimSpace(threadID)
|
||
title = strings.TrimSpace(title)
|
||
for index := len(messages) - 1; index >= 0; index-- {
|
||
message := messages[index]
|
||
if threadID != "" && strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(message.Title) == title && strings.EqualFold(strings.TrimSpace(message.Role), "tool") {
|
||
return index
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func hasRunningGenerationToolMessage(messages []design.Message, threadID string, title string) bool {
|
||
index := latestGenerationToolMessageIndex(messages, threadID, title)
|
||
if index < 0 {
|
||
return false
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(messages[index].Status), "running")
|
||
}
|
||
|
||
func markGenerationToolMessages(messages []design.Message, threadID string, title string, status string) {
|
||
threadID = strings.TrimSpace(threadID)
|
||
for index := range messages {
|
||
if threadID != "" && strings.TrimSpace(messages[index].ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(messages[index].Title) != title || !strings.EqualFold(strings.TrimSpace(messages[index].Role), "tool") {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(messages[index].Status), "running") || strings.TrimSpace(messages[index].Status) == "" {
|
||
messages[index].Status = status
|
||
}
|
||
}
|
||
}
|
||
|
||
func generationHeartbeatContent(prompt string, elapsed time.Duration) string {
|
||
elapsedText := approximateDuration(elapsed)
|
||
if prefersEnglishText(prompt) {
|
||
return "Image generation is still running, about " + elapsedText + " elapsed. The canvas placeholder will be replaced as soon as the image returns."
|
||
}
|
||
return "图片还在生成中,已等待约 " + elapsedText + "。结果返回后会立即替换画布占位。"
|
||
}
|
||
|
||
func approximateDuration(duration time.Duration) string {
|
||
if duration < time.Second {
|
||
return "0s"
|
||
}
|
||
totalSeconds := int(duration.Round(time.Second).Seconds())
|
||
minutes := totalSeconds / 60
|
||
seconds := totalSeconds % 60
|
||
if minutes <= 0 {
|
||
return fmt.Sprintf("%ds", seconds)
|
||
}
|
||
if seconds == 0 {
|
||
return fmt.Sprintf("%dm", minutes)
|
||
}
|
||
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||
}
|
||
|
||
func (s *DesignService) maybeAddResearchStep(ctx context.Context, projectID string, threadID string, project design.Project, prompt string, mode string, messages []design.AgentChatMessage, enabled bool) error {
|
||
if !enabled || s.search == nil {
|
||
return nil
|
||
}
|
||
decision := s.decideResearch(ctx, design.AgentResearchDecisionRequest{
|
||
Project: project,
|
||
Prompt: prompt,
|
||
Mode: mode,
|
||
Messages: messages,
|
||
Memory: buildAgentMemory(project, prompt, mode, messages),
|
||
})
|
||
query := enabledResearchQuery(prompt, mode, decision)
|
||
if query == "" {
|
||
return nil
|
||
}
|
||
return s.addResearchStep(ctx, projectID, threadID, query)
|
||
}
|
||
|
||
func (s *DesignService) maybeAddPlannedResearchStep(ctx context.Context, projectID string, threadID string, project design.Project, prompt string, mode string, messages []design.AgentChatMessage, enabled bool, plan design.AgentTaskPlan) error {
|
||
if !enabled || s.search == nil {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(plan.Intent) == "" {
|
||
return s.maybeAddResearchStep(ctx, projectID, threadID, project, prompt, mode, messages, enabled)
|
||
}
|
||
query := strings.TrimSpace(plan.SearchQuery)
|
||
if query == "" && plan.ShouldSearch {
|
||
query = fallbackResearchQuery(prompt, mode)
|
||
}
|
||
if query == "" {
|
||
return nil
|
||
}
|
||
return s.addResearchStep(ctx, projectID, threadID, query)
|
||
}
|
||
|
||
func enabledResearchQuery(prompt string, mode string, decision design.AgentResearchDecision) string {
|
||
if strings.TrimSpace(decision.Query) != "" {
|
||
return strings.TrimSpace(decision.Query)
|
||
}
|
||
if !shouldSearchWhenEnabled(prompt, mode) {
|
||
return ""
|
||
}
|
||
return fallbackResearchQuery(prompt, mode)
|
||
}
|
||
|
||
func shouldSearchWhenEnabled(prompt string, mode string) bool {
|
||
text := strings.ToLower(strings.TrimSpace(prompt))
|
||
if text == "" || isConversationalPrompt(text) {
|
||
return false
|
||
}
|
||
modeText := strings.ToLower(strings.TrimSpace(mode))
|
||
if strings.Contains(modeText, "image-action") {
|
||
return shouldRunResearchFallback(prompt, mode)
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *DesignService) decideResearch(ctx context.Context, req design.AgentResearchDecisionRequest) design.AgentResearchDecision {
|
||
if decider, ok := s.creativeAgent.(design.ResearchDecider); ok {
|
||
if decision, err := decider.DecideResearch(ctx, req); err == nil {
|
||
decision.Query = strings.TrimSpace(decision.Query)
|
||
return decision
|
||
}
|
||
return design.AgentResearchDecision{Reason: "research decision unavailable"}
|
||
}
|
||
if shouldRunResearchFallback(req.Prompt, req.Mode) {
|
||
return design.AgentResearchDecision{
|
||
ShouldSearch: true,
|
||
Query: fallbackResearchQuery(req.Prompt, req.Mode),
|
||
Reason: "fallback research intent",
|
||
}
|
||
}
|
||
return design.AgentResearchDecision{Reason: "no research needed"}
|
||
}
|
||
|
||
func fallbackResearchQuery(prompt string, mode string) string {
|
||
text := strings.ToLower(strings.TrimSpace(prompt + " " + mode))
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
terms := []string{"design reference", "case study", "visual identity", "moodboard"}
|
||
if hasAny(text, []string{"logo", "品牌标识", "标志", "商标"}) {
|
||
terms = []string{"logo design case study", "brand identity", "visual identity", "design process", "moodboard", "-maker", "-generator", "-template"}
|
||
}
|
||
if hasAny(text, []string{"海报", "poster"}) {
|
||
terms = []string{"poster design reference", "campaign key visual", "layout inspiration", "case study"}
|
||
}
|
||
if hasAny(text, []string{"包装", "packaging"}) {
|
||
terms = []string{"packaging design reference", "brand identity system", "case study"}
|
||
}
|
||
if hasAny(text, []string{"官网", "首页", "website", "landing", "ui"}) {
|
||
terms = []string{"website design reference", "landing page case study", "brand visual style", "ui inspiration"}
|
||
}
|
||
if hasAny(text, []string{"设计 agent", "设计agent", "design agent", "设计智能体"}) {
|
||
terms = append([]string{"AI design agent"}, terms...)
|
||
}
|
||
return strings.Join(terms, " ")
|
||
}
|
||
|
||
func toolConfigEnablesSearch(config design.AgentToolConfig) bool {
|
||
for category, tools := range config.PreferToolCategories {
|
||
if strings.EqualFold(category, "SEARCH") && len(tools) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func shouldRunResearchFallback(prompt string, mode string) bool {
|
||
text := strings.ToLower(strings.TrimSpace(prompt + "\n" + mode))
|
||
if text == "" {
|
||
return false
|
||
}
|
||
keywords := []string{
|
||
"联网",
|
||
"搜索",
|
||
"调研",
|
||
"查找",
|
||
"参考网页",
|
||
"竞品",
|
||
"趋势",
|
||
"最新",
|
||
"官网参考",
|
||
"web",
|
||
"search",
|
||
"research",
|
||
"latest",
|
||
"trend",
|
||
"competitor",
|
||
}
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(text, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func shouldGenerateForAgentPrompt(prompt string, mode string, messages []design.AgentChatMessage) bool {
|
||
text := strings.ToLower(strings.TrimSpace(prompt))
|
||
modeText := strings.ToLower(strings.TrimSpace(mode))
|
||
if text == "" {
|
||
return false
|
||
}
|
||
if strings.Contains(modeText, "image-action") {
|
||
return true
|
||
}
|
||
if modeText == "image" || modeText == "pure-image" {
|
||
return true
|
||
}
|
||
if hasExplicitImageGenerationIntent(text, hasAgentImageInput(messages)) {
|
||
return true
|
||
}
|
||
if strings.Contains(modeText, "chat") || isConversationalPrompt(text) {
|
||
return false
|
||
}
|
||
return false
|
||
}
|
||
|
||
func shouldForceImageMode(mode string) bool {
|
||
modeText := strings.ToLower(strings.TrimSpace(mode))
|
||
return strings.Contains(modeText, "image-action") || modeText == "image" || modeText == "pure-image"
|
||
}
|
||
|
||
func hasAgentImageInput(messages []design.AgentChatMessage) bool {
|
||
for _, message := range messages {
|
||
for _, content := range message.Contents {
|
||
if strings.EqualFold(strings.TrimSpace(content.Type), "image") && strings.TrimSpace(content.ImageURL) != "" {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasExplicitImageGenerationIntent(text string, hasImageInput bool) bool {
|
||
if hasAny(text, []string{
|
||
"生成", "生图", "出图", "画一", "画个", "做一", "做个", "设计", "制作", "创建", "产出", "输出",
|
||
"改", "修改", "调整", "优化", "精修", "换色", "换背景", "去背景", "抠图", "放大", "高清", "扩展", "适配", "重做", "重新",
|
||
"generate", "create", "make", "design", "edit", "replace", "remove background", "upscale", "outpaint", "revise",
|
||
}) && hasAny(text, visualIntentKeywords()) {
|
||
return true
|
||
}
|
||
if hasAny(text, []string{"去背景", "抠图", "放大", "高清", "换背景", "换色", "局部", "擦除", "移动端", "pdp", "banner", "logo"}) {
|
||
return true
|
||
}
|
||
if hasImageInput && hasAny(text, []string{
|
||
"没有", "不如", "不好", "不够", "更", "保留", "延续", "参考", "像", "调整", "优化", "精修", "重做", "重新", "换", "改", "版本", "v2", "v3", "v4", "v5",
|
||
}) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func visualIntentKeywords() []string {
|
||
return []string{
|
||
"图", "图片", "图像", "海报", "视觉", "官网", "首页", "页面", "详情页", "电商", "产品", "模特", "背景", "品牌", "logo", "banner", "画面", "设计稿", "素材",
|
||
"image", "poster", "website", "landing", "page", "hero", "visual", "brand", "product", "background", "mockup",
|
||
}
|
||
}
|
||
|
||
func isConversationalPrompt(text string) bool {
|
||
return isGreetingPrompt(text) || isCapabilityPrompt(text) || hasAny(text, []string{
|
||
"谢谢", "辛苦", "你是谁", "介绍一下", "怎么用", "help", "thanks", "thank you", "who are you",
|
||
})
|
||
}
|
||
|
||
func isGreetingPrompt(text string) bool {
|
||
trimmed := strings.TrimSpace(strings.Trim(text, "。.!!??~ "))
|
||
return hasAny(trimmed, []string{"你好", "您好", "hi", "hello", "hey", "在吗", "嗨"}) && runeLen(trimmed) <= 12
|
||
}
|
||
|
||
func isCapabilityPrompt(text string) bool {
|
||
return hasAny(text, []string{
|
||
"你可以干嘛", "你能干嘛", "你会干嘛", "你可以做什么", "你能做什么", "你有什么用", "能帮我做什么", "可以帮我做什么",
|
||
"what can you do", "how can you help",
|
||
})
|
||
}
|
||
|
||
func hasAny(text string, keywords []string) bool {
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(text, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func runeLen(value string) int {
|
||
return len([]rune(value))
|
||
}
|
||
|
||
func (s *DesignService) addResearchStep(ctx context.Context, projectID string, threadID string, prompt string) error {
|
||
if s.search == nil {
|
||
return nil
|
||
}
|
||
|
||
running := design.ResearchReport{
|
||
Kind: "web_search",
|
||
Source: "duckduckgo",
|
||
Query: strings.TrimSpace(prompt),
|
||
}
|
||
messageID, err := s.addResearchStepMessage(ctx, projectID, threadID, running, "running")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
report, err := s.search.Research(ctx, prompt)
|
||
if err != nil {
|
||
report.Kind = "web_search"
|
||
report.Source = "duckduckgo"
|
||
report.Query = strings.TrimSpace(prompt)
|
||
report.Error = err.Error()
|
||
}
|
||
return s.updateResearchStepMessage(ctx, projectID, messageID, report, "success")
|
||
}
|
||
|
||
func (s *DesignService) addResearchStepMessage(ctx context.Context, projectID string, threadID string, report design.ResearchReport, status string) (string, error) {
|
||
content, err := researchStepContent(report, status)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(projectID, threadID) {
|
||
return "", context.Canceled
|
||
}
|
||
now := s.now()
|
||
messageID := newID()
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: messageID,
|
||
Role: "tool",
|
||
Type: "tool",
|
||
Title: "Synthesize Skills",
|
||
Content: content,
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: status,
|
||
CreatedAt: now,
|
||
})
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
return "", err
|
||
}
|
||
return messageID, nil
|
||
}
|
||
|
||
func (s *DesignService) updateResearchStepMessage(ctx context.Context, projectID string, messageID string, report design.ResearchReport, status string) error {
|
||
content, err := researchStepContent(report, status)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for index := range project.Messages {
|
||
if project.Messages[index].ID != messageID {
|
||
continue
|
||
}
|
||
project.Messages[index].Content = content
|
||
project.Messages[index].Status = status
|
||
project.UpdatedAt = s.now()
|
||
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func researchStepContent(report design.ResearchReport, status string) (string, error) {
|
||
payload := struct {
|
||
design.ResearchReport
|
||
Status string `json:"status,omitempty"`
|
||
}{
|
||
ResearchReport: report,
|
||
Status: strings.TrimSpace(status),
|
||
}
|
||
content, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(content), nil
|
||
}
|
||
|
||
func (s *DesignService) markGenerationFailed(ctx context.Context, projectID string, failure error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
project.Nodes, _ = clearFailedCanvasPlaceholders(project.Nodes)
|
||
project.Status = design.StatusFailed
|
||
project.UpdatedAt = now
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片生成失败",
|
||
Content: generationFailureMessage(failure, false),
|
||
ThreadID: project.LastThreadID,
|
||
ActionID: project.LastThreadID,
|
||
StepID: newID(),
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) markAgentGenerationFailed(ctx context.Context, projectID string, threadID string, failure error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
project.Nodes = clearRemainingAgentGenerationPlaceholders(project.Nodes, "")
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
||
project.UpdatedAt = now
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" {
|
||
threadID = project.LastThreadID
|
||
}
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "failed")
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片生成失败",
|
||
Content: generationFailureMessage(failure, false),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) markAgentGenerationPartiallyFailed(ctx context.Context, projectID string, threadID string, targetNodeID string, generatedNodes []design.Node, failure error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
generatedNodes = completedGeneratedImageNodes(generatedNodes)
|
||
if len(generatedNodes) > 0 {
|
||
if strings.TrimSpace(targetNodeID) != "" {
|
||
project.Nodes = mergeGeneratedNodesIntoTarget(nonGeneratingNodesExceptTarget(project.Nodes, targetNodeID), generatedNodes, targetNodeID)
|
||
} else {
|
||
for _, node := range generatedNodes {
|
||
project.Nodes = upsertGeneratedNode(project.Nodes, node)
|
||
}
|
||
}
|
||
}
|
||
project.Nodes = failRemainingAgentGenerationPlaceholders(project.Nodes, targetNodeID)
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
||
project.UpdatedAt = now
|
||
threadID = strings.TrimSpace(threadID)
|
||
if threadID == "" {
|
||
threadID = project.LastThreadID
|
||
}
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "failed")
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片生成部分失败",
|
||
Content: generationFailureMessage(failure, true),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) markNodeActionFailed(ctx context.Context, projectID string, threadID string, targetNodeID string, failure error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
if nodeIndex := findNodeIndex(project.Nodes, targetNodeID); nodeIndex >= 0 {
|
||
project.Nodes[nodeIndex].Status = "error"
|
||
project.Nodes[nodeIndex].Title = "图片处理失败"
|
||
}
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
||
project.UpdatedAt = now
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片处理失败",
|
||
Content: imageActionFailureMessage(failure),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Name: "canvas_action",
|
||
ToolHint: "canvas_action",
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func generationFailureMessage(failure error, partial bool) string {
|
||
if partial {
|
||
if isTimeoutLikeFailure(failure) {
|
||
return "部分图片已经生成完成,剩余图片耗时较久没有成功返回。已保留成功结果,你可以只重试失败的部分。"
|
||
}
|
||
return "部分图片已经生成完成,剩余图片没有成功生成。已保留成功结果,你可以只重试失败的部分。"
|
||
}
|
||
if isTimeoutLikeFailure(failure) {
|
||
return "图片生成耗时较久,这次没有成功完成。画布内容已保留,你可以直接重试,或先降低图片数量/尺寸后再生成。"
|
||
}
|
||
if isTemporaryUpstreamFailure(failure) {
|
||
return "图片生成服务暂时繁忙,这次没有成功完成。画布内容已保留,请稍后重试。"
|
||
}
|
||
return "图片生成没有成功完成。画布内容已保留,你可以重新发起生成。"
|
||
}
|
||
|
||
func imageActionFailureMessage(failure error) string {
|
||
if isTimeoutLikeFailure(failure) {
|
||
return "图片处理耗时较久,这次没有成功完成。原图已保留,你可以稍后重试。"
|
||
}
|
||
if isTemporaryUpstreamFailure(failure) {
|
||
return "图片处理服务暂时繁忙,这次没有成功完成。原图已保留,请稍后重试。"
|
||
}
|
||
return "图片处理没有成功完成。原图已保留,你可以重新发起处理。"
|
||
}
|
||
|
||
func isTimeoutLikeFailure(failure error) bool {
|
||
if failure == nil {
|
||
return false
|
||
}
|
||
if errors.Is(failure, context.DeadlineExceeded) || errors.Is(failure, context.Canceled) {
|
||
return true
|
||
}
|
||
text := strings.ToLower(failure.Error())
|
||
return strings.Contains(text, "deadline") ||
|
||
strings.Contains(text, "timeout") ||
|
||
strings.Contains(text, "timed out") ||
|
||
strings.Contains(text, "awaiting headers") ||
|
||
strings.Contains(text, "lease expired")
|
||
}
|
||
|
||
func isTemporaryUpstreamFailure(failure error) bool {
|
||
if failure == nil {
|
||
return false
|
||
}
|
||
text := strings.ToLower(failure.Error())
|
||
return strings.Contains(text, "overload") ||
|
||
strings.Contains(text, "too many requests") ||
|
||
strings.Contains(text, "rate limit") ||
|
||
strings.Contains(text, "temporar") ||
|
||
strings.Contains(text, "service unavailable") ||
|
||
strings.Contains(text, "bad gateway") ||
|
||
strings.Contains(text, "gateway")
|
||
}
|
||
|
||
func (s *DesignService) markIsolatedGenerationFailed(ctx context.Context, projectID string, targetNodeID string, _ error) error {
|
||
project, err := s.repo.Get(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now()
|
||
project.Nodes = clearRemainingAgentGenerationPlaceholders(project.Nodes, targetNodeID)
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
return s.repo.Save(ctx, project)
|
||
}
|
||
|
||
func (s *DesignService) recoverOrphanedAgentGeneration(ctx context.Context, project design.Project, now time.Time) (design.Project, bool) {
|
||
if project.Status != design.StatusExploring || !hasRecoverableAgentGenerationPlaceholders(project.Nodes) {
|
||
return project, false
|
||
}
|
||
if s.jobs == nil && s.agent == nil {
|
||
return project, false
|
||
}
|
||
threadID := strings.TrimSpace(project.LastThreadID)
|
||
if threadID == "" || !hasRunningGenerationToolMessage(project.Messages, threadID, "GPT Image 2") {
|
||
return project, false
|
||
}
|
||
if project.UpdatedAt.IsZero() || now.Sub(project.UpdatedAt) < generationRecoveryAfter {
|
||
return project, false
|
||
}
|
||
attempt := generationRecoveryAttempt(project.Messages, threadID) + 1
|
||
if attempt > generationRecoveryMaxAttempts {
|
||
project.Nodes = failRemainingAgentGenerationPlaceholders(project.Nodes, "")
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
||
project.UpdatedAt = now
|
||
markGenerationToolMessages(project.Messages, threadID, "GPT Image 2", "failed")
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片生成失败",
|
||
Content: "图片生成任务恢复多次后仍未能落库,请重新发起生成。",
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
_ = s.repo.Save(ctx, project)
|
||
return project, true
|
||
}
|
||
|
||
prompt := latestUserPromptForThread(project.Messages, threadID)
|
||
if prompt == "" {
|
||
prompt = project.Brief
|
||
}
|
||
placeholders := recoverableAgentGenerationPlaceholders(project.Nodes)
|
||
taskPlan := agentTaskPlanFromGenerationPlaceholders(prompt, placeholders)
|
||
imageSize := imageSizeFromGenerationPlaceholder(placeholders)
|
||
touchLatestGenerationToolMessage(project.Messages, threadID, "GPT Image 2", generationRecoveryContent(prompt, attempt), fmt.Sprintf("%s:%d", generationRecoveryToolHint, attempt), now)
|
||
project.UpdatedAt = now
|
||
project = s.refreshCanvasSnapshot(project, now)
|
||
if err := s.repo.Save(ctx, project); err != nil {
|
||
logx.Errorf("save recovered generation marker failed project=%s thread=%s err=%v", project.ID, threadID, err)
|
||
return project, false
|
||
}
|
||
|
||
messages := []design.AgentChatMessage{
|
||
{
|
||
Role: "user",
|
||
Contents: []design.AgentContent{
|
||
{Type: "text", Text: prompt, TextSource: "input"},
|
||
},
|
||
},
|
||
}
|
||
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, "design", "gpt-image-2", imageSize, messages, false, false, taskPlan)
|
||
logx.Errorf("recovered orphaned image generation project=%s thread=%s attempt=%d placeholders=%d", project.ID, threadID, attempt, len(placeholders))
|
||
return project, true
|
||
}
|
||
|
||
func hasRecoverableAgentGenerationPlaceholders(nodes []design.Node) bool {
|
||
return len(recoverableAgentGenerationPlaceholders(nodes)) > 0
|
||
}
|
||
|
||
func recoverableAgentGenerationPlaceholders(nodes []design.Node) []design.Node {
|
||
placeholders := make([]design.Node, 0)
|
||
for _, node := range nodes {
|
||
if node.Type != design.NodeTypeImage || node.Status != "generating" || node.LayerRole == "image-generator" {
|
||
continue
|
||
}
|
||
placeholders = append(placeholders, node)
|
||
}
|
||
return placeholders
|
||
}
|
||
|
||
func agentTaskPlanFromGenerationPlaceholders(prompt string, nodes []design.Node) design.AgentTaskPlan {
|
||
tasks := make([]design.AgentImageTask, 0, len(nodes))
|
||
for index, node := range nodes {
|
||
title := strings.TrimSpace(node.Title)
|
||
if title == "" {
|
||
title = generatedTaskFallbackTitle(prompt, index, len(nodes))
|
||
}
|
||
brief := strings.TrimSpace(node.Content)
|
||
if brief == "" || isGeneratedImageContent(brief) {
|
||
brief = prompt
|
||
}
|
||
tasks = append(tasks, design.AgentImageTask{Title: title, Brief: brief})
|
||
}
|
||
return design.AgentTaskPlan{
|
||
Intent: "image",
|
||
UserFacingResponse: strings.TrimSpace(prompt),
|
||
ImageCount: len(tasks),
|
||
ImageTasks: tasks,
|
||
}
|
||
}
|
||
|
||
func imageSizeFromGenerationPlaceholder(nodes []design.Node) string {
|
||
if len(nodes) == 0 {
|
||
return ""
|
||
}
|
||
width := int(math.Round(nodes[0].Width))
|
||
height := int(math.Round(nodes[0].Height))
|
||
if width <= 0 || height <= 0 {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("%dx%d", width, height)
|
||
}
|
||
|
||
func latestUserPromptForThread(messages []design.Message, threadID string) string {
|
||
threadID = strings.TrimSpace(threadID)
|
||
for index := len(messages) - 1; index >= 0; index-- {
|
||
message := messages[index]
|
||
if threadID != "" && strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(message.Role), "user") {
|
||
return strings.TrimSpace(message.Content)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func generationRecoveryAttempt(messages []design.Message, threadID string) int {
|
||
threadID = strings.TrimSpace(threadID)
|
||
maxAttempt := 0
|
||
for _, message := range messages {
|
||
if threadID != "" && strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
hint := strings.TrimSpace(message.ToolHint)
|
||
if !strings.HasPrefix(hint, generationRecoveryToolHint) {
|
||
continue
|
||
}
|
||
_, value, ok := strings.Cut(hint, ":")
|
||
if !ok {
|
||
if maxAttempt < 1 {
|
||
maxAttempt = 1
|
||
}
|
||
continue
|
||
}
|
||
attempt, err := strconv.Atoi(strings.TrimSpace(value))
|
||
if err == nil && attempt > maxAttempt {
|
||
maxAttempt = attempt
|
||
}
|
||
}
|
||
return maxAttempt
|
||
}
|
||
|
||
func touchLatestGenerationToolMessage(messages []design.Message, threadID string, title string, content string, toolHint string, now time.Time) {
|
||
index := latestGenerationToolMessageIndex(messages, threadID, title)
|
||
if index < 0 {
|
||
return
|
||
}
|
||
messages[index].Content = content
|
||
messages[index].Status = "running"
|
||
messages[index].ToolHint = toolHint
|
||
messages[index].CreatedAt = now
|
||
if strings.TrimSpace(messages[index].ActionID) == "" {
|
||
messages[index].ActionID = threadID
|
||
}
|
||
if strings.TrimSpace(messages[index].StepID) == "" {
|
||
messages[index].StepID = newID()
|
||
}
|
||
}
|
||
|
||
func generationRecoveryContent(prompt string, attempt int) string {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" {
|
||
return fmt.Sprintf("检测到图片生成链路中断,正在第 %d 次自动恢复。", attempt)
|
||
}
|
||
return fmt.Sprintf("检测到图片生成链路中断,正在第 %d 次自动恢复:%s", attempt, truncateTextRunes(prompt, 80))
|
||
}
|
||
|
||
func truncateTextRunes(value string, maxRunes int) string {
|
||
value = strings.TrimSpace(value)
|
||
if maxRunes <= 0 {
|
||
return ""
|
||
}
|
||
runes := []rune(value)
|
||
if len(runes) <= maxRunes {
|
||
return value
|
||
}
|
||
return strings.TrimSpace(string(runes[:maxRunes]))
|
||
}
|
||
|
||
func (s *DesignService) clearStaleGeneratingPlaceholders(project design.Project, now time.Time) (design.Project, bool) {
|
||
if project.Status != design.StatusExploring || !generationPlaceholdersAreStale(project, now) {
|
||
return project, false
|
||
}
|
||
nodes, changed := clearFailedCanvasPlaceholders(project.Nodes)
|
||
if !changed {
|
||
return project, false
|
||
}
|
||
project.Nodes = nodes
|
||
project.Status = design.StatusFailed
|
||
project.UpdatedAt = now
|
||
project.Messages = append(project.Messages, design.Message{
|
||
ID: newID(),
|
||
Role: "error",
|
||
Type: "error",
|
||
Title: "图片生成超时",
|
||
Content: "图片生成任务长时间没有返回,已移除过期占位。请重新发起生成。",
|
||
ThreadID: project.LastThreadID,
|
||
ActionID: project.LastThreadID,
|
||
StepID: newID(),
|
||
Status: "failed",
|
||
CreatedAt: now,
|
||
})
|
||
return project, true
|
||
}
|
||
|
||
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
|
||
artifactsByID := generatedImageArtifactsByID(project.Messages)
|
||
if len(artifactsByID) == 0 {
|
||
return project, false
|
||
}
|
||
next := make([]design.Node, 0, len(project.Nodes))
|
||
changed := false
|
||
for _, node := range project.Nodes {
|
||
if artifact, ok := artifactsByID[node.ID]; ok && node.Status == "generating" {
|
||
node = restoreNodeFromGeneratedArtifact(node, artifact)
|
||
changed = true
|
||
}
|
||
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
|
||
changed = true
|
||
continue
|
||
}
|
||
next = append(next, node)
|
||
}
|
||
if !changed {
|
||
return project, false
|
||
}
|
||
project.Nodes = next
|
||
project.Status = projectStatusAfterCanvasTask(project.Nodes, project.Status)
|
||
project.UpdatedAt = now
|
||
return project, true
|
||
}
|
||
|
||
func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact {
|
||
artifactsByID := make(map[string]design.GeneratorTaskArtifact)
|
||
for _, message := range messages {
|
||
if !isToolArtifactsMessage(message) || !strings.EqualFold(strings.TrimSpace(message.Status), "success") {
|
||
continue
|
||
}
|
||
for _, artifact := range parseGeneratorArtifacts(message.Content) {
|
||
id := strings.TrimSpace(artifact.Metadata.ArtifactID)
|
||
if id == "" || strings.TrimSpace(artifact.Content) == "" {
|
||
continue
|
||
}
|
||
artifactsByID[id] = artifact
|
||
}
|
||
}
|
||
return artifactsByID
|
||
}
|
||
|
||
func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.GeneratorTaskArtifact) design.Node {
|
||
node.Type = design.NodeTypeImage
|
||
if title := strings.TrimSpace(artifact.Metadata.NodeName); title != "" {
|
||
node.Title = title
|
||
} else if strings.TrimSpace(node.Title) == "" {
|
||
node.Title = "Generated image"
|
||
}
|
||
node.Content = strings.TrimSpace(artifact.Content)
|
||
node.Status = "success"
|
||
node.LayerRole = ""
|
||
if strings.TrimSpace(node.Tone) == "" || node.Tone == "image-generator" || node.Tone == "transparent-image" {
|
||
node.Tone = "visual"
|
||
}
|
||
if artifact.Metadata.Width > 0 {
|
||
node.Width = float64(artifact.Metadata.Width)
|
||
}
|
||
if artifact.Metadata.Height > 0 {
|
||
node.Height = float64(artifact.Metadata.Height)
|
||
}
|
||
return node
|
||
}
|
||
|
||
func generationPlaceholdersAreStale(project design.Project, now time.Time) bool {
|
||
if !hasGeneratingCanvasWork(project.Nodes) {
|
||
return false
|
||
}
|
||
startedAt := latestGenerationStartedAt(project.Messages, project.LastThreadID)
|
||
if startedAt.IsZero() {
|
||
startedAt = project.UpdatedAt
|
||
}
|
||
if startedAt.IsZero() {
|
||
return false
|
||
}
|
||
return now.Sub(startedAt) > generationBudget+30*time.Second
|
||
}
|
||
|
||
func hasGeneratingCanvasWork(nodes []design.Node) bool {
|
||
for _, node := range nodes {
|
||
if node.Status == "generating" || node.Status == "uploading" || node.Status == "text-extracting" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func latestGenerationStartedAt(messages []design.Message, threadID string) time.Time {
|
||
threadID = strings.TrimSpace(threadID)
|
||
for index := len(messages) - 1; index >= 0; index-- {
|
||
message := messages[index]
|
||
if threadID != "" && strings.TrimSpace(message.ThreadID) != threadID {
|
||
continue
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(message.Role), "tool") || strings.TrimSpace(message.Title) != "GPT Image 2" {
|
||
continue
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(message.Status), "running") && strings.TrimSpace(message.Status) != "" {
|
||
continue
|
||
}
|
||
return message.CreatedAt
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
func projectStatusAfterCanvasTask(nodes []design.Node, completedStatus design.ProjectStatus) design.ProjectStatus {
|
||
for _, node := range nodes {
|
||
if node.Status == "generating" || node.Status == "uploading" || node.Status == "text-extracting" || node.Status == "mockup-modeling" {
|
||
return design.StatusExploring
|
||
}
|
||
}
|
||
return completedStatus
|
||
}
|
||
|
||
func clearFailedCanvasPlaceholders(nodes []design.Node) ([]design.Node, bool) {
|
||
next := make([]design.Node, 0, len(nodes))
|
||
changed := false
|
||
for _, node := range nodes {
|
||
if node.Status != "generating" && node.Status != "error" {
|
||
next = append(next, node)
|
||
continue
|
||
}
|
||
if node.Type == design.NodeTypeImage && !isGeneratedImageContent(node.Content) {
|
||
changed = true
|
||
continue
|
||
}
|
||
if node.Status == "generating" {
|
||
node.Status = "error"
|
||
changed = true
|
||
}
|
||
next = append(next, node)
|
||
}
|
||
return next, changed
|
||
}
|
||
|
||
func clearLegacyMockCanvasNodes(nodes []design.Node) ([]design.Node, bool) {
|
||
next := make([]design.Node, 0, len(nodes))
|
||
changed := false
|
||
for _, node := range nodes {
|
||
if isLegacyMockCanvasNode(node) {
|
||
changed = true
|
||
continue
|
||
}
|
||
next = append(next, node)
|
||
}
|
||
return next, changed
|
||
}
|
||
|
||
func isLegacyMockCanvasNode(node design.Node) bool {
|
||
if node.Status == "generating" || strings.TrimSpace(node.LayerRole) != "" {
|
||
return false
|
||
}
|
||
content := strings.TrimSpace(node.Content)
|
||
if node.Type == design.NodeTypeImage {
|
||
return content == "" || !isGeneratedImageContent(content)
|
||
}
|
||
if node.Type == design.NodeTypeFrame {
|
||
return content != "" && !isGeneratedImageContent(content)
|
||
}
|
||
return false
|
||
}
|
||
|
||
func projectWithoutGeneratingNodes(project design.Project) design.Project {
|
||
project.Nodes = nonGeneratingNodes(project.Nodes)
|
||
return project
|
||
}
|
||
|
||
func nonGeneratingNodes(nodes []design.Node) []design.Node {
|
||
filtered := make([]design.Node, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if node.Status != "generating" {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func nonGeneratingNodesExceptTarget(nodes []design.Node, targetNodeID string) []design.Node {
|
||
filtered := make([]design.Node, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if node.ID == targetNodeID || node.Status != "generating" {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func upsertGeneratedNode(nodes []design.Node, generated design.Node) []design.Node {
|
||
next := append([]design.Node(nil), nodes...)
|
||
for index, node := range next {
|
||
if node.ID == generated.ID {
|
||
next[index] = generated
|
||
return next
|
||
}
|
||
}
|
||
return append(next, generated)
|
||
}
|
||
|
||
func clearRemainingAgentGenerationPlaceholders(nodes []design.Node, targetNodeID string) []design.Node {
|
||
targetNodeID = strings.TrimSpace(targetNodeID)
|
||
next := make([]design.Node, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if node.Status == "generating" && node.Type == design.NodeTypeImage && shouldClearRemainingGenerationPlaceholder(node, targetNodeID) {
|
||
continue
|
||
}
|
||
next = append(next, node)
|
||
}
|
||
return next
|
||
}
|
||
|
||
func failRemainingAgentGenerationPlaceholders(nodes []design.Node, targetNodeID string) []design.Node {
|
||
targetNodeID = strings.TrimSpace(targetNodeID)
|
||
next := append([]design.Node(nil), nodes...)
|
||
for index := range next {
|
||
if next[index].Status != "generating" || next[index].Type != design.NodeTypeImage || !shouldClearRemainingGenerationPlaceholder(next[index], targetNodeID) {
|
||
continue
|
||
}
|
||
next[index].Status = "error"
|
||
if strings.TrimSpace(next[index].Title) == "" {
|
||
next[index].Title = "图片生成未返回"
|
||
}
|
||
}
|
||
return next
|
||
}
|
||
|
||
func shouldClearRemainingGenerationPlaceholder(node design.Node, targetNodeID string) bool {
|
||
if targetNodeID != "" {
|
||
return node.ID == targetNodeID
|
||
}
|
||
return node.LayerRole != "image-generator"
|
||
}
|
||
|
||
func markImageGeneratorTargetGenerating(nodes []design.Node, targetNodeID string, prompt string, imageSize string) []design.Node {
|
||
next := append([]design.Node(nil), nodes...)
|
||
for index := range next {
|
||
if next[index].ID == targetNodeID && next[index].Type == design.NodeTypeImage && next[index].LayerRole == "image-generator" {
|
||
next[index].Title = generationPlaceholderTitle(prompt)
|
||
next[index].Content = prompt
|
||
next[index].Tone = "image-generator"
|
||
next[index].Status = "generating"
|
||
return next
|
||
}
|
||
}
|
||
return append(next, newGeneratingImageNode(prompt, len(next), imageSize))
|
||
}
|
||
|
||
func isImageGeneratorRequest(mode string, targetNodeID string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(mode), "image-generator") && strings.TrimSpace(targetNodeID) != ""
|
||
}
|
||
|
||
func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string {
|
||
const targetPrefix = "image generator target node:"
|
||
for _, message := range messages {
|
||
for _, content := range message.Contents {
|
||
if strings.ToLower(strings.TrimSpace(content.Type)) != "text" {
|
||
continue
|
||
}
|
||
for _, line := range strings.Split(content.Text, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if strings.HasPrefix(strings.ToLower(line), targetPrefix) {
|
||
return strings.TrimSpace(line[len(targetPrefix):])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node {
|
||
if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 {
|
||
return append(nodes, generated...)
|
||
}
|
||
targetIndex := -1
|
||
for index, node := range nodes {
|
||
if node.ID == targetNodeID && node.Type == design.NodeTypeImage && node.LayerRole == "image-generator" {
|
||
targetIndex = index
|
||
break
|
||
}
|
||
}
|
||
if targetIndex < 0 {
|
||
return append(nodes, generated...)
|
||
}
|
||
|
||
primary := generated[0]
|
||
target := nodes[targetIndex]
|
||
centerX := target.X + target.Width/2
|
||
centerY := target.Y + target.Height/2
|
||
target.Title = primary.Title
|
||
target.Content = primary.Content
|
||
target.Tone = primary.Tone
|
||
target.Status = primary.Status
|
||
target.LayerRole = ""
|
||
if primary.Width > 0 {
|
||
target.Width = primary.Width
|
||
target.X = centerX - target.Width/2
|
||
}
|
||
if primary.Height > 0 {
|
||
target.Height = primary.Height
|
||
target.Y = centerY - target.Height/2
|
||
}
|
||
nodes[targetIndex] = target
|
||
if len(generated) > 1 {
|
||
return append(nodes, generated[1:]...)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
func findNodeIndex(nodes []design.Node, nodeID string) int {
|
||
for index, node := range nodes {
|
||
if node.ID == nodeID {
|
||
return index
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func normalizeNodeAction(action string) string {
|
||
action = strings.ToLower(strings.TrimSpace(action))
|
||
action = strings.ReplaceAll(action, "_", "-")
|
||
switch action {
|
||
case "quick-edit", "upscale", "remove-background", "eraser", "edit-elements", "edit-text", "multi-angle", "move-object", "mockup", "expand", "adjust", "crop", "vectorize":
|
||
return action
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func newBackgroundRemovalPlaceholder(target design.Node) design.Node {
|
||
result := target
|
||
result.ID = newID()
|
||
result.Title = nodeActionWorkingTitle("remove-background")
|
||
result.X = target.X + target.Width + 32
|
||
result.Y = target.Y
|
||
result.Tone = "transparent-image"
|
||
result.Status = "generating"
|
||
result.ParentID = target.ID
|
||
result.LayerRole = "background-removed"
|
||
result.MockupSurface = design.MockupSurface{}
|
||
result.MockupModel = design.MockupModel{}
|
||
result.MockupSourceContent = ""
|
||
return result
|
||
}
|
||
|
||
func newVectorizePlaceholder(target design.Node) design.Node {
|
||
result := target
|
||
result.ID = newID()
|
||
result.Title = nodeActionWorkingTitle("vectorize")
|
||
result.X = target.X + target.Width + 32
|
||
result.Y = target.Y
|
||
result.Status = "generating"
|
||
result.ParentID = target.ID
|
||
result.LayerRole = ""
|
||
result.MockupSurface = design.MockupSurface{}
|
||
result.MockupModel = design.MockupModel{}
|
||
result.MockupSourceContent = ""
|
||
return result
|
||
}
|
||
|
||
func newMoveObjectPlaceholder(target design.Node) design.Node {
|
||
result := target
|
||
result.ID = newID()
|
||
result.Title = nodeActionWorkingTitle("move-object")
|
||
result.X = target.X + target.Width + 32
|
||
result.Y = target.Y
|
||
result.Status = "generating"
|
||
result.ParentID = target.ID
|
||
result.LayerRole = "moved-object"
|
||
result.MockupSurface = design.MockupSurface{}
|
||
result.MockupModel = design.MockupModel{}
|
||
result.MockupSourceContent = ""
|
||
return result
|
||
}
|
||
|
||
func nodeActionResultNodeID(target design.Node, req design.NodeActionRequest) string {
|
||
if (req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object") && len(req.Options) > 0 {
|
||
if id := strings.TrimSpace(req.Options[nodeActionResultNodeIDOption]); id != "" {
|
||
return id
|
||
}
|
||
}
|
||
return target.ID
|
||
}
|
||
|
||
func firstGeneratedImageURL(nodes []design.Node) string {
|
||
for _, node := range nodes {
|
||
if node.Type == design.NodeTypeImage && isGeneratedImageContent(node.Content) {
|
||
return strings.TrimSpace(node.Content)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func applyExpandResultGeometry(node *design.Node, req design.NodeActionRequest) {
|
||
if node == nil {
|
||
return
|
||
}
|
||
width, widthOK := positiveFloatOption(req.Options, "targetWidth")
|
||
height, heightOK := positiveFloatOption(req.Options, "targetHeight")
|
||
if !widthOK || !heightOK {
|
||
width, height = expandGeometryFromOptions(*node, req.Options)
|
||
}
|
||
if width <= 0 || height <= 0 {
|
||
return
|
||
}
|
||
|
||
centerX := node.X + node.Width/2
|
||
centerY := node.Y + node.Height/2
|
||
x, xOK := floatOption(req.Options, "targetX")
|
||
y, yOK := floatOption(req.Options, "targetY")
|
||
if !xOK {
|
||
x = centerX - width/2
|
||
}
|
||
if !yOK {
|
||
y = centerY - height/2
|
||
}
|
||
node.X = x
|
||
node.Y = y
|
||
node.Width = width
|
||
node.Height = height
|
||
}
|
||
|
||
func expandGeometryFromOptions(node design.Node, options map[string]string) (float64, float64) {
|
||
if node.Width <= 0 || node.Height <= 0 {
|
||
return 0, 0
|
||
}
|
||
ratio := expandRatioFromOption(options["ratio"], node.Width/node.Height)
|
||
scale := expandScaleFromOption(options["scale"])
|
||
sourceRatio := node.Width / node.Height
|
||
width := node.Width
|
||
height := node.Height
|
||
if ratio > sourceRatio {
|
||
width = node.Height * ratio
|
||
} else {
|
||
height = node.Width / math.Max(ratio, 0.01)
|
||
}
|
||
return width * scale, height * scale
|
||
}
|
||
|
||
func expandRatioFromOption(value string, fallback float64) float64 {
|
||
switch strings.TrimSpace(value) {
|
||
case "1:1":
|
||
return 1
|
||
case "3:4":
|
||
return 3.0 / 4.0
|
||
case "2:3":
|
||
return 2.0 / 3.0
|
||
case "9:16":
|
||
return 9.0 / 16.0
|
||
case "4:3":
|
||
return 4.0 / 3.0
|
||
case "3:2":
|
||
return 3.0 / 2.0
|
||
case "16:9":
|
||
return 16.0 / 9.0
|
||
case "4:5":
|
||
return 4.0 / 5.0
|
||
case "5:4":
|
||
return 5.0 / 4.0
|
||
default:
|
||
if fallback > 0 {
|
||
return fallback
|
||
}
|
||
return 1
|
||
}
|
||
}
|
||
|
||
func expandScaleFromOption(value string) float64 {
|
||
value = strings.TrimSuffix(strings.TrimSpace(value), "x")
|
||
scale, err := strconv.ParseFloat(value, 64)
|
||
if err != nil || scale < 1 {
|
||
return 1
|
||
}
|
||
return math.Min(scale, 2)
|
||
}
|
||
|
||
func positiveFloatOption(options map[string]string, key string) (float64, bool) {
|
||
value, ok := floatOption(options, key)
|
||
return value, ok && value > 0
|
||
}
|
||
|
||
func positiveIntOption(options map[string]string, key string) (int, bool) {
|
||
value, ok := floatOption(options, key)
|
||
if !ok || value <= 0 {
|
||
return 0, false
|
||
}
|
||
return int(math.Round(value)), true
|
||
}
|
||
|
||
func floatOption(options map[string]string, key string) (float64, bool) {
|
||
if len(options) == 0 {
|
||
return 0, false
|
||
}
|
||
value, err := strconv.ParseFloat(strings.TrimSpace(options[key]), 64)
|
||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||
return 0, false
|
||
}
|
||
return value, true
|
||
}
|
||
|
||
func firstCompletedImageURL(nodes []design.Node) string {
|
||
for _, node := range nodes {
|
||
if (node.Type == design.NodeTypeImage || node.Type == design.NodeTypeFrame) && node.Status != "generating" && isGeneratedImageContent(node.Content) {
|
||
return strings.TrimSpace(node.Content)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *DesignService) persistNodeImageAssets(ctx context.Context, projectID string, nodes []design.Node) ([]design.Node, error) {
|
||
if s.assets == nil {
|
||
return nodes, nil
|
||
}
|
||
|
||
next := append([]design.Node(nil), nodes...)
|
||
for index, node := range next {
|
||
if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame {
|
||
continue
|
||
}
|
||
data, contentType, ext, ok, err := decodeDataImage(node.Content)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
continue
|
||
}
|
||
if strings.ToLower(strings.TrimSpace(contentType)) != "image/svg+xml" {
|
||
data, contentType, err = encodeRasterAssetAsWebP(data, contentType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
|
||
FileName: fmt.Sprintf("%s-%s%s", projectID, node.ID, ext),
|
||
ContentType: contentType,
|
||
Data: data,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(object.PublicURL) != "" {
|
||
next[index].Content = object.PublicURL
|
||
}
|
||
}
|
||
return next, nil
|
||
}
|
||
|
||
func decodeDataImage(value string) ([]byte, string, string, bool, error) {
|
||
value = strings.TrimSpace(value)
|
||
if !strings.HasPrefix(value, "data:image/") {
|
||
return nil, "", "", false, nil
|
||
}
|
||
|
||
header, payload, ok := strings.Cut(value, ",")
|
||
if !ok {
|
||
return nil, "", "", false, fmt.Errorf("%w: unsupported image data URL", design.ErrInvalidInput)
|
||
}
|
||
|
||
contentType := strings.TrimPrefix(strings.Split(header, ";")[0], "data:")
|
||
if contentType == "" {
|
||
contentType = "image/png"
|
||
}
|
||
var data []byte
|
||
if strings.Contains(strings.ToLower(header), ";base64") {
|
||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
return nil, "", "", false, err
|
||
}
|
||
data = decoded
|
||
} else if contentType == "image/svg+xml" {
|
||
decoded, err := url.PathUnescape(payload)
|
||
if err != nil {
|
||
return nil, "", "", false, err
|
||
}
|
||
data = []byte(decoded)
|
||
} else {
|
||
return nil, "", "", false, fmt.Errorf("%w: unsupported image data URL", design.ErrInvalidInput)
|
||
}
|
||
|
||
ext := ".png"
|
||
switch contentType {
|
||
case "image/jpeg", "image/jpg":
|
||
ext = ".jpg"
|
||
case "image/svg+xml":
|
||
ext = ".svg"
|
||
case "image/webp":
|
||
ext = ".webp"
|
||
case "image/gif":
|
||
ext = ".gif"
|
||
}
|
||
return data, contentType, ext, true, nil
|
||
}
|
||
|
||
func isGeneratedImageContent(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "data:image/") || strings.HasPrefix(value, "/")
|
||
}
|
||
|
||
func promptFromAgentMessages(messages []design.AgentChatMessage) string {
|
||
var builder strings.Builder
|
||
for _, message := range messages {
|
||
if strings.TrimSpace(message.Role) != "" && strings.ToLower(strings.TrimSpace(message.Role)) != "user" {
|
||
continue
|
||
}
|
||
for _, content := range message.Contents {
|
||
switch strings.ToLower(strings.TrimSpace(content.Type)) {
|
||
case "text":
|
||
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
||
continue
|
||
}
|
||
if text := strings.TrimSpace(content.Text); text != "" {
|
||
text = stripImageGeneratorDirectiveLines(text)
|
||
if text == "" {
|
||
continue
|
||
}
|
||
if builder.Len() > 0 {
|
||
builder.WriteString("\n")
|
||
}
|
||
builder.WriteString(text)
|
||
}
|
||
case "image", "mask":
|
||
if imageURL := strings.TrimSpace(content.ImageURL); imageURL != "" {
|
||
if builder.Len() > 0 {
|
||
builder.WriteString("\n")
|
||
}
|
||
builder.WriteString("Reference image")
|
||
if imageName := strings.TrimSpace(content.ImageName); imageName != "" {
|
||
builder.WriteString(" (")
|
||
builder.WriteString(compactReferenceImageName(imageName))
|
||
builder.WriteString(")")
|
||
}
|
||
builder.WriteString(": ")
|
||
builder.WriteString(imageURL)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func compactReferenceImageName(value string) string {
|
||
return strings.Join(strings.Fields(value), " ")
|
||
}
|
||
|
||
func hasUserPromptTextInAgentMessages(messages []design.AgentChatMessage) bool {
|
||
for _, message := range messages {
|
||
if strings.TrimSpace(message.Role) != "" && strings.ToLower(strings.TrimSpace(message.Role)) != "user" {
|
||
continue
|
||
}
|
||
for _, content := range message.Contents {
|
||
if strings.ToLower(strings.TrimSpace(content.Type)) != "text" {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
||
continue
|
||
}
|
||
text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text))
|
||
if design.NormalizePrompt(text) != "" {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func stripImageGeneratorDirectiveLines(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
filtered := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "image generator target node:") {
|
||
continue
|
||
}
|
||
filtered = append(filtered, line)
|
||
}
|
||
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
||
}
|
||
|
||
func stripPromptReferenceDirectives(text string) string {
|
||
text = promptImageTokenPattern.ReplaceAllString(text, "")
|
||
text = promptImageDirectivePattern.ReplaceAllString(text, "")
|
||
lines := strings.Split(text, "\n")
|
||
filtered := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " "))
|
||
if trimmed == "" || isPromptReferenceDirectiveLine(trimmed) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, line)
|
||
}
|
||
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
||
}
|
||
|
||
func isPromptReferenceDirectiveLine(line string) bool {
|
||
lower := strings.ToLower(line)
|
||
hasURL := strings.Contains(lower, "http://") || strings.Contains(lower, "https://")
|
||
if !hasURL {
|
||
return false
|
||
}
|
||
return strings.HasPrefix(line, "参考图") || strings.HasPrefix(lower, "reference image")
|
||
}
|
||
|
||
func toolArtifactsMessage(threadID string, prompt string, nodes []design.Node, createdAt time.Time) design.Message {
|
||
artifacts := make([]map[string]any, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if node.Type != design.NodeTypeImage || !isGeneratedImageContent(node.Content) {
|
||
continue
|
||
}
|
||
artifacts = append(artifacts, map[string]any{
|
||
"artifact_id": node.ID,
|
||
"artifact_type": "image_info",
|
||
"description": prompt,
|
||
"format": generatorArtifactFormat("", node.Content),
|
||
"height": int64(node.Height),
|
||
"image_name": node.Title,
|
||
"image_url": node.Content,
|
||
"prompt_text": prompt,
|
||
"source": "generate_media",
|
||
"task_type": "TEXT_TO_IMAGE",
|
||
"thread_id": threadID,
|
||
"width": int64(node.Width),
|
||
})
|
||
}
|
||
|
||
content, _ := json.Marshal([]map[string]any{
|
||
{
|
||
"event_index": 0,
|
||
"event_type": "tool_artifacts",
|
||
"event_data": map[string]any{
|
||
"artifact": artifacts,
|
||
},
|
||
},
|
||
})
|
||
return design.Message{
|
||
ID: newID(),
|
||
Role: "tool",
|
||
Type: "tool_use",
|
||
Title: "generate_media",
|
||
Content: string(content),
|
||
ThreadID: threadID,
|
||
ActionID: threadID,
|
||
StepID: newID(),
|
||
ToolCallID: "call_" + strings.ReplaceAll(newID(), "-", ""),
|
||
Name: "generate_media",
|
||
ToolHint: "generate_image_gpt_image_2",
|
||
Status: "success",
|
||
CreatedAt: createdAt,
|
||
}
|
||
}
|
||
|
||
func (s *DesignService) refreshCanvasSnapshot(project design.Project, now time.Time) design.Project {
|
||
if now.IsZero() {
|
||
now = s.now()
|
||
}
|
||
project.UpdatedAt = now
|
||
project.Thumbnail = projectThumbnail(project)
|
||
project.Version = design.CanvasVersion(now)
|
||
if strings.TrimSpace(project.SnapshotID) == "" {
|
||
project.SnapshotID = newID()
|
||
}
|
||
canvas, err := design.EncodeCanvasSnapshot(project)
|
||
if err == nil {
|
||
project.Canvas = canvas
|
||
}
|
||
return project
|
||
}
|
||
|
||
func mainCanvasPreviewImage(nodes []design.Node) string {
|
||
bestURL := ""
|
||
bestArea := -1.0
|
||
for _, node := range nodes {
|
||
if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame {
|
||
continue
|
||
}
|
||
if node.Status == "generating" || node.Status == "error" || node.LayerRole == "pen-stroke" {
|
||
continue
|
||
}
|
||
content := strings.TrimSpace(node.Content)
|
||
if content == "" || strings.HasPrefix(content, "data:image/") || !isGeneratedImageContent(content) {
|
||
continue
|
||
}
|
||
area := node.Width * node.Height
|
||
if area >= bestArea {
|
||
bestArea = area
|
||
bestURL = content
|
||
}
|
||
}
|
||
return bestURL
|
||
}
|
||
|
||
func buildNodeActionPrompt(req design.NodeActionRequest, target design.Node) string {
|
||
var builder strings.Builder
|
||
builder.WriteString("Use the selected source image as the base image. Source image URL: ")
|
||
builder.WriteString(strings.TrimSpace(target.Content))
|
||
builder.WriteString("\nPreserve the original subject identity, composition intent, and design style unless the action explicitly changes them.\n")
|
||
|
||
switch req.Action {
|
||
case "quick-edit":
|
||
builder.WriteString("Action: quick image edit. Apply the user's prompt directly to the selected image.")
|
||
case "upscale":
|
||
builder.WriteString("Action: model upscale. Increase detail, sharpness, texture fidelity, and perceived resolution while preserving the image exactly.")
|
||
if value := req.Options["scale"]; value != "" {
|
||
builder.WriteString("\nTarget upscale: ")
|
||
builder.WriteString(value)
|
||
}
|
||
case "remove-background":
|
||
builder.WriteString("Action: remove background. Isolate the main subject cleanly, remove the environment/background, keep natural edges and transparent or plain background if supported.")
|
||
case "eraser":
|
||
builder.WriteString("Action: local inpaint from eraser mask. Only edit the masked area; keep all unmasked pixels visually unchanged.")
|
||
if req.Mask != "" {
|
||
builder.WriteString("\nMask data: ")
|
||
builder.WriteString(req.Mask)
|
||
}
|
||
case "edit-elements":
|
||
builder.WriteString("Action: element separation. Do not redraw the full image. Preserve the original base image as much as possible, remove only readable text pixels and their antialiased edges, strokes, shadows, or glow, then inpaint those small removed areas naturally. Do not keep or redraw any removed words in the base image; OCR will recreate all separated words as editable canvas text layers.")
|
||
case "edit-text":
|
||
builder.WriteString("Action: editable text extraction. Do not redraw the full image. Preserve the original base image as much as possible, remove only original text pixels and their antialiased edges, strokes, shadows, or glow, then inpaint those small removed areas naturally. Do not include the removed words in the base image; OCR will recreate them as editable overlay text layers.")
|
||
case "multi-angle":
|
||
builder.WriteString("Action: create a new angle of the same subject. Keep product/character identity consistent and change only camera/view direction as requested.")
|
||
mode := strings.TrimSpace(req.Options["tab"])
|
||
rotation := strings.TrimSpace(req.Options["rotation"])
|
||
tilt := strings.TrimSpace(req.Options["tilt"])
|
||
zoom := strings.TrimSpace(req.Options["zoom"])
|
||
if mode == "subject" {
|
||
builder.WriteString("\nMulti-angle mode: subject orbit. Rotate the subject/object itself according to the rotation and tilt values while keeping the camera framing clean and stable. Output one final image, not a contact sheet.")
|
||
} else if mode == "camera" {
|
||
builder.WriteString("\nMulti-angle mode: camera orbit. Move the virtual camera around the subject according to the rotation and tilt values while keeping the subject identity and pose stable. Output one final image, not a contact sheet.")
|
||
}
|
||
if rotation != "" || tilt != "" || zoom != "" {
|
||
builder.WriteString("\nAngle controls:")
|
||
if rotation != "" {
|
||
builder.WriteString("\n- rotation: ")
|
||
builder.WriteString(rotation)
|
||
builder.WriteString(" degrees")
|
||
}
|
||
if tilt != "" {
|
||
builder.WriteString("\n- tilt: ")
|
||
builder.WriteString(tilt)
|
||
builder.WriteString(" degrees")
|
||
}
|
||
if zoom != "" {
|
||
builder.WriteString("\n- zoom: ")
|
||
builder.WriteString(zoom)
|
||
builder.WriteString(" / 100")
|
||
}
|
||
}
|
||
case "move-object":
|
||
builder.WriteString("Action: move selected object. Recompose the object according to the selection and movement instruction, then inpaint the original area naturally.")
|
||
case "mockup":
|
||
builder.WriteString("Action: mockup. Place the selected design into a realistic product or device mockup scene. Preserve the design content and make perspective, lighting, and material integration believable.")
|
||
case "expand":
|
||
builder.WriteString("Action: image outpaint/expand. Extend the canvas around the original image naturally while preserving the selected image as the center source.")
|
||
case "adjust":
|
||
builder.WriteString("Action: image adjustment. Apply the requested color, light, detail, or effect adjustments while preserving the subject and composition.")
|
||
case "crop":
|
||
builder.WriteString("Action: crop. Keep only the selected crop region or requested framing, then output a clean image at the requested composition.")
|
||
case "vectorize":
|
||
builder.WriteString("Action: vectorize. Convert the selected image into a clean vector-style result with crisp shapes, simplified paths, and preserved visual identity.")
|
||
}
|
||
|
||
if req.Selection != "" {
|
||
builder.WriteString("\nSelection data: ")
|
||
builder.WriteString(req.Selection)
|
||
}
|
||
if len(req.Options) > 0 {
|
||
builder.WriteString("\nStructured options:")
|
||
for key, value := range req.Options {
|
||
if strings.TrimSpace(value) == "" {
|
||
continue
|
||
}
|
||
builder.WriteString("\n- ")
|
||
builder.WriteString(key)
|
||
builder.WriteString(": ")
|
||
builder.WriteString(value)
|
||
}
|
||
}
|
||
if strings.TrimSpace(req.Prompt) != "" {
|
||
builder.WriteString("\nUser instruction: ")
|
||
builder.WriteString(strings.TrimSpace(req.Prompt))
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func nodeActionWorkingTitle(action string) string {
|
||
switch action {
|
||
case "quick-edit":
|
||
return "快捷编辑中"
|
||
case "upscale":
|
||
return "模型放大中"
|
||
case "remove-background":
|
||
return "去背景中"
|
||
case "eraser":
|
||
return "局部覆盖中"
|
||
case "edit-elements":
|
||
return "元素分离中"
|
||
case "edit-text":
|
||
return "文字提取中"
|
||
case "multi-angle":
|
||
return "多角度生成中"
|
||
case "move-object":
|
||
return "移动对象中"
|
||
case "mockup":
|
||
return "Mockup 生成中"
|
||
case "expand":
|
||
return "扩图中"
|
||
case "adjust":
|
||
return "调整中"
|
||
case "crop":
|
||
return "裁剪中"
|
||
case "vectorize":
|
||
return "矢量化中"
|
||
default:
|
||
return "图片处理中"
|
||
}
|
||
}
|
||
|
||
func nodeActionResultTitle(action string) string {
|
||
switch action {
|
||
case "quick-edit":
|
||
return "Quick Edit"
|
||
case "upscale":
|
||
return "Upscaled Image"
|
||
case "remove-background":
|
||
return "Background Removed"
|
||
case "eraser":
|
||
return "Local Edit"
|
||
case "edit-elements":
|
||
return "Separated Image Layer"
|
||
case "edit-text":
|
||
return "Editable Text Image"
|
||
case "multi-angle":
|
||
return "Multi-angle View"
|
||
case "move-object":
|
||
return "Moved Object"
|
||
case "mockup":
|
||
return "Mockup Image"
|
||
case "expand":
|
||
return "Expanded Image"
|
||
case "adjust":
|
||
return "Adjusted Image"
|
||
case "crop":
|
||
return "Cropped Image"
|
||
case "vectorize":
|
||
return "Vectorized Image"
|
||
default:
|
||
return "Edited Image"
|
||
}
|
||
}
|
||
|
||
func vectorizedNodeTitle(target design.Node) string {
|
||
title := strings.TrimSpace(target.Title)
|
||
if title == "" {
|
||
title = "Vectorized Image"
|
||
}
|
||
lower := strings.ToLower(title)
|
||
if strings.HasSuffix(lower, "_svg") || strings.HasSuffix(lower, ".svg") {
|
||
return title
|
||
}
|
||
return title + "_SVG"
|
||
}
|
||
|
||
func nodeActionUserTitle(action string) string {
|
||
return nodeActionResultTitle(action)
|
||
}
|
||
|
||
func nodeActionUserContent(req design.NodeActionRequest, target design.Node) string {
|
||
prompt := strings.TrimSpace(req.Prompt)
|
||
if prompt == "" {
|
||
prompt = nodeActionThinkingContent(req.Action)
|
||
}
|
||
return fmt.Sprintf("%s\n\n目标节点:%s", prompt, target.Title)
|
||
}
|
||
|
||
func nodeActionThinkingContent(action string) string {
|
||
switch action {
|
||
case "quick-edit":
|
||
return "正在按提示词对选中图片做快速编辑。"
|
||
case "upscale":
|
||
return "正在通过模型增强分辨率、细节和边缘质量。"
|
||
case "remove-background":
|
||
return "正在识别主体并移除背景。"
|
||
case "eraser":
|
||
return "正在读取涂抹区域并执行局部覆盖。"
|
||
case "edit-elements":
|
||
return "正在分离图片层和文字层,完成后文字会变成可编辑节点。"
|
||
case "edit-text":
|
||
return "正在识别并提取图片中的文字,生成可编辑文本层。"
|
||
case "multi-angle":
|
||
return "正在保持主体一致性并生成指定视角。"
|
||
case "move-object":
|
||
return "正在根据选择区域移动对象并补全背景。"
|
||
case "mockup":
|
||
return "正在把选中图片放入真实 mockup 场景。"
|
||
case "expand":
|
||
return "正在扩展画面边界并补全场景。"
|
||
case "adjust":
|
||
return "正在调整光线、色彩和细节。"
|
||
case "crop":
|
||
return "正在按裁剪区域重构画面。"
|
||
case "vectorize":
|
||
return "正在将图片转换为清晰矢量风格。"
|
||
default:
|
||
return "正在处理选中图片。"
|
||
}
|
||
}
|
||
|
||
func nodeActionToolContent(action string) string {
|
||
switch action {
|
||
case "quick-edit":
|
||
return "准备图片引用、用户提示词和目标约束。"
|
||
case "upscale":
|
||
return "准备高清放大提示词,约束构图不漂移。"
|
||
case "remove-background":
|
||
return "准备主体分割和干净边缘约束。"
|
||
case "eraser":
|
||
return "准备局部蒙版、覆盖区域和修补提示词。"
|
||
case "edit-elements":
|
||
return "准备图层分离提示词,输出图片层并创建可编辑文字层。"
|
||
case "edit-text":
|
||
return "准备文字检测、文字擦除和可编辑文本层参数。"
|
||
case "multi-angle":
|
||
return "准备主体一致性和相机视角参数。"
|
||
case "move-object":
|
||
return "准备对象选择、目标位置和背景补全约束。"
|
||
case "mockup":
|
||
return "准备 mockup 场景、透视和材质融合约束。"
|
||
case "expand":
|
||
return "准备扩图边距、比例和背景延展约束。"
|
||
case "adjust":
|
||
return "准备光线、色彩、锐度和风格调整参数。"
|
||
case "crop":
|
||
return "准备裁剪区域和构图约束。"
|
||
case "vectorize":
|
||
return "提交 GPT 5.5 图片矢量化任务,等待 SVG 结果。"
|
||
default:
|
||
return "准备图片动作参数。"
|
||
}
|
||
}
|
||
|
||
func nodeActionDoneTitle(action string) string {
|
||
switch action {
|
||
case "quick-edit":
|
||
return "快捷编辑完成"
|
||
case "upscale":
|
||
return "模型放大完成"
|
||
case "remove-background":
|
||
return "去背景完成"
|
||
case "eraser":
|
||
return "局部覆盖完成"
|
||
case "edit-elements":
|
||
return "元素分离完成"
|
||
case "edit-text":
|
||
return "文字提取完成"
|
||
case "multi-angle":
|
||
return "多角度生成完成"
|
||
case "move-object":
|
||
return "移动对象完成"
|
||
case "mockup":
|
||
return "Mockup 生成完成"
|
||
case "expand":
|
||
return "扩图完成"
|
||
case "adjust":
|
||
return "调整完成"
|
||
case "crop":
|
||
return "裁剪完成"
|
||
case "vectorize":
|
||
return "矢量化完成"
|
||
default:
|
||
return "图片处理完成"
|
||
}
|
||
}
|
||
|
||
func nodeActionDoneContent(action string, prompt string) string {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" {
|
||
prompt = nodeActionThinkingContent(action)
|
||
}
|
||
if action == "edit-elements" || action == "edit-text" {
|
||
return fmt.Sprintf("已完成图片动作:%s。结果已拆成无文字底图和可编辑文字层并回写到无限画布。", prompt)
|
||
}
|
||
if action == "remove-background" {
|
||
return fmt.Sprintf("已完成图片动作:%s。无背景主体图已生成在原图旁边。", prompt)
|
||
}
|
||
if action == "vectorize" {
|
||
return fmt.Sprintf("已完成图片动作:%s。SVG 矢量结果已生成在原图旁边,可直接下载。", prompt)
|
||
}
|
||
return fmt.Sprintf("已完成图片动作:%s。结果已回写到无限画布中的选中图片。", prompt)
|
||
}
|
||
|
||
func (s *DesignService) extractTextExtraction(ctx context.Context, target design.Node, req design.NodeActionRequest) (design.TextExtraction, error) {
|
||
if extraction, ok, err := textExtractionFromOptions(req.Options); ok || err != nil {
|
||
return extraction, err
|
||
}
|
||
cacheKey := textExtractionCacheKey(target, req)
|
||
if s.textExtractionCache != nil && cacheKey != "" {
|
||
var cached design.TextExtraction
|
||
if ok, err := s.textExtractionCache.Get(ctx, cacheKey, &cached); err == nil && ok {
|
||
return cached, nil
|
||
}
|
||
}
|
||
if s.textExtractor == nil {
|
||
return design.TextExtraction{}, fmt.Errorf("ocr text extractor is not configured")
|
||
}
|
||
extraction, err := s.textExtractor.ExtractText(ctx, design.TextExtractionRequest{
|
||
ImageURL: target.Content,
|
||
Prompt: req.Prompt,
|
||
Width: target.Width,
|
||
Height: target.Height,
|
||
})
|
||
if err != nil {
|
||
return design.TextExtraction{}, err
|
||
}
|
||
normalizeOriginalText(&extraction)
|
||
if s.textExtractionCache != nil && cacheKey != "" {
|
||
_ = s.textExtractionCache.Set(context.Background(), cacheKey, extraction, s.textExtractionCacheTTL)
|
||
}
|
||
return extraction, nil
|
||
}
|
||
|
||
func normalizeOriginalText(extraction *design.TextExtraction) {
|
||
for index := range extraction.Layers {
|
||
if strings.TrimSpace(extraction.Layers[index].OriginalText) == "" {
|
||
extraction.Layers[index].OriginalText = extraction.Layers[index].Text
|
||
}
|
||
}
|
||
}
|
||
|
||
type textExtractionResultPayload struct {
|
||
Items []textExtractionResultItem `json:"items"`
|
||
}
|
||
|
||
type textExtractionResultItem struct {
|
||
Text string `json:"text"`
|
||
OriginalText string `json:"originalText,omitempty"`
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
Width float64 `json:"width"`
|
||
Height float64 `json:"height"`
|
||
FontSize float64 `json:"fontSize,omitempty"`
|
||
FontFamily string `json:"fontFamily,omitempty"`
|
||
FontWeight string `json:"fontWeight,omitempty"`
|
||
Color string `json:"color,omitempty"`
|
||
TextAlign string `json:"textAlign,omitempty"`
|
||
LineHeight float64 `json:"lineHeight,omitempty"`
|
||
LetterSpacing float64 `json:"letterSpacing,omitempty"`
|
||
StrokeColor string `json:"strokeColor,omitempty"`
|
||
StrokeWidth float64 `json:"strokeWidth,omitempty"`
|
||
Opacity float64 `json:"opacity,omitempty"`
|
||
Rotation float64 `json:"rotation,omitempty"`
|
||
Confidence float64 `json:"confidence,omitempty"`
|
||
}
|
||
|
||
func textExtractionResultJSON(extraction design.TextExtraction) (string, error) {
|
||
payload := textExtractionResultPayload{Items: make([]textExtractionResultItem, 0, len(extraction.Layers))}
|
||
for _, item := range extraction.Layers {
|
||
originalText := item.OriginalText
|
||
if originalText == "" {
|
||
originalText = item.Text
|
||
}
|
||
payload.Items = append(payload.Items, textExtractionResultItem{
|
||
Text: item.Text,
|
||
OriginalText: originalText,
|
||
X: item.X,
|
||
Y: item.Y,
|
||
Width: item.Width,
|
||
Height: item.Height,
|
||
FontSize: item.FontSize,
|
||
FontFamily: item.FontFamily,
|
||
FontWeight: item.FontWeight,
|
||
Color: item.Color,
|
||
TextAlign: item.TextAlign,
|
||
LineHeight: item.LineHeight,
|
||
LetterSpacing: item.LetterSpacing,
|
||
StrokeColor: item.StrokeColor,
|
||
StrokeWidth: item.StrokeWidth,
|
||
Opacity: item.Opacity,
|
||
Rotation: item.Rotation,
|
||
Confidence: item.Confidence,
|
||
})
|
||
}
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func textExtractionCacheKey(target design.Node, req design.NodeActionRequest) string {
|
||
content := strings.TrimSpace(target.Content)
|
||
if content == "" {
|
||
return ""
|
||
}
|
||
hash := sha256.Sum256([]byte(strings.Join([]string{
|
||
"v1",
|
||
content,
|
||
fmt.Sprintf("%.3f", target.Width),
|
||
fmt.Sprintf("%.3f", target.Height),
|
||
strings.TrimSpace(req.Prompt),
|
||
}, "\x00")))
|
||
return "ocr:text-extraction:" + hex.EncodeToString(hash[:])
|
||
}
|
||
|
||
func textExtractionFromOptions(options map[string]string) (design.TextExtraction, bool, error) {
|
||
if len(options) == 0 {
|
||
return design.TextExtraction{}, false, nil
|
||
}
|
||
raw := strings.TrimSpace(options["textLayers"])
|
||
if raw == "" {
|
||
return design.TextExtraction{}, false, nil
|
||
}
|
||
|
||
type rawLayer struct {
|
||
Text string `json:"text"`
|
||
OriginalText string `json:"originalText"`
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
Width float64 `json:"width"`
|
||
Height float64 `json:"height"`
|
||
FontSize float64 `json:"fontSize"`
|
||
FontFamily string `json:"fontFamily"`
|
||
FontWeight string `json:"fontWeight"`
|
||
Color string `json:"color"`
|
||
TextAlign string `json:"textAlign"`
|
||
LineHeight float64 `json:"lineHeight"`
|
||
LetterSpacing float64 `json:"letterSpacing"`
|
||
StrokeColor string `json:"strokeColor"`
|
||
StrokeWidth float64 `json:"strokeWidth"`
|
||
Opacity float64 `json:"opacity"`
|
||
Rotation float64 `json:"rotation"`
|
||
Confidence float64 `json:"confidence"`
|
||
}
|
||
var layers []rawLayer
|
||
if err := json.Unmarshal([]byte(raw), &layers); err != nil {
|
||
return design.TextExtraction{}, true, fmt.Errorf("%w: invalid textLayers option", design.ErrInvalidInput)
|
||
}
|
||
|
||
extraction := design.TextExtraction{Layers: make([]design.ExtractedTextLayer, 0, len(layers))}
|
||
for _, item := range layers {
|
||
originalText := strings.TrimSpace(item.OriginalText)
|
||
text := strings.TrimSpace(item.Text)
|
||
if originalText == "" {
|
||
originalText = text
|
||
}
|
||
if originalText == "" && text == "" {
|
||
continue
|
||
}
|
||
if item.Width <= 0 || item.Height <= 0 {
|
||
continue
|
||
}
|
||
extraction.Layers = append(extraction.Layers, design.ExtractedTextLayer{
|
||
Text: text,
|
||
OriginalText: originalText,
|
||
X: clamp01(item.X),
|
||
Y: clamp01(item.Y),
|
||
Width: clamp01(item.Width),
|
||
Height: clamp01(item.Height),
|
||
FontSize: item.FontSize,
|
||
FontFamily: strings.TrimSpace(item.FontFamily),
|
||
FontWeight: strings.TrimSpace(item.FontWeight),
|
||
Color: strings.TrimSpace(item.Color),
|
||
TextAlign: strings.TrimSpace(item.TextAlign),
|
||
LineHeight: item.LineHeight,
|
||
LetterSpacing: item.LetterSpacing,
|
||
StrokeColor: strings.TrimSpace(item.StrokeColor),
|
||
StrokeWidth: item.StrokeWidth,
|
||
Opacity: item.Opacity,
|
||
Rotation: item.Rotation,
|
||
Confidence: item.Confidence,
|
||
})
|
||
}
|
||
return extraction, true, nil
|
||
}
|
||
|
||
func (s *DesignService) createTextRemovedImage(ctx context.Context, projectID string, target design.Node, extraction design.TextExtraction) (string, error) {
|
||
if len(extraction.Layers) == 0 {
|
||
return target.Content, nil
|
||
}
|
||
data, _, err := loadImageContent(ctx, target.Content)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
source, _, err := image.Decode(bytes.NewReader(data))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
bounds := source.Bounds()
|
||
canvas := image.NewRGBA(bounds)
|
||
draw.Draw(canvas, bounds, source, bounds.Min, draw.Src)
|
||
for _, layer := range extraction.Layers {
|
||
if strings.TrimSpace(layer.Text) == "" && strings.TrimSpace(layer.OriginalText) == "" {
|
||
continue
|
||
}
|
||
removeTextLayerFromImage(canvas, layer)
|
||
}
|
||
|
||
var output bytes.Buffer
|
||
if err := png.Encode(&output, canvas); err != nil {
|
||
return "", err
|
||
}
|
||
if s.assets == nil {
|
||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(output.Bytes()), nil
|
||
}
|
||
data, contentType, err := encodeRasterAssetAsWebP(output.Bytes(), "image/png")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
|
||
FileName: fmt.Sprintf("%s-%s-clean.png", projectID, target.ID),
|
||
ContentType: contentType,
|
||
Data: data,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return object.PublicURL, nil
|
||
}
|
||
|
||
func loadImageContent(ctx context.Context, value string) ([]byte, string, error) {
|
||
value = strings.TrimSpace(value)
|
||
if strings.HasPrefix(value, "data:image/") {
|
||
header, payload, ok := strings.Cut(value, ",")
|
||
if !ok {
|
||
return nil, "", fmt.Errorf("%w: invalid image data url", design.ErrInvalidInput)
|
||
}
|
||
contentType := strings.TrimPrefix(strings.Split(header, ";")[0], "data:")
|
||
data, err := base64.StdEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return data, contentType, nil
|
||
}
|
||
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
|
||
return nil, "", fmt.Errorf("%w: unsupported image url", design.ErrInvalidInput)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, value, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("User-Agent", "img-infinite-canvas/1.0")
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, "", fmt.Errorf("download source image failed: %s", resp.Status)
|
||
}
|
||
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if len(data) == 0 {
|
||
return nil, "", fmt.Errorf("download source image returned empty body")
|
||
}
|
||
return data, contentType, nil
|
||
}
|
||
|
||
func normalizedTextRect(bounds image.Rectangle, layer design.ExtractedTextLayer) image.Rectangle {
|
||
x := clamp01(layer.X)
|
||
y := clamp01(layer.Y)
|
||
width := clamp01(layer.Width)
|
||
height := clamp01(layer.Height)
|
||
if x+width > 1 {
|
||
width = 1 - x
|
||
}
|
||
if y+height > 1 {
|
||
height = 1 - y
|
||
}
|
||
imageWidth := float64(bounds.Dx())
|
||
imageHeight := float64(bounds.Dy())
|
||
padding := math.Max(3, math.Min(imageWidth*width, imageHeight*height)*0.18)
|
||
minX := bounds.Min.X + int(math.Floor(x*imageWidth-padding))
|
||
minY := bounds.Min.Y + int(math.Floor(y*imageHeight-padding))
|
||
maxX := bounds.Min.X + int(math.Ceil((x+width)*imageWidth+padding))
|
||
maxY := bounds.Min.Y + int(math.Ceil((y+height)*imageHeight+padding))
|
||
return image.Rect(
|
||
clampInt(minX, bounds.Min.X, bounds.Max.X),
|
||
clampInt(minY, bounds.Min.Y, bounds.Max.Y),
|
||
clampInt(maxX, bounds.Min.X, bounds.Max.X),
|
||
clampInt(maxY, bounds.Min.Y, bounds.Max.Y),
|
||
)
|
||
}
|
||
|
||
func normalizedTextMaskRect(bounds image.Rectangle, layer design.ExtractedTextLayer) image.Rectangle {
|
||
x := clamp01(layer.X)
|
||
y := clamp01(layer.Y)
|
||
width := clamp01(layer.Width)
|
||
height := clamp01(layer.Height)
|
||
if x+width > 1 {
|
||
width = 1 - x
|
||
}
|
||
if y+height > 1 {
|
||
height = 1 - y
|
||
}
|
||
imageWidth := float64(bounds.Dx())
|
||
imageHeight := float64(bounds.Dy())
|
||
padding := clampFloat(math.Min(imageWidth*width, imageHeight*height)*0.12, 3, 14)
|
||
minX := bounds.Min.X + int(math.Floor(x*imageWidth-padding))
|
||
minY := bounds.Min.Y + int(math.Floor(y*imageHeight-padding))
|
||
maxX := bounds.Min.X + int(math.Ceil((x+width)*imageWidth+padding))
|
||
maxY := bounds.Min.Y + int(math.Ceil((y+height)*imageHeight+padding))
|
||
return image.Rect(
|
||
clampInt(minX, bounds.Min.X, bounds.Max.X),
|
||
clampInt(minY, bounds.Min.Y, bounds.Max.Y),
|
||
clampInt(maxX, bounds.Min.X, bounds.Max.X),
|
||
clampInt(maxY, bounds.Min.Y, bounds.Max.Y),
|
||
)
|
||
}
|
||
|
||
func removeTextLayerFromImage(img *image.RGBA, layer design.ExtractedTextLayer) {
|
||
rect := normalizedTextMaskRect(img.Bounds(), layer)
|
||
if rect.Empty() {
|
||
return
|
||
}
|
||
mask, count := buildTextRemovalMask(img, rect, layer)
|
||
if count > 0 {
|
||
radius := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.075)), 2, 8)
|
||
if layer.StrokeWidth > 0 {
|
||
radius = clampInt(radius+int(math.Ceil(layer.StrokeWidth*0.5)), 2, 12)
|
||
}
|
||
mask, count = dilateTextMask(mask, rect, radius)
|
||
if count > 0 {
|
||
originalMask := cloneAlpha(mask)
|
||
inpaintTextMask(img, mask, rect, count)
|
||
softenMaskedPixels(img, originalMask, rect)
|
||
return
|
||
}
|
||
}
|
||
|
||
fallback := rect
|
||
if fallback.Empty() {
|
||
return
|
||
}
|
||
fillRectFromEdges(img, fallback)
|
||
softenRect(img, fallback, 1)
|
||
}
|
||
|
||
func buildTextRemovalMask(img *image.RGBA, rect image.Rectangle, layer design.ExtractedTextLayer) (*image.Alpha, int) {
|
||
mask := image.NewAlpha(img.Bounds())
|
||
textColor, hasTextColor := parseCanvasColor(layer.Color)
|
||
count := 0
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
pixel := img.RGBAAt(x, y)
|
||
background := estimatedRectBackgroundColor(img, rect, x, y)
|
||
backgroundDistance := colorDistanceRGBA(pixel, background)
|
||
textDistance := math.Inf(1)
|
||
if hasTextColor {
|
||
textDistance = colorDistanceNRGBA(pixel, textColor)
|
||
}
|
||
matches := false
|
||
switch {
|
||
case hasTextColor && textDistance <= 138 && backgroundDistance >= 8:
|
||
matches = true
|
||
case hasTextColor && backgroundDistance >= 34 && textDistance <= backgroundDistance+42:
|
||
matches = true
|
||
case !hasTextColor && backgroundDistance >= 34:
|
||
matches = true
|
||
}
|
||
if matches {
|
||
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return mask, count
|
||
}
|
||
|
||
func estimatedRectBackgroundColor(img *image.RGBA, rect image.Rectangle, x int, y int) imageColor {
|
||
bounds := img.Bounds()
|
||
left := img.RGBAAt(clampInt(rect.Min.X-1, bounds.Min.X, bounds.Max.X-1), clampInt(y, bounds.Min.Y, bounds.Max.Y-1))
|
||
right := img.RGBAAt(clampInt(rect.Max.X, bounds.Min.X, bounds.Max.X-1), clampInt(y, bounds.Min.Y, bounds.Max.Y-1))
|
||
top := img.RGBAAt(clampInt(x, bounds.Min.X, bounds.Max.X-1), clampInt(rect.Min.Y-1, bounds.Min.Y, bounds.Max.Y-1))
|
||
bottom := img.RGBAAt(clampInt(x, bounds.Min.X, bounds.Max.X-1), clampInt(rect.Max.Y, bounds.Min.Y, bounds.Max.Y-1))
|
||
horizontal := float64(x-rect.Min.X) / math.Max(1, float64(rect.Dx()-1))
|
||
vertical := float64(y-rect.Min.Y) / math.Max(1, float64(rect.Dy()-1))
|
||
row := mixRGBA(left, right, horizontal)
|
||
col := mixRGBA(top, bottom, vertical)
|
||
return mixRGBA(row, col, 0.5)
|
||
}
|
||
|
||
func dilateTextMask(mask *image.Alpha, rect image.Rectangle, radius int) (*image.Alpha, int) {
|
||
next := image.NewAlpha(mask.Bounds())
|
||
count := 0
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
if mask.AlphaAt(x, y).A == 0 {
|
||
continue
|
||
}
|
||
for oy := -radius; oy <= radius; oy++ {
|
||
for ox := -radius; ox <= radius; ox++ {
|
||
if ox*ox+oy*oy > radius*radius {
|
||
continue
|
||
}
|
||
px := clampInt(x+ox, rect.Min.X, rect.Max.X-1)
|
||
py := clampInt(y+oy, rect.Min.Y, rect.Max.Y-1)
|
||
if next.AlphaAt(px, py).A == 0 {
|
||
next.SetAlpha(px, py, color.Alpha{A: 255})
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return next, count
|
||
}
|
||
|
||
func inpaintTextMask(img *image.RGBA, mask *image.Alpha, rect image.Rectangle, remaining int) {
|
||
bounds := img.Bounds()
|
||
for pass := 0; remaining > 0 && pass < 96; pass++ {
|
||
clone := image.NewRGBA(bounds)
|
||
draw.Draw(clone, bounds, img, bounds.Min, draw.Src)
|
||
nextMask := cloneAlpha(mask)
|
||
changed := 0
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
if mask.AlphaAt(x, y).A == 0 {
|
||
continue
|
||
}
|
||
var r, g, b, a, count int
|
||
for oy := -1; oy <= 1; oy++ {
|
||
for ox := -1; ox <= 1; ox++ {
|
||
if ox == 0 && oy == 0 {
|
||
continue
|
||
}
|
||
px := clampInt(x+ox, bounds.Min.X, bounds.Max.X-1)
|
||
py := clampInt(y+oy, bounds.Min.Y, bounds.Max.Y-1)
|
||
if mask.AlphaAt(px, py).A != 0 {
|
||
continue
|
||
}
|
||
c := clone.RGBAAt(px, py)
|
||
r += int(c.R)
|
||
g += int(c.G)
|
||
b += int(c.B)
|
||
a += int(c.A)
|
||
count++
|
||
}
|
||
}
|
||
if count == 0 {
|
||
continue
|
||
}
|
||
img.SetRGBA(x, y, colorRGBA(r/count, g/count, b/count, a/count))
|
||
nextMask.SetAlpha(x, y, color.Alpha{})
|
||
changed++
|
||
}
|
||
}
|
||
if changed == 0 {
|
||
break
|
||
}
|
||
mask = nextMask
|
||
remaining -= changed
|
||
}
|
||
}
|
||
|
||
func softenMaskedPixels(img *image.RGBA, mask *image.Alpha, rect image.Rectangle) {
|
||
bounds := img.Bounds()
|
||
clone := image.NewRGBA(bounds)
|
||
draw.Draw(clone, bounds, img, bounds.Min, draw.Src)
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
if mask.AlphaAt(x, y).A == 0 {
|
||
continue
|
||
}
|
||
var r, g, b, a, count int
|
||
for oy := -1; oy <= 1; oy++ {
|
||
for ox := -1; ox <= 1; ox++ {
|
||
c := clone.RGBAAt(clampInt(x+ox, bounds.Min.X, bounds.Max.X-1), clampInt(y+oy, bounds.Min.Y, bounds.Max.Y-1))
|
||
r += int(c.R)
|
||
g += int(c.G)
|
||
b += int(c.B)
|
||
a += int(c.A)
|
||
count++
|
||
}
|
||
}
|
||
img.SetRGBA(x, y, colorRGBA(r/count, g/count, b/count, a/count))
|
||
}
|
||
}
|
||
}
|
||
|
||
func cloneAlpha(mask *image.Alpha) *image.Alpha {
|
||
clone := image.NewAlpha(mask.Bounds())
|
||
copy(clone.Pix, mask.Pix)
|
||
return clone
|
||
}
|
||
|
||
func colorDistanceRGBA(a imageColor, b imageColor) float64 {
|
||
dr := float64(a.R) - float64(b.R)
|
||
dg := float64(a.G) - float64(b.G)
|
||
db := float64(a.B) - float64(b.B)
|
||
return math.Sqrt(dr*dr + dg*dg + db*db)
|
||
}
|
||
|
||
func colorDistanceNRGBA(a imageColor, b color.NRGBA) float64 {
|
||
dr := float64(a.R) - float64(b.R)
|
||
dg := float64(a.G) - float64(b.G)
|
||
db := float64(a.B) - float64(b.B)
|
||
return math.Sqrt(dr*dr + dg*dg + db*db)
|
||
}
|
||
|
||
func fillRectFromEdges(img *image.RGBA, rect image.Rectangle) {
|
||
bounds := img.Bounds()
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
left := img.RGBAAt(clampInt(rect.Min.X-1, bounds.Min.X, bounds.Max.X-1), clampInt(y, bounds.Min.Y, bounds.Max.Y-1))
|
||
right := img.RGBAAt(clampInt(rect.Max.X, bounds.Min.X, bounds.Max.X-1), clampInt(y, bounds.Min.Y, bounds.Max.Y-1))
|
||
top := img.RGBAAt(clampInt(x, bounds.Min.X, bounds.Max.X-1), clampInt(rect.Min.Y-1, bounds.Min.Y, bounds.Max.Y-1))
|
||
bottom := img.RGBAAt(clampInt(x, bounds.Min.X, bounds.Max.X-1), clampInt(rect.Max.Y, bounds.Min.Y, bounds.Max.Y-1))
|
||
horizontal := float64(x-rect.Min.X) / math.Max(1, float64(rect.Dx()-1))
|
||
vertical := float64(y-rect.Min.Y) / math.Max(1, float64(rect.Dy()-1))
|
||
row := mixRGBA(left, right, horizontal)
|
||
col := mixRGBA(top, bottom, vertical)
|
||
img.SetRGBA(x, y, mixRGBA(row, col, 0.5))
|
||
}
|
||
}
|
||
}
|
||
|
||
func softenRect(img *image.RGBA, rect image.Rectangle, passes int) {
|
||
bounds := img.Bounds()
|
||
for pass := 0; pass < passes; pass++ {
|
||
clone := image.NewRGBA(bounds)
|
||
draw.Draw(clone, bounds, img, bounds.Min, draw.Src)
|
||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||
var r, g, b, a, count int
|
||
for oy := -1; oy <= 1; oy++ {
|
||
for ox := -1; ox <= 1; ox++ {
|
||
c := clone.RGBAAt(clampInt(x+ox, bounds.Min.X, bounds.Max.X-1), clampInt(y+oy, bounds.Min.Y, bounds.Max.Y-1))
|
||
r += int(c.R)
|
||
g += int(c.G)
|
||
b += int(c.B)
|
||
a += int(c.A)
|
||
count++
|
||
}
|
||
}
|
||
img.SetRGBA(x, y, colorRGBA(r/count, g/count, b/count, a/count))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func mixRGBA(a imageColor, b imageColor, t float64) imageColor {
|
||
t = clampFloat(t, 0, 1)
|
||
return colorRGBA(
|
||
int(float64(a.R)*(1-t)+float64(b.R)*t),
|
||
int(float64(a.G)*(1-t)+float64(b.G)*t),
|
||
int(float64(a.B)*(1-t)+float64(b.B)*t),
|
||
int(float64(a.A)*(1-t)+float64(b.A)*t),
|
||
)
|
||
}
|
||
|
||
type imageColor = color.RGBA
|
||
|
||
func colorRGBA(r int, g int, b int, a int) imageColor {
|
||
return imageColor{
|
||
R: uint8(clampInt(r, 0, 255)),
|
||
G: uint8(clampInt(g, 0, 255)),
|
||
B: uint8(clampInt(b, 0, 255)),
|
||
A: uint8(clampInt(a, 0, 255)),
|
||
}
|
||
}
|
||
|
||
func clampInt(value int, min int, max int) int {
|
||
if value < min {
|
||
return min
|
||
}
|
||
if value > max {
|
||
return max
|
||
}
|
||
return value
|
||
}
|
||
|
||
func newEditableTextLayers(target design.Node, extraction design.TextExtraction) []design.Node {
|
||
nodes := make([]design.Node, 0, len(extraction.Layers))
|
||
for index, layer := range extraction.Layers {
|
||
content := strings.TrimSpace(layer.Text)
|
||
if content == "" {
|
||
continue
|
||
}
|
||
x := clamp01(layer.X)
|
||
y := clamp01(layer.Y)
|
||
width := clamp01(layer.Width)
|
||
height := clamp01(layer.Height)
|
||
if width <= 0 || height <= 0 {
|
||
continue
|
||
}
|
||
if x+width > 1 {
|
||
width = 1 - x
|
||
}
|
||
if y+height > 1 {
|
||
height = 1 - y
|
||
}
|
||
if width <= 0 || height <= 0 {
|
||
continue
|
||
}
|
||
|
||
nodeWidth := maxFloat(24, target.Width*width)
|
||
nodeHeight := maxFloat(18, target.Height*height)
|
||
fontSize := layer.FontSize
|
||
if fontSize <= 0 || fontSize > nodeHeight*1.35 {
|
||
fontSize = clampFloat(nodeHeight*0.72, 10, 96)
|
||
}
|
||
fontFamily := strings.TrimSpace(layer.FontFamily)
|
||
if fontFamily == "" {
|
||
fontFamily = "Inter, Arial, sans-serif"
|
||
}
|
||
fontWeight := strings.TrimSpace(layer.FontWeight)
|
||
if fontWeight == "" {
|
||
fontWeight = "700"
|
||
}
|
||
color := strings.TrimSpace(layer.Color)
|
||
if color == "" {
|
||
color = "#111827"
|
||
}
|
||
textAlign := strings.TrimSpace(layer.TextAlign)
|
||
if textAlign == "" {
|
||
textAlign = "center"
|
||
}
|
||
lineHeight := layer.LineHeight
|
||
if lineHeight <= 0 || lineHeight > 2.5 {
|
||
lineHeight = 1.08
|
||
}
|
||
letterSpacing := layer.LetterSpacing
|
||
if letterSpacing < 0 {
|
||
letterSpacing = 0
|
||
}
|
||
strokeColor := strings.TrimSpace(layer.StrokeColor)
|
||
strokeWidth := layer.StrokeWidth
|
||
if strokeColor == "" || strokeWidth <= 0 {
|
||
strokeColor = ""
|
||
strokeWidth = 0
|
||
}
|
||
opacity := layer.Opacity
|
||
if opacity <= 0 || opacity > 1 {
|
||
opacity = 1
|
||
}
|
||
|
||
nodes = append(nodes, design.Node{
|
||
ID: newID(),
|
||
Type: design.NodeTypeText,
|
||
Title: fmt.Sprintf("Editable Text Layer %d", index+1),
|
||
X: target.X + target.Width*x,
|
||
Y: target.Y + target.Height*y,
|
||
Width: nodeWidth,
|
||
Height: nodeHeight,
|
||
Content: content,
|
||
Tone: "extracted-text",
|
||
Status: "editable",
|
||
ParentID: target.ID,
|
||
LayerRole: "editable-text",
|
||
FontSize: fontSize,
|
||
FontFamily: fontFamily,
|
||
FontWeight: fontWeight,
|
||
Color: color,
|
||
TextAlign: textAlign,
|
||
LineHeight: lineHeight,
|
||
LetterSpacing: letterSpacing,
|
||
StrokeColor: strokeColor,
|
||
StrokeWidth: strokeWidth,
|
||
Opacity: opacity,
|
||
Rotation: layer.Rotation,
|
||
})
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
func clamp01(value float64) float64 {
|
||
if value < 0 {
|
||
return 0
|
||
}
|
||
if value > 1 {
|
||
return 1
|
||
}
|
||
return value
|
||
}
|
||
|
||
func clampFloat(value float64, min float64, max float64) float64 {
|
||
if value < min {
|
||
return min
|
||
}
|
||
if value > max {
|
||
return max
|
||
}
|
||
return value
|
||
}
|
||
|
||
func maxFloat(a float64, b float64) float64 {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func quickActions() []design.QuickAction {
|
||
return []design.QuickAction{
|
||
{ID: "commerce", Label: "电商海报", Prompt: "为一款夏季护肤新品设计电商专题页,突出清爽、成分可信和限时套装。", Icon: "shopping-bag"},
|
||
{ID: "ui", Label: "UI 界面", Prompt: "设计一个智能家居数据仪表盘,包含地图、能耗、设备状态和异常提醒。", Icon: "layout-dashboard"},
|
||
{ID: "retouch", Label: "产品精修", Prompt: "基于高级香氛产品做广告精修,画面需要奢华、克制、适合官网首屏。", Icon: "wand"},
|
||
{ID: "style", Label: "风格迁移", Prompt: "把街头服饰品牌官网迁移到 editorial luxury 风格,保留年轻张力。", Icon: "swatches"},
|
||
}
|
||
}
|
||
|
||
func inspirationItems() []design.InspirationItem {
|
||
return []design.InspirationItem{
|
||
{ID: "luxury-spring", Title: "2026 春季系列设计趋势", Category: "Luxury Showcase", Description: "强对比摄影、低饱和色块与清晰购买动线。", Accent: "#111827"},
|
||
{ID: "natural-ui", Title: "极简自然主义 UI", Category: "Mobile App", Description: "玻璃拟态信息卡与植物识别流程。", Accent: "#6b8f71"},
|
||
{ID: "blue-geometry", Title: "蓝色立体几何广告", Category: "Campaign", Description: "冷感材质、漂浮图形和高亮 CTA。", Accent: "#2563eb"},
|
||
{ID: "brand-kit", Title: "天然护肤品牌板", Category: "Brand Kit", Description: "字体、插画、包装和详情页模块统一沉淀。", Accent: "#c8a568"},
|
||
}
|
||
}
|
||
|
||
func seedProjects(now time.Time) []design.Project {
|
||
prompts := []string{
|
||
"夏日护肤专题海报,AURA 品牌,自然成分与清透质感。",
|
||
"智能家居仪表盘,深色数据大屏和高密度状态卡。",
|
||
"未命名品牌设计,植物插画、包装系统和色彩规范。",
|
||
}
|
||
projects := make([]design.Project, 0, len(prompts))
|
||
for index, prompt := range prompts {
|
||
updated := now.Add(time.Duration(-index*26) * time.Hour)
|
||
nodes := composeBoard(prompt, index*10)
|
||
project := design.Project{
|
||
ID: newID(),
|
||
Title: []string{"夏日护肤专题海报", "智能家居仪表盘", "未命名品牌设计"}[index],
|
||
Brief: prompt,
|
||
Status: design.StatusReady,
|
||
Thumbnail: inferThumbnail(prompt),
|
||
UpdatedAt: updated,
|
||
Version: design.CanvasVersion(updated),
|
||
SnapshotID: newID(),
|
||
Viewport: design.Viewport{X: 420, Y: 260, K: 0.62},
|
||
Nodes: nodes,
|
||
Connections: nil,
|
||
Messages: []design.Message{
|
||
{ID: newID(), Role: "assistant", Type: "assistant", Title: "方向已完成", Content: "已完成视觉探索,可继续展开页面模块或生成替代风格。", CreatedAt: updated},
|
||
},
|
||
}
|
||
if canvas, err := design.EncodeCanvasSnapshot(project); err == nil {
|
||
project.Canvas = canvas
|
||
}
|
||
projects = append(projects, project)
|
||
}
|
||
return projects
|
||
}
|
||
|
||
func composeBoard(prompt string, offset int) []design.Node {
|
||
baseX := float64((offset%3)*210 - 220)
|
||
baseY := float64((offset/3)*210 - 140)
|
||
tone := inferTone(prompt)
|
||
status := "success"
|
||
if offset == 0 {
|
||
status = "active"
|
||
}
|
||
|
||
return []design.Node{
|
||
{
|
||
ID: newID(),
|
||
Type: design.NodeTypeFrame,
|
||
Title: inferFrameTitle(prompt),
|
||
X: baseX,
|
||
Y: baseY,
|
||
Width: 520,
|
||
Height: 300,
|
||
Content: prompt,
|
||
Tone: tone,
|
||
Status: status,
|
||
},
|
||
{
|
||
ID: newID(),
|
||
Type: design.NodeTypeImage,
|
||
Title: "Mood x Color",
|
||
X: baseX,
|
||
Y: baseY + 340,
|
||
Width: 520,
|
||
Height: 640,
|
||
Content: prompt,
|
||
Tone: tone,
|
||
Status: "success",
|
||
},
|
||
{
|
||
ID: newID(),
|
||
Type: design.NodeTypeImage,
|
||
Title: "Mobile Page",
|
||
X: baseX + 575,
|
||
Y: baseY + 250,
|
||
Width: 260,
|
||
Height: 460,
|
||
Content: prompt,
|
||
Tone: tone,
|
||
Status: "success",
|
||
},
|
||
{
|
||
ID: newID(),
|
||
Type: design.NodeTypeFrame,
|
||
Title: "Soft Variant",
|
||
X: baseX + 875,
|
||
Y: baseY + 210,
|
||
Width: 360,
|
||
Height: 430,
|
||
Content: prompt,
|
||
Tone: tone,
|
||
Status: "success",
|
||
},
|
||
{
|
||
ID: newID(),
|
||
Type: design.NodeTypeImage,
|
||
Title: "Detail Page",
|
||
X: baseX + 585,
|
||
Y: baseY + 790,
|
||
Width: 310,
|
||
Height: 560,
|
||
Content: prompt,
|
||
Tone: tone,
|
||
Status: "success",
|
||
},
|
||
}
|
||
}
|
||
|
||
func inferTitle(prompt string) string {
|
||
lower := strings.ToLower(prompt)
|
||
if prefersEnglishText(prompt) {
|
||
switch {
|
||
case strings.Contains(lower, "ecommerce") || strings.Contains(lower, "product"):
|
||
return "Ecommerce visual canvas"
|
||
case strings.Contains(lower, "ui") || strings.Contains(lower, "dashboard"):
|
||
return "Interface design exploration"
|
||
case strings.Contains(lower, "brand"):
|
||
return "Brand design canvas"
|
||
default:
|
||
return "Untitled design project"
|
||
}
|
||
}
|
||
switch {
|
||
case strings.Contains(prompt, "电商") || strings.Contains(prompt, "护肤"):
|
||
return "电商视觉方案"
|
||
case strings.Contains(lower, "ui") || strings.Contains(prompt, "仪表盘"):
|
||
return "界面设计探索"
|
||
case strings.Contains(prompt, "品牌"):
|
||
return "品牌设计画布"
|
||
default:
|
||
return "未命名设计项目"
|
||
}
|
||
}
|
||
|
||
func generationPlaceholderTitle(prompt string) string {
|
||
title := inferGeneratedArtifactTitle(prompt)
|
||
if prefersEnglishText(prompt) {
|
||
return title + " generating"
|
||
}
|
||
return title + " 生成中"
|
||
}
|
||
|
||
func inferGeneratedArtifactTitle(prompt string) string {
|
||
lower := strings.ToLower(prompt)
|
||
if prefersEnglishText(prompt) {
|
||
switch {
|
||
case strings.Contains(lower, "pdp") || strings.Contains(lower, "detail page"):
|
||
return "Product detail page"
|
||
case strings.Contains(lower, "homepage") || strings.Contains(lower, "website") || strings.Contains(lower, "landing"):
|
||
return "Homepage visual"
|
||
case strings.Contains(lower, "poster"):
|
||
return "Poster visual"
|
||
case strings.Contains(lower, "banner"):
|
||
return "Banner visual"
|
||
case strings.Contains(lower, "logo"):
|
||
return "Logo visual"
|
||
case strings.Contains(lower, "product"):
|
||
return "Product visual"
|
||
default:
|
||
return "Image"
|
||
}
|
||
}
|
||
switch {
|
||
case strings.Contains(prompt, "详情页"):
|
||
return "电商详情页"
|
||
case strings.Contains(prompt, "首页") || strings.Contains(prompt, "官网"):
|
||
return "官网首页视觉"
|
||
case strings.Contains(prompt, "海报"):
|
||
return "海报视觉"
|
||
case strings.Contains(prompt, "banner"):
|
||
return "Banner 视觉"
|
||
case strings.Contains(prompt, "logo"):
|
||
return "Logo 视觉"
|
||
case strings.Contains(prompt, "产品") || strings.Contains(prompt, "主图"):
|
||
return "产品视觉"
|
||
default:
|
||
return "图片"
|
||
}
|
||
}
|
||
|
||
func prefersEnglishText(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if r >= 0x4E00 && r <= 0x9FFF {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func inferThumbnail(prompt string) string {
|
||
switch {
|
||
case strings.Contains(prompt, "仪表盘") || strings.Contains(strings.ToLower(prompt), "dashboard"):
|
||
return "dashboard"
|
||
case strings.Contains(prompt, "护肤") || strings.Contains(prompt, "香氛") || strings.Contains(prompt, "产品"):
|
||
return "commerce"
|
||
case strings.Contains(prompt, "品牌") || strings.Contains(prompt, "包装"):
|
||
return "brand"
|
||
default:
|
||
return "canvas"
|
||
}
|
||
}
|
||
|
||
func inferTone(prompt string) string {
|
||
switch {
|
||
case strings.Contains(prompt, "奢华") || strings.Contains(strings.ToLower(prompt), "luxury"):
|
||
return "luxury"
|
||
case strings.Contains(prompt, "自然") || strings.Contains(prompt, "植物"):
|
||
return "natural"
|
||
case strings.Contains(prompt, "深色") || strings.Contains(prompt, "数据"):
|
||
return "technical"
|
||
case strings.Contains(prompt, "街头") || strings.Contains(strings.ToLower(prompt), "street"):
|
||
return "street"
|
||
default:
|
||
return "modern"
|
||
}
|
||
}
|
||
|
||
func inferFrameTitle(prompt string) string {
|
||
if strings.Contains(prompt, "仪表盘") {
|
||
return "Dashboard Layout"
|
||
}
|
||
if strings.Contains(prompt, "官网") {
|
||
return "Website Hero"
|
||
}
|
||
if strings.Contains(prompt, "品牌") {
|
||
return "Brand System"
|
||
}
|
||
return "Campaign Layout"
|
||
}
|
||
|
||
func inferFrameContent(prompt string) string {
|
||
if strings.Contains(prompt, "仪表盘") {
|
||
return "顶部指标条 / 世界地图 / 设备状态 / 能耗图表 / 异常队列"
|
||
}
|
||
if strings.Contains(prompt, "品牌") {
|
||
return "Logo lockup / Color tokens / Typography / Packaging / Social cards"
|
||
}
|
||
return "Hero Banner / Mood & Color / Product Grid / Detail Modules / Footer CTA"
|
||
}
|
||
|
||
func newID() string {
|
||
return uuid.NewString()
|
||
}
|