package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" "strings" "sync" "time" "unicode/utf8" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" ) type Service struct { repo *repository.Repository dataDir string 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 } 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"` Config providerConfig `json:"config"` } 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"` 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"` 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"` } 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"` Config providerConfig `json:"config"` ShotDuration int `json:"shotDurationSeconds"` ShotCount int `json:"shotCount"` } type storyboardAsset struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Tags []string `json:"tags"` Prompt string `json:"prompt"` } 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"` } func New(repo *repository.Repository, dataDir string) *Service { coordinator, err := newRuntimeCoordinator(repo.Dialect()) return &Service{repo: repo, dataDir: dataDir, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()} } func (s *Service) StartWorker() { 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") } 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, Input: map[string]any{"references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "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 } 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 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 err != nil { return nil, err } s.recordActivity(userID, "task", 1) _ = s.log(userID, task.ID, "info", "任务已进入队列", "") return taskForOutput(task), nil } // 所有任务输入先收敛为 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 } 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 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 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 } 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.ID, 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(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() 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 { s.cancelActiveTask(task.ID) 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 { 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, "会话任务已取消。") } _ = 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, ErrorCode: errorCode, PreviewURL: previewURL, PreviewKind: previewKind, Attempts: task.Attempts, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } } // 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 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) 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) if err := s.MarkBillingRunning(task.BillingOrderID); err != nil { task.Status = model.TaskStatusFailed task.Stage = "计费准备失败" task.Error = taskFailureMessage(err) task.CompletedAt = ptr(time.Now()) _ = s.repo.Save(task) _ = s.RefundBilling(task.BillingOrderID, "计费准备失败,上游请求未发出") return err } result, canvasOps, err := s.processTask(ctx, *task) if stateErr := s.refreshTaskProviderState(task); stateErr != nil { return stateErr } 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.Save(task) if channelSlotFailedBeforeRequest { _ = s.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出") } else { _ = s.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确") } _ = s.markSessionFailed(*task, "会话任务已取消。") _ = s.log(task.UserID, task.ID, "warn", "任务已取消", "") return nil } if errors.Is(err, context.DeadlineExceeded) { err = errors.New(taskTimeoutMessage(task.Type)) } task.Status = model.TaskStatusFailed task.Stage = "任务失败" task.Error = taskFailureMessage(err) task.CompletedAt = ptr(time.Now()) _ = s.repo.Save(task) 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 { _ = 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 { task.Status = model.TaskStatusFailed task.Stage = "任务结果保存失败" task.Error = taskFailureMessage(err) task.CompletedAt = ptr(time.Now()) _ = s.repo.Save(task) _ = s.MarkBillingUncertain(task.BillingOrderID, "上游已成功但任务结果未保存:"+task.Error) _ = s.markSessionFailed(*task, task.Error) _ = s.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error) return err } 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 time.Duration(policy.VideoTimeoutMinutes) * 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 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.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) } } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) } plan := fallbackAgentStoryboardPlan(task.Prompt) if providerConfigReady(input.Config) { config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, nil, err } result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, 0, 0), Config: config}) if err != nil { return nil, nil, err } text, _ := result["text"].(string) nextPlan, err := parseAgentStoryboardPlan(text) if err != nil { return nil, nil, err } plan = nextPlan } return buildAgentStoryboardResult(task, plan, assets) } 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("请先配置可用的文本模型") } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) } config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, nil, err } result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, input.ShotDuration, input.ShotCount), Config: config}) if err != nil { return nil, nil, err } text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { err = validateStoryboardShotDuration(plan, input.ShotDuration) } if err == nil { err = validateStoryboardShotCount(plan, input.ShotCount) } if err != nil { _ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55) repairPrompt := fmt.Sprintf("请修复下面的分镜 JSON。原始校验错误:%s。必须保持原有剧情和镜头内容,补齐缺失字段并修复非法字段值;durationSeconds 必须是 1 到 60 的整数。\n\n%s\n\n只返回完整 JSON,不要 markdown 或解释。\n\n原始输出:\n%s", err.Error(), storyboardCinematicQualityContract(input.ShotDuration, input.ShotCount), text) repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config}) if repairErr != nil { return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr) } repairedText, _ := repaired["text"].(string) plan, err = parseAgentStoryboardPlan(repairedText) if err == nil { err = validateStoryboardShotDuration(plan, input.ShotDuration) } if err == nil { err = validateStoryboardShotCount(plan, input.ShotCount) } if err != nil { return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err) } } rows := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { 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": []any{}, "shotSize": shot.ShotSize, "emotion": shot.Emotion, "lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects, "imageGenerationPrompt": shot.VisualPrompt, "videoMotionPrompt": buildStoryboardVideoPrompt(plan.StyleGuide, shot), "camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative, "referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags, }) } return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil } 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 } 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 { plan.Shots = plan.Shots[:12] } for i := range plan.Shots { if strings.TrimSpace(plan.Shots[i].Title) == "" { plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1) } 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 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 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 extractJSONText(raw string) (string, error) { trimmed := strings.TrimSpace(raw) trimmed = strings.TrimPrefix(trimmed, "```json") trimmed = strings.TrimPrefix(trimmed, "```") trimmed = strings.TrimSuffix(trimmed, "```") start := strings.Index(trimmed, "{") end := strings.LastIndex(trimmed, "}") if start < 0 || end < start { return "", errors.New("分镜模型返回的不是 JSON") } return trimmed[start : end+1], nil } func fallbackAgentStoryboardPlan(prompt string) agentStoryboardPlan { title := shortTitle(prompt, 18) return agentStoryboardPlan{ Title: title, Logline: "围绕用户 brief 拆解的影视短片工作流。", StyleGuide: "真实电影机拍摄,自然曝光,低饱和色彩,保持角色、空间、道具和镜头语言一致。", Characters: []string{"主角:根据 brief 保持服装、动作动机和情绪连续。"}, Locations: []string{"主场景:根据 brief 建立前景、中景、远景和可信光源。"}, Shots: []agentStoryboardShot{ { Title: "开场建立", Description: "建立故事空间、主角状态和情绪基调。", VisualPrompt: "以真实电影机语言建立主要空间和角色状态,前景、中景、远景层次清晰。", VideoPrompt: "8 秒连续镜头,从故事空间中的人类尺度前景开始,摄影机缓慢前推,先展示环境细节和主角状态,中段让关键冲突迹象进入画面,结尾停在主角反应。自然曝光,真实高光滚降,空气介质和轻微胶片颗粒,避免廉价特效感、均匀平光和无尺度参照。", Camera: "中景,平视到轻微低机位,中等焦段", Motion: "缓慢前推,结尾停住", }, { Title: "冲突推进", Description: "推进动作、关系变化和核心冲突。", VisualPrompt: "主体动作、道具和环境反馈同时出现,空间调度明确。", VideoPrompt: "10 秒连续镜头,摄影机从主角侧后方跟随移动,开始画面聚焦人物动作和关键道具,中段冲突升级,环境中的灯光、尘埃、水汽或人群反应随动作发生变化,结尾用中近景压住情绪。真实电影机拍摄,受控冷暖对比,运动处保留自然模糊,避免过度锐化、过亮轮廓和塑料表面。", Camera: "中近景,侧后方跟拍,中长焦压缩空间", Motion: "跟拍加轻微抬镜", }, { Title: "结果与钩子", Description: "交代结果并留下下一段钩子。", VisualPrompt: "主角反应、环境后果和悬念信息同框。", VideoPrompt: "8 秒连续镜头,从冲突后的环境细节开始,摄影机缓慢横移揭示结果,中段主角进入画面并完成关键反应,结尾停在一个可延续的悬念物或空间方向。真实电影摄影质感,暗部保留层次,高光不过曝,低饱和色彩,避免海报式摆拍、干净空白背景和主体完整居中平铺。", Camera: "中景到近景,横移构图", Motion: "缓慢横移,结尾定格", }, }, } } func buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset) (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", "风格板", styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "风格板", "status": "success", "content": plan.StyleGuide}), 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 { shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1) matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) 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": buildStoryboardVideoPrompt(plan.StyleGuide, shot), "composerContent": shotComposerContent(buildStoryboardVideoPrompt(plan.StyleGuide, shot), matchedAssets), "videoEditOperation": "image_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 || fmt.Sprint(node["type"]) != "image" { continue } metadata, _ := node["metadata"].(map[string]interface{}) if metadata == nil { metadata = map[string]interface{}{} } 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 == "" { continue } assets = append(assets, storyboardAsset{ID: id, Title: defaultString(stringValue(node["title"]), "未命名图片"), Type: "image", Tags: tags, Prompt: prompt}) 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 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 buildStoryboardVideoPrompt(styleGuide string, shot agentStoryboardShot) 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), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") parts := []string{ "【氛围与画质】\n" + strings.TrimSpace(styleGuide), "【镜头设计】\n" + strings.TrimSpace(shot.ShotSize) + ";" + camera + ";运镜:" + motion, "【画面内容】\n" + timeBeats, } if strings.TrimSpace(shot.Dialogue) != "" || strings.TrimSpace(shot.AudioEffects) != "" { parts = append(parts, "【台词/声音】\n"+strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects)) } if strings.TrimSpace(shot.VideoPrompt) != "" { parts = append(parts, "【执行约束】\n"+strings.TrimSpace(shot.VideoPrompt)) } parts = append(parts, "【负面要求】\n"+negative) return strings.Join(parts, "\n\n") } 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 buildAgentResult(task model.Task) (map[string]any, []map[string]any) { title := strings.TrimSpace(task.Prompt) if len([]rune(title)) > 28 { title = string([]rune(title)[:28]) + "..." } result := map[string]any{ "taskId": task.ID, "operation": task.Operation, "provider": defaultString(task.Provider, "internal-agent"), "model": defaultString(task.Model, "workflow-router"), "plan": []map[string]any{ {"kind": "script", "title": "创意脚本", "content": task.Prompt}, {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, }, } ops := []map[string]any{ nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), connectOp("script-"+task.ID, "scene-"+task.ID), connectOp("scene-"+task.ID, "shot-"+task.ID), connectOp("shot-"+task.ID, "final-"+task.ID), } return result, ops } func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { title := strings.TrimSpace(task.Prompt) if len([]rune(title)) > 28 { title = string([]rune(title)[:28]) + "..." } operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) result := map[string]any{ "taskId": task.ID, "operation": operation, "provider": defaultString(task.Provider, "internal-agent"), "model": defaultString(task.Model, "workflow-router"), "plan": []map[string]any{ {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, }, } ops := []map[string]any{ nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), connectOp("video-shot-"+task.ID, "video-result-"+task.ID), } return result, ops } 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[:]) }