Files
ttstd-infinite-canvas/backend/internal/service/service.go

2290 lines
84 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 == "<nil>" {
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[:])
}