package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" "regexp" "strings" "sync" "time" "unicode/utf8" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" ) type Service struct { repo *repository.Repository dataDir string runtimeCapabilities RuntimeCapabilities cancelMu sync.Mutex registrationMu sync.Mutex emailCodeMu sync.Mutex redeemBatchMu sync.Mutex storageMu sync.Mutex characterTaskMu sync.Mutex activeCancels map[string]context.CancelFunc pendingStorage map[string]int64 coordinator *runtimeCoordinator runtimeErr error workerID string routeCatalogMu sync.RWMutex routeCatalogRefreshMu sync.Mutex routeCatalog *routeCatalogSnapshot routeCatalogTTL time.Duration routeCatalogMaxStale time.Duration routeCatalogVersion int64 routeHealthMu sync.Mutex routeHealthBlocked map[string]time.Time } const taskWorkerConcurrency = 3 const taskLogPayloadLimit = 4000 type CreateSessionRequest struct { ProjectID string `json:"projectId"` Prompt string `json:"prompt"` CanvasSnapshot map[string]any `json:"canvasSnapshot"` References []string `json:"references"` Requirements string `json:"requirements"` CanvasAssets []storyboardAsset `json:"canvasAssets"` ProjectStyle storyboardProjectStyle `json:"projectStyle"` Characters []storyboardCharacterCard `json:"characters"` Config providerConfig `json:"config"` LogicalModelID string `json:"logicalModelId"` } type CreateTaskRequest struct { SessionID string `json:"sessionId"` ProjectID string `json:"projectId"` Type string `json:"type"` Operation string `json:"operation"` Prompt string `json:"prompt"` Provider string `json:"provider"` Model string `json:"model"` LogicalModelID string `json:"logicalModelId"` Input map[string]any `json:"input"` } type SessionDetail struct { Session model.Session `json:"session"` Messages []model.Message `json:"messages"` Tasks []TaskSummary `json:"tasks"` Results []model.Result `json:"results"` } type TaskSummary struct { ID string `json:"id"` SessionID string `json:"sessionId,omitempty"` ProjectID string `json:"projectId,omitempty"` Type string `json:"type"` Status model.TaskStatus `json:"status"` Stage string `json:"stage"` Progress int `json:"progress"` Prompt string `json:"prompt"` Operation string `json:"operation,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` ProviderRequestID string `json:"providerRequestId,omitempty"` ProviderCancelStatus model.ProviderCancelStatus `json:"providerCancelStatus,omitempty"` ProviderCancelError string `json:"providerCancelError,omitempty"` ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` ErrorCode string `json:"errorCode,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` PreviewKind string `json:"previewKind,omitempty"` Attempts int `json:"attempts"` StartedAt *time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Billing *TaskBillingSummary `json:"billing,omitempty"` ClientContext *TaskClientContext `json:"clientContext,omitempty"` } type TaskClientContext struct { ConversationID string `json:"conversationId"` MessageID string `json:"messageId"` BatchIndex int `json:"batchIndex,omitempty"` BatchCount int `json:"batchCount,omitempty"` } type TaskBillingSummary struct { AmountMicrocredits int64 `json:"amountMicrocredits"` Status model.BillingStatus `json:"status"` } type TaskListOptions struct { Limit int ProjectID string ActiveOnly bool } type agentStoryboardInput struct { References []string `json:"references"` CanvasSnapshot map[string]any `json:"canvasSnapshot"` Requirements string `json:"requirements"` CanvasAssets []storyboardAsset `json:"canvasAssets"` ProjectStyle storyboardProjectStyle `json:"projectStyle"` Characters []storyboardCharacterCard `json:"characters"` Config providerConfig `json:"config"` ShotDuration int `json:"shotDurationSeconds"` ShotCount int `json:"shotCount"` } type storyboardProjectStyle struct { PresetID string `json:"presetId"` Title string `json:"title"` Prompt string `json:"prompt"` ProfileJSON string `json:"profileJson,omitempty"` } type storyboardCharacterCard struct { AssetID string `json:"assetId"` VersionID string `json:"versionId"` Name string `json:"name"` Definition map[string]any `json:"definition"` } type storyboardAsset struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Tags []string `json:"tags"` Prompt string `json:"prompt"` CharacterAssetID string `json:"characterAssetId,omitempty"` CharacterVersionID string `json:"characterVersionId,omitempty"` } type agentStoryboardPlan struct { Title string `json:"title"` Logline string `json:"logline"` StyleGuide string `json:"styleGuide"` Characters []string `json:"characters"` Locations []string `json:"locations"` Shots []agentStoryboardShot `json:"shots"` Raw map[string]interface{} `json:"-"` } type agentStoryboardShot struct { Title string `json:"title"` Description string `json:"description"` Duration int `json:"durationSeconds"` Dialogue string `json:"dialogue"` ShotSize string `json:"shotSize"` Emotion string `json:"emotion"` Lighting string `json:"lightingAndAtmosphere"` AudioEffects string `json:"audioEffects"` VisualPrompt string `json:"visualPrompt"` VideoPrompt string `json:"videoPrompt"` Camera string `json:"camera"` Motion string `json:"motion"` TimeBeats string `json:"timeBeats"` Negative string `json:"negativePrompt"` AssetTags []string `json:"assetTags"` CharacterIDs []string `json:"characterIds"` Intent string `json:"narrativeIntent"` ViewerPOV string `json:"viewerPOV"` Performance string `json:"performanceBlocking"` MustHave []string `json:"mustHave"` Optional []string `json:"optionalDetails"` ContinuityOut string `json:"continuityOut"` } func New(repo *repository.Repository, dataDir string) *Service { return NewWithRuntimeCapabilities(repo, dataDir, RuntimeCapabilities{}) } func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service { coordinator, err := newRuntimeCoordinator(repo.Dialect()) return &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)} } func (s *Service) StartWorker() { s.startTextReplayCleanup() s.startProviderCancellationReconciliation() s.startBillingReviewAudit() go func() { slots := make(chan struct{}, maxChannelConcurrencyLimit) dispatch := func() { setting, err := s.runtimeConcurrencySetting() if err != nil { return } workerConcurrency := setting.WorkerConcurrency for len(slots) < workerConcurrency { releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute) if err != nil || !acquired { return } task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) if err != nil || task == nil { releaseGlobal() return } slots <- struct{}{} go func(task *model.Task) { defer func() { <-slots; releaseGlobal() }() _ = s.processClaimedTask(task) }(task) } } dispatch() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for range ticker.C { dispatch() } }() } func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) { prompt := strings.TrimSpace(req.Prompt) if prompt == "" { return nil, errors.New("prompt is required") } if err := validateStoryboardContext(req.ProjectStyle, req.Characters); err != nil { return nil, err } compactedSnapshot := compactPersistedValue(req.CanvasSnapshot) snapshotJSON, _ := json.Marshal(compactedSnapshot) session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)} policy, err := s.RuntimePolicy() if err != nil { return nil, err } s.storageMu.Lock() usage, err := s.repo.UserStorageUsage(userID) if err != nil { s.storageMu.Unlock() return nil, err } incomingBytes := int64(len([]byte(prompt))*2 + len(snapshotJSON)) if err := validateStructuredStorageQuotaWithPolicy(usage, "session", true, incomingBytes, policy.Resource); err != nil { s.storageMu.Unlock() return nil, err } if err := s.repo.Create(&session); err != nil { s.storageMu.Unlock() return nil, err } if err := s.repo.Create(&model.Message{ID: newID(), UserID: userID, SessionID: session.ID, Role: "user", Content: prompt}); err != nil { cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID) s.storageMu.Unlock() if cleanupErr != nil { return nil, fmt.Errorf("创建会话消息失败:%v;清理会话失败:%w", err, cleanupErr) } return nil, err } s.storageMu.Unlock() taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, LogicalModelID: req.LogicalModelID, Input: map[string]any{"mode": "text", "references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "projectStyle": req.ProjectStyle, "characters": req.Characters, "config": req.Config}} if _, err := s.CreateTask(userID, taskReq); err != nil { s.storageMu.Lock() cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID) s.storageMu.Unlock() if cleanupErr != nil { return nil, fmt.Errorf("创建会话任务失败:%v;清理会话失败:%w", err, cleanupErr) } return nil, err } s.recordActivity(userID, "agent_message", 1) return s.SessionDetail(userID, session.ID) } func channelModelNames(channel model.ModelChannel) []string { models := []string{} _ = json.Unmarshal([]byte(channel.ModelsJSON), &models) return uniqueNonEmpty(models) } func (s *Service) SessionDetail(userID string, id string) (*SessionDetail, error) { session, err := s.repo.SessionForUser(userID, id) if err != nil { return nil, err } messages, err := s.repo.SessionMessages(userID, id) if err != nil { return nil, err } tasks, err := s.repo.SessionTasks(userID, id) if err != nil { return nil, err } taskSummaries := taskSummariesForOutput(tasks) results, err := s.repo.SessionResults(userID, id) if err != nil { return nil, err } return &SessionDetail{Session: *session, Messages: messages, Tasks: taskSummaries, Results: results}, nil } func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) { prompt := strings.TrimSpace(req.Prompt) if prompt == "" { return nil, errors.New("prompt is required") } normalizedInput, err := normalizeTaskInput(req.Input) if err != nil { return nil, err } var routed *RoutedModel logicalModelID := strings.TrimSpace(req.LogicalModelID) if logicalModelID != "" { intent := ModelRequestIntentFromTaskInput(normalizedInput, req.Type, req.Operation) routed, err = s.ResolveLogicalModel(logicalModelID, intent) if err != nil { return nil, err } normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) } // 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。 if isTextReplayTaskRequest(normalizedInput) { return s.createTextReplayTask(userID, req, normalizedInput) } if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil { return nil, err } if err := s.ValidateTaskCapability(normalizedInput); err != nil { return nil, err } if containsInlineMediaDataURL(normalizedInput) { return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储") } policy, err := s.RuntimePolicy() if err != nil { return nil, err } activeTasks, err := s.repo.ActiveTaskCountForUser(userID) if err != nil { return nil, err } if activeTasks >= int64(policy.Task.ActiveTaskLimit) { return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) } taskType := req.Type if taskType == "" { taskType = "video_image_to_video" } task := model.Task{ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model} if routed != nil { task.LogicalModelID = routed.LogicalModel.ID task.LogicalModelRevisionID = routed.Revision.ID task.RouteID = routed.Route.ID task.ChannelModelID = routed.ChannelModel.ID task.RouteRun = 1 task.Model = routed.LogicalModel.Code task.Provider = "managed" } if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil { return nil, err } billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput) if err != nil { return nil, err } if err := s.protectTaskSecrets(normalizedInput); err != nil { return nil, err } inputJSON, _ := json.Marshal(normalizedInput) task.InputJSON = string(inputJSON) if billingOrder != nil { task.BillingOrderID = billingOrder.ID } err = s.createTaskWithinStorageQuota(&task, billingOrder, policy) if errors.Is(err, repository.ErrActiveTaskLimit) { return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) } if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("积分不足,请先使用兑换码充值") } if errors.Is(err, repository.ErrLogicalModelUnavailable) { return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择") } if err != nil { return nil, err } s.recordActivity(userID, "task", 1) _ = s.log(userID, task.ID, "info", "任务已进入队列", "") return taskForOutput(task), nil } func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any { config, _ := input["config"].(map[string]any) nextConfig := make(map[string]any, len(config)+2) for key, value := range config { switch key { case "channelId", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig": continue default: nextConfig[key] = value } } for key, value := range routed.Defaults { canonical := canonicalCapabilityOptionName(key) if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" { nextConfig[canonical] = providerConfigOptionValue(value) } } // 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。 if options, ok := input["capabilityOptions"].(map[string]any); ok { for key, value := range options { canonical := canonicalCapabilityOptionName(key) if isProviderCapabilityOption(canonical) { nextConfig[canonical] = providerConfigOptionValue(value) } } } nextConfig["channelId"] = routed.ChannelModel.ChannelID nextConfig["model"] = routed.ChannelModel.ModelKey input["config"] = nextConfig return input } func providerConfigOptionValue(value any) string { switch typed := value.(type) { case string: return typed case json.Number: return typed.String() default: return fmt.Sprint(value) } } // 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。 func normalizeTaskInput(input map[string]any) (map[string]any, error) { if input == nil { return map[string]any{}, nil } encoded, err := json.Marshal(input) if err != nil { return nil, BadAuthRequest("任务输入格式无效") } var normalized map[string]any if err := json.Unmarshal(encoded, &normalized); err != nil { return nil, BadAuthRequest("任务输入格式无效") } if snapshot, ok := normalized["canvasSnapshot"]; ok { normalized["canvasSnapshot"] = compactPersistedValue(snapshot) } return normalized, nil } // createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay, // 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。 func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) { prompt := strings.TrimSpace(req.Prompt) if prompt == "" { prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"])) } if prompt == "" { return nil, errors.New("prompt is required") } taskType := strings.TrimSpace(req.Type) if taskType == "" { taskType = "text" } task := model.Task{ ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model), } if err := s.protectTaskSecrets(normalizedInput); err != nil { return nil, err } inputJSON, _ := json.Marshal(normalizedInput) task.InputJSON = string(inputJSON) policy, err := s.RuntimePolicy() if err != nil { return nil, err } if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil { return nil, err } _ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "") return taskForOutput(task), nil } func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error { if !taskInputUsesCustomChannel(input) { return nil } return s.RequireFeature(FeatureCustomChannels) } func taskInputUsesCustomChannel(input map[string]any) bool { config, ok := input["config"].(map[string]any) if !ok { return false } channelID, _ := config["channelId"].(string) baseURL, _ := config["baseUrl"].(string) apiKey, _ := config["apiKey"].(string) if strings.TrimSpace(channelID) != "" || systemChannelIDFromBaseURL(baseURL) != "" { return false } return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" } func compactPersistedValue(value interface{}) interface{} { switch item := value.(type) { case map[string]interface{}: result := make(map[string]interface{}, len(item)) for key, child := range item { if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") { result[key] = "" continue } result[key] = compactPersistedValue(child) } return result case []interface{}: result := make([]interface{}, len(item)) for index, child := range item { result[index] = compactPersistedValue(child) } return result default: return value } } func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) { return s.TasksWithOptions(userID, TaskListOptions{Limit: limit}) } func (s *Service) TasksWithOptions(userID string, options TaskListOptions) ([]TaskSummary, error) { tasks, err := s.repo.Tasks(userID, options.Limit, options.ProjectID, options.ActiveOnly) if err != nil { return nil, err } orders, err := s.repo.BillingOrdersByTaskIDs(userID, taskBillingTaskIDs(tasks)) if err != nil { return nil, err } return taskSummariesForOutputWithBilling(tasks, orders), nil } func (s *Service) Task(userID string, id string) (*model.Task, error) { task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err } s.hydrateTaskProviderRequestID(task) return taskForOutput(*task), nil } func (s *Service) hydrateTaskProviderRequestID(task *model.Task) { if task == nil || task.ProviderRequestID != "" { return } if task.BillingOrderID != "" { if order, err := s.repo.BillingOrder(task.BillingOrderID); err == nil { task.ProviderRequestID = strings.TrimSpace(order.ProviderRequestID) } } if task.ProviderRequestID == "" { if providerRequestID, err := s.repo.LatestProviderRequestIDForTask(task.ID); err == nil { task.ProviderRequestID = providerRequestID } } } // 上游请求日志会在任务执行期间更新 provider 状态,终态保存前必须重新合并,避免旧任务对象覆盖可恢复 ID。 func (s *Service) refreshTaskProviderState(task *model.Task) error { if task == nil || task.ID == "" { return errors.New("任务状态无效") } latest, err := s.repo.Task(task.ID) if err != nil { return fmt.Errorf("刷新任务上游状态失败:%w", err) } if latest.ProviderRequestID != "" { task.ProviderRequestID = latest.ProviderRequestID } task.PollStage = latest.PollStage task.NextPollAt = latest.NextPollAt task.ProviderCancelStatus = latest.ProviderCancelStatus task.ProviderCancelError = latest.ProviderCancelError task.ProviderCancelAttempts = latest.ProviderCancelAttempts task.ProviderCancelRequestedAt = latest.ProviderCancelRequestedAt task.ProviderCancelledAt = latest.ProviderCancelledAt task.ProviderCancelNextCheckAt = latest.ProviderCancelNextCheckAt return nil } func (s *Service) RetryTask(userID string, id string) (*model.Task, error) { task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err } if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled { return nil, errors.New("only failed or cancelled tasks can be retried") } if task.ProviderCancelStatus == model.ProviderCancelStatusRequested { return nil, BadAuthRequest("上游取消状态仍在确认中,请确认费用结果后再重试") } if task.BillingOrderID != "" { if order, orderErr := s.repo.BillingOrder(task.BillingOrderID); orderErr == nil && order.Status == model.BillingStatusUncertain { return nil, BadAuthRequest("上一次调用费用仍在核对中,处理完成前不能重复提交") } } if isContentModerationFailure(task.Error) { return nil, BadAuthRequest(contentModerationRetryMessage) } decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) if err != nil { return nil, err } var billingInput map[string]any if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil { return nil, err } if err := s.prepareLogicalTaskRetry(task, billingInput); err != nil { return nil, err } if err := s.requireCustomChannelsForTaskInput(billingInput); err != nil { return nil, err } billingOrder, err := s.taskBillingOrder(userID, task, billingInput) if err != nil { return nil, err } policy, err := s.RuntimePolicy() if err != nil { return nil, err } if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil { return nil, err } task, err = s.repo.RetryTaskWithBilling(userID, task, billingOrder, policy.Task.ActiveTaskLimit) if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("积分不足,请先使用兑换码充值") } if errors.Is(err, repository.ErrActiveTaskLimit) { return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit)) } if errors.Is(err, repository.ErrTaskNotRetryable) { return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试") } if err != nil { return nil, err } if task.SessionID != "" { if session, err := s.repo.SessionForUser(task.UserID, task.SessionID); err == nil { session.Status = model.SessionStatusActive session.CanvasOpsJSON = "" _ = s.repo.Save(session) } } _ = s.log(userID, task.ID, "info", "任务已重新入队", "") return taskForOutput(*task), nil } func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*model.Task, error) { task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err } if task.Status == model.TaskStatusSucceeded { return nil, errors.New("completed task cannot be cancelled") } now := time.Now() cancelledRunningTask := false if task.Status == model.TaskStatusQueued { cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusQueued, now) if err != nil { return nil, err } if cancelled { if err := s.RefundBilling(task.BillingOrderID, "任务在调用上游前取消"); err != nil { return nil, err } task, err = s.repo.TaskForUser(userID, id) if err != nil { return nil, err } } else { task, err = s.repo.TaskForUser(userID, id) if err != nil { return nil, err } } } if task.Status == model.TaskStatusRunning { cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusRunning, now) if err != nil { return nil, err } if !cancelled { latest, latestErr := s.repo.TaskForUser(userID, id) if latestErr != nil { return nil, latestErr } if latest.Status == model.TaskStatusSucceeded { return nil, errors.New("completed task cannot be cancelled") } task = latest } else { cancelledRunningTask = true s.cancelActiveTask(task.ID) if err := s.MarkBillingUncertain(task.BillingOrderID, "运行中的上游请求被用户取消,费用状态待核对"); err != nil { return nil, err } task, err = s.repo.TaskForUser(userID, id) if err != nil { return nil, err } } } if task.Status != model.TaskStatusCancelled { return nil, errors.New("task cannot be cancelled in its current state") } if task.SessionID != "" { _ = s.markSessionFailed(*task, "会话任务已取消。") } if cancelledRunningTask { if err := s.requestProviderCancellation(ctx, task); err != nil { return nil, err } task, err = s.repo.TaskForUser(userID, id) if err != nil { return nil, err } } if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil { _ = s.log(userID, task.ID, "error", "文本回放草稿归并失败", err.Error()) } _ = s.log(userID, task.ID, "warn", "任务已取消", "") return taskForOutput(*task), nil } func (s *Service) TaskLogs(userID string, id string) ([]model.TaskLog, error) { return s.repo.TaskLogs(userID, id) } func taskSummariesForOutput(tasks []model.Task) []TaskSummary { return taskSummariesForOutputWithBilling(tasks, nil) } func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary { result := make([]TaskSummary, 0, len(tasks)) for _, task := range tasks { summary := taskSummaryForOutput(task) if order, ok := orders[task.ID]; ok { summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status} if summary.ProviderRequestID == "" { summary.ProviderRequestID = order.ProviderRequestID } } result = append(result, summary) } return result } func taskBillingTaskIDs(tasks []model.Task) []string { ids := make([]string, 0, len(tasks)) seen := map[string]struct{}{} for _, task := range tasks { if task.BillingOrderID == "" { continue } if _, ok := seen[task.ID]; ok { continue } seen[task.ID] = struct{}{} ids = append(ids, task.ID) } return ids } func taskSummaryForOutput(task model.Task) TaskSummary { errorCode := "" if isContentModerationFailure(task.Error) { errorCode = contentModerationErrorCode } previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) return TaskSummary{ ID: task.ID, SessionID: task.SessionID, ProjectID: task.ProjectID, Type: task.Type, Status: task.Status, Stage: task.Stage, Progress: task.Progress, Prompt: truncateRunes(task.Prompt, 500), Operation: task.Operation, Provider: task.Provider, Model: task.Model, ProviderRequestID: task.ProviderRequestID, ProviderCancelStatus: task.ProviderCancelStatus, ProviderCancelError: task.ProviderCancelError, ProviderCancelAttempts: task.ProviderCancelAttempts, ProviderCancelRequestedAt: task.ProviderCancelRequestedAt, ProviderCancelledAt: task.ProviderCancelledAt, ErrorCode: errorCode, PreviewURL: previewURL, PreviewKind: previewKind, Attempts: task.Attempts, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, ClientContext: taskClientContext(task.InputJSON), } } // 列表只暴露创作页恢复所需的关联 ID,不下发完整任务输入或其他 metadata。 func taskClientContext(raw string) *TaskClientContext { var input struct { Metadata struct { Source string `json:"source"` ConversationID string `json:"conversationId"` MessageID string `json:"messageId"` BatchIndex int `json:"batchIndex"` BatchCount int `json:"batchCount"` } `json:"metadata"` } if json.Unmarshal([]byte(raw), &input) != nil || input.Metadata.Source != "create-page" || input.Metadata.ConversationID == "" || input.Metadata.MessageID == "" { return nil } return &TaskClientContext{ ConversationID: input.Metadata.ConversationID, MessageID: input.Metadata.MessageID, BatchIndex: input.Metadata.BatchIndex, BatchCount: input.Metadata.BatchCount, } } // 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 func taskMediaPreview(raw string, taskType string) (string, string) { if strings.TrimSpace(raw) == "" { return "", "" } var payload any if json.Unmarshal([]byte(raw), &payload) != nil { return "", "" } defaultKind := "image" if strings.Contains(strings.ToLower(taskType), "video") { defaultKind = "video" } return findTaskMediaPreview(payload, defaultKind) } func findTaskMediaPreview(value any, hint string) (string, string) { switch item := value.(type) { case string: text := strings.TrimSpace(item) if !strings.HasPrefix(text, "/api/resources/") && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") { return "", "" } kind := hint lower := strings.ToLower(text) if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") { kind = "video" } else if kind != "video" { kind = "image" } return text, kind case []any: for _, child := range item { if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" { return previewURL, previewKind } } case map[string]any: for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} { child, exists := item[key] if !exists { continue } childHint := hint if key == "video" { childHint = "video" } else if key == "images" || key == "image" { childHint = "image" } if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" { return previewURL, previewKind } } } return "", "" } func truncateRunes(value string, limit int) string { text := []rune(value) if len(text) <= limit { return value } return string(text[:limit]) + "..." } func taskForOutput(task model.Task) *model.Task { task.InputJSON = publicTaskInputJSON(task.InputJSON) // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 task.LogicalModelRevisionID = "" task.RouteID = "" task.ChannelModelID = "" return &task } func publicTaskInputJSON(raw string) string { if strings.TrimSpace(raw) == "" { return "" } var input map[string]any if err := json.Unmarshal([]byte(raw), &input); err != nil { return "" } public := map[string]any{} // 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。 for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} { if value, ok := input[key]; ok { public[key] = value } } if len(public) == 0 { return "" } data, _ := json.Marshal(public) return string(data) } func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) { policy, err := s.RuntimePolicy() if err != nil { return nil, err } maxBytes := megabytes(policy.Resource.SessionUploadMB) if header == nil || header.Size > maxBytes { return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) } day, err := s.reserveSessionUploadQuota(userID, header.Size) if err != nil { return nil, err } reserved := true defer func() { if reserved { s.releaseUserUploadQuota(userID, day, header.Size) } }() file, err := header.Open() if err != nil { return nil, err } defer file.Close() uploadDir := filepath.Join(s.dataDir, "uploads") if err := os.MkdirAll(uploadDir, 0o750); err != nil { return nil, err } if strings.TrimSpace(sessionID) != "" { if _, err := s.repo.SessionForUser(userID, sessionID); err != nil { return nil, err } } storedName := newID() + "-" + filepath.Base(header.Filename) path := filepath.Join(uploadDir, storedName) dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640) if err != nil { return nil, err } size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1)) closeErr := dst.Close() if err != nil { _ = os.Remove(path) return nil, err } if closeErr != nil { _ = os.Remove(path) return nil, closeErr } if size > maxBytes { _ = os.Remove(path) return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB)) } item := model.SessionFile{ID: newID(), UserID: userID, SessionID: sessionID, FileName: header.Filename, MimeType: header.Header.Get("Content-Type"), Path: path, Size: size} if err := s.repo.Create(&item); err != nil { _ = os.Remove(path) return nil, err } s.commitUserUploadQuota(userID, header.Size) reserved = false return &item, nil } func (s *Service) ProcessNextTask() error { task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second) if err != nil || task == nil { return err } return s.processClaimedTask(task) } func (s *Service) processClaimedTask(task *model.Task) error { _ = s.log(task.UserID, task.ID, "info", "后端任务开始处理", "") policy, err := s.RuntimePolicy() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task)) defer cancel() leaseDone := make(chan struct{}) leaseLost := make(chan error, 1) go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: if err := s.repo.RenewTaskLease(task.ID, s.workerID, 45*time.Second); err != nil { leaseLost <- err cancel() return } case <-leaseDone: return } } }() defer close(leaseDone) s.registerActiveTask(task.ID, cancel) defer s.unregisterActiveTask(task.ID) task.Stage = "调用生成模型" task.Progress = 35 _ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress) routeAttempt, err := s.beginTaskRouteAttempt(task) if err != nil { task.Status = model.TaskStatusFailed task.Stage = "路由准备失败" task.Error = s.UserFacingErrorMessage(err) task.CompletedAt = ptr(time.Now()) _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) if isRouteDispatchUncertain(err) { _ = s.MarkBillingUncertain(task.BillingOrderID, task.Error) } else { _ = s.RefundBilling(task.BillingOrderID, "路由准备失败,上游请求未发出") } return err } if err := s.MarkBillingRunning(task.BillingOrderID); err != nil { task.Status = model.TaskStatusFailed task.Stage = "计费准备失败" task.Error = s.UserFacingErrorMessage(err) task.CompletedAt = ptr(time.Now()) _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) _ = s.RefundBilling(task.BillingOrderID, "计费准备失败,上游请求未发出") return err } var result map[string]interface{} var canvasOps []map[string]interface{} for { if dispatchErr := s.markRouteAttemptDispatching(routeAttempt); dispatchErr != nil { err = dispatchErr break } result, canvasOps, err = s.processTask(ctx, *task) if stateErr := s.refreshTaskProviderState(task); stateErr != nil { return stateErr } s.finishTaskRouteAttempt(routeAttempt, task, err) if err == nil { break } nextAttempt, routeErr := s.nextRouteAttemptAfterFailure(task, routeAttempt, err) if routeErr != nil { _ = s.log(task.UserID, task.ID, "warn", "备用路由不可用,保留原始失败", routeErr.Error()) break } if nextAttempt == nil { break } routeAttempt = nextAttempt _ = s.log(task.UserID, task.ID, "warn", "上游未创建任务,切换备用能力路由", nextAttempt.RouteID) } providerSucceeded := err == nil if err == nil { result, err = s.persistGeneratedMediaResult(task.UserID, result) } if err == nil { _, err = s.finalizeCharacterTurnaroundTask(*task, result) } if err != nil { channelSlotFailedBeforeRequest := false if code, _ := ChannelSlotFailureDetails(err); code != "" { channelSlotFailedBeforeRequest = true } select { case leaseErr := <-leaseLost: _ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error()) return leaseErr default: } if errors.Is(err, context.Canceled) { task.Status = model.TaskStatusCancelled task.Stage = "任务已取消" task.Error = "任务已取消" task.CompletedAt = ptr(time.Now()) _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) if channelSlotFailedBeforeRequest { _ = s.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出") } else { _ = s.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确") } _ = s.markSessionFailed(*task, "会话任务已取消。") if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); compactErr != nil { _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) } _ = s.log(task.UserID, task.ID, "warn", "任务已取消", "") return nil } if errors.Is(err, context.DeadlineExceeded) { decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON) if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) { if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "后台仍在生成", 15*time.Second); deferErr != nil { return deferErr } _ = s.log(task.UserID, task.ID, "info", "前台等待结束,上游视频仍在生成,将继续回查原任务", task.PollStage) return nil } err = errors.New(taskTimeoutMessage(task.Type)) } task.Status = model.TaskStatusFailed task.Stage = "任务失败" task.Error = s.UserFacingErrorMessage(err) task.CompletedAt = ptr(time.Now()) _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusFailed); compactErr != nil { _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) } if providerSucceeded || (!channelSlotFailedBeforeRequest && s.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err)) { _ = s.MarkBillingUncertain(task.BillingOrderID, task.Error) } else { _ = s.RefundBilling(task.BillingOrderID, task.Error) } _ = s.markSessionFailed(*task, task.Error) _ = s.log(task.UserID, task.ID, "error", "任务处理失败", task.Error) return err } latest, err := s.repo.Task(task.ID) if err != nil { return err } if latest.Status == model.TaskStatusCancelled { if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); compactErr != nil { _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) } _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") _ = s.markSessionFailed(*latest, "会话任务已取消。") _ = s.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "") return nil } resultJSON, _ := json.Marshal(result) opsJSON, _ := json.Marshal(canvasOps) task.Stage = "持久化生成结果" task.Progress = 90 _ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress) if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, opsJSON, len(canvasOps) > 0); err != nil { if errors.Is(err, repository.ErrTaskStateConflict) { latest, latestErr := s.repo.Task(task.ID) if latestErr == nil && latest.Status == model.TaskStatusCancelled { _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") _ = s.markSessionFailed(*latest, "会话任务已取消。") _ = s.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "") return nil } } task.Status = model.TaskStatusFailed task.Stage = "任务结果保存失败" task.Error = s.UserFacingErrorMessage(err) task.CompletedAt = ptr(time.Now()) _, _ = s.repo.UpdateTaskTerminalState(task.ID, model.TaskStatusRunning, task.Status, task.Stage, task.Error, *task.CompletedAt) if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusFailed); compactErr != nil { _ = s.log(task.UserID, task.ID, "error", "文本回放草稿归并失败", compactErr.Error()) } _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已成功但任务结果未保存:"+task.Error) _ = s.markSessionFailed(*task, task.Error) _ = s.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error) return err } if compactErr := s.finalizeTaskTextReplay(task.ID, model.TaskStatusSucceeded); compactErr != nil { _ = s.log(task.UserID, task.ID, "error", "文本回放窗口更新失败", compactErr.Error()) } if completedTask, fetchErr := s.repo.Task(task.ID); fetchErr == nil { if registerErr := s.RegisterTaskOutputFromTask(*completedTask); registerErr != nil { // 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。 _ = s.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error()) } } if err := s.SettleBilling(task.BillingOrderID, ""); err != nil { _ = s.MarkBillingUncertain(task.BillingOrderID, "生成成功但积分结算失败:"+err.Error()) _ = s.log(task.UserID, task.ID, "error", "积分结算失败,已进入待核对", err.Error()) } _ = s.log(task.UserID, task.ID, "info", "任务完成,结果已持久化", "") return nil } func taskFailureMessage(err error) string { if err == nil { return "任务处理失败" } return truncateRunes(err.Error(), 2_000) } func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration { switch { case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows": return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"): return max(time.Duration(policy.VideoTimeoutMinutes)*time.Minute, 5*time.Minute) case strings.HasPrefix(taskType, "canvas_image"): return time.Duration(policy.ImageTimeoutMinutes) * time.Minute case strings.HasPrefix(taskType, "canvas_audio"): return time.Duration(policy.AudioTimeoutMinutes) * time.Minute case strings.HasPrefix(taskType, "canvas_text"): return time.Duration(policy.TextTimeoutMinutes) * time.Minute default: return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute } } func (s *Service) shouldDeferVideoProviderTask(task model.Task, decryptedInput string, err error) bool { if !errors.Is(err, context.DeadlineExceeded) || strings.TrimSpace(task.ProviderRequestID) == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) { return false } var input canvasGenerationInput if json.Unmarshal([]byte(decryptedInput), &input) != nil { return false } resolved, resolveErr := s.resolveProviderConfig(input.Config) return resolveErr == nil && resolved.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) } func taskTimeoutMessage(taskType string) string { if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") { return "视频生成等待超时,请稍后到任务中心查看或重试。" } if strings.HasPrefix(taskType, "canvas_image") { return "图片生成等待超时,请稍后重试。" } return "任务执行超时,请稍后重试。" } func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON) if err != nil { return nil, nil, err } task.InputJSON = decryptedInput ctx = withProviderAnalytics(ctx, s, task) if task.Type == "agent_storyboard_rows" { return s.processStoryboardRowsTask(ctx, task) } if strings.HasPrefix(task.Type, "canvas_") || canRunProviderTask(task) { result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) return result, nil, err } if task.Type == "agent_storyboard" { return s.processAgentStoryboardTask(ctx, task) } if strings.HasPrefix(task.Type, "video_") { result, ops := buildVideoWorkflowResult(task) return result, ops, nil } result, ops := buildAgentResult(task) return result, ops, nil } func canRunProviderTask(task model.Task) bool { if !strings.HasPrefix(task.Type, "video_") || strings.TrimSpace(task.InputJSON) == "" { return false } var input map[string]any if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { return false } mode, _ := input["mode"].(string) config, ok := input["config"].(map[string]any) if mode != "video" || !ok || strings.TrimSpace(fmt.Sprint(config["model"])) == "" { return false } return strings.TrimSpace(fmt.Sprint(config["channelId"])) != "" || (strings.TrimSpace(fmt.Sprint(config["baseUrl"])) != "" && strings.TrimSpace(fmt.Sprint(config["apiKey"])) != "") } func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { input := agentStoryboardInput{} if strings.TrimSpace(task.InputJSON) != "" { if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { return nil, nil, fmt.Errorf("Agent 会话输入解析失败:%w", err) } } if !providerConfigReady(input.Config) { return nil, nil, errors.New("请先配置可用的文本模型") } if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { return nil, nil, err } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) } config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, nil, err } ctx = withProviderOutboundPolicy(ctx, config) plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, 0, 0) if err != nil { return nil, nil, err } result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true}) if err != nil { return nil, nil, err } text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { normalizeAutomaticStoryboardDurations(&plan, 0) err = validateStoryboardPlan(plan, 0, 0, input.Characters) } if err != nil { plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, 0, 0) if err != nil { return nil, nil, err } } if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) } return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle) } func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { input := agentStoryboardInput{} if strings.TrimSpace(task.InputJSON) != "" { if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { return nil, nil, fmt.Errorf("脚本任务输入解析失败:%w", err) } } if !providerConfigReady(input.Config) { return nil, nil, errors.New("请先配置可用的文本模型") } if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { return nil, nil, err } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) } config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, nil, err } ctx = withProviderOutboundPolicy(ctx, config) plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount) if err != nil { return nil, nil, err } result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true}) if err != nil { return nil, nil, err } text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { normalizeAutomaticStoryboardDurations(&plan, input.ShotDuration) err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters) } if err != nil { plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, input.ShotDuration, input.ShotCount) if err != nil { return nil, nil, err } } if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) } rows := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) videoPromptVariables := storyboardVideoPromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) imagePrompt, promptErr := s.compileStoryboardImagePrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) if promptErr != nil { return nil, nil, promptErr } videoPrompt, promptErr := s.compileStoryboardVideoPrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) if promptErr != nil { return nil, nil, promptErr } matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) referenceNodeIDs := make([]string, 0, len(matchedAssets)) for _, asset := range matchedAssets { referenceNodeIDs = append(referenceNodeIDs, asset.ID) } rows = append(rows, map[string]any{ "shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description, "dialogue": shot.Dialogue, "characters": storyboardRowCharacters(shot, input.Characters), "shotSize": shot.ShotSize, "emotion": shot.Emotion, "lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects, "imageGenerationPrompt": imagePrompt, "videoMotionPrompt": videoPrompt, "imagePromptTemplateVariables": imagePromptVariables, "videoPromptTemplateVariables": videoPromptVariables, "camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative, "narrativeIntent": shot.Intent, "viewerPOV": shot.ViewerPOV, "performanceBlocking": shot.Performance, "mustHave": shot.MustHave, "optionalDetails": shot.Optional, "continuityOut": shot.ContinuityOut, "referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags, }) } return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil } const maxStoryboardRepairAttempts = 2 func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, config providerConfig, originalText string, validationErr error, shotDuration int, shotCount int) (agentStoryboardPlan, error) { currentText := originalText currentErr := validationErr for attempt := 1; attempt <= maxStoryboardRepairAttempts; attempt++ { _ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55+attempt*10) repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, task.Prompt, currentErr, input, currentText) if promptErr != nil { return agentStoryboardPlan{}, promptErr } repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true}) if repairErr != nil { return agentStoryboardPlan{}, fmt.Errorf("分镜结构修复失败:%w", repairErr) } repairedText, _ := repaired["text"].(string) plan, parseErr := parseAgentStoryboardPlan(repairedText) if parseErr == nil { normalizeAutomaticStoryboardDurations(&plan, shotDuration) parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters) } if parseErr == nil { return plan, nil } currentText = repairedText currentErr = parseErr } return agentStoryboardPlan{}, fmt.Errorf("分镜模型结构修复后仍不合法:%w", currentErr) } func providerConfigReady(config providerConfig) bool { return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != "")) } func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { jsonText, err := extractJSONText(raw) if err != nil { return agentStoryboardPlan{}, err } if err := validateStoryboardJSONFields(jsonText); err != nil { return agentStoryboardPlan{}, err } var plan agentStoryboardPlan if err := json.Unmarshal([]byte(jsonText), &plan); err != nil { return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err) } plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜") plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案") plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "严格沿用当前项目画风,保持角色、空间、道具、色彩和视觉媒介一致。") if len(plan.Shots) == 0 { return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots") } if len(plan.Shots) > 12 { return agentStoryboardPlan{}, fmt.Errorf("分镜数量最多 12 个,实际返回 %d 个", len(plan.Shots)) } for i := range plan.Shots { if strings.TrimSpace(plan.Shots[i].Title) == "" { plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1) } plan.Shots[i].CharacterIDs = nonNilStrings(plan.Shots[i].CharacterIDs) plan.Shots[i].AssetTags = nonNilStrings(plan.Shots[i].AssetTags) plan.Shots[i].Optional = nonNilStrings(plan.Shots[i].Optional) plan.Shots[i].Intent = defaultString(strings.TrimSpace(plan.Shots[i].Intent), strings.TrimSpace(plan.Shots[i].Description)) plan.Shots[i].ViewerPOV = defaultString(strings.TrimSpace(plan.Shots[i].ViewerPOV), "客观观察当前主要角色与事件") plan.Shots[i].Performance = defaultString(strings.TrimSpace(plan.Shots[i].Performance), strings.TrimSpace(plan.Shots[i].Description)) plan.Shots[i].ContinuityOut = defaultString(strings.TrimSpace(plan.Shots[i].ContinuityOut), "保持本镜头结尾的人物位置、动作状态、道具和光线方向进入下一镜") if len(plan.Shots[i].MustHave) == 0 { plan.Shots[i].MustHave = []string{"主要角色身份与当前版本稳定", "主要动作完成并有清晰落点", "结尾状态可供下一镜继承"} } if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" { plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description) } if strings.TrimSpace(plan.Shots[i].VisualPrompt) == "" { return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 visualPrompt", i+1) } if strings.TrimSpace(plan.Shots[i].Camera) == "" || strings.TrimSpace(plan.Shots[i].Motion) == "" || strings.TrimSpace(plan.Shots[i].TimeBeats) == "" { return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 camera、motion 或 timeBeats", i+1) } if plan.Shots[i].Duration <= 0 || plan.Shots[i].Duration > 60 { return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 的 durationSeconds 必须在 1 到 60 之间", i+1) } } return plan, nil } func validateStoryboardJSONFields(jsonText string) error { var root map[string]json.RawMessage if err := json.Unmarshal([]byte(jsonText), &root); err != nil { return fmt.Errorf("分镜 JSON 解析失败:%w", err) } for _, field := range []string{"title", "logline", "styleGuide", "characters", "locations", "shots"} { if _, ok := root[field]; !ok { return fmt.Errorf("分镜 JSON 缺少受保护字段 %s", field) } } var shots []map[string]json.RawMessage if err := json.Unmarshal(root["shots"], &shots); err != nil { return errors.New("分镜 JSON 的 shots 必须是数组") } required := []string{"title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetTags"} for index, shot := range shots { for _, field := range required { if _, ok := shot[field]; !ok { return fmt.Errorf("镜头 %d 缺少受保护字段 %s", index+1, field) } } } return nil } func nonNilStrings(values []string) []string { if values == nil { return []string{} } return values } func validateStoryboardContext(projectStyle storyboardProjectStyle, characters []storyboardCharacterCard) error { if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" { return errors.New("请先设置项目画风,再生成分镜") } if strings.TrimSpace(projectStyle.ProfileJSON) != "" { if _, err := validateStyleProfileJSON(projectStyle.ProfileJSON); err != nil { return err } } for _, character := range characters { if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") } } return nil } func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error { if target == 0 { return nil } if target != 5 && target != 10 && target != 15 && target != 30 { return fmt.Errorf("不支持的单镜头时长:%d 秒", target) } for index, shot := range plan.Shots { if shot.Duration != target { return fmt.Errorf("镜头 %d 的时长必须是 %d 秒", index+1, target) } } return nil } func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard) error { if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 { return errors.New("styleGuide 最多 120 个中文字符") } if err := validateStoryboardShotDuration(plan, shotDuration); err != nil { return err } if err := validateStoryboardShotCount(plan, shotCount); err != nil { return err } if err := validateStoryboardCharacterIDs(plan, characters); err != nil { return err } return nil } func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { if target == 0 { return nil } if target < 1 || target > 10 { return fmt.Errorf("分镜数量必须在 1 到 10 之间") } if len(plan.Shots) != target { return fmt.Errorf("分镜数量必须是 %d,实际生成 %d", target, len(plan.Shots)) } return nil } func validateStoryboardComplexity(plan agentStoryboardPlan) error { issues := make([]string, 0) for index, shot := range plan.Shots { shotNumber := index + 1 if len(shot.CharacterIDs) > 2 { issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, len(shot.CharacterIDs))) } if len(shot.MustHave) > 3 { issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave))) } if beats := storyboardBeatCount(shot.TimeBeats); beats > 3 { issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个时间节拍,最多 3 个", shotNumber, beats)) } if movements := storyboardCameraMovementCount(shot.Motion); movements > 1 { issues = append(issues, fmt.Sprintf("镜头 %d 包含 %d 种主运镜,最多 1 种", shotNumber, movements)) } dialogueLimit := max(24, shot.Duration*5) if dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)); dialogueLength > dialogueLimit { issues = append(issues, fmt.Sprintf("镜头 %d 台词 %d 字,%d 秒镜头最多约 %d 字", shotNumber, dialogueLength, shot.Duration, dialogueLimit)) } } if len(issues) == 0 { return nil } return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";")) } func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int) { if plan == nil || target != 0 { return } for index := range plan.Shots { shot := &plan.Shots[index] dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)) requiredDuration := (dialogueLength + 4) / 5 shot.Duration = min(60, max(1, shot.Duration, requiredDuration)) } } func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { allowed := make(map[string]bool, len(characters)) for _, character := range characters { allowed[character.AssetID] = true } for index, shot := range plan.Shots { for _, assetID := range shot.CharacterIDs { if !allowed[assetID] { return fmt.Errorf("镜头 %d 引用了不存在或非当前版本的角色 assetId:%s", index+1, assetID) } } } return nil } func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any { byID := make(map[string]storyboardCharacterCard, len(characters)) for _, character := range characters { byID[character.AssetID] = character } result := make([]map[string]any, 0, len(shot.CharacterIDs)) for _, assetID := range shot.CharacterIDs { character, ok := byID[assetID] if !ok { continue } result = append(result, map[string]any{ "characterName": character.Name, "characterAssetId": character.AssetID, "characterVersionId": character.VersionID, }) } return result } func storyboardBeatCount(value string) int { parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' }) count := 0 for _, part := range parts { if strings.TrimSpace(part) != "" { count++ } } timecodeCount := len(storyboardTimecodePattern.FindAllString(value, -1)) return max(count, timecodeCount) } var storyboardTimecodePattern = regexp.MustCompile(`\d+(?:\.\d+)?\s*[-~—至到]\s*\d+(?:\.\d+)?\s*秒`) func storyboardCameraMovementCount(value string) int { movements := []string{"推进", "推近", "拉远", "摇摄", "横移", "侧移", "跟拍", "跟随", "升降", "上升", "下降", "环绕", "俯冲", "变焦", "甩镜", "穿越"} count := 0 for _, movement := range movements { if strings.Contains(value, movement) { count++ } } return count } func extractJSONText(raw string) (string, error) { // Text models may prepend an explanation or append a Markdown fence despite // the JSON-only contract. Scan complete JSON values instead of pairing the // first opening brace with the final closing brace, which can merge prose and // make an otherwise valid character breakdown fail validation. for start := 0; start < len(raw); start++ { if raw[start] != '{' && raw[start] != '[' { continue } end := jsonValueEnd(raw, start) if end < start { continue } candidate := raw[start : end+1] var decoded interface{} if json.Unmarshal([]byte(candidate), &decoded) == nil { return candidate, nil } } return "", errors.New("模型返回的不是 JSON") } func jsonValueEnd(source string, start int) int { stack := make([]byte, 0, 8) inString := false escaped := false for index := start; index < len(source); index++ { value := source[index] if inString { if escaped { escaped = false } else if value == '\\' { escaped = true } else if value == '"' { inString = false } continue } switch value { case '"': inString = true case '{', '[': stack = append(stack, value) case '}', ']': if len(stack) == 0 { return -1 } opener := stack[len(stack)-1] if (value == '}' && opener != '{') || (value == ']' && opener != '[') { return -1 } stack = stack[:len(stack)-1] if len(stack) == 0 { return index } } } return -1 } func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset, projectStyle storyboardProjectStyle) (map[string]interface{}, []map[string]interface{}, error) { prefix := "agent-" + task.ID scriptID := prefix + "-script" sceneID := prefix + "-scenes" styleID := prefix + "-style" referenceID := prefix + "-assets" finalID := prefix + "-final" sceneX := 380 styleX := sceneX + 380 ops := []map[string]any{ nodeOpWithMetadata(scriptID, "text", "剧本 · "+shortTitle(plan.Title, 24), 0, 0, map[string]any{"workflowKind": "script", "workflowTitle": "剧本", "status": "success", "content": strings.Join([]string{plan.Title, "", plan.Logline, "", task.Prompt}, "\n")}), nodeOpWithMetadata(sceneID, "text", "场景设定", sceneX, 0, map[string]any{"workflowKind": "scene", "workflowTitle": "场景", "status": "success", "content": listContent("场景", plan.Locations)}), nodeOpWithMetadata(styleID, "text", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "styleProfileJson": projectStyle.ProfileJSON, "status": "success", "content": projectStyle.Prompt, "prompt": projectStyle.Prompt}), nodeOpWithMetadata(referenceID, "text", "参考素材组", 0, 270, map[string]any{"workflowKind": "reference_set", "workflowTitle": "参考素材组", "status": "success", "content": storyboardAssetsContent(assets)}), nodeOpWithMetadata(finalID, "video", "成片 · 待生成", styleX, 270, map[string]any{"workflowKind": "final", "workflowTitle": "成片", "status": "idle"}), connectOp(scriptID, sceneID), } resultShots := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { videoPrompt, err := s.compileStoryboardVideoPrompt(task.UserID, projectStyle.Prompt, plan.StyleGuide, shot) if err != nil { return nil, nil, err } shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1) matchedAssets := matchStoryboardAssetsForShot(assets, shot) assetIDs := make([]string, 0, len(matchedAssets)) for _, asset := range matchedAssets { assetIDs = append(assetIDs, asset.ID) } ops = append(ops, nodeOpWithMetadata(shotID, "video", fmt.Sprintf("镜头 %d · %s", index+1, shortTitle(shot.Title, 18)), index*360, 560, map[string]any{ "workflowKind": "shot", "workflowTitle": shot.Title, "workflowDescription": shotDescription(shot), "shotIndex": index + 1, "generationMode": "video", "prompt": videoPrompt, "composerContent": shotComposerContent(videoPrompt, matchedAssets), "videoEditOperation": "text_to_video", "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs, "status": "idle", }), connectOp(scriptID, shotID), connectOp(shotID, finalID), ) for _, asset := range matchedAssets { ops = append(ops, connectOp(asset.ID, shotID)) } resultShots = append(resultShots, map[string]any{"title": shot.Title, "description": shot.Description, "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs}) } ops = append(ops, map[string]any{"type": "select_nodes", "ids": shotIDs(prefix, len(plan.Shots))}) result := map[string]any{ "taskId": task.ID, "operation": task.Operation, "provider": defaultString(task.Provider, "internal-agent"), "model": defaultString(task.Model, "workflow-router"), "title": plan.Title, "logline": plan.Logline, "styleGuide": plan.StyleGuide, "characters": plan.Characters, "locations": plan.Locations, "shots": resultShots, } return result, ops, nil } func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset { rawNodes, _ := snapshot["nodes"].([]interface{}) assets := make([]storyboardAsset, 0, len(rawNodes)) for _, raw := range rawNodes { node, _ := raw.(map[string]interface{}) if node == nil { continue } metadata, _ := node["metadata"].(map[string]interface{}) if metadata == nil { metadata = map[string]interface{}{} } nodeType := stringValue(node["type"]) isCharacterCard := stringValue(metadata["workflowKind"]) == "character" && stringValue(metadata["characterAssetId"]) != "" && stringValue(metadata["characterVersionId"]) != "" if nodeType != "image" && !isCharacterCard { continue } id := stringValue(node["id"]) if id == "" { continue } tags := stringSlice(metadata["assetTags"]) prompt := stringValue(metadata["prompt"]) content := stringValue(metadata["content"]) if len(tags) == 0 && prompt == "" && content == "" && !isCharacterCard { continue } assets = append(assets, storyboardAsset{ ID: id, Title: defaultString(stringValue(node["title"]), "未命名图片"), Type: defaultString(nodeType, "reference"), Tags: tags, Prompt: prompt, CharacterAssetID: stringValue(metadata["characterAssetId"]), CharacterVersionID: stringValue(metadata["characterVersionId"]), }) if len(assets) >= 30 { break } } return assets } func matchStoryboardAssets(assets []storyboardAsset, shotTags []string) []storyboardAsset { wanted := map[string]bool{} for _, tag := range shotTags { for _, token := range storyboardTagTokens(tag) { wanted[token] = true } } if len(wanted) == 0 { return nil } matched := make([]storyboardAsset, 0) for _, asset := range assets { tokens := map[string]bool{} for _, token := range storyboardTagTokens(asset.Title) { tokens[token] = true } for _, tag := range asset.Tags { for _, token := range storyboardTagTokens(tag) { tokens[token] = true } } if storyboardTokensMatch(wanted, tokens) { matched = append(matched, asset) } if len(matched) >= 6 { break } } return matched } func matchStoryboardAssetsForShot(assets []storyboardAsset, shot agentStoryboardShot) []storyboardAsset { matched := make([]storyboardAsset, 0, 6) seen := make(map[string]bool, 6) wantedCharacters := make(map[string]bool, len(shot.CharacterIDs)) for _, assetID := range shot.CharacterIDs { wantedCharacters[assetID] = true } for _, asset := range assets { if len(matched) >= 6 { break } if !seen[asset.ID] && wantedCharacters[asset.CharacterAssetID] { matched = append(matched, asset) seen[asset.ID] = true } } for _, asset := range matchStoryboardAssets(assets, shot.AssetTags) { if len(matched) >= 6 { break } if !seen[asset.ID] { matched = append(matched, asset) seen[asset.ID] = true } } return matched } func storyboardTokensMatch(wanted map[string]bool, tokens map[string]bool) bool { for want := range wanted { if tokens[want] { return true } for token := range tokens { if meaningfulStoryboardTagToken(want) && meaningfulStoryboardTagToken(token) && (strings.Contains(token, want) || strings.Contains(want, token)) { return true } } } return false } func storyboardTagTokens(value string) []string { normalized := strings.ToLower(strings.ReplaceAll(strings.Join(strings.Fields(strings.ReplaceAll(value, ":", ":")), ""), ",", ",")) if normalized == "" { return nil } tokens := []string{normalized} if index := strings.Index(normalized, ":"); index >= 0 { tokens = append(tokens, normalized[index+1:]) } unique := make([]string, 0, len(tokens)) seen := map[string]bool{} for _, token := range tokens { if meaningfulStoryboardTagToken(token) && !seen[token] { seen[token] = true unique = append(unique, token) } } return unique } func meaningfulStoryboardTagToken(value string) bool { if len([]rune(value)) < 2 { return false } switch value { case "角色", "环境", "场景", "道具", "武器", "风格": return false } return true } func listContent(title string, items []string) string { if len(items) == 0 { return title + "\n\n- 暂无明确内容。" } lines := []string{title, ""} for _, item := range items { if strings.TrimSpace(item) != "" { lines = append(lines, "- "+item) } } return strings.Join(lines, "\n") } func storyboardAssetsContent(assets []storyboardAsset) string { if len(assets) == 0 { return "当前画布暂无可用图片资产。建议先给角色、环境、道具图片添加资产标签。" } lines := make([]string, 0, len(assets)) for _, asset := range assets { line := asset.Title + "\nID: " + asset.ID if len(asset.Tags) > 0 { line += "\n标签: " + strings.Join(asset.Tags, "、") } if asset.Prompt != "" { line += "\n原提示词: " + asset.Prompt } lines = append(lines, line) } return strings.Join(lines, "\n\n") } func shotDescription(shot agentStoryboardShot) string { parts := []string{shot.Description} if strings.TrimSpace(shot.VisualPrompt) != "" { parts = append(parts, "画面提示词:"+shot.VisualPrompt) } if strings.TrimSpace(shot.Camera) != "" { parts = append(parts, "镜头:"+shot.Camera) } if strings.TrimSpace(shot.Motion) != "" { parts = append(parts, "运动:"+shot.Motion) } if strings.TrimSpace(shot.TimeBeats) != "" { parts = append(parts, "时间节拍:"+shot.TimeBeats) } filtered := make([]string, 0, len(parts)) for _, part := range parts { if strings.TrimSpace(part) != "" { filtered = append(filtered, part) } } return strings.Join(filtered, "\n\n") } func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") return map[string]string{ "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), "表演起始状态": compactPromptText(shot.Performance, 180), "负面要求": compactPromptText(negative, 140), } } func buildStoryboardImagePrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { definition, _ := promptDefinition(promptOperationStoryboardFirstFrame) prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardImagePromptValues(projectStyle, styleGuide, shot)) return prompt } func (s *Service) compileStoryboardImagePrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { compiled, err := s.compilePrompt(userID, promptOperationStoryboardFirstFrame, storyboardImagePromptValues(projectStyle, styleGuide, shot)) return compiled.Content, err } func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次") motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作") timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") values := map[string]string{ "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), "表演与调度": compactPromptText(shot.Performance, 180), "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), "时间节拍": compactPromptText(timeBeats, 240), "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), "负面要求": compactPromptText(negative, 160), } if len(shot.MustHave) > 0 { priority := "必须完成:" + strings.Join(shot.MustHave, ";") if len(shot.Optional) > 0 { priority += "。可以简化:" + strings.Join(shot.Optional, ";") } values["执行优先级"] = compactPromptText(priority, 140) } return values } func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { definition, _ := promptDefinition(promptOperationStoryboardVideo) prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) return prompt } func (s *Service) compileStoryboardVideoPrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { compiled, err := s.compilePrompt(userID, promptOperationStoryboardVideo, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) return compiled.Content, err } func storyboardProjectVisualSummary(projectStyle string, styleGuide string) string { identity := "" for _, line := range strings.Split(projectStyle, "\n") { if strings.TrimSpace(line) != "" { identity = strings.TrimSpace(line) break } } parts := make([]string, 0, 2) if identity != "" { parts = append(parts, identity) } if strings.TrimSpace(styleGuide) != "" { parts = append(parts, strings.TrimSpace(styleGuide)) } return compactPromptText(strings.Join(parts, ";"), 180) } func compactPromptText(value string, limit int) string { text := strings.TrimSpace(value) if utf8.RuneCountInString(text) <= limit { return text } runes := []rune(text) return strings.TrimSpace(string(runes[:limit])) + "。" } func shotComposerContent(prompt string, assets []storyboardAsset) string { if len(assets) == 0 { return prompt } lines := []string{"参考素材:"} for _, asset := range assets { label := asset.Title if len(asset.Tags) > 0 { label += "(" + strings.Join(asset.Tags, "、") + ")" } lines = append(lines, "- "+label+":@[node:"+asset.ID+"]") } lines = append(lines, "", "分镜视频提示词:", prompt) return strings.Join(lines, "\n") } func shotIDs(prefix string, count int) []string { ids := make([]string, 0, count) for index := 0; index < count; index++ { ids = append(ids, fmt.Sprintf("%s-shot-%d", prefix, index+1)) } return ids } func stringSlice(value any) []string { items, ok := value.([]interface{}) if !ok { text := stringValue(value) if text == "" { return nil } return []string{text} } result := make([]string, 0, len(items)) for _, item := range items { if text := strings.TrimSpace(fmt.Sprint(item)); text != "" { result = append(result, text) } } return result } func stringValue(value any) string { text := strings.TrimSpace(fmt.Sprint(value)) if text == "" { return "" } return text } func (s *Service) log(userID string, taskID string, level string, message string, payload string) error { return s.repo.Create(&model.TaskLog{ID: newID(), UserID: userID, TaskID: taskID, Level: level, Message: message, Payload: truncateTaskLogPayload(payload)}) } func truncateTaskLogPayload(payload string) string { if len(payload) <= taskLogPayloadLimit { return payload } end := taskLogPayloadLimit for end > 0 && !utf8.ValidString(payload[:end]) { end-- } return payload[:end] + fmt.Sprintf("\n...(日志内容已截断,原始长度 %d 字符)", len(payload)) } func (s *Service) registerActiveTask(id string, cancel context.CancelFunc) { s.cancelMu.Lock() defer s.cancelMu.Unlock() s.activeCancels[id] = cancel } func (s *Service) unregisterActiveTask(id string) { s.cancelMu.Lock() defer s.cancelMu.Unlock() delete(s.activeCancels, id) } func (s *Service) cancelActiveTask(id string) { s.cancelMu.Lock() cancel := s.activeCancels[id] s.cancelMu.Unlock() if cancel != nil { cancel() } } func (s *Service) markSessionFailed(task model.Task, message string) error { if task.SessionID == "" { return nil } session, err := s.repo.SessionForUser(task.UserID, task.SessionID) if err != nil { return err } session.Status = model.SessionStatusFailed if err := s.repo.Save(session); err != nil { return err } return s.repo.Create(&model.Message{ID: newID(), UserID: task.UserID, SessionID: task.SessionID, Role: "assistant", Content: defaultString(message, "会话任务失败。")}) } func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any { return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"}) } func nodeOpWithMetadata(id string, nodeType string, title string, x int, y int, metadata map[string]any) map[string]any { return map[string]any{ "type": "add_node", "id": id, "nodeType": nodeType, "title": title, "position": map[string]int{"x": x, "y": y}, "metadata": metadata, } } func connectOp(from string, to string) map[string]any { return map[string]any{"type": "connect_nodes", "fromNodeId": from, "toNodeId": to} } func ptr[T any](value T) *T { return &value } func shortTitle(value string, max int) string { title := strings.TrimSpace(value) if title == "" { title = "影视分镜" } if len([]rune(title)) > max { return string([]rune(title)[:max]) + "..." } return title } func defaultString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func newID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) }