feat(application): PSD layer separation and image quality/count threading

Convert GPT Image 2 layered PSD output into canvas image/text nodes with a
local separation fallback (new layer_separation.go, oov/psd dependency),
default layer-separation/edit-elements output_format to psd, and thread
imageQuality/imageCount through project generation and job processing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:42:42 +08:00
parent 00b70298f2
commit cb24ffd185
7 changed files with 565 additions and 78 deletions
+85 -63
View File
@@ -51,25 +51,26 @@ 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
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
layeredDocumentGenerator design.LayeredDocumentGenerator
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 agentTaskCancelHandle struct {
@@ -136,6 +137,10 @@ func (s *DesignService) SetBackgroundRemover(remover design.BackgroundRemover) {
s.backgroundRemover = remover
}
func (s *DesignService) SetLayeredDocumentGenerator(generator design.LayeredDocumentGenerator) {
s.layeredDocumentGenerator = generator
}
func (s *DesignService) SetObjectRecognizer(recognizer design.ObjectRecognizer) {
s.objectRecognizer = recognizer
}
@@ -313,7 +318,7 @@ func (s *DesignService) DeleteProjects(ctx context.Context, ids []string) (int64
return deleted, nil
}
func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) {
func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) {
prompt = design.NormalizePrompt(prompt)
if prompt == "" {
project, _ := s.newProjectShell(ctx, title, prompt)
@@ -347,11 +352,13 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt
}
agentReq := design.AgentRequest{
Project: project,
Prompt: prompt,
Mode: "design",
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
Project: project,
Prompt: prompt,
Mode: "design",
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
ImageQuality: strings.TrimSpace(imageQuality),
ImageCount: imageCount,
Messages: []design.AgentChatMessage{
{
Role: "user",
@@ -401,7 +408,7 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt
return project, nil
}
func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) {
func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) {
project, prompt := s.newProjectShell(ctx, title, prompt)
if prompt == "" {
project = s.refreshCanvasSnapshot(project, project.UpdatedAt)
@@ -425,11 +432,11 @@ func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, pr
return design.Project{}, err
}
s.startCreateProjectGeneration(project.ID, project.UserID, threadID, prompt, imageModel, imageSize, enableWebSearch, design.AgentTaskPlan{})
s.startCreateProjectGeneration(project.ID, project.UserID, threadID, prompt, imageModel, imageSize, imageQuality, imageCount, 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) {
func (s *DesignService) Generate(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int) (GenerateResult, error) {
prompt = design.NormalizePrompt(prompt)
if prompt == "" {
return GenerateResult{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
@@ -441,11 +448,13 @@ func (s *DesignService) Generate(ctx context.Context, projectID string, prompt s
}
agentReq := design.AgentRequest{
Project: project,
Prompt: prompt,
Mode: mode,
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
Project: project,
Prompt: prompt,
Mode: mode,
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
ImageQuality: strings.TrimSpace(imageQuality),
ImageCount: imageCount,
Messages: []design.AgentChatMessage{
{
Role: "user",
@@ -494,7 +503,7 @@ func (s *DesignService) Generate(ctx context.Context, projectID string, prompt s
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) {
func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int) (design.Project, error) {
thread, err := s.AgentChat(ctx, projectID, design.AgentChatRequest{
Messages: []design.AgentChatMessage{
{
@@ -507,6 +516,8 @@ func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, pro
ThreadIDType: 9,
ImageModel: imageModel,
ImageSize: imageSize,
ImageQuality: imageQuality,
ImageCount: imageCount,
Mode: mode,
})
if err != nil {
@@ -603,7 +614,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
}
var taskPlan design.AgentTaskPlan
if directImageMode {
taskPlan = directImageTaskPlan(prompt, req.Messages)
taskPlan = directImageTaskPlan(prompt, req.ImageCount, req.Messages)
} else {
taskPlan = s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory)
}
@@ -672,7 +683,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
}
if shouldGenerateImage {
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, mode, req.ImageModel, req.ImageSize, req.Messages, enableWebSearch, isolatedImageGenerator, taskPlan)
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, mode, req.ImageModel, req.ImageSize, req.ImageQuality, req.ImageCount, req.Messages, enableWebSearch, isolatedImageGenerator, taskPlan)
} else {
if assistantMessageID == "" && len(project.Messages) > 0 {
assistantMessageID = project.Messages[len(project.Messages)-1].ID
@@ -1650,9 +1661,9 @@ 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))
return s.finishGenerationJob(job, s.completeCreateProjectGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.ImageModel, job.ImageSize, job.ImageQuality, job.ImageCount, 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))
return s.finishGenerationJob(job, s.completeFollowupGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.Mode, job.ImageModel, job.ImageSize, job.ImageQuality, job.ImageCount, 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:
@@ -2466,7 +2477,7 @@ func mergeSavedCanvasWorkState(existing []design.Node, incoming []design.Node) [
return next
}
func (s *DesignService) startCreateProjectGeneration(projectID string, userID string, threadID string, prompt string, imageModel string, imageSize string, enableWebSearch bool, taskPlan design.AgentTaskPlan) {
func (s *DesignService) startCreateProjectGeneration(projectID string, userID string, threadID string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, taskPlan design.AgentTaskPlan) {
s.enqueueOrRunJob(design.Job{
Kind: design.JobCreateProjectGeneration,
UserID: userID,
@@ -2475,14 +2486,16 @@ func (s *DesignService) startCreateProjectGeneration(projectID string, userID st
Prompt: prompt,
ImageModel: imageModel,
ImageSize: imageSize,
ImageQuality: imageQuality,
ImageCount: imageCount,
TaskPlan: taskPlan,
EnableWebSearch: enableWebSearch,
}, func(ctx context.Context) error {
return s.completeCreateProjectGeneration(ctx, projectID, threadID, prompt, imageModel, imageSize, enableWebSearch, taskPlan)
return s.completeCreateProjectGeneration(ctx, projectID, threadID, prompt, imageModel, imageSize, imageQuality, imageCount, 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 {
func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, projectID string, threadID string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, taskPlan design.AgentTaskPlan) error {
ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID)
defer cleanup()
if err := ctx.Err(); err != nil {
@@ -2570,14 +2583,16 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro
}
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,
Project: project,
Prompt: prompt,
Mode: "design",
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
ImageQuality: strings.TrimSpace(imageQuality),
ImageCount: imageCount,
Messages: messages,
Memory: buildAgentMemory(project, prompt, "design", messages),
TaskPlan: taskPlan,
})
if err != nil {
if s.isUserCancelledGeneration(ctx, projectID, threadID, err) {
@@ -2865,7 +2880,7 @@ func findPlannerMessageIndex(messages []design.Message, threadID string) int {
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) {
func (s *DesignService) startFollowupGeneration(projectID string, userID string, threadID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) {
s.enqueueOrRunJob(design.Job{
Kind: design.JobFollowupGeneration,
UserID: userID,
@@ -2875,16 +2890,18 @@ func (s *DesignService) startFollowupGeneration(projectID string, userID string,
Mode: mode,
ImageModel: imageModel,
ImageSize: imageSize,
ImageQuality: imageQuality,
ImageCount: imageCount,
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)
return s.completeFollowupGeneration(ctx, projectID, threadID, prompt, mode, imageModel, imageSize, imageQuality, imageCount, 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 {
func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectID string, threadID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int, 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 {
@@ -2924,14 +2941,16 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI
}
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,
Project: projectScopedToAgentThread(project, threadID),
Prompt: prompt,
Mode: mode,
ImageModel: strings.TrimSpace(imageModel),
ImageSize: strings.TrimSpace(imageSize),
ImageQuality: strings.TrimSpace(imageQuality),
ImageCount: imageCount,
Messages: messages,
Memory: buildAgentMemory(projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, messages),
TaskPlan: taskPlan,
}
var plan design.AgentPlan
var incremental bool
@@ -3507,8 +3526,11 @@ func isDirectImageGenerationMode(mode string, isolated bool) bool {
return modeText == "image" || modeText == "pure-image" || modeText == "image-generator" || strings.Contains(modeText, "image-action")
}
func directImageTaskPlan(prompt string, messages []design.AgentChatMessage) design.AgentTaskPlan {
count := imageModeSettingsCount(messages)
func directImageTaskPlan(prompt string, imageCount int, messages []design.AgentChatMessage) design.AgentTaskPlan {
count := imageCount
if count <= 0 {
count = imageModeSettingsCount(messages)
}
if count <= 0 {
count = 1
}
@@ -3999,7 +4021,7 @@ func (s *DesignService) recoverOrphanedAgentGeneration(ctx context.Context, proj
},
},
}
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, "design", "gpt-image-2", imageSize, messages, false, false, taskPlan)
s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, "design", "gpt-image-2", imageSize, "", 0, 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
}