diff --git a/.gitignore b/.gitignore index f89cc1d40..f688cafc7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ docs/content/docs/backend/* !docs/content/docs/backend/http-api.mdx !docs/content/docs/backend/code-map.mdx !docs/content/docs/backend/newapi-channel-2.mdx +!docs/content/docs/backend/creative-agent-runtime.mdx +# Keep the creative Agent design versioned without unignoring generated docs. +!docs/content/docs/overview/ +docs/content/docs/overview/* +!docs/content/docs/overview/creative-agent-design.mdx .env* !.env.example data/ diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 3ad31829a..58da72d4c 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -284,6 +284,8 @@ func migrations() []tableMigration { migrateTable[model.UserPromptCustomization]("user_prompt_customizations"), migrateTable[model.Announcement]("announcements"), migrateTable[model.UserAnnouncementRead]("user_announcement_reads"), + migrateTable[model.CreationRun]("creation_runs"), + migrateTable[model.CreationSubmission]("creation_submissions"), migrateTable[model.Task]("tasks"), migrateTable[model.TaskTextDelta]("task_text_delta"), migrateTable[model.Session]("sessions"), diff --git a/backend/internal/database/creation_migration_test.go b/backend/internal/database/creation_migration_test.go new file mode 100644 index 000000000..30fa7e55d --- /dev/null +++ b/backend/internal/database/creation_migration_test.go @@ -0,0 +1,33 @@ +package database + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "testing" +) + +func TestCreationMigrationKeepsLegacyTasksNullableAndEnforcesSubmissionUnique(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = migrateSchemaV10(db); err != nil { + t.Fatal(err) + } + for _, id := range []string{"manual-one", "manual-two"} { + if err = db.Create(&model.Task{ID: id}).Error; err != nil { + t.Fatal(err) + } + } + sid := "submission" + if err = db.Create(&model.Task{ID: "creation-one", CreationSubmissionID: &sid}).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&model.Task{ID: "creation-two", CreationSubmissionID: &sid}).Error; err == nil { + t.Fatal("duplicate execution admitted") + } + if !db.Migrator().HasTable(&model.CreationRun{}) || !db.Migrator().HasTable(&model.CreationSubmission{}) { + t.Fatal("creation tables missing") + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index b9328539e..11a910067 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 9 +const CurrentSchemaVersion int64 = 10 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -20,6 +20,7 @@ const paymentTopupChecksum = "sha256:payment-topup-v5-20260902" const resourcePlaybackChecksum = "sha256:resource-playback-v6-20260902" const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902" const logicalModelActiveCodeChecksum = "sha256:logical-model-active-code-v8-20260905" +const creationRuntimeChecksum = "sha256:creation-runtime-v10-20260909" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -55,6 +56,7 @@ var schemaMigrations = []migration{ {version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7}, {version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8}, {version: 9, name: "channel_presentation", checksum: "sha256:channel-presentation-v9-20260908", apply: migrateChannelPresentation}, + {version: 10, name: "creation_runtime", checksum: creationRuntimeChecksum, apply: migrateSchemaV10}, } func migrateChannelPresentation(tx *gorm.DB) error { @@ -213,6 +215,14 @@ func migrateSchemaV8(tx *gorm.DB) error { return nil } +// migrateSchemaV10 只增加创作运行时表和任务幂等关联;旧任务的空 submission ID 必须继续合法。 +func migrateSchemaV10(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.CreationRun{}, &model.CreationSubmission{}, &model.Task{}); err != nil { + return fmt.Errorf("创建创作运行时结构:%w", err) + } + return nil +} + func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 301c93e60..0e21a8d84 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -87,6 +87,8 @@ func Models() []any { &model.Announcement{}, &model.UserAnnouncementRead{}, &model.Task{}, + &model.CreationRun{}, + &model.CreationSubmission{}, &model.TaskTextDelta{}, &model.Session{}, &model.Message{}, diff --git a/backend/internal/handler/api.go b/backend/internal/handler/api.go index 39a4bfd52..02ad23f67 100644 --- a/backend/internal/handler/api.go +++ b/backend/internal/handler/api.go @@ -22,6 +22,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) { RegisterAdminSystemPerformanceRoutes(api, svc) RegisterAnnouncementRoutes(api, svc) RegisterFinanceRoutes(api, svc) + RegisterCreationRoutes(api, svc) RegisterPaymentRoutes(api, svc) RegisterLibTVRoutes(api, svc) RegisterTapNowRoutes(api, svc) diff --git a/backend/internal/handler/creation.go b/backend/internal/handler/creation.go new file mode 100644 index 000000000..0f7d6cbab --- /dev/null +++ b/backend/internal/handler/creation.go @@ -0,0 +1,87 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "infinite-canvas/backend/internal/service" + "net/http" +) + +func RegisterCreationRoutes(r *gin.RouterGroup, svc *service.Service) { + read := func(action string) gin.HandlerFunc { + return func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var value any + switch action { + case "list": + value, err = svc.ListCreationRuns(user.ID) + case "get": + value, err = svc.GetCreationRun(user.ID, c.Param("id")) + case "snapshot": + value, err = svc.CreationCanvasSnapshot(user.ID, c.Param("id")) + } + if err != nil { + failService(c, err) + return + } + ok(c, value) + } + } + write := func(action string) gin.HandlerFunc { + return func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + var req service.CreationRequest + if err = c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + var value any + id := c.Param("id") + switch action { + case "create": + value, err = svc.CreateCreationRun(user.ID, req) + case "prepare": + value, err = svc.PrepareCreationSubmission(user.ID, id, req) + case "approve": + value, err = svc.ApproveCreationSubmissions(user.ID, id, req) + case "refresh-quote": + value, err = svc.RefreshCreationSubmission(user.ID, id, req) + case "execute": + value, err = svc.ExecuteCreationSubmission(user.ID, id, req) + case "canvas": + value, err = svc.CreateRunCanvas(user.ID, id, req) + case "commit": + value, err = svc.CommitCreationCanvas(user.ID, id, req) + default: + value, err = svc.ChangeCreationRun(user.ID, id, action, req) + } + if err != nil { + failService(c, err) + return + } + ok(c, value) + } + } + r.GET("/creation-runs", read("list")) + r.POST("/creation-runs", write("create")) + r.GET("/creation-runs/:id", read("get")) + r.PATCH("/creation-runs/:id", write("save")) + for _, action := range []string{"claim", "heartbeat", "release", "proposal-approve", "proposal-invalidate"} { + r.POST("/creation-runs/:id/"+action, write(action)) + } + r.POST("/creation-runs/:id/submissions/prepare", write("prepare")) + r.POST("/creation-runs/:id/submissions/approve", write("approve")) + r.POST("/creation-runs/:id/submissions/refresh", write("refresh-quote")) + r.POST("/creation-runs/:id/execute", write("execute")) + r.POST("/creation-runs/:id/canvas", write("canvas")) + r.GET("/creation-runs/:id/canvas-snapshot", read("snapshot")) + r.POST("/creation-runs/:id/canvas-commit", write("commit")) +} diff --git a/backend/internal/model/models_creation.go b/backend/internal/model/models_creation.go new file mode 100644 index 000000000..ea2fba361 --- /dev/null +++ b/backend/internal/model/models_creation.go @@ -0,0 +1,44 @@ +package model + +import "time" + +// CreationRun keeps execution authority separate from untrusted conversation state. +type CreationRun struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_creation_client,priority:1"` + ClientKey string `json:"-" gorm:"size:120;uniqueIndex:idx_creation_client,priority:2"` + CreateHash string `json:"-" gorm:"size:64"` + CanvasID string `json:"canvasId,omitempty" gorm:"size:80"` + Revision int64 `json:"revision"` + ExecutionEpoch int64 `json:"executionEpoch"` + ExecutionOwner string `json:"executionOwner" gorm:"size:120"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + Status string `json:"status" gorm:"size:32"` + StateJSON string `json:"-" gorm:"type:text"` + ApprovedProposalVersion int64 `json:"approvedProposalVersion,omitempty"` + ApprovedProposalHash string `json:"approvedProposalHash,omitempty" gorm:"size:64"` + ApprovedOperationsJSON string `json:"-" gorm:"type:text"` + ApprovedCanvasJSON string `json:"-" gorm:"type:text"` + ApprovedAt *time.Time `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreationSubmission struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"-" gorm:"size:36;index"` + RunID string `json:"runId" gorm:"size:36;uniqueIndex:idx_creation_item,priority:1"` + ItemKey string `json:"itemKey" gorm:"size:160;uniqueIndex:idx_creation_item,priority:2"` + ProposalVersion int64 `json:"-"` + ProposalHash string `json:"-" gorm:"size:64"` + RequestJSON string `json:"-" gorm:"type:text"` + RequestHash string `json:"requestHash" gorm:"size:64"` + QuoteJSON string `json:"-" gorm:"type:text"` + PriceSignature string `json:"-" gorm:"type:text"` + ExpiresAt time.Time `json:"-"` + ApprovedAt *time.Time `json:"approvedAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + TaskID *string `json:"taskId,omitempty" gorm:"size:36;uniqueIndex"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` +} diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go index 77d9273cd..226607bce 100644 --- a/backend/internal/model/models_task.go +++ b/backend/internal/model/models_task.go @@ -3,6 +3,7 @@ package model import "time" type Task struct { + CreationSubmissionID *string `json:"creationSubmissionId,omitempty" gorm:"size:36;uniqueIndex"` ID string `json:"id" gorm:"primaryKey;size:36"` UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1;index:idx_tasks_user_project_created,priority:1"` TraceID string `json:"-" gorm:"index;size:96"` diff --git a/backend/internal/repository/creation.go b/backend/internal/repository/creation.go new file mode 100644 index 000000000..906ab6c79 --- /dev/null +++ b/backend/internal/repository/creation.go @@ -0,0 +1,168 @@ +package repository + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +var ErrCreationConflict = errors.New("creation state changed; reload before continuing") + +func (r *Repository) CreationStorageUsage(userID string) (int64, int64, error) { + var usage struct { + Count int64 + Bytes int64 + } + query := `SELECT (SELECT COUNT(*) FROM creation_runs WHERE user_id = ?) AS count, + (SELECT COALESCE(SUM(length(CAST(COALESCE(state_json,'') AS BLOB))+length(CAST(COALESCE(approved_operations_json,'') AS BLOB))+length(CAST(COALESCE(approved_canvas_json,'') AS BLOB))),0) FROM creation_runs WHERE user_id = ?) + + (SELECT COALESCE(SUM(length(CAST(COALESCE(request_json,'') AS BLOB))+length(CAST(COALESCE(quote_json,'') AS BLOB))+length(CAST(COALESCE(price_signature,'') AS BLOB))),0) FROM creation_submissions WHERE user_id = ?) AS bytes` + if r.Dialect() == "postgres" { + query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(") + query = strings.ReplaceAll(query, ",'') AS BLOB))", ",''))") + } + err := r.db.Raw(query, userID, userID, userID).Scan(&usage).Error + return usage.Count, usage.Bytes, err +} + +func (r *Repository) CreationRun(userID, id string) (*model.CreationRun, error) { + var run model.CreationRun + err := r.db.First(&run, "id = ? AND user_id = ?", id, userID).Error + return &run, err +} + +func (r *Repository) CreationRunByClientKey(userID, key string) (*model.CreationRun, error) { + var run model.CreationRun + err := r.db.First(&run, "user_id = ? AND client_key = ?", userID, key).Error + return &run, err +} +func (r *Repository) CreationRuns(userID string) ([]model.CreationRun, error) { + items := []model.CreationRun{} + err := r.db.Where("user_id = ?", userID).Order("updated_at DESC").Limit(100).Find(&items).Error + return items, err +} +func (r *Repository) CreateCreationRun(run *model.CreationRun) error { + var old model.CreationRun + if err := r.db.First(&old, "user_id = ? AND client_key = ?", run.UserID, run.ClientKey).Error; err == nil { + if old.CreateHash != run.CreateHash { + return ErrCreationConflict + } + *run = old + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := r.db.Create(run).Error; err != nil { + if e := r.db.First(&old, "user_id = ? AND client_key = ?", run.UserID, run.ClientKey).Error; e == nil && old.CreateHash == run.CreateHash { + *run = old + return nil + } + return err + } + return nil +} + +// A conditional write obtains the run row's write lock in both SQLite and PostgreSQL. +// All submission consumption and task reservation use the returned transaction repository. +func (r *Repository) MutateCreationRun(userID, id string, fn func(*model.CreationRun, *Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.CreationRun{}).Where("id = ? AND user_id = ?", id, userID).UpdateColumn("revision", gorm.Expr("revision")) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + scoped := New(tx) + run, err := scoped.CreationRun(userID, id) + if err != nil { + return err + } + if err := fn(run, scoped); err != nil { + return err + } + return tx.Save(run).Error + }) +} +func (r *Repository) CreationSubmissions(userID, runID string) ([]model.CreationSubmission, error) { + items := []model.CreationSubmission{} + err := r.db.Where("user_id = ? AND run_id = ?", userID, runID).Order("created_at").Find(&items).Error + return items, err +} +func (r *Repository) CreationSubmission(userID, runID, id string) (*model.CreationSubmission, error) { + var item model.CreationSubmission + err := r.db.First(&item, "id = ? AND user_id = ? AND run_id = ?", id, userID, runID).Error + return &item, err +} +func (r *Repository) SaveCreationSubmission(item *model.CreationSubmission) error { + return r.db.Save(item).Error +} +func (r *Repository) RevokeCreationSubmissions(runID string) error { + return r.db.Model(&model.CreationSubmission{}).Where("run_id = ? AND task_id IS NULL AND revoked_at IS NULL", runID).Update("revoked_at", time.Now()).Error +} +func (r *Repository) CreateCreationCanvas(canvas *model.CanvasProject) error { + return r.db.Create(canvas).Error +} +func (r *Repository) CompareSaveCreationCanvas(canvas *model.CanvasProject, previous string) error { + result := r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND payload_json = ?", canvas.ID, canvas.UserID, previous).Updates(map[string]any{"payload_json": canvas.PayloadJSON, "title": canvas.Title, "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrCreationConflict + } + return nil +} + +// Capture server-side configuration versions, never channel credentials. +func (r *Repository) CreationPriceSignature(task *model.Task, channelID, modelKey string) (string, error) { + db := r.db.Clauses(clause.Locking{Strength: "SHARE"}).Session(&gorm.Session{}) + values := map[string]any{} + var cm model.ChannelModel + query := db.Where("channel_id = ? AND model_key = ?", channelID, modelKey) + if task.ChannelModelID != "" { + query = db.Where("id = ?", task.ChannelModelID) + } + if err := query.First(&cm).Error; err != nil { + return "", err + } + values["channelModel"] = cm + values["capabilityConfig"] = cm.CapabilityConfigJSON + var channel struct { + ID string + Enabled bool + UpdatedAt time.Time + } + if err := db.Model(&model.ModelChannel{}).Select("id, enabled, updated_at").First(&channel, "id = ?", cm.ChannelID).Error; err != nil { + return "", err + } + values["channel"] = channel + var tiers []model.ChannelModelPriceTier + if err := db.Where("channel_model_id = ?", cm.ID).Order("id").Find(&tiers).Error; err != nil { + return "", err + } + values["tiers"] = tiers + if task.LogicalModelID != "" { + var logical model.LogicalModel + if err := db.First(&logical, "id = ?", task.LogicalModelID).Error; err != nil { + return "", err + } + values["logical"] = logical + var route model.LogicalModelRoute + if err := db.First(&route, "id = ?", task.RouteID).Error; err != nil { + return "", err + } + values["route"] = route + } + var settings []model.SystemSetting + if err := db.Where("key IN ?", []string{"credit_policy", "feature_availability"}).Order("key").Find(&settings).Error; err != nil { + return "", err + } + values["pricingSettings"] = settings + b, err := json.Marshal(values) + return string(b), err +} diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 72966a342..e5eb62b48 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -118,6 +118,20 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s for _, session := range sessions { snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "会话", ID: session.ID, Title: session.Prompt, PrimaryJSON: session.CanvasSnapshotJSON, SecondaryJSON: session.CanvasOpsJSON}) } + var runs []model.CreationRun + if err := r.db.Where("user_id = ?", userID).Find(&runs).Error; err != nil { + return snapshot, err + } + for _, run := range runs { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "创作会话", ID: run.ID, Title: "智能创作", PrimaryJSON: run.StateJSON, SecondaryJSON: run.ApprovedOperationsJSON}) + } + var submissions []model.CreationSubmission + if err := r.db.Where("user_id = ? AND revoked_at IS NULL", userID).Find(&submissions).Error; err != nil { + return snapshot, err + } + for _, submission := range submissions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "创作执行项", ID: submission.ID, Title: submission.ItemKey, PrimaryJSON: submission.RequestJSON}) + } var messages []model.Message if err := r.db.Select("id", "content", "payload").Where("user_id = ?", userID).Find(&messages).Error; err != nil { diff --git a/backend/internal/service/creation.go b/backend/internal/service/creation.go new file mode 100644 index 000000000..e0e3b5252 --- /dev/null +++ b/backend/internal/service/creation.go @@ -0,0 +1,903 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +type CreationGuard struct { + ExecutionEpoch int64 `json:"executionEpoch"` + Owner string `json:"owner"` +} +type CreationRequest struct { + CreationGuard + ClientKey string `json:"clientKey"` + CanvasID string `json:"canvasId"` + Revision int64 `json:"revision"` + ExpectedEpoch int64 `json:"expectedEpoch"` + State map[string]any `json:"state"` + Status string `json:"status"` + ProposalVersion int64 `json:"proposalVersion"` + Proposal json.RawMessage `json:"proposal"` + Ops []CreationCanvasOp `json:"ops"` + ItemKey string `json:"itemKey"` + Request CreateTaskRequest `json:"request"` + SubmissionIDs []string `json:"submissionIds"` + SubmissionID string `json:"submissionId"` + ExpectedSnapshotHash string `json:"expectedSnapshotHash"` + Document json.RawMessage `json:"document"` +} +type CreationRunOutput struct { + model.CreationRun + State map[string]any `json:"state"` +} +type CreationQuote struct { + Model string `json:"model"` + BillingMode string `json:"billingMode"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Estimated bool `json:"estimated"` + ExpiresAt time.Time `json:"expiresAt"` + QuoteHash string `json:"quoteHash"` + Options map[string]any `json:"options,omitempty"` +} +type CreationSubmissionOutput struct { + model.CreationSubmission + Quote CreationQuote `json:"quote"` +} +type CreationDetail struct { + Run CreationRunOutput `json:"run"` + Submissions []CreationSubmissionOutput `json:"submissions"` +} +type creationTaskPreparation struct{ Order *model.BillingOrder } + +func creationConflict(message string) error { + return &AppError{Status: 409, Code: 409, Message: message} +} +func creationError(err error) error { + if errors.Is(err, repository.ErrInsufficientCredits) { + return BadAuthRequest("积分不足,请充值后重试") + } + if errors.Is(err, repository.ErrActiveTaskLimit) { + return BadAuthRequest("同时排队或运行的任务已达到上限") + } + if errors.Is(err, repository.ErrLogicalModelUnavailable) { + return creationConflict("模型已更新或停用,请重新准备报价") + } + if errors.Is(err, repository.ErrCreationConflict) { + return creationConflict("创作状态已变化,请重新读取后继续") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return &AppError{Status: 404, Code: 404, Message: "创作记录不存在或无权访问"} + } + return err +} +func creationHash(value any) string { + b, _ := json.Marshal(value) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} +func creationRunOutput(run model.CreationRun) CreationRunOutput { + state := map[string]any{} + _ = json.Unmarshal([]byte(run.StateJSON), &state) + return CreationRunOutput{run, state} +} +func creationSubmissionOutput(item model.CreationSubmission) CreationSubmissionOutput { + var quote CreationQuote + _ = json.Unmarshal([]byte(item.QuoteJSON), "e) + return CreationSubmissionOutput{item, quote} +} +func validateCreationGuard(run *model.CreationRun, guard CreationGuard) error { + if guard.Owner == "" || run.ExecutionOwner != guard.Owner || run.ExecutionEpoch != guard.ExecutionEpoch || run.LeaseExpiresAt == nil || !run.LeaseExpiresAt.After(time.Now()) { + return creationConflict("执行控制权已过期,请在当前页面重新接管") + } + return nil +} +func validateCreationJSON(value any) error { + b, err := json.Marshal(value) + if err != nil || len(b) > 1<<20 { + return BadAuthRequest("创作内容超过限制或格式无效") + } + var decoded any + _ = json.Unmarshal(b, &decoded) + var visit func(any) bool + visit = func(v any) bool { + switch item := v.(type) { + case map[string]any: + for key, child := range item { + switch strings.ToLower(key) { + case "apikey", "secretkey", "authorization", "cookie", "headers", "baseurl", "token", "accesstoken", "refresh_token": + if child != nil && child != "" { + return false + } + } + if !visit(child) { + return false + } + } + case []any: + for _, child := range item { + if !visit(child) { + return false + } + } + case string: + if strings.HasPrefix(item, "data:") || strings.Contains(item, "X-Amz-Signature=") || strings.Contains(item, "X-Tos-Signature=") { + return false + } + } + return true + } + if !visit(decoded) { + return BadAuthRequest("创作记录不能包含密钥、内嵌媒体或临时签名链接") + } + return nil +} +func (s *Service) CreateCreationRun(userID string, req CreationRequest) (*CreationDetail, error) { + if strings.TrimSpace(userID) == "" || strings.TrimSpace(req.ClientKey) == "" || len(req.ClientKey) > 120 { + return nil, BadAuthRequest("缺少稳定会话键") + } + if err := validateCreationJSON(req.State); err != nil { + return nil, err + } + if req.CanvasID != "" { + if _, err := s.repo.CanvasProjectForUser(userID, req.CanvasID); err != nil { + return nil, creationError(err) + } + } + b, _ := json.Marshal(req.State) + run := model.CreationRun{ID: newID(), UserID: userID, ClientKey: req.ClientKey, CreateHash: creationHash([]any{req.CanvasID, req.State}), CanvasID: req.CanvasID, Revision: 1, Status: "idle", StateJSON: string(b)} + if old, e := s.repo.CreationRunByClientKey(userID, req.ClientKey); e == nil { + if old.CreateHash != run.CreateHash { + return nil, creationConflict("同一会话键的内容不同") + } + return s.GetCreationRun(userID, old.ID) + } else if !errors.Is(e, gorm.ErrRecordNotFound) { + return nil, e + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + if err := s.validateCreationStorage(s.repo, userID, true, int64(len(b))); err != nil { + return nil, err + } + if err := s.repo.CreateCreationRun(&run); err != nil { + return nil, creationError(err) + } + return s.GetCreationRun(userID, run.ID) +} +func (s *Service) GetCreationRun(userID, id string) (*CreationDetail, error) { + run, err := s.repo.CreationRun(userID, id) + if err != nil { + return nil, creationError(err) + } + items, err := s.repo.CreationSubmissions(userID, id) + if err != nil { + return nil, err + } + out := &CreationDetail{Run: creationRunOutput(*run), Submissions: []CreationSubmissionOutput{}} + for _, item := range items { + out.Submissions = append(out.Submissions, creationSubmissionOutput(item)) + } + return out, nil +} +func (s *Service) ListCreationRuns(userID string) (map[string]any, error) { + items, err := s.repo.CreationRuns(userID) + out := []CreationRunOutput{} + for _, run := range items { + out = append(out, creationRunOutput(run)) + } + return map[string]any{"runs": out}, err +} +func (s *Service) ChangeCreationRun(userID, id, action string, req CreationRequest) (any, error) { + if action == "save" || action == "proposal-approve" || action == "proposal-invalidate" { + s.storageMu.Lock() + defer s.storageMu.Unlock() + } + var out any + err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error { + previousBytes := len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON) + now := time.Now() + if action == "claim" { + if req.Owner == "" || len(req.Owner) > 120 || req.ExpectedEpoch != run.ExecutionEpoch { + return creationConflict("执行代次已变化") + } + run.ExecutionEpoch++ + run.ExecutionOwner = req.Owner + until := now.Add(45 * time.Second) + run.LeaseExpiresAt = &until + } else { + if err := validateCreationGuard(run, req.CreationGuard); err != nil { + return err + } + switch action { + case "heartbeat": + until := now.Add(45 * time.Second) + run.LeaseExpiresAt = &until + out = map[string]any{"leaseExpiresAt": until} + return nil + case "release": + run.LeaseExpiresAt = nil + out = map[string]any{"released": true} + return nil + case "save": + if req.Revision != run.Revision { + return repository.ErrCreationConflict + } + if err := validateCreationJSON(req.State); err != nil { + return err + } + if !strings.Contains("|idle|running|waiting_answer|waiting_proposal|waiting_canvas|waiting_payment|waiting_task|paused|completed|cancelled|", "|"+req.Status+"|") || req.Status == "" { + return BadAuthRequest("创作状态无效") + } + if run.Status == "cancelled" && req.Status != "cancelled" { + return creationConflict("已取消会话不能继续执行") + } + b, _ := json.Marshal(req.State) + run.StateJSON = string(b) + run.Status = req.Status + case "proposal-approve": + if req.Revision != run.Revision || req.ProposalVersion <= 0 { + return repository.ErrCreationConflict + } + if err := validateCreationOps(req.Ops); err != nil { + return err + } + if err := validateCreationJSON(req.Proposal); err != nil { + return err + } + for _, op := range req.Ops { + if key := stringValue(op.Metadata["storageKey"]); key != "" { + if !strings.HasPrefix(key, "resource:") { + return BadAuthRequest("已有素材必须来自当前账号资源库") + } + resource, e := repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:")) + if e != nil { + return e + } + if resource.Status != model.ResourceStatusReady { + return BadAuthRequest("已有素材尚未就绪") + } + } + } + hash := creationHash([]any{req.Proposal, req.Ops}) + if req.ProposalVersion <= run.ApprovedProposalVersion { + if req.ProposalVersion == run.ApprovedProposalVersion && hash == run.ApprovedProposalHash { + out = creationRunOutput(*run) + return nil + } + return repository.ErrCreationConflict + } + if err := repo.RevokeCreationSubmissions(id); err != nil { + return err + } + b, _ := json.Marshal(req.Ops) + run.ApprovedOperationsJSON = string(b) + run.ApprovedProposalVersion = req.ProposalVersion + run.ApprovedProposalHash = hash + run.ApprovedAt = &now + run.ApprovedCanvasJSON = "" + if run.CanvasID != "" { + canvas, e := repo.CanvasProjectForUser(userID, run.CanvasID) + if e != nil { + return e + } + baseline, e := creationApprovalBaseline(canvas.PayloadJSON, req.Ops) + if e != nil { + return e + } + run.ApprovedCanvasJSON = baseline + } + case "proposal-invalidate": + if req.Revision != run.Revision { + return repository.ErrCreationConflict + } + if err := repo.RevokeCreationSubmissions(id); err != nil { + return err + } + run.ApprovedAt = nil + run.ApprovedProposalHash = "" + run.ApprovedOperationsJSON = "" + run.ApprovedCanvasJSON = "" + default: + return BadAuthRequest("未知创作操作") + } + } + run.Revision++ + if action == "save" || action == "proposal-approve" { + delta := int64(len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON) - previousBytes) + if e := s.validateCreationStorage(repo, userID, false, delta); e != nil { + return e + } + } + out = creationRunOutput(*run) + return nil + }) + return out, creationError(err) +} + +func (s *Service) prepareCreationTask(userID string, req CreateTaskRequest) (*model.Task, *model.BillingOrder, string, error) { + config, _ := req.Input["config"].(map[string]any) + if req.LogicalModelID == "" && strings.TrimSpace(stringValue(config["channelId"])) == "" { + return nil, nil, "", BadAuthRequest("智能创作目前仅支持后端受管模型,请在原入口使用其他渠道") + } + if taskInputUsesWorkflowProvider(req.Input) || isTextReplayTaskRequest(req.Input) { + return nil, nil, "", BadAuthRequest("智能创作不支持本机、工作流或文本回放任务") + } + // System selectors are the only authority; discard frontend sentinel credentials. + safeConfig := map[string]any{} + for _, key := range []string{"channelId", "channelModelKey", "model", "priceTierId", "apiFormat", "interfaceType", "size", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload", "systemPrompt"} { + if value, ok := config[key]; ok { + safeConfig[key] = value + } + } + config = safeConfig + req.Input["config"] = safeConfig + if err := validateCreationJSON(req); err != nil { + return nil, nil, "", err + } + if req.Type != "canvas_text" && req.Type != "text" && req.Type != "agent_storyboard_rows" && req.Type != "canvas_image" && req.Type != "canvas_video" { + return nil, nil, "", BadAuthRequest("智能创作任务类型不受支持") + } + expectedMode := map[string]string{"text": "text", "canvas_text": "text", "agent_storyboard_rows": "text", "canvas_image": "image", "canvas_video": "video"}[req.Type] + if stringValue(req.Input["mode"]) != expectedMode || strings.TrimSpace(stringValue(req.Input["prompt"])) != strings.TrimSpace(req.Prompt) { + return nil, nil, "", BadAuthRequest("任务类型、模式和实际提示词必须一致") + } + if req.SessionID != "" { + return nil, nil, "", BadAuthRequest("智能创作不能写入旧会话") + } + if expectedMode != "text" && req.Input["agentRequests"] != nil { + return nil, nil, "", BadAuthRequest("媒体任务不允许携带独立模型协议请求") + } + if count := stringValue(config["count"]); count != "" && count != "1" { + return nil, nil, "", BadAuthRequest("每个获批执行项只能生成一个产物") + } + if req.Input["mask"] != nil { + return nil, nil, "", BadAuthRequest("本期智能创作暂不支持蒙版任务") + } + if expectedMode != "text" { + canvas, e := s.repo.CanvasProjectForUser(userID, req.ProjectID) + if e != nil { + return nil, nil, "", creationError(e) + } + doc, e := creationDocument(canvas.PayloadJSON) + if e != nil { + return nil, nil, "", e + } + nodes, e := creationObjects(doc["nodes"]) + if e != nil { + return nil, nil, "", e + } + if refs, ok := req.Input["referenceImages"].([]any); ok { + for _, raw := range refs { + ref, _ := raw.(map[string]any) + node := nodes[stringValue(ref["id"])] + meta, _ := node["metadata"].(map[string]any) + if node == nil || node["type"] != "image" || meta["status"] != "success" || stringValue(ref["storageKey"]) != stringValue(meta["storageKey"]) { + return nil, nil, "", creationConflict("参考素材已变化或尚未就绪") + } + } + } + } + for _, name := range []string{"referenceImages", "referenceVideos", "referenceAudios"} { + if list, ok := req.Input[name].([]any); ok { + for _, raw := range list { + media, ok := raw.(map[string]any) + if !ok { + return nil, nil, "", BadAuthRequest("素材引用格式无效") + } + key := stringValue(media["storageKey"]) + if !strings.HasPrefix(key, "resource:") { + return nil, nil, "", BadAuthRequest("请先将参考素材保存到当前账号资源库") + } + resource, err := s.repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:")) + if err != nil { + return nil, nil, "", creationError(err) + } + if resource.Status != "ready" { + return nil, nil, "", BadAuthRequest("参考素材尚未就绪") + } + delete(media, "url") + delete(media, "dataUrl") + media["bytes"] = resource.Size + media["durationMs"] = resource.DurationMs + } + } + } + prepared := &creationTaskPreparation{} + req.creationPrepare = prepared + task, err := s.CreateTask(userID, req) + if err != nil { + return nil, nil, "", err + } + var input map[string]any + _ = json.Unmarshal([]byte(task.InputJSON), &input) + resolved, _ := input["config"].(map[string]any) + for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count"} { + if requested := stringValue(config[key]); requested != "" && !strings.EqualFold(requested, stringValue(resolved[key])) { + return nil, nil, "", creationConflict("模型解析后的生成规格与请求不同,请调整方案后重新报价") + } + } + channel, channelErr := s.repo.SystemChannel(stringValue(resolved["channelId"])) + if channelErr != nil { + return nil, nil, "", channelErr + } + if channel.AllowLocalChannel { + return nil, nil, "", BadAuthRequest("智能创作暂不支持本机受管渠道") + } + if expectedMode == "text" { + var typed canvasGenerationInput + if err = json.Unmarshal([]byte(task.InputJSON), &typed); err != nil { + return nil, nil, "", err + } + if len(typed.ReferenceImages) > 0 { + cm, e := s.repo.ChannelModelByKey(channel.ID, stringValue(resolved["model"])) + if e != nil { + return nil, nil, "", e + } + profile, e := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON) + if e != nil || profile == nil || profile.Text == nil || profile.Text.References.MaxImages < len(typed.ReferenceImages) { + return nil, nil, "", BadAuthRequest("当前文本模型未配置足够的图片理解能力") + } + } + if err = validateAgentResourcePlaceholders(typed); err != nil { + return nil, nil, "", err + } + } + sig, err := s.repo.CreationPriceSignature(task, stringValue(resolved["channelId"]), stringValue(resolved["model"])) + return task, prepared.Order, sig, err +} +func creationQuoteFor(task *model.Task, order *model.BillingOrder, signature string, expires time.Time) CreationQuote { + quote := CreationQuote{Model: task.Model, BillingMode: "free", Quantity: 1, ExpiresAt: expires} + if order != nil { + quote.BillingMode = order.BillingMode + quote.Quantity = order.Quantity + quote.AmountMicrocredits = order.AmountMicrocredits + quote.Estimated = order.BillingMode == "token" + } + var input map[string]any + _ = json.Unmarshal([]byte(task.InputJSON), &input) + config, _ := input["config"].(map[string]any) + quote.Options = map[string]any{} + for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "maxTokens"} { + if value, ok := config[key]; ok { + quote.Options[key] = value + } + } + quote.QuoteHash = creationHash([]any{signature, quote.BillingMode, quote.Quantity, quote.AmountMicrocredits, quote.Options}) + return quote +} +func validateCreationSubmissionScope(run *model.CreationRun, version int64, req CreateTaskRequest) error { + if run.Status == "paused" || run.Status == "cancelled" || run.Status == "completed" { + return creationConflict("请先恢复创作任务") + } + if req.Type == "canvas_text" || req.Type == "text" || req.Type == "agent_storyboard_rows" { + if req.ProjectID != "" && req.ProjectID != run.CanvasID { + return creationConflict("规划任务的画布关联不匹配") + } + return nil + } + if run.CanvasID == "" || req.ProjectID != run.CanvasID { + return creationConflict("媒体任务必须提交到当前创作画布") + } + if run.ApprovedAt == nil || version != run.ApprovedProposalVersion || run.ApprovedProposalHash == "" { + return creationConflict("请先确认当前方案") + } + var ops []CreationCanvasOp + _ = json.Unmarshal([]byte(run.ApprovedOperationsJSON), &ops) + nodeID := stringValue(req.Input["nodeId"]) + metadata, _ := req.Input["metadata"].(map[string]any) + if nodeID == "" { + nodeID = stringValue(metadata["nodeId"]) + } + for _, op := range ops { + if op.ID != nodeID || nodeID == "" { + continue + } + if op.Type != "add_node" && op.Type != "update_node" { + continue + } + meta := op.Metadata + if nested, ok := op.Patch["metadata"].(map[string]any); ok { + meta = mergeCreationMaps(nested, meta) + } + prompt := stringValue(meta["prompt"]) + if prompt == "" { + prompt = stringValue(meta["text"]) + } + if strings.TrimSpace(req.Prompt) != strings.TrimSpace(prompt) || prompt == "" { + return creationConflict("任务提示词已超出获批方案") + } + if stringValue(meta["model"]) == "" || stringValue(meta["model"]) != req.Model { + return creationConflict("模型与已批准方案不同") + } + config, _ := req.Input["config"].(map[string]any) + for _, key := range []string{"size", "videoSeconds", "vquality", "quality"} { + metadataKey := key + if key == "videoSeconds" { + metadataKey = "seconds" + } + if approved := stringValue(meta[metadataKey]); approved != "" && approved != stringValue(config[key]) { + return creationConflict("生成规格与已批准方案不同") + } + } + refs, _ := meta["referenceNodeIds"].([]any) + images, _ := req.Input["referenceImages"].([]any) + if len(refs) != len(images) { + return creationConflict("参考素材数量与已批准方案不同") + } + for index, ref := range refs { + image, _ := images[index].(map[string]any) + if stringValue(ref) != stringValue(image["id"]) { + return creationConflict("参考素材与已批准方案不同") + } + } + for _, kind := range []string{"referenceVideos", "referenceAudios"} { + if values, ok := req.Input[kind].([]any); ok && len(values) > 0 { + return creationConflict("本期方案尚未授权视频或音频参考输入") + } + } + return nil + } + return creationConflict("任务节点不在已批准方案范围内") +} +func (s *Service) PrepareCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) { + if req.ItemKey == "" || len(req.ItemKey) > 160 { + return nil, BadAuthRequest("缺少稳定执行项键") + } + if strings.HasPrefix(req.ItemKey, "requote:") { + return nil, BadAuthRequest("该执行项键由报价刷新接口保留") + } + run, err := s.repo.CreationRun(userID, id) + if err != nil { + return nil, creationError(err) + } + if err = validateCreationGuard(run, req.CreationGuard); err != nil { + return nil, err + } + if err = validateCreationSubmissionScope(run, req.ProposalVersion, req.Request); err != nil { + return nil, err + } + item, original, err := s.buildCreationSubmission(userID, run, req.ItemKey, req.ProposalVersion, req.Request) + if err != nil { + return nil, err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error { + if e := validateCreationGuard(current, req.CreationGuard); e != nil { + return e + } + if e := validateCreationSubmissionScope(current, req.ProposalVersion, original); e != nil { + return e + } + items, e := repo.CreationSubmissions(userID, id) + if e != nil { + return e + } + for _, old := range items { + if old.ItemKey == req.ItemKey { + if old.RequestHash != item.RequestHash || old.ProposalVersion != item.ProposalVersion { + return repository.ErrCreationConflict + } + item = old + return nil + } + } + if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil { + return e + } + return repo.SaveCreationSubmission(&item) + }) + if err != nil { + return nil, creationError(err) + } + out := creationSubmissionOutput(item) + return &out, nil +} + +func (s *Service) buildCreationSubmission(userID string, run *model.CreationRun, itemKey string, proposalVersion int64, request CreateTaskRequest) (model.CreationSubmission, CreateTaskRequest, error) { + // JSON normalization also isolates the immutable snapshot from mutable caller maps. + b, err := json.Marshal(request) + if err != nil { + return model.CreationSubmission{}, CreateTaskRequest{}, err + } + var normalized CreateTaskRequest + if err = json.Unmarshal(b, &normalized); err != nil { + return model.CreationSubmission{}, normalized, err + } + task, order, signature, err := s.prepareCreationTask(userID, normalized) + if err != nil { + return model.CreationSubmission{}, normalized, err + } + expires := time.Now().Add(5 * time.Minute) + quote := creationQuoteFor(task, order, signature, expires) + quoteJSON, _ := json.Marshal(quote) + requestJSON, _ := json.Marshal(normalized) + item := model.CreationSubmission{ID: newID(), UserID: userID, RunID: run.ID, ItemKey: itemKey, ProposalVersion: proposalVersion, ProposalHash: run.ApprovedProposalHash, RequestJSON: string(requestJSON), RequestHash: creationHash(normalized), QuoteJSON: string(quoteJSON), PriceSignature: signature, ExpiresAt: expires} + return item, normalized, nil +} + +// Refresh creates an unapproved successor. The old quote and approval remain immutable. +func (s *Service) RefreshCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) { + run, err := s.repo.CreationRun(userID, id) + if err != nil { + return nil, creationError(err) + } + if err = validateCreationGuard(run, req.CreationGuard); err != nil { + return nil, err + } + old, err := s.repo.CreationSubmission(userID, id, req.SubmissionID) + if err != nil { + return nil, creationError(err) + } + if old.TaskID != nil { + return nil, creationConflict("任务已提交,请查看原任务,不需要刷新报价") + } + successorKey := "requote:" + old.ID + items, err := s.repo.CreationSubmissions(userID, id) + if err != nil { + return nil, err + } + for _, item := range items { + if item.ItemKey == successorKey { + out := creationSubmissionOutput(item) + return &out, nil + } + } + if old.RevokedAt != nil { + return nil, creationConflict("原方案已撤销,请重新准备生成项") + } + var request CreateTaskRequest + if err = json.Unmarshal([]byte(old.RequestJSON), &request); err != nil { + return nil, err + } + if err = validateCreationSubmissionScope(run, old.ProposalVersion, request); err != nil { + return nil, err + } + if old.ProposalVersion > 0 && old.ProposalHash != run.ApprovedProposalHash { + return nil, creationConflict("原方案已变化,请重新准备生成项") + } + item, normalized, err := s.buildCreationSubmission(userID, run, successorKey, old.ProposalVersion, request) + if err != nil { + return nil, err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error { + if e := validateCreationGuard(current, req.CreationGuard); e != nil { + return e + } + fresh, e := repo.CreationSubmission(userID, id, old.ID) + if e != nil { + return e + } + if fresh.TaskID != nil { + return creationConflict("任务已经提交,不能刷新报价") + } + all, e := repo.CreationSubmissions(userID, id) + if e != nil { + return e + } + for _, existing := range all { + if existing.ItemKey == successorKey { + item = existing + return nil + } + } + if fresh.RevokedAt != nil { + return creationConflict("原报价已撤销") + } + if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, normalized); e != nil { + return e + } + if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash { + return repository.ErrCreationConflict + } + if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil { + return e + } + now := time.Now() + fresh.RevokedAt = &now + if e = repo.SaveCreationSubmission(fresh); e != nil { + return e + } + return repo.SaveCreationSubmission(&item) + }) + if err != nil { + return nil, creationError(err) + } + out := creationSubmissionOutput(item) + return &out, nil +} + +func (s *Service) validateCreationStorage(repo *repository.Repository, userID string, creating bool, delta int64) error { + usage, err := repo.UserStorageUsage(userID) + if err != nil { + return err + } + count, bytes, err := repo.CreationStorageUsage(userID) + if err != nil { + return err + } + usage.SessionCount += count + usage.SessionBytes += bytes + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + return validateStructuredStorageQuotaWithPolicy(usage, "session", creating, delta, policy.Resource) +} +func (s *Service) ApproveCreationSubmissions(userID, id string, req CreationRequest) (map[string]any, error) { + if len(req.SubmissionIDs) == 0 || len(req.SubmissionIDs) > 20 { + return nil, BadAuthRequest("请选择 1 到 20 项生成任务") + } + // Compute current prices before the write transaction; transaction checks their config signatures again. + prepared := map[string]*model.Task{} + signatures := map[string]string{} + for _, sid := range req.SubmissionIDs { + item, err := s.repo.CreationSubmission(userID, id, sid) + if err != nil { + return nil, creationError(err) + } + var request CreateTaskRequest + _ = json.Unmarshal([]byte(item.RequestJSON), &request) + task, order, sig, err := s.prepareCreationTask(userID, request) + if err != nil { + return nil, err + } + if creationQuoteFor(task, order, sig, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash { + return nil, creationConflict("报价已变化,请重新准备并确认") + } + prepared[sid] = task + signatures[sid] = sig + } + out := []CreationSubmissionOutput{} + err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error { + if e := validateCreationGuard(run, req.CreationGuard); e != nil { + return e + } + now := time.Now() + for _, sid := range req.SubmissionIDs { + item, e := repo.CreationSubmission(userID, id, sid) + if e != nil { + return e + } + if item.RevokedAt != nil || !item.ExpiresAt.After(now) { + return creationConflict("报价已过期或撤销") + } + var request CreateTaskRequest + _ = json.Unmarshal([]byte(item.RequestJSON), &request) + if e = validateCreationSubmissionScope(run, item.ProposalVersion, request); e != nil { + return e + } + if item.ProposalVersion > 0 && item.ProposalHash != run.ApprovedProposalHash { + return repository.ErrCreationConflict + } + if e = checkCreationPriceSignature(repo, prepared[sid], signatures[sid]); e != nil { + return e + } + if item.ApprovedAt == nil { + item.ApprovedAt = &now + if e = repo.SaveCreationSubmission(item); e != nil { + return e + } + } + out = append(out, creationSubmissionOutput(*item)) + } + return nil + }) + return map[string]any{"submissions": out}, creationError(err) +} +func checkCreationPriceSignature(repo *repository.Repository, task *model.Task, want string) error { + var input map[string]any + _ = json.Unmarshal([]byte(task.InputJSON), &input) + config, _ := input["config"].(map[string]any) + got, err := repo.CreationPriceSignature(task, stringValue(config["channelId"]), stringValue(config["model"])) + if err != nil { + return err + } + if got != want { + return creationConflict("模型或价格配置已更新,请重新确认") + } + return nil +} +func (s *Service) ExecuteCreationSubmission(userID, id string, req CreationRequest) (*model.Task, error) { + run, err := s.repo.CreationRun(userID, id) + if err != nil { + return nil, creationError(err) + } + if err = validateCreationGuard(run, req.CreationGuard); err != nil { + return nil, err + } + item, err := s.repo.CreationSubmission(userID, id, req.SubmissionID) + if err != nil { + return nil, creationError(err) + } + if item.TaskID != nil { + task, e := s.repo.TaskForUser(userID, *item.TaskID) + if e != nil { + return nil, e + } + return taskForOutput(*task), nil + } + var request CreateTaskRequest + if err = json.Unmarshal([]byte(item.RequestJSON), &request); err != nil { + return nil, err + } + if err = validateCreationSubmissionScope(run, item.ProposalVersion, request); err != nil { + return nil, err + } + task, order, signature, err := s.prepareCreationTask(userID, request) + if err != nil { + return nil, err + } + if creationQuoteFor(task, order, signature, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash { + return nil, creationConflict("报价已变化,请重新确认") + } + var input map[string]any + _ = json.Unmarshal([]byte(task.InputJSON), &input) + if err = s.protectTaskSecrets(input); err != nil { + return nil, err + } + b, _ := json.Marshal(input) + task.InputJSON = string(b) + task.CreationSubmissionID = &item.ID + if order != nil { + task.BillingOrderID = order.ID + } + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error { + if e := validateCreationGuard(current, req.CreationGuard); e != nil { + return e + } + fresh, e := repo.CreationSubmission(userID, id, item.ID) + if e != nil { + return e + } + if fresh.TaskID != nil { + existing, e := repo.TaskForUser(userID, *fresh.TaskID) + if e != nil { + return e + } + task = existing + return nil + } + if fresh.ApprovedAt == nil || fresh.RevokedAt != nil || !fresh.ExpiresAt.After(time.Now()) { + return creationConflict("任务尚未批准或报价已过期") + } + if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, request); e != nil { + return e + } + if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash { + return repository.ErrCreationConflict + } + if e = checkCreationPriceSignature(repo, task, signature); e != nil { + return e + } + if e = createTaskWithStorageQuotaRepository(repo, task, order, policy); e != nil { + return e + } + fresh.TaskID = &task.ID + if e = repo.SaveCreationSubmission(fresh); e != nil { + return e + } + return nil + }) + if err != nil { + return nil, creationError(err) + } + return taskForOutput(*task), nil +} diff --git a/backend/internal/service/creation_agent_references.go b/backend/internal/service/creation_agent_references.go new file mode 100644 index 000000000..2e31ea417 --- /dev/null +++ b/backend/internal/service/creation_agent_references.go @@ -0,0 +1,170 @@ +package service + +import ( + "encoding/json" + "strings" +) + +func validateAgentResourcePlaceholders(input canvasGenerationInput) error { + _, err := resolveAgentResourcePlaceholders(input, false) + return err +} + +// Only hydrated references can replace protocol placeholders. The returned request +// is an in-memory copy and must never be written back to Task.InputJSON. +func resolveAgentResourcePlaceholders(input canvasGenerationInput, hydrate bool) (canvasGenerationInput, error) { + if input.AgentRequests == nil { + return input, nil + } + if hydrate { + encoded, err := json.Marshal(input.AgentRequests) + if err != nil { + return input, err + } + if !strings.Contains(string(encoded), `"resource:`) { + return input, nil + } + } + references := map[string]string{} + for _, media := range input.ReferenceImages { + if !strings.HasPrefix(media.StorageKey, "resource:") { + return input, BadAuthRequest("规划图片必须引用当前账号资源") + } + value := media.StorageKey + if hydrate { + value = firstNonEmpty(media.DataURL, media.URL) + if value == "" { + return input, BadAuthRequest("规划图片尚未读取成功") + } + } + references[media.StorageKey] = value + } + b, err := json.Marshal(input.AgentRequests) + if err != nil { + return input, err + } + var root any + if err = json.Unmarshal(b, &root); err != nil { + return input, err + } + var visit func(any) (any, error) + visit = func(value any) (any, error) { + switch typed := value.(type) { + case string: + if strings.HasPrefix(typed, "resource:") { + resolved, ok := references[typed] + if !ok { + return nil, BadAuthRequest("模型协议引用了未获准的图片") + } + return resolved, nil + } + return typed, nil + case []any: + for i, child := range typed { + next, e := visit(child) + if e != nil { + return nil, e + } + typed[i] = next + } + return typed, nil + case map[string]any: + if !hydrate { + var imageRef any + switch typed["type"] { + case "image_url": + imageRef = typed["image_url"] + case "input_image": + imageRef = typed["image_url"] + case "image": + source, _ := typed["source"].(map[string]any) + if source["type"] != "url" { + return nil, BadAuthRequest("规划图片必须使用资源占位") + } + imageRef = source["url"] + } + if object, ok := imageRef.(map[string]any); ok { + imageRef = object["url"] + } + if imageRef != nil { + ref, ok := imageRef.(string) + if !ok || references[ref] == "" { + return nil, BadAuthRequest("规划图片不在获准资源清单") + } + } + if raw, ok := typed["fileData"].(map[string]any); ok { + ref, _ := raw["fileUri"].(string) + if references[ref] == "" { + return nil, BadAuthRequest("规划图片不在获准资源清单") + } + } + if typed["inlineData"] != nil { + return nil, BadAuthRequest("规划图片不能持久化内嵌数据") + } + } + // Claude URL source and Gemini fileData need protocol-specific base64 envelopes. + if hydrate && typed["type"] == "url" { + if ref, ok := typed["url"].(string); ok && strings.HasPrefix(ref, "resource:") { + data, ok := references[ref] + if !ok { + return nil, BadAuthRequest("模型图片未获准") + } + mime, payload, ok := splitCreationDataURL(data) + if !ok { + return nil, BadAuthRequest("模型图片需要可读取的图片数据") + } + return map[string]any{"type": "base64", "media_type": mime, "data": payload}, nil + } + } + if hydrate { + if raw, ok := typed["fileData"].(map[string]any); ok { + if ref, ok := raw["fileUri"].(string); ok && strings.HasPrefix(ref, "resource:") { + data, ok := references[ref] + if !ok { + return nil, BadAuthRequest("模型图片未获准") + } + mime, payload, ok := splitCreationDataURL(data) + if !ok { + return nil, BadAuthRequest("模型图片需要可读取的图片数据") + } + delete(typed, "fileData") + typed["inlineData"] = map[string]any{"mimeType": mime, "data": payload} + } + } + } + for key, child := range typed { + next, e := visit(child) + if e != nil { + return nil, e + } + typed[key] = next + } + return typed, nil + } + return value, nil + } + root, err = visit(root) + if err != nil { + return input, err + } + b, err = json.Marshal(root) + if err != nil { + return input, err + } + var requests agentToolRequests + if err = json.Unmarshal(b, &requests); err != nil { + return input, err + } + input.AgentRequests = &requests + return input, nil +} +func splitCreationDataURL(value string) (string, string, bool) { + if !strings.HasPrefix(value, "data:") { + return "", "", false + } + parts := strings.SplitN(strings.TrimPrefix(value, "data:"), ";base64,", 2) + if len(parts) != 2 || !strings.HasPrefix(parts[0], "image/") { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/backend/internal/service/creation_agent_references_test.go b/backend/internal/service/creation_agent_references_test.go new file mode 100644 index 000000000..e4d36561b --- /dev/null +++ b/backend/internal/service/creation_agent_references_test.go @@ -0,0 +1,56 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCreationAgentImagesResolveOnlyApprovedResources(t *testing.T) { + input := canvasGenerationInput{ReferenceImages: []providerMedia{{StorageKey: "resource:allowed", DataURL: "data:image/png;base64,aW1hZ2U="}}, AgentRequests: &agentToolRequests{ + ChatCompletion: map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"type": "image_url", "image_url": map[string]any{"url": "resource:allowed"}}}}}}, + Claude: map[string]any{"messages": []any{map[string]any{"content": []any{map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "resource:allowed"}}}}}}, + Gemini: map[string]any{"contents": []any{map[string]any{"parts": []any{map[string]any{"fileData": map[string]any{"fileUri": "resource:allowed", "mimeType": "image/png"}}}}}}, + }} + if err := validateAgentResourcePlaceholders(input); err != nil { + t.Fatal(err) + } + resolved, err := resolveAgentResourcePlaceholders(input, true) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(resolved.AgentRequests) + text := string(b) + for _, want := range []string{"data:image/png;base64,aW1hZ2U=", `"type":"base64"`, `"inlineData"`} { + if !strings.Contains(text, want) { + t.Fatalf("missing protocol image: %s", want) + } + } + original, _ := json.Marshal(input.AgentRequests) + if strings.Contains(string(original), "base64") { + t.Fatal("persistable protocol mutated") + } + input.AgentRequests.Responses = map[string]any{"input": []any{map[string]any{"image_url": "resource:other"}}} + if err := validateAgentResourcePlaceholders(input); err == nil { + t.Fatal("unlisted resource allowed") + } +} + +func TestCreationManagedConfigSecretsAreNotPersisted(t *testing.T) { + s, _, id, guard := creationTestService(t) + request := creationTextRequest() + config := request.Input["config"].(map[string]any) + config["apiKey"] = "system-sentinel" + config["baseUrl"] = "/api/ai/system/channel" + item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "sanitized", Request: request}) + if err != nil { + t.Fatal(err) + } + stored, err := s.repo.CreationSubmission("user", id, item.ID) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stored.RequestJSON, "system-sentinel") || strings.Contains(stored.RequestJSON, "baseUrl") { + t.Fatal("sentinel config persisted") + } +} diff --git a/backend/internal/service/creation_canvas.go b/backend/internal/service/creation_canvas.go new file mode 100644 index 000000000..2ab841a8a --- /dev/null +++ b/backend/internal/service/creation_canvas.go @@ -0,0 +1,581 @@ +package service + +import ( + "encoding/json" + "reflect" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +type CreationCanvasOp struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + NodeType string `json:"nodeType,omitempty"` + Title string `json:"title,omitempty"` + Position map[string]any `json:"position,omitempty"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Patch map[string]any `json:"patch,omitempty"` + FromNodeID string `json:"fromNodeId,omitempty"` + ToNodeID string `json:"toNodeId,omitempty"` + FromHandleID string `json:"fromHandleId,omitempty"` + ToHandleID string `json:"toHandleId,omitempty"` + IDs []string `json:"ids,omitempty"` +} + +func validateCreationOps(ops []CreationCanvasOp) error { + if len(ops) == 0 || len(ops) > 100 { + return BadAuthRequest("方案必须包含 1 到 100 项明确画布操作") + } + if err := validateCreationJSON(ops); err != nil { + return err + } + ids := map[string]bool{} + for _, op := range ops { + switch op.Type { + case "add_node": + if op.ID == "" || ids[op.ID] { + return BadAuthRequest("新增节点必须使用不重复的稳定 ID") + } + ids[op.ID] = true + switch op.NodeType { + case "image", "video", "text", "markdown", "frame", "script": + default: + return BadAuthRequest("该节点类型不在本期创作范围") + } + case "update_node": + if op.ID == "" { + return BadAuthRequest("更新节点缺少 ID") + } + for key := range op.Patch { + switch key { + case "title", "position", "width", "height", "metadata": + default: + return BadAuthRequest("方案包含不支持的节点更新字段") + } + } + case "connect_nodes": + if op.ID == "" || op.FromNodeID == "" || op.ToNodeID == "" { + return BadAuthRequest("连线必须有稳定 ID 和两个端点") + } + case "select_nodes": + default: + return BadAuthRequest("创作方案仅允许新增、连线、更新和选择节点") + } + } + return nil +} +func mergeCreationMaps(left, right map[string]any) map[string]any { + out := map[string]any{} + for k, v := range left { + out[k] = v + } + for k, v := range right { + out[k] = v + } + return out +} +func creationDocument(raw string) (map[string]any, error) { + var doc map[string]any + err := json.Unmarshal([]byte(raw), &doc) + if doc == nil && err == nil { + return nil, BadAuthRequest("画布文档格式无效") + } + return doc, err +} + +func creationApprovalBaseline(raw string, ops []CreationCanvasOp) (string, error) { + doc, err := creationDocument(raw) + if err != nil { + return "", err + } + nodes, err := creationObjects(doc["nodes"]) + if err != nil { + return "", err + } + selected := []any{} + for _, op := range ops { + if op.Type != "update_node" { + continue + } + node := nodes[op.ID] + if node == nil { + return "", creationConflict("待修改节点不存在") + } + baseline := map[string]any{"id": op.ID} + for key := range op.Patch { + if key != "metadata" { + baseline[key] = node[key] + } + } + metadata, _ := node["metadata"].(map[string]any) + patch, _ := op.Patch["metadata"].(map[string]any) + saved := map[string]any{} + for key := range mergeCreationMaps(patch, op.Metadata) { + saved[key] = metadata[key] + } + baseline["metadata"] = saved + selected = append(selected, baseline) + } + value := map[string]any{"nodes": selected} + if err = validateCreationJSON(value); err != nil { + return "", err + } + b, err := json.Marshal(value) + return string(b), err +} +func (s *Service) CreationCanvasSnapshot(userID, id string) (map[string]any, error) { + run, err := s.repo.CreationRun(userID, id) + if err != nil { + return nil, creationError(err) + } + canvas, err := s.repo.CanvasProjectForUser(userID, run.CanvasID) + if err != nil { + return nil, creationError(err) + } + doc, err := creationDocument(canvas.PayloadJSON) + return map[string]any{"document": doc, "snapshotHash": creationHash(doc)}, err +} +func (s *Service) CreateRunCanvas(userID, id string, req CreationRequest) (map[string]any, error) { + s.storageMu.Lock() + defer s.storageMu.Unlock() + var out map[string]any + err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error { + if err := validateCreationGuard(run, req.CreationGuard); err != nil { + return err + } + if run.Status == "paused" || run.Status == "cancelled" { + return creationConflict("请先恢复创作任务") + } + if run.ApprovedAt == nil { + return creationConflict("请先确认方案") + } + if run.CanvasID != "" { + if _, err := repo.CanvasProjectForUser(userID, run.CanvasID); err != nil { + return creationConflict("已关联画布失效,请核对后继续") + } + out = map[string]any{"run": creationRunOutput(*run), "canvasId": run.CanvasID} + return nil + } + now := time.Now() + cid := newID() + doc := map[string]any{"id": cid, "title": "智能创作", "createdAt": now.Format(time.RFC3339Nano), "updatedAt": now.Format(time.RFC3339Nano), "nodes": []any{}, "connections": []any{}, "chatSessions": []any{}, "activeChatId": nil, "backgroundMode": "dots", "showImageInfo": true, "viewport": map[string]any{"x": 0, "y": 0, "k": 1}, "directorScenes": []any{}} + raw, _ := json.Marshal(doc) + canvas := model.CanvasProject{ID: cid, UserID: userID, Title: "智能创作", PayloadJSON: string(raw), CreatedAt: now, UpdatedAt: now} + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + usage, err := repo.UserStorageUsage(userID) + if err != nil { + return err + } + if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", true, int64(len(raw)), policy.Resource); err != nil { + return err + } + if err = repo.CreateCreationCanvas(&canvas); err != nil { + return err + } + run.CanvasID = cid + run.Revision++ + run.Status = "waiting_canvas" + out = map[string]any{"run": creationRunOutput(*run), "canvasId": cid} + return nil + }) + return out, creationError(err) +} +func (s *Service) CommitCreationCanvas(userID, id string, req CreationRequest) (map[string]any, error) { + s.storageMu.Lock() + defer s.storageMu.Unlock() + if err := validateSyncedPayload(req.Document, "画布"); err != nil { + return nil, err + } + if err := validateCreationJSON(req.Document); err != nil { + return nil, err + } + if err := s.validateCanvasMediaAssets(userID, req.Document); err != nil { + return nil, err + } + doc, err := creationDocument(string(req.Document)) + if err != nil { + return nil, err + } + var out map[string]any + err = s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error { + if err := validateCreationGuard(run, req.CreationGuard); err != nil { + return err + } + if run.Status == "paused" || run.Status == "cancelled" { + return creationConflict("请先恢复创作任务") + } + if run.ApprovedAt == nil { + return creationConflict("当前方案尚未批准") + } + canvas, err := repo.CanvasProjectForUser(userID, run.CanvasID) + if err != nil { + return err + } + before, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return err + } + if creationHash(before) != req.ExpectedSnapshotHash { + return creationConflict("画布已变化,请重新读取后核对") + } + var ops []CreationCanvasOp + if err = json.Unmarshal([]byte(run.ApprovedOperationsJSON), &ops); err != nil { + return err + } + if err = validateCreationCanvasDiff(repo, userID, run, before, doc, ops); err != nil { + return err + } + previous := canvas.PayloadJSON + canvas.PayloadJSON = string(req.Document) + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + usage, err := repo.UserStorageUsage(userID) + if err != nil { + return err + } + if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(req.Document)-len(previous)), policy.Resource); err != nil { + return err + } + if err = repo.CompareSaveCreationCanvas(canvas, previous); err != nil { + return err + } + out = map[string]any{"snapshotHash": creationHash(doc)} + return nil + }) + return out, creationError(err) +} +func creationObjects(value any) (map[string]map[string]any, error) { + list, ok := value.([]any) + if !ok { + return nil, BadAuthRequest("画布节点或连线格式无效") + } + out := map[string]map[string]any{} + for _, raw := range list { + item, ok := raw.(map[string]any) + if !ok { + return nil, BadAuthRequest("画布对象格式无效") + } + id := stringValue(item["id"]) + if id == "" || out[id] != nil { + return nil, BadAuthRequest("画布对象 ID 缺失或重复") + } + out[id] = item + } + return out, nil +} +func validateCreationCanvasDiff(repo *repository.Repository, userID string, run *model.CreationRun, before, after map[string]any, ops []CreationCanvasOp) error { + baselineNodes := map[string]map[string]any{} + if run.ApprovedCanvasJSON != "" { + baseline, e := creationDocument(run.ApprovedCanvasJSON) + if e != nil { + return e + } + baselineNodes, e = creationObjects(baseline["nodes"]) + if e != nil { + return e + } + } + for key, value := range before { + if key == "nodes" || key == "connections" || key == "updatedAt" { + continue + } + if !reflect.DeepEqual(value, after[key]) { + return creationConflict("画布提交修改了方案外内容") + } + } + for key := range after { + if _, ok := before[key]; !ok && key != "updatedAt" { + return creationConflict("画布提交包含方案外字段") + } + } + oldNodes, err := creationObjects(before["nodes"]) + if err != nil { + return err + } + newNodes, err := creationObjects(after["nodes"]) + if err != nil { + return err + } + oldEdges, err := creationObjects(before["connections"]) + if err != nil { + return err + } + newEdges, err := creationObjects(after["connections"]) + if err != nil { + return err + } + add := map[string]CreationCanvasOp{} + updates := map[string][]CreationCanvasOp{} + edges := map[string]CreationCanvasOp{} + approved := map[string]bool{} + for _, op := range ops { + switch op.Type { + case "add_node": + add[op.ID] = op + approved[op.ID] = true + case "update_node": + updates[op.ID] = append(updates[op.ID], op) + approved[op.ID] = true + case "connect_nodes": + edges[op.ID] = op + } + } + for id, old := range oldNodes { + next := newNodes[id] + if next == nil { + return creationConflict("不允许删除已有节点") + } + if reflect.DeepEqual(old, next) { + continue + } + expected := mergeCreationMaps(old, nil) + metadata, _ := old["metadata"].(map[string]any) + metadata = mergeCreationMaps(metadata, nil) + for _, op := range updates[id] { + if baseline := baselineNodes[id]; baseline != nil { + for key, want := range op.Patch { + if key == "metadata" { + continue + } + if !reflect.DeepEqual(old[key], baseline[key]) && !reflect.DeepEqual(old[key], want) { + return creationConflict("节点已被手工编辑,请重新确认修改范围") + } + } + baselineMeta, _ := baseline["metadata"].(map[string]any) + patchMeta, _ := op.Patch["metadata"].(map[string]any) + for key, want := range mergeCreationMaps(patchMeta, op.Metadata) { + if !reflect.DeepEqual(metadata[key], baselineMeta[key]) && !reflect.DeepEqual(metadata[key], want) { + return creationConflict("节点内容已被手工编辑,请重新确认") + } + } + } + for key, value := range op.Patch { + if key == "metadata" { + m, _ := value.(map[string]any) + metadata = mergeCreationMaps(metadata, m) + } else { + expected[key] = value + } + } + metadata = mergeCreationMaps(metadata, op.Metadata) + } + expected["metadata"] = metadata + if reflect.DeepEqual(expected, next) { + continue + } + if !approved[id] { + return creationConflict("不能修改方案外节点") + } + actualMeta, _ := next["metadata"].(map[string]any) + withoutMeta := mergeCreationMaps(next, nil) + withoutMeta["metadata"] = metadata + if !reflect.DeepEqual(expected, withoutMeta) { + return creationConflict("节点字段超出批准范围") + } + if err := validateCreationResultMetadata(repo, userID, run.ID, id, metadata, actualMeta); err != nil { + return err + } + } + for id, node := range newNodes { + if oldNodes[id] != nil { + continue + } + op, ok := add[id] + if !ok { + return creationConflict("节点未获方案批准") + } + expected := creationAddedNode(op) + if !reflect.DeepEqual(expected, node) { + return creationConflict("新增节点参数与批准方案不同") + } + } + for id, edge := range oldEdges { + if !reflect.DeepEqual(edge, newEdges[id]) { + return creationConflict("不允许修改或删除已有连线") + } + } + for id, edge := range newEdges { + if oldEdges[id] != nil { + continue + } + op, ok := edges[id] + if !ok { + return creationConflict("连线未获批准") + } + expected := map[string]any{"id": id, "fromNodeId": op.FromNodeID, "toNodeId": op.ToNodeID} + if op.FromHandleID != "" { + expected["fromHandleId"] = op.FromHandleID + } + if op.ToHandleID != "" { + expected["toHandleId"] = op.ToHandleID + } + if !reflect.DeepEqual(expected, edge) || newNodes[op.FromNodeID] == nil || newNodes[op.ToNodeID] == nil { + return creationConflict("连线参数与批准范围不同") + } + } + return nil +} +func creationAddedNode(op CreationCanvasOp) map[string]any { + width, height, title := 340.0, 240.0, "Note" + metadata := map[string]any{"content": "", "status": "idle"} + switch op.NodeType { + case "image": + width, height, title = 720, 405, "图片" + case "video": + width, height, title = 720, 405, "视频" + case "markdown": + width, height, title = 420, 320, "Markdown" + case "frame": + width, height, title = 760, 520, "未命名背板" + metadata = map[string]any{"frame": map[string]any{"collapsed": false, "expandedWidth": float64(760), "expandedHeight": float64(520)}} + case "script": + width, height, title = 920, 360, "分镜脚本" + metadata = map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}} + case "text": + metadata["fontSize"] = float64(14) + } + if op.Width != nil { + width = *op.Width + } + if op.Height != nil { + height = *op.Height + } + if op.Title != "" { + title = op.Title + } + position := op.Position + if position == nil { + position = map[string]any{"x": float64(0), "y": float64(0)} + if op.X != nil { + position["x"] = *op.X + } + if op.Y != nil { + position["y"] = *op.Y + } + } + return map[string]any{"id": op.ID, "type": op.NodeType, "title": title, "position": position, "width": width, "height": height, "metadata": mergeCreationMaps(metadata, op.Metadata)} +} +func validateCreationResultMetadata(repo *repository.Repository, userID, runID, nodeID string, before, after map[string]any) error { + taskID := stringValue(after["taskId"]) + if taskID == "" { + taskID = stringValue(after["generationTaskId"]) + } + if taskID == "" { + return creationConflict("结果回写缺少真实任务") + } + items, err := repo.CreationSubmissions(userID, runID) + if err != nil { + return err + } + found := false + for _, item := range items { + if item.TaskID == nil || *item.TaskID != taskID { + continue + } + var request CreateTaskRequest + _ = json.Unmarshal([]byte(item.RequestJSON), &request) + if stringValue(request.Input["nodeId"]) == nodeID { + found = true + break + } + } + if !found { + return creationConflict("任务不属于当前创作节点") + } + task, err := repo.TaskForUser(userID, taskID) + if err != nil { + return err + } + if task.Status != model.TaskStatusSucceeded { + return creationConflict("生成任务尚未成功") + } + var result any + if err = json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { + return err + } + storageKey := stringValue(after["storageKey"]) + if !strings.HasPrefix(storageKey, "resource:") || !creationResultContains(result, []string{"storageKey"}, storageKey) { + return creationConflict("回写素材不是任务生成资源") + } + resource, err := repo.ResourceForUser(userID, strings.TrimPrefix(storageKey, "resource:")) + if err != nil { + return err + } + if resource.Status != model.ResourceStatusReady { + return creationConflict("任务资源尚未就绪") + } + for key := range before { + if _, ok := after[key]; !ok { + return creationConflict("结果回写不能移除已有字段") + } + } + for key, value := range after { + if reflect.DeepEqual(value, before[key]) { + continue + } + switch key { + case "taskId", "generationTaskId": + if value != taskID { + return creationConflict("任务引用不匹配") + } + case "status": + if value != "success" { + return creationConflict("结果状态不匹配") + } + case "assetId": + assets, e := repo.AssetsForUserIDs(userID, []string{stringValue(value)}) + if e != nil { + return e + } + if len(assets) != 1 || !documentReferencesResources(assets[0].PayloadJSON, map[string]struct{}{resource.ID: {}}) { + return creationConflict("素材记录不属于任务结果资源") + } + case "content", "storageKey", "naturalWidth", "naturalHeight", "durationMs", "bytes", "mimeType": + resourceValue := map[string]any{"content": "/api/resources/" + resource.ID + "/file", "storageKey": storageKey, "naturalWidth": float64(resource.Width), "naturalHeight": float64(resource.Height), "durationMs": float64(resource.DurationMs), "bytes": float64(resource.Size), "mimeType": resource.MimeType} + if reflect.DeepEqual(value, resourceValue[key]) { + continue + } + keys := map[string][]string{"content": {"url", "imageUrl", "videoUrl", "content"}, "storageKey": {"storageKey"}, "naturalWidth": {"width", "naturalWidth"}, "naturalHeight": {"height", "naturalHeight"}, "durationMs": {"durationMs"}, "bytes": {"bytes", "size"}, "mimeType": {"mimeType"}} + if !creationResultContains(result, keys[key], value) { + return creationConflict("回写资源字段不属于真实任务结果") + } + default: + return creationConflict("结果回写试图修改手工字段") + } + } + return nil +} +func creationResultContains(value any, keys []string, want any) bool { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + for _, allowed := range keys { + if key == allowed && reflect.DeepEqual(child, want) { + return true + } + } + if creationResultContains(child, keys, want) { + return true + } + } + case []any: + for _, child := range v { + if creationResultContains(child, keys, want) { + return true + } + } + } + return false +} diff --git a/backend/internal/service/creation_test.go b/backend/internal/service/creation_test.go new file mode 100644 index 000000000..8518db144 --- /dev/null +++ b/backend/internal/service/creation_test.go @@ -0,0 +1,417 @@ +package service + +import ( + "encoding/json" + "path/filepath" + "sync" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func creationTestService(t *testing.T) (*Service, *gorm.DB, string, CreationGuard) { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "creation.db")+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(database.Models()...); err != nil { + t.Fatal(err) + } + s := &Service{repo: repository.New(db), dataDir: t.TempDir()} + capabilityConfig := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-test")) + for _, item := range []any{&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Enabled: true, Name: "受控测试"}, &model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: capabilityConfig, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000}} { + if err = db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + detail, err := s.CreateCreationRun("user", CreationRequest{ClientKey: "client", State: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + id := detail.Run.ID + if _, err = s.ChangeCreationRun("user", id, "claim", CreationRequest{ExpectedEpoch: 0, CreationGuard: CreationGuard{Owner: "page"}}); err != nil { + t.Fatal(err) + } + return s, db, id, CreationGuard{ExecutionEpoch: 1, Owner: "page"} +} +func creationTextRequest() CreateTaskRequest { + return CreateTaskRequest{Type: "canvas_text", Prompt: "只输出测试", Model: "text-test", Input: map[string]any{"mode": "text", "prompt": "只输出测试", "config": map[string]any{"channelId": "channel", "model": "text-test"}}} +} + +func TestCreationStoryboardRequiresApprovalAndCanvasScope(t *testing.T) { + s, _, id, guard := creationTestService(t) + req := creationTextRequest() + req.Type = "agent_storyboard_rows" + req.Operation = "storyboard_rows" + item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "storyboard", Request: req}) + if err != nil { + t.Fatal(err) + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil { + t.Fatal("unapproved storyboard executed") + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err != nil { + t.Fatal(err) + } + task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}) + if err != nil || task.Type != "agent_storyboard_rows" { + t.Fatalf("professional storyboard not submitted: task=%v error=%v", task, err) + } + if err = validateCreationSubmissionScope(&model.CreationRun{CanvasID: "canvas", Status: "running"}, 0, CreateTaskRequest{Type: "agent_storyboard_rows", ProjectID: "other"}); err == nil { + t.Fatal("cross-canvas storyboard accepted") + } +} +func prepareApprovedCreation(t *testing.T, s *Service, id string, guard CreationGuard) *CreationSubmissionOutput { + t.Helper() + item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: creationTextRequest()}) + if err != nil { + t.Fatal(err) + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err != nil { + t.Fatal(err) + } + return item +} + +func TestCreationRunOwnershipCASAndFence(t *testing.T) { + s, _, id, guard := creationTestService(t) + if _, err := s.GetCreationRun("other", id); err == nil { + t.Fatal("cross-user read accepted") + } + if _, err := s.ChangeCreationRun("user", id, "save", CreationRequest{CreationGuard: guard, Revision: 1, Status: "running", State: map[string]any{}}); err == nil { + t.Fatal("stale revision accepted") + } + if _, err := s.ChangeCreationRun("user", id, "save", CreationRequest{CreationGuard: guard, Revision: 2, Status: "running", State: map[string]any{"approved": true}}); err != nil { + t.Fatal(err) + } + if _, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard}); err == nil { + t.Fatal("untrusted state approved canvas") + } + if _, err := s.ChangeCreationRun("user", id, "claim", CreationRequest{ExpectedEpoch: 1, CreationGuard: CreationGuard{Owner: "new-page"}}); err != nil { + t.Fatal(err) + } + if _, err := s.ChangeCreationRun("user", id, "heartbeat", CreationRequest{CreationGuard: guard}); err == nil { + t.Fatal("old owner renewed lease") + } + if _, err := s.ChangeCreationRun("user", id, "release", CreationRequest{CreationGuard: guard}); err == nil { + t.Fatal("old owner released new lease") + } +} + +func TestCreationSubmissionApprovalExpiryAndChangedQuote(t *testing.T) { + s, db, id, guard := creationTestService(t) + item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: creationTextRequest()}) + if err != nil { + t.Fatal(err) + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil { + t.Fatal("unapproved task executed") + } + changed := creationTextRequest() + changed.Prompt = "changed" + changed.Input["prompt"] = "changed" + if _, err = s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: changed}); err == nil { + t.Fatal("same item accepted different request") + } + if err = db.Model(&model.CreationSubmission{}).Where("id = ?", item.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err == nil { + t.Fatal("expired quote approved") + } + item, err = s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-2", Request: creationTextRequest()}) + if err != nil { + t.Fatal(err) + } + if err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier").Updates(map[string]any{"unit_price_microcredits": 200, "price_version": 2}).Error; err != nil { + t.Fatal(err) + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err == nil { + t.Fatal("changed quote approved") + } + var count int64 + db.Model(&model.Task{}).Count(&count) + if count != 0 { + t.Fatalf("unexpected tasks: %d", count) + } +} + +func TestCreationConcurrentExecuteChargesOnceAndRollback(t *testing.T) { + s, db, id, guard := creationTestService(t) + item := prepareApprovedCreation(t, s, id, guard) + if err := db.Callback().Create().Before("gorm:create").Register("creation_force_failure", func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Task" { + tx.AddError(creationConflict("injected failure")) + } + }); err != nil { + t.Fatal(err) + } + if _, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil { + t.Fatal("injected failure not propagated") + } + for _, table := range []any{&model.Task{}, &model.BillingOrder{}, &model.CreditLedgerEntry{}} { + var count int64 + db.Model(table).Count(&count) + if count != 0 { + t.Fatalf("partial transaction committed: %T %d", table, count) + } + } + var sub model.CreationSubmission + db.First(&sub, "id = ?", item.ID) + if sub.TaskID != nil || sub.ApprovedAt == nil { + t.Fatal("failed admission consumed approval") + } + if err := db.Callback().Create().Remove("creation_force_failure"); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + ids := make(chan string, 8) + errs := make(chan error, 8) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}) + if err != nil { + errs <- err + return + } + ids <- task.ID + }() + } + wg.Wait() + close(errs) + close(ids) + for err := range errs { + t.Error(err) + } + first := "" + for tid := range ids { + if first == "" { + first = tid + } + if first != tid { + t.Fatal("duplicate task") + } + } + for _, table := range []any{&model.Task{}, &model.BillingOrder{}, &model.CreditLedgerEntry{}} { + var count int64 + db.Model(table).Count(&count) + if count != 1 { + t.Fatalf("expected one %T, got %d", table, count) + } + } + var account model.CreditAccount + db.First(&account, "user_id = ?", "user") + if account.AvailableMicrocredits != 9900 || account.ReservedMicrocredits != 100 { + t.Fatalf("account = %#v", account) + } + db.Model(&model.CreationSubmission{}).Where("id = ?", item.ID).Update("expires_at", time.Now().Add(-time.Hour)) + if task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err != nil || task.ID != first { + t.Fatalf("consumed replay: %v %v", task, err) + } + db.Model(&model.Task{}).Where("id = ?", first).Update("status", model.TaskStatusFailed) + if _, err := s.RetryTask("user", first); err == nil { + t.Fatal("legacy retry bypass accepted") + } +} + +func TestCreationCanvasScopeAndStableCreate(t *testing.T) { + s, _, id, guard := creationTestService(t) + ops := []CreationCanvasOp{{Type: "add_node", ID: "node", NodeType: "text", Title: "计划", Position: map[string]any{"x": float64(0), "y": float64(0)}, Metadata: map[string]any{"content": "已批准"}}} + if _, err := s.ChangeCreationRun("user", id, "proposal-approve", CreationRequest{CreationGuard: guard, Revision: 2, ProposalVersion: 1, Proposal: json.RawMessage(`{"title":"计划"}`), Ops: ops}); err != nil { + t.Fatal(err) + } + one, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard}) + if err != nil { + t.Fatal(err) + } + two, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard}) + if err != nil || one["canvasId"] != two["canvasId"] { + t.Fatal("canvas creation not stable") + } + snapshot, err := s.CreationCanvasSnapshot("user", id) + if err != nil { + t.Fatal(err) + } + doc := snapshot["document"].(map[string]any) + doc["nodes"] = []any{creationAddedNode(ops[0])} + raw, _ := json.Marshal(doc) + if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err != nil { + t.Fatal(err) + } + if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err == nil { + t.Fatal("stale canvas hash accepted") + } + snapshot, _ = s.CreationCanvasSnapshot("user", id) + doc = snapshot["document"].(map[string]any) + doc["title"] = "unauthorized" + raw, _ = json.Marshal(doc) + if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err == nil { + t.Fatal("out-of-scope document accepted") + } +} + +func TestCreationResultWritesOnlyBoundResources(t *testing.T) { + s, db, id, _ := creationTestService(t) + sid := "sub-result" + taskID := "task-result" + request := creationTextRequest() + request.Input["nodeId"] = "image-node" + raw, _ := json.Marshal(request) + for _, item := range []any{&model.Resource{ID: "output", UserID: "user", Status: model.ResourceStatusReady, Kind: "media", MimeType: "image/png", Size: 123, Width: 720, Height: 1280}, &model.Asset{ID: "asset-output", UserID: "user", Kind: "image", PayloadJSON: `{"storageKey":"resource:output"}`}, &model.Task{ID: taskID, UserID: "user", CreationSubmissionID: &sid, Status: model.TaskStatusSucceeded, ResultJSON: `{"images":[{"storageKey":"resource:output","url":"/api/resources/output/file"}]}`}, &model.CreationSubmission{ID: sid, UserID: "user", RunID: id, ItemKey: "result", RequestJSON: string(raw), TaskID: &taskID}} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + before := map[string]any{"prompt": "保留手工提示词"} + after := mergeCreationMaps(before, map[string]any{"content": "/api/resources/output/file", "storageKey": "resource:output", "assetId": "asset-output", "generationTaskId": taskID, "status": "success", "naturalWidth": float64(720)}) + if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err != nil { + t.Fatal(err) + } + after["prompt"] = "覆盖" + if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err == nil { + t.Fatal("result changed manual prompt") + } + after["prompt"] = before["prompt"] + after["storageKey"] = "resource:other" + if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err == nil { + t.Fatal("unbound resource accepted") + } +} + +func TestCreationApprovedUpdateDoesNotOverwriteLaterManualEdit(t *testing.T) { + before := map[string]any{"id": "canvas", "nodes": []any{map[string]any{"id": "n", "type": "text", "title": "before", "metadata": map[string]any{"content": "original"}}}, "connections": []any{}} + ops := []CreationCanvasOp{{Type: "update_node", ID: "n", Metadata: map[string]any{"content": "approved"}}} + raw, _ := json.Marshal(before) + baseline, err := creationApprovalBaseline(string(raw), ops) + if err != nil { + t.Fatal(err) + } + run := &model.CreationRun{ApprovedCanvasJSON: baseline} + nodes := before["nodes"].([]any) + nodes[0].(map[string]any)["metadata"] = map[string]any{"content": "later manual edit"} + afterRaw, _ := json.Marshal(before) + var after map[string]any + _ = json.Unmarshal(afterRaw, &after) + after["nodes"].([]any)[0].(map[string]any)["metadata"] = map[string]any{"content": "approved"} + if err := validateCreationCanvasDiff(nil, "user", run, before, after, ops); err == nil { + t.Fatal("approved operation overwrote later manual edit") + } +} + +func TestCreationMediaScopeRejectsChangedModelSpecAndReferences(t *testing.T) { + now := time.Now() + ops := []CreationCanvasOp{{Type: "add_node", ID: "v", NodeType: "video", Metadata: map[string]any{"prompt": "短片", "model": "managed-video", "size": "9:16", "seconds": "15", "referenceNodeIds": []any{"ref"}}}} + b, _ := json.Marshal(ops) + run := &model.CreationRun{CanvasID: "canvas", Status: "running", ApprovedProposalVersion: 1, ApprovedProposalHash: "hash", ApprovedOperationsJSON: string(b), ApprovedAt: &now} + request := CreateTaskRequest{Type: "canvas_video", ProjectID: "canvas", Prompt: "短片", Model: "managed-video", Input: map[string]any{"nodeId": "v", "config": map[string]any{"size": "9:16", "videoSeconds": "15"}, "referenceImages": []any{map[string]any{"id": "ref"}}}} + if err := validateCreationSubmissionScope(run, 1, request); err != nil { + t.Fatal(err) + } + request.Input["config"].(map[string]any)["videoSeconds"] = "5" + if err := validateCreationSubmissionScope(run, 1, request); err == nil { + t.Fatal("duration silently changed") + } + request.Input["config"].(map[string]any)["videoSeconds"] = "15" + request.Model = "other" + if err := validateCreationSubmissionScope(run, 1, request); err == nil { + t.Fatal("model changed") + } + request.Model = "managed-video" + request.Input["referenceImages"] = []any{map[string]any{"id": "other"}} + if err := validateCreationSubmissionScope(run, 1, request); err == nil { + t.Fatal("reference changed") + } +} + +func TestCreationRefreshCreatesOneUnapprovedSuccessor(t *testing.T) { + s, db, id, guard := creationTestService(t) + old := prepareApprovedCreation(t, s, id, guard) + if err := db.Model(&model.CreationSubmission{}).Where("id = ?", old.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + stored, err := s.repo.CreationSubmission("user", id, old.ID) + if err != nil { + t.Fatal(err) + } + originalQuote := stored.QuoteJSON + originalApprovedAt := stored.ApprovedAt + // A changed server price must produce a new quote without modifying the old evidence. + if err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier").Updates(map[string]any{"unit_price_microcredits": 200, "price_version": 2}).Error; err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + results := make(chan *CreationSubmissionOutput, 6) + errs := make(chan error, 6) + for i := 0; i < 6; i++ { + wg.Add(1) + go func() { + defer wg.Done() + out, e := s.RefreshCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID, Request: CreateTaskRequest{Prompt: "must be ignored"}}) + if e != nil { + errs <- e + return + } + results <- out + }() + } + wg.Wait() + close(results) + close(errs) + for e := range errs { + t.Error(e) + } + var successor *CreationSubmissionOutput + for out := range results { + if successor == nil { + successor = out + } + if out.ID != successor.ID { + t.Fatal("refresh created duplicate successors") + } + } + if successor == nil { + t.Fatal("no successor") + } + if successor.ApprovedAt != nil || successor.TaskID != nil || successor.Quote.AmountMicrocredits != 200 || !successor.Quote.ExpiresAt.After(time.Now()) { + t.Fatalf("bad successor: %#v", successor) + } + stored, err = s.repo.CreationSubmission("user", id, old.ID) + if err != nil { + t.Fatal(err) + } + if stored.RevokedAt == nil || stored.QuoteJSON != originalQuote || !stored.ApprovedAt.Equal(*originalApprovedAt) { + t.Fatal("old quote evidence was changed") + } + var count int64 + db.Model(&model.CreationSubmission{}).Where("run_id = ?", id).Count(&count) + if count != 2 { + t.Fatalf("submission count = %d", count) + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID}); err == nil { + t.Fatal("old revoked quote executed") + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err == nil { + t.Fatal("refresh inherited old approval") + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{successor.ID}}); err != nil { + t.Fatal(err) + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err != nil { + t.Fatal(err) + } + if _, err = s.RefreshCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err == nil { + t.Fatal("consumed submission refreshed") + } + if _, err = s.RefreshCreationSubmission("other", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID}); err == nil { + t.Fatal("cross user refresh allowed") + } +} diff --git a/backend/internal/service/project_settings_test.go b/backend/internal/service/project_settings_test.go index af35f8bf9..06226d9b5 100644 --- a/backend/internal/service/project_settings_test.go +++ b/backend/internal/service/project_settings_test.go @@ -22,6 +22,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) { &model.CanvasProject{}, &model.StyleProfile{}, &model.ProjectAssetCandidate{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{}, &model.Task{}, &model.Session{}, &model.Message{}, &model.TaskLog{}, &model.Result{}, + &model.CreationRun{}, &model.CreationSubmission{}, &model.Announcement{}, &model.AnnouncementImageDraft{}, ); err != nil { t.Fatal(err) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index b7dc729d6..b23fb2a4d 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -48,6 +48,7 @@ type canvasTextOptions struct { } type agentToolRequests struct { + Canonical *canonicalAgentRequest `json:"canonical,omitempty"` Responses map[string]interface{} `json:"responses"` ChatCompletion map[string]interface{} `json:"chatCompletion"` Claude map[string]interface{} `json:"claude"` @@ -421,6 +422,10 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string return runImageTask(ctx, input) case "text": if input.AgentRequests != nil { + input, err = resolveAgentResourcePlaceholders(input, true) + if err != nil { + return nil, err + } return runAgentToolTask(ctx, input) } result, taskErr := runTextTask(ctx, input) diff --git a/backend/internal/service/provider_agent_protocol.go b/backend/internal/service/provider_agent_protocol.go new file mode 100644 index 000000000..0f8d7e7eb --- /dev/null +++ b/backend/internal/service/provider_agent_protocol.go @@ -0,0 +1,391 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// The browser sends one protocol-neutral conversation. Only the selected +// provider body is materialized; declarative plugins retain their request paths. +type canonicalAgentRequest struct { + Messages []map[string]interface{} `json:"messages"` + Tools []map[string]interface{} `json:"tools"` + ToolChoice interface{} `json:"toolChoice"` + SystemPrompt string `json:"systemPrompt"` +} + +func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerConfig, declarative bool) (*agentToolRequests, error) { + if len(source.Messages) == 0 { + return nil, errors.New("画布 Agent 请求缺少会话内容") + } + for _, message := range source.Messages { + if stringField(message, "type") == "function_call" { + if stringField(message, "call_id") == "" || stringField(message, "name") == "" { + return nil, errors.New("画布 Agent 工具调用缺少标识或名称") + } + if _, ok := message["arguments"].(string); !ok { + return nil, errors.New("画布 Agent 工具调用参数无效") + } + continue + } + if stringField(message, "type") != "" { + return nil, errors.New("画布 Agent 会话条目类型无效") + } + switch stringField(message, "role") { + case "system", "user", "assistant": + case "tool": + if stringField(message, "tool_call_id") == "" { + return nil, errors.New("画布 Agent 工具结果缺少调用标识") + } + if _, ok := message["content"].(string); !ok { + return nil, errors.New("画布 Agent 工具结果内容无效") + } + default: + return nil, errors.New("画布 Agent 会话角色无效") + } + if err := validateCanonicalAgentContent(message["content"]); err != nil { + return nil, err + } + } + for _, tool := range source.Tools { + function, ok := tool["function"].(map[string]interface{}) + if !ok || stringField(function, "name") == "" { + return nil, errors.New("画布 Agent 工具定义缺少名称") + } + } + switch choice := source.ToolChoice.(type) { + case string: + if choice != "auto" && choice != "required" { + return nil, errors.New("画布 Agent 工具选择无效") + } + case map[string]interface{}: + if stringField(choice, "type") != "function" || stringField(choice, "name") == "" { + return nil, errors.New("画布 Agent 工具选择缺少名称") + } + default: + return nil, errors.New("画布 Agent 请求缺少工具选择") + } + messages := source.Messages + if prompt := strings.TrimSpace(source.SystemPrompt); prompt != "" { + present := false + for _, message := range messages { + if stringField(message, "role") == "system" && strings.TrimSpace(stringField(message, "content")) == prompt { + present = true + break + } + } + if !present { + messages = append([]map[string]interface{}{{"role": "system", "content": prompt}}, messages...) + } + } + request := *source + request.Messages = messages + result := &agentToolRequests{} + if declarative { + result.ChatCompletion = canonicalAgentChatBody(&request, false) + result.Responses = canonicalAgentResponsesBody(&request) + result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true)) + result.Claude["model"] = config.Model + result.Gemini = canonicalAgentGeminiBody(&request) + return result, nil + } + switch config.InterfaceType { + case string(model.ChannelInterfaceOpenAIResponse): + result.Responses = canonicalAgentResponsesBody(&request) + case string(model.ChannelInterfaceClaudeAPI): + result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true)) + default: + result.ChatCompletion = canonicalAgentChatBody(&request, false) + } + return result, nil +} + +func canonicalAgentChatBody(source *canonicalAgentRequest, claude bool) map[string]interface{} { + messages := make([]interface{}, 0, len(source.Messages)) + format := "chat" + if claude { + format = "claude" + } + for index := 0; index < len(source.Messages); { + message := source.Messages[index] + if stringField(message, "type") == "function_call" { + calls := []interface{}{} + for index < len(source.Messages) && stringField(source.Messages[index], "type") == "function_call" { + call := source.Messages[index] + arguments := call["arguments"] + if claude { + arguments = canonicalAgentJSONObject(arguments) + } + calls = append(calls, map[string]interface{}{ + "id": call["call_id"], "type": "function", + "function": map[string]interface{}{"name": call["name"], "arguments": arguments}, + }) + index++ + } + messages = append(messages, map[string]interface{}{"role": "assistant", "content": nil, "tool_calls": calls}) + continue + } + converted := map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], format)} + if claude && stringField(message, "role") == "system" { + converted["content"] = canonicalAgentText(message["content"]) + } + if stringField(message, "role") == "tool" { + converted["tool_call_id"] = message["tool_call_id"] + } + messages = append(messages, converted) + index++ + } + tools := make([]interface{}, 0, len(source.Tools)) + for _, tool := range source.Tools { + tools = append(tools, tool) + } + choice := source.ToolChoice + if named, ok := choice.(map[string]interface{}); ok { + choice = map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": named["name"]}} + } + return map[string]interface{}{"messages": messages, "tools": tools, "tool_choice": choice, "parallel_tool_calls": false} +} + +func canonicalAgentResponsesBody(source *canonicalAgentRequest) map[string]interface{} { + messages := make([]interface{}, 0, len(source.Messages)) + for _, message := range source.Messages { + switch { + case stringField(message, "type") == "function_call": + messages = append(messages, map[string]interface{}{ + "type": "function_call", "call_id": message["call_id"], "name": message["name"], "arguments": message["arguments"], + }) + case stringField(message, "role") == "tool": + messages = append(messages, map[string]interface{}{"type": "function_call_output", "call_id": message["tool_call_id"], "output": message["content"]}) + default: + messages = append(messages, map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], "responses")}) + } + } + tools := make([]interface{}, 0, len(source.Tools)) + for _, tool := range source.Tools { + function, _ := tool["function"].(map[string]interface{}) + converted := cloneStringAnyMap(function) + converted["type"] = "function" + tools = append(tools, converted) + } + return map[string]interface{}{"input": messages, "tools": tools, "tool_choice": source.ToolChoice, "parallel_tool_calls": false} +} + +func canonicalAgentGeminiBody(source *canonicalAgentRequest) map[string]interface{} { + contents := make([]interface{}, 0, len(source.Messages)) + var system []string + callNames := map[string]string{} + for _, message := range source.Messages { + role := "user" + var parts interface{} + switch { + case stringField(message, "type") == "function_call": + id, name := stringField(message, "call_id"), stringField(message, "name") + callNames[id] = name + part := map[string]interface{}{"functionCall": map[string]interface{}{"id": id, "name": name, "args": canonicalAgentJSONObject(message["arguments"])}} + if signature := stringField(message, "thoughtSignature"); signature != "" { + part["thoughtSignature"] = signature + } + role, parts = "model", []interface{}{part} + case stringField(message, "role") == "system": + if text := canonicalAgentText(message["content"]); text != "" { + system = append(system, text) + } + continue + case stringField(message, "role") == "tool": + id := stringField(message, "tool_call_id") + name := callNames[id] + if name == "" { + name = "tool_result" + } + parts = []interface{}{map[string]interface{}{"functionResponse": map[string]interface{}{ + "id": id, "name": name, "response": map[string]interface{}{"result": canonicalAgentJSONValue(message["content"])}, + }}} + default: + if stringField(message, "role") == "assistant" { + role = "model" + } + parts = canonicalAgentContent(message["content"], "gemini") + } + contents = append(contents, map[string]interface{}{"role": role, "parts": parts}) + } + body := map[string]interface{}{"contents": contents} + if len(system) > 0 { + body["systemInstruction"] = map[string]interface{}{"parts": []interface{}{map[string]interface{}{"text": strings.Join(system, "\n\n")}}} + } + if len(source.Tools) > 0 { + declarations := make([]interface{}, 0, len(source.Tools)) + for _, tool := range source.Tools { + function, _ := tool["function"].(map[string]interface{}) + declaration := cloneStringAnyMap(function) + delete(declaration, "strict") + declarations = append(declarations, declaration) + } + choice := map[string]interface{}{"mode": "AUTO"} + if named, ok := source.ToolChoice.(map[string]interface{}); ok { + choice["mode"], choice["allowedFunctionNames"] = "ANY", []interface{}{named["name"]} + } else if source.ToolChoice == "required" { + choice["mode"] = "ANY" + } + body["tools"] = []interface{}{map[string]interface{}{"functionDeclarations": declarations}} + body["toolConfig"] = map[string]interface{}{"functionCallingConfig": choice} + } + return body +} + +func canonicalAgentContent(content interface{}, format string) interface{} { + items, array := content.([]interface{}) + if !array { + if content == nil { + content = "" + } + if format == "gemini" { + return []interface{}{map[string]interface{}{"text": content}} + } + return content + } + result := make([]interface{}, 0, len(items)) + for _, value := range items { + item, _ := value.(map[string]interface{}) + kind := stringField(item, "type") + if kind == "text" { + part := map[string]interface{}{"text": item["text"]} + if format != "gemini" { + part["type"] = "text" + if format == "responses" { + part["type"] = "input_text" + } + } + result = append(result, part) + continue + } + file, _ := item[kind].(map[string]interface{}) + url := stringField(file, "url") + mimeType := stringField(file, "mimeType") + if kind == "image_url" { + mimeType = "image/png" + } + inlineMIME, inlineData, inline := canonicalAgentDataURL(url) + var part map[string]interface{} + switch format { + case "responses": + part = map[string]interface{}{"type": "input_image", "image_url": url} + if kind == "file_url" { + part = map[string]interface{}{"type": "input_file", "filename": file["name"]} + if strings.HasPrefix(url, "data:") { + part["file_data"] = url + } else { + part["file_url"] = url + } + } + case "claude": + if inline { + blockType := "image" + if kind == "file_url" && inlineMIME == "application/pdf" { + blockType = "document" + } + part = map[string]interface{}{"type": blockType, "source": map[string]interface{}{"type": "base64", "media_type": inlineMIME, "data": inlineData}} + } else if kind == "image_url" { + part = map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "url", "url": url}} + } else { + part = map[string]interface{}{"type": "text", "text": stringField(file, "name") + ": " + url} + } + case "gemini": + if inline { + part = map[string]interface{}{"inlineData": map[string]interface{}{"mimeType": inlineMIME, "data": inlineData}} + } else { + part = map[string]interface{}{"fileData": map[string]interface{}{"fileUri": url, "mimeType": mimeType}} + } + default: + part = item + if kind == "file_url" { + converted := map[string]interface{}{"filename": file["name"]} + if strings.HasPrefix(url, "data:") { + converted["file_data"] = url + } else { + converted["file_url"] = url + } + part = map[string]interface{}{"type": "file", "file": converted} + } + } + result = append(result, part) + } + return result +} + +func canonicalAgentDataURL(value string) (string, string, bool) { + if !strings.HasPrefix(value, "data:") { + return "", "", false + } + mimeType, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ";base64,") + return mimeType, data, ok && mimeType != "" && data != "" +} + +func validateCanonicalAgentContent(content interface{}) error { + if _, ok := content.(string); ok { + return nil + } + parts, ok := content.([]interface{}) + if !ok { + return errors.New("画布 Agent 消息内容无效") + } + for _, value := range parts { + part, _ := value.(map[string]interface{}) + switch kind := stringField(part, "type"); kind { + case "text": + if _, ok := part["text"].(string); !ok { + return errors.New("画布 Agent 文本内容无效") + } + case "image_url", "file_url": + file, _ := part[kind].(map[string]interface{}) + if stringField(file, "url") == "" { + return errors.New("画布 Agent 素材地址为空") + } + if kind == "file_url" && (stringField(file, "name") == "" || stringField(file, "mimeType") == "") { + return errors.New("画布 Agent 文件缺少名称或类型") + } + default: + return errors.New("画布 Agent 消息内容类型无效") + } + } + return nil +} + +func canonicalAgentJSONValue(value interface{}) interface{} { + if raw, ok := value.(string); ok { + var parsed interface{} + if json.Unmarshal([]byte(raw), &parsed) == nil { + return parsed + } + } + return value +} + +func canonicalAgentJSONObject(value interface{}) map[string]interface{} { + if parsed, ok := canonicalAgentJSONValue(value).(map[string]interface{}); ok { + return parsed + } + return map[string]interface{}{} +} + +func canonicalAgentText(content interface{}) string { + if text, ok := content.(string); ok { + return text + } + var texts []string + if parts, ok := content.([]interface{}); ok { + for _, value := range parts { + part, _ := value.(map[string]interface{}) + if stringField(part, "type") == "text" { + texts = append(texts, stringField(part, "text")) + } else if image, ok := part["image_url"].(map[string]interface{}); ok { + texts = append(texts, stringField(image, "url")) + } else if file, ok := part["file_url"].(map[string]interface{}); ok { + texts = append(texts, stringField(file, "name")+": "+stringField(file, "url")) + } + } + } + return strings.Join(texts, "\n") +} diff --git a/backend/internal/service/provider_text.go b/backend/internal/service/provider_text.go index fd4aa42b0..20d71665d 100644 --- a/backend/internal/service/provider_text.go +++ b/backend/internal/service/provider_text.go @@ -19,6 +19,16 @@ import ( ) func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + // 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议, + // 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。 + if input.AgentRequests != nil && input.AgentRequests.Canonical != nil { + _, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType) + requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative) + if err != nil { + return nil, err + } + input.AgentRequests = requests + } if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { return runDeclarativeAgentTask(ctx, input, adapter) } @@ -33,6 +43,9 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str path = "/responses" protocol = "responses" } else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) { + if input.AgentRequests.Claude != nil { + request = input.AgentRequests.Claude + } path = "/messages" protocol = "claude-api" } @@ -40,7 +53,7 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str return nil, errors.New("画布 Agent 工具请求缺少协议参数") } body := cloneStringAnyMap(request) - if protocol == "claude-api" { + if protocol == "claude-api" && input.AgentRequests.Claude == nil { body = claudeAgentBody(body) } body["model"] = input.Config.Model diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index eeaafeb2d..c40e1b03b 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -83,17 +83,18 @@ type CreateSessionRequest struct { } 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"` - TraceID string `json:"-"` - RequestID string `json:"-"` + creationPrepare *creationTaskPreparation + 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"` + TraceID string `json:"-"` + RequestID string `json:"-"` } type SessionDetail struct { diff --git a/backend/internal/service/storage_quota.go b/backend/internal/service/storage_quota.go index 750a2dea9..ca1532667 100644 --- a/backend/internal/service/storage_quota.go +++ b/backend/internal/service/storage_quota.go @@ -97,7 +97,11 @@ func validateStructuredReplacementQuotaWithPolicy(usage repository.UserStorageUs func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error { s.storageMu.Lock() defer s.storageMu.Unlock() - usage, err := s.repo.UserStorageUsage(task.UserID) + return createTaskWithStorageQuotaRepository(s.repo, task, billingOrder, policy) +} + +func createTaskWithStorageQuotaRepository(repo *repository.Repository, task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error { + usage, err := repo.UserStorageUsage(task.UserID) if err != nil { return err } @@ -106,9 +110,9 @@ func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *m return err } if billingOrder != nil { - return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit) + return repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit) } - return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit) + return repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit) } // 任务完成会同时扩张任务历史和 Agent 会话数据,必须在同一临界区核算并原子写入。 diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index b3872b797..91783fb9e 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -103,6 +103,15 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if err != nil { return nil, err } + if req.creationPrepare != nil { + encoded, encodeErr := json.Marshal(normalizedInput) + if encodeErr != nil { + return nil, encodeErr + } + task.InputJSON = string(encoded) + req.creationPrepare.Order = billingOrder + return &task, nil + } if err := s.protectTaskSecrets(normalizedInput); err != nil { return nil, err } diff --git a/backend/internal/service/task_lifecycle.go b/backend/internal/service/task_lifecycle.go index 273d1f498..b4046f4b9 100644 --- a/backend/internal/service/task_lifecycle.go +++ b/backend/internal/service/task_lifecycle.go @@ -38,6 +38,9 @@ func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.T if err != nil { return nil, err } + if task.CreationSubmissionID != nil { + return nil, creationConflict("智能创作重做需要新的报价批准,请回到创作会话继续") + } if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled { return nil, errors.New("only failed or cancelled tasks can be retried") } diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index b2cb65917..a843a64cd 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 9。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置。 +当前结构版本为 10。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -74,6 +74,9 @@ description: 影策后端核心持久化表与状态边界 | `shot_asset_references` | 将镜头绑定到具体资产版本 | 修改引用时只使该镜头的下游产物过期,不影响其他镜头 | | `shot_artifacts` | 保存分镜图、动作预演、首尾帧、视频等版本化产物 | `selected` 表示当前采用版本;上游变化后状态转为 `stale`,历史资源继续保留 | | `production_task_links` | 显式记录生成任务所属的领域项目、画布、章节、镜头和工作流步骤 | `Task.project_id` 仍兼容画布任务身份,不再用它单独推断短剧领域归属 | +| `creation_runs` | 保存创作 Agent 的服务端权威状态、执行租约、已批准提案和画布快照 | `(user_id, client_key)` 唯一;客户端会话只作为输入,修订号、执行 epoch 和租约由 service 强校验,不能据前端状态跳过审批或重复执行 | +| `creation_submissions` | 保存创作提案中每个生成项的请求、报价签名、审批和任务绑定 | `(run_id, item_key)` 与 `task_id` 分别唯一;相同提交只允许创建一个任务,过期、撤销或提案哈希不一致时拒绝执行 | +| `tasks.creation_submission_id` | 将生成任务反向绑定到已批准的创作提交 | 字段可空以兼容普通任务和旧数据;非空值唯一,阻止同一提交产生多个任务 | | `workflow_template_versions` / `workflow_instances` / `workflow_step_instances` | 保存短剧生产模板和项目、章节实例 | 短剧分镜工作流 v2 顺序为剧情、资产、分镜、动作预演、视频、交付 | 短剧项目状态以这些领域表为准。自由画布可以承载探索节点,但画布 metadata 不作为生产完成度、镜头版本或交付可用性的真相源。 diff --git a/docs/content/docs/backend/creative-agent-runtime.mdx b/docs/content/docs/backend/creative-agent-runtime.mdx new file mode 100644 index 000000000..c684469df --- /dev/null +++ b/docs/content/docs/backend/creative-agent-runtime.mdx @@ -0,0 +1,112 @@ +--- +title: 智能创作会话与执行 +description: 第一期在线创作的问答、审批、任务幂等与画布提交边界 +--- + +# 智能创作会话与执行 + +画布沿用原 `CanvasAssistantPanel` 的会话、输入框、模型选择、素材引用、Skills 和在线工具循环。`creative_respond` 是原工具目录中的新能力:模型自主判断是否需要补问或方案,同批出现该工具时直接形成等待屏障,不执行其余工具。普通对话可以直接输出文字,原画布读写工具继续保留。 + +`CanvasCreativeInteraction` 只渲染原助手消息中的交互卡片,没有独立输入框或会话列表。`CreativeAgentController.presentInteraction` 接收原 Agent 已返回的内容,负责校验、保存、批准和媒体执行,不再次请求模型。用户提交回答后回到原 Agent 工具循环,结合已确认需求继续规划。 + +首页独立“智能创作”入口已撤回;结构化创作从原画布助手使用。画布里的批准作用于当前真实画布。首页原会话通过下述接续入口进入画布。 + +## 首页会话接续 + +首页/创作页沿用原文本与媒体创作逻辑,新增工具栏“画布中继续”,原媒体结果入口也接到同一流程。`creation-canvas-conversation.ts` 将当前对话转换为画布原 `CanvasAssistantSession`,保留用户/助手消息、生成状态、任务 ID、模型规格及资料/技能引用目录;内部事实只写入 detail,并在后续模型请求中提供,不向用户展示大段协议 JSON。 + +来源会话记录 `canvasId`,画布 session 和来源消息使用稳定 ID。重复进入加载已关联画布,更新来源消息并保留画布中新增的对话与执行状态。首次进入把会话作为初始画布内容保存,源会话与画布均使用已有账号隔离存储;云端同步失败时明确提示仅本机保存。来源绑定目前随首页本机历史保存,不宣称跨设备双向同步或统一服务端任务 run。 + +媒体结果及可匹配的上传参考图/视频复用原 asset handoff,在画布页面加载后插入,已有 assetId 不重复插入。生成尚未完成时要求完成或停止;生成结果尚未对应到已保存资产时明确等待。音频和文件保留资料目录,此次不新增对应节点导入能力。会话合并是单向接续,画布中新对话不会反向复制首页。 + +画布通过 `conversation` 查询参数选择已保存的会话并打开原在线助手;参数只携带 ID,消费后移除,不自动发送模型请求、批准方案或重放任务。首页没有引入任何画布操作工具;需要继续执行时在画布原输入框输入要求。后续可在此基础上接电商工作台及更完整的角色上下文。 + +## 简化交互 + +模型根据相邻明确建议理解“可以/好的”,在当前任务范围内承接用户授权;常规创意选择、读取、已授权编辑和技术纠错不重复询问。请求上下文携带实际工具确认开关:关闭时直接调用授权范围内的普通画布工具,开启时交给原工具确认 UI,不增加前置口头确认。继续创作入口发送继续执行语义,默认回复简短。结构化方案及具体费用仍以程序批准记录为准,当前没有把自然语言自动转换为这两类批准。 + +前端仅传递 `input.agentRequests.canonical`,其中包括标准 `messages`、`tools`、`toolChoice`、`systemPrompt`。后端完成真实模型路由与资源归属检查、素材读取后,才转换实际供应商协议。普通内建协议只构造所选请求;声明式插件所需的四套字段仅在执行内存中展开,不写回任务正文。工具选择降级仍沿用原路径。 + +初始上下文只提供图片目录;模型通过只读 `canvas_inspect_image` 读取当前画布真实图片节点。构图预览最长边不超过 1600 像素、单图不超过 256 KiB;`original` 使用原图,`crop` 按归一化区域读取原始像素 PNG。单轮图片总预算为 6 MiB,超出则要求分批或缩小局部,不能悄悄降低细节精度。工具历史保存节点和读取方式,像素仅加入模型请求,新一批看图时释放上一批像素。原素材不变,读取事实不等于模型已完成视觉判断。 + +看图派生的预览与裁剪结果先上传资源存储,以内容摘要作为上传幂等键;原图复用已有账号资源。标准消息的图片地址只允许 `resource:` 引用,任务准备入口从这些引用生成去重的 `referenceImages` 清单。后端按当前用户校验资源归属和就绪状态,执行时才将引用替换为供应商需要的图片数据或临时地址;任务持久化正文不包含 Base64。上传失败时明确返回工具错误,不继续发送缺失素材的模型请求。 + +初始文本上下文按 192 KiB / 粗估 48,000 token 预算组织;当前要求、系统指令和已确认需求不截断,旧对话按完整会话组省略并告知模型。已落地方案使用结构摘要,待确认/失败修正方案保留原文。实时工具调用与结果保持配对,不做截断;包含工具目录的完整文本交换另有 384 KiB / 粗估 96,000 token 上限。估算不等于供应商 tokenizer 或真实模型窗口,达到预算时保留执行事实并提示缩小范围。完整工具任务发送前检查 15 MiB,后端 `/tasks` 的 16 MiB 限制不变。 + +当前消息自动恢复执行租约,连接异常后在后台重试,不再要求用户反复点击接管。历史消息只读,不竞争执行权;方案批准和具体费用批准仍保留,自动恢复连接不会自动批准或重新生成。 + +历史问答、已确认方案和报价默认折叠。当前阶段突出一个主操作,修改放在旁边,作品重做等低频操作收进“更多”;报价明细按需展开。布局沿用系统主题、间距和控件 token。点击修改回到原输入框,当前方案修改会撤销未消费的批准;已提交生成须先同步结果再调整。停止只阻止后续制作,画布撤销沿用原助手的撤销能力,不取消生成任务或退费。过期报价点击确认时先更新价格,仍须再次确认。 + +通用 Agent 根据所在页面提供角色、技能与素材上下文;本次只调整画布助手,不新增首页或其他工作台入口。 + +后端入口为 `handler/creation.go`,业务和权限在 `service/creation.go`、`creation_canvas.go`、`creation_agent_references.go`,持久化在 `repository/creation.go`。两张创作表和 Task 的提交关联见[数据库说明](backend-database.mdx)。 + +## 正式场景配置 + +内置场景统一维护在 `web/src/lib/creation/creative-scenarios.ts`,由 `creativeScenarioPrompt` 注入原在线 Agent 请求,模型自动识别场景,无需另选“智能创作”。当前属于随应用版本发布的内置策略,不是后台在线编辑器。已提交或已报价的请求保留原快照,发布配置后新的规划请求才采用新规则。 + +| 场景 | 主要业务 | 关键配置 | +| --- | --- | --- | +| 通用创作 | 咨询、文字创作、跨行业创作与场景识别 | 交付目标、受众、平台、素材、数量与必要规格;按主要目标选择行业 | +| 短剧创作 | 原创故事、剧本改编、单集片段与预告 | 剧情、题材、角色、场景、道具、对白、时长与画幅;输出完整剧情和视听时序 | +| 营销创作 | 品牌海报、社交种草、促销文案与单段广告视频 | 商品、受众、卖点、钩子、品牌、真实优惠、文案、数量与视频时长 | +| 电商创作 | 商品主图、详情图、场景图、搭配展示与商品视频 | 商品/服装/模特素材角色、外观保留、展示角度、真实规格、数量和视频时长 | + +每个场景包含 `instruction`(角色与目标)、`fields`(需求字段白名单)、`questionGuides`(补问条件、问法和候选选项)、`proposalSections`(方案内容要求)及 `workflowRules`(节点和引用规则)。短剧展示名更新,持久化标识仍为 `short-film`。 + +模型先读取已知信息,再按条件选问,默认每轮 1~3 题,必要时最多 6 题;推荐选项须根据当前需求和真实模型能力调整。场景字段仅作为参考,允许通过安全字段校验的自定义需求;需求字段不能赋予审批或执行权限。用户已明确的题材、数量、比例、时长等不再询问。用户授权自主创意时可直接提方案,建议不能伪装成用户已确认信息。 + +`creative_respond.plan` 可以描述最多 12 个动态步骤及调整原因;`creative-plan.ts` 将其关联到真实问答、方案、画布与媒体状态,不接受模型填写完成状态。计划保存在原消息 state,并在下一轮保留已观察完成的步骤。未返回动态计划的旧消息继续采用原进度展示。专业指导可以跨场景组合,当前仍按原批准与媒体依赖执行,不等同于新任务级调度器。 + +已完成或暂停的交互提供“让助手分析结果与下一步”,用户点击后发起原文本模型请求,携带最近执行记录与当前画布。此操作不自动重跑媒体;新方案继续要求批准。结果是否可视觉检查取决于实际输入和模型能力,任务成功不等于画面质量检查通过。 + +`creative-agent-recovery.ts` 在原模型首轮与后续工具回复呈现前校验交互。错误及原始工具参数回到模型,最多两次自动修正,只开放创作交互工具;不能执行同批夹带的画布操作。模型可修正方案或提出动态问题;持续无效时展示恢复选择,原错误保留诊断日志。自动修正属于文本推理请求,沿用文本计费,不代表免费,也不替代媒体费用批准。取消时不再启动后续修正。 + +短剧只按真实内容需要生成缺失角色/场景/道具图;营销的海报版本分别体现不同传播角度;电商明确商品主体与可变背景。已有图片作为引用节点,不进入重复收费的生成项。只要文案时不自动附加媒体任务;缺少真实商品素材时不以虚构商品冒充实物。独立配音、合成和精确试衣不由场景提示词凭空赋予。 + +### 模型、素材与技能设置 + +场景不绑定固定模型或价格。部署中需要配置可用的系统受管文本模型;问答和方案使用工具调用,有图片资料时文本模型还需支持视觉理解。图片模型需配置真实支持的尺寸和参考图数量;视频模型需配置真实支持的时长、比例、参考图数量及声音能力。生成报价仍走现有模型计费,不能使用场景推荐值作为价格或能力依据。 + +用户继续通过原助手的模型选择、选中画布素材和技能入口提供上下文。Skills 仍由原在线 Agent 按现有运行时装载,不自动安装插件或绑定不存在的技能。没有额外行业 Skill 也会加载内置场景策略。 + +### 原历史会话中的持久化 + +交互存入 `CanvasAssistantMessage.detail`,标记为 `creative-interaction`,包含原模型输入、服务端 runId、结构化需求、问题与已提交答案、方案及版本、报价与批准摘要、执行状态和产物资源引用。它随原 `chatSessions` 进入已有账号隔离的画布存储与云同步,不建立第二套聊天历史。 + +每条结构化回复拥有稳定的提交身份,刷新按 runId 读取服务端运行事实;回看旧卡片不会自动批准或重放任务。未提交答案按账号、原会话和消息暂存本机。已提交答案进入原会话,并作为下一次原 Agent 请求的上下文。报价历史保留金额与批准摘要,账务仍以服务端为准。 + +在使用过程中调整补问方式修改 `questionGuides`,调整方案内容修改 `proposalSections`,调整素材依赖和产物组织修改 `workflowRules`;发布后对新的规划请求生效。已批准方案和费用不会因场景配置变动而自动扩大执行范围。 + +## API 合同 + +以下路径均以 `/api/creation-runs` 为前缀,使用现有登录 Cookie 和统一业务响应。对象归属由 service 校验。 + +| 请求 | 用途 | +| --- | --- | +| `POST /`、`GET /`、`GET /:id` | 按 clientKey 幂等创建、列表和恢复 run/submissions | +| `PATCH /:id` | 用 revision 保存结构化会话和状态 | +| `POST /:id/claim` | 用 expectedEpoch 接管执行,更新 owner 和 epoch | +| `POST /:id/heartbeat`、`release` | 续租与释放执行权 | +| `POST /:id/proposal-approve`、`proposal-invalidate` | 批准具体版本和操作范围,或使原批准失效 | +| `POST /:id/canvas` | 批准后绑定稳定画布,不提前建草稿节点 | +| `POST /:id/submissions/prepare` | 按 itemKey 准备具体请求和报价,不扣费、不提交 Task | +| `POST /:id/submissions/approve` | 批准列出的提交,核对报价有效性 | +| `POST /:id/submissions/refresh` | 从原请求生成后继报价,撤销未消费旧项,等待新批准 | +| `POST /:id/execute` | 使用已批准提交进入原 Task/账务事务,幂等返回任务 | +| `GET /:id/canvas-snapshot` | 获取当前画布和 snapshotHash | +| `POST /:id/canvas-commit` | 检查 epoch、批准范围与 snapshotHash 后提交画布 | + +写请求携带 `owner`、`executionEpoch`;会话和方案写入还携带 `revision`。服务端批准字段独立于页面 state JSON;修改 state 不能自行授权。execute 返回 Task,不修改 run revision,刷新依 submission.taskId 对账,不以超时作为重复生成依据。 + +图片在会话中使用 `resource:` 标识,后端检查资源归属和模型视觉能力,执行时转换为相应供应商输入;不把带签名 URL 或 data URL 持久化进规划。图片生成先进入现有素材库再回写节点 assetId,引用下游生成时使用真实已就绪资源。 + +结构化方案的媒体提交仅支持受管模型,单次输出 count 为 1,媒体生成单独报价批准。原在线 Agent 的对话与工具推理沿用既有文本请求和计费方式,不再使用被撤回独立界面的分析报价入口。估算价格按实际用量结算,不代表消费上限。修改模型、参数、提示词或引用需重新批准具体提交;原任务仍运行或账务不明确时不能重做收费。 + +## 恢复与限制 + +关闭浏览器不会取消已提交的后端任务,后续编排需要重新打开会话并取得执行权。方案批准、费用批准和结果回写分别持久化;重做保留原节点及成功产物,新一轮使用新的提交标识。失败不得自动创建第二个收费任务。 + +画布写入只在对应画布适配器就绪时发生,服务端按批准范围限制改动。当前标签页通过现有远端同步队列串行化提交;这不是全站协作协议,旧页面跨标签或设备保存仍需避免同时编辑同一画布。既有直接生成和本机 Agent 不因此获得新审批规则。 + +实际短片、UI 与 PostgreSQL 联调状态见[待测试](../progress/pending-test.mdx)。 diff --git a/docs/content/docs/overview/creative-agent-design.mdx b/docs/content/docs/overview/creative-agent-design.mdx new file mode 100644 index 000000000..db1486c04 --- /dev/null +++ b/docs/content/docs/overview/creative-agent-design.mdx @@ -0,0 +1,703 @@ +--- +title: 通用创作 Agent 需求与设计 +description: 复用现有在线助手、画布、插件和生成任务,实现跨场景问答、确认、辅助编辑、自定义业务节点与生成交付的增量设计 +--- + +# 通用创作 Agent 需求与设计 + +> 文档状态:需求与设计评审稿。产品边界依据已确认的讨论整理;标注为“设计建议”的接口、字段和文件拆分仍需在实现时对照调用方细化。本文不代表相关功能已经实现或通过运行验收。 + +第一期的媒体复核、页面状态、交互细节和代码改动边界见[第一期体验与实现设计](../../../design/creative-agent-phase-one.mdx),另附[交互原型](../../../design/creative-agent-phase-one-prototype.html)。第一期发布包含首页同会话接续和真实单段短片产出;画布内部闭环只是开发里程碑。 + +## 1. 产品目标与最终决策 + +影策提供通用创作助手,用户输入目标并提供素材,Agent 理解上下文、补问缺失信息、给出大纲和执行方案;用户确认后,Agent 实际创建画布节点、连接流程、提交获批的生成任务,并把真实结果写回画布。用户可以在过程中对话、修改方案、手工编辑画布和选择局部重做。 + +底层能力支持创作、影视与短剧、营销、电商等场景。行业差异通过场景配置、自定义提示词、Skills 和现有插件/工具实现,不为每个场景复制一套 Agent。 + +Agent 同时承担已有画布的辅助编辑:修改内容和参数、调整节点布局与连线、局部重做;还可以在已接入的执行能力范围内提出新的业务节点定义并编排流程。分镜、视频拉片/抽帧、角色/场景/道具替换是重点用例。新节点定义不能自动产生系统尚未接入的算法或模型能力。 + +### 1.1 已确认的约束 + +| 编号 | 最终决策 | 实现含义 | +| --- | --- | --- | +| D01 | 不接入 Harness | 沿用现有在线模型调用和浏览器工具循环,不引入新的 Agent 引擎或运行服务 | +| D02 | 不建设完整服务端画布 MCP | 在线助手继续通过前端函数调用画布;接口保持清晰,但不为未来 MCP 提前重构 | +| D03 | 画布仍是独立功能 | 画布可由人直接使用,Agent 经明确操作接口使用画布,不绕过校验直接改内部状态 | +| D04 | 不增加草稿节点 | 方案先在对话中展示,用户确认后才创建或修改对应画布节点 | +| D05 | 问答结合上下文动态生成 | 支持场景模板、模型自主提问及混合模式;已明确的信息不重复问 | +| D06 | 确认控制实际执行 | 方案批准与费用授权有明确范围,模型不能自行批准或跳过收费门槛 | +| D07 | 计划面板按任务出现 | 普通聊天不展示;多步骤任务展示;结束后收起并保留历史 | +| D08 | 增量调整,优先复用 | 不按概念清单全面拆模块,不复制行业页面,不建立平行生成/计费/素材系统 | +| D09 | 新增共性能力适度封装 | 问答、方案卡和计划展示可独立封装;现有逻辑只在出现真实共享需求时提取 | +| D10 | 前端执行边界如实展示 | 关闭网页后不承诺 Agent 继续推进;后端已接受的任务按其现有运行方式继续 | +| D11 | 支持已有节点辅助编辑 | 明确的小修改复用现有工具,不强制长问答;复杂调整说明范围,收费生成另行授权 | +| D12 | 支持组合已有能力定义业务节点 | Agent 生成结构化节点定义,由系统校验输入、参数、输出和执行绑定,确认后创建实例 | +| D13 | 插件扩展真实能力 | 当前项目节点可保存为模板;需要分发、专用界面或新执行器时复用插件体系,不为每个节点都开发插件 | + +本次不要求删除本机 Codex、影策 MCP 插件或本机渠道。它们不是新链路的必需依赖,是否保留入口按已有产品功能处理,不连带删除 Dreamina、ComfyUI Bridge 等能力。 + +### 1.2 不纳入本轮的能力 + +- 服务端常驻通用 Agent、按用户创建 Harness 容器、自动多 Agent 团队。 +- 关闭浏览器后继续发起下一阶段、无人值守跨日执行。 +- 任意生成 React 组件、任意代码节点或在服务器自由安装插件。 +- 仅凭节点名称或任意 metadata 自动获得新算法;把当前插件字段展示器视为已实现的动态表单与执行器。 +- 全部历史会话格式统一迁移、重建画布、替换模型协议层。 +- 仅凭提示词保证商品纹样、Logo、角色或服装细节完全一致。 +- 首版完成所有行业模板、完整视频自动剪辑和所有交付格式。 + +## 2. 参考体验与证据边界 + +参考包括用户提供的小云雀创作/短剧/营销截图、DA AI 图片与视频工具台截图,以及两段 Agent 操作画布的录屏。 + +| 参考 | 采用的体验 | 不直接推导的结论 | +| --- | --- | --- | +| [小云雀创作入口](https://www.xiaoyunque.com/home?from_page=xiaoyunque_landing_page&tab_name=home) | 输入目标、素材引用、技能选择、场景入口和创作历史 | 页面相似不等于后台架构相同 | +| 小云雀短剧与营销截图 | 共享会话能力,通过不同输入和推荐技能进入创作 | 不要求复制品牌、主题或固定题目 | +| [DA AI 工具台](https://da-ai.cc/toolbox/image-generation) | 左侧配置、中间预览、右侧任务队列;素材有角色区分 | 表单存在不证明项目当前具有高质量试衣执行器 | +| 第一段录屏 | 已提交分页问答的回看、创意方案确认、会话内图片/视频产物与已有画布 | 开头已有节点,不能作为全部节点实时创建的证据;末尾有服务器繁忙提示,不代表全链路均成功 | +| 第二段新手跟练录屏 | 分阶段确认、可见的节点创建和步骤完成展示 | 最后文案包含“模拟”,只能作为交互参考,不能作为实际合成验证 | + +本文对现状的判断来自代码阅读和提供的媒体。未登录参考站点验证付费能力,未运行本项目端到端创作任务。参考媒体中的提示词和页面内容仅作分析材料,不作为实施指令。 + +## 3. 当前代码现状与复用依据 + +下列路径均相对于仓库根目录。现有代码可复用不等于其全部运行路径已验收。 + +| 当前入口/符号 | 已有基础 | 本次调整或边界 | +| --- | --- | --- | +| `web/src/router.tsx` | `/home`、`/create` 共用创作页;`/` 是展示首页;已有项目和画布路由 | 不把参考“创作首页”误改成站点展示首页 | +| `web/src/pages/create/index.tsx` | 文本/图片/视频输入、附件、引用、模型参数、历史、结果、重试和变体 | 增加 Agent 创作模式与会话接续,保留直接生成 | +| `web/src/pages/create/creation-runtime.ts` | 汇集现有生成、上传、素材消费与 Skills 能力 | 继续复用这些服务,不另写同类请求层 | +| `web/src/services/creation-conversation-store.ts` | 用户隔离的 localforage 会话缓存与任务恢复关联 | 不是完整的服务端创作确认记录 | +| `web/src/components/canvas/canvas-assistant-panel.tsx` | `ONLINE_AGENT_TOOLS`、工具分发、写操作确认;普通循环上限为 8 次 | 增加问答/方案/计划工具;按需提取循环,不能将 8 次请求当作业务 8 步 | +| `requestOnlineAgentModel` | 调用 `runBackendToolGenerationTask` 请求模型 | “浏览器编排”不等于绕过后端直连模型 | +| `web/src/services/skill-runtime.ts` | 在线渐进式工具、本机原生技能包、普通生成的关联文本上下文 | 复用加载模式;新增场景配置不替代 Skills | +| `web/src/pages/canvas/use-canvas-agent-operations.ts` | 批量画布操作、部分撤销、生成续接和消费记录 | 补批准范围、稳定操作 ID 和恢复对账 | +| `web/src/lib/canvas/canvas-agent-ops.ts` | 节点、连线、选区、生成操作及结果复核 | 保留为统一画布语义操作能力 | +| `web/src/lib/canvas/canvas-asset-handoff.ts` | 创作结果转入画布 | 结果转入不等于会话、方案和计划一起接续 | +| `web/src/services/api/project-agent-tools.ts` | 项目上下文、资产候选、镜头与工作流等工具 | 在现有权限与业务范围内复用,不只做页面跳转 | +| `web/src/pages/projects/detail/workflow-production-workbench.tsx` | 分镜图、预演、镜头视频和素材引用 | 保留专业生产流程,不复制新的短剧系统 | +| `backend/internal/service/task_creation.go` | 任务校验、模型路由、并发额度、任务与计费入口 | 新费用授权需要在实际收费入口闭合校验 | +| `backend/internal/service/task_billing.go` | 结算、退款、状态不确定时的重试门槛 | 不以本轮新授权替代现有账务核对 | +| `backend/internal/service/task_render.go` | 已有 FFmpeg 时间线渲染 | 不声称缺少全部合成能力;需要补 Agent 到已有渲染的调用链 | +| `workflow-stage-views.tsx` 的 `DeliveryStage` | 交付状态展示 | 当前交付包按钮仍标注后端未接入 | +| `web/src/lib/plugins/`、`web/src/services/plugin-host.ts` | 应用插件清单、注册、权限、UI/协议贡献 | 插件启用不自动等于 Agent 获得相应工具 | +| `web/src/lib/plugins/plugin-types.ts` 的 `PluginCanvasNodeContribution` | `contributes.canvasNodes` 声明名称、默认尺寸、schema、renderer、输入类别和输出连接点 | 尚缺角色化多端口、节点执行绑定等完整合同 | +| `web/src/lib/canvas/node-registry/` | 内置/插件节点共用注册表;`CanvasNodeTypeId` 容纳插件类型 | 复用注册与查询,补类型冲突保护、版本与作用域;Agent 的创建操作仍需对齐动态类型 | +| `web/src/components/canvas/canvas-node-content.tsx` | 通用声明式插件节点读取 `pluginData` 并展示字段;内置专用节点可单独渲染 | 通用 schema 当前不自动生成编辑表单;sandbox 仍显示等待隔离运行时 | +| `web/src/lib/canvas/canvas-video-frame.ts`、`web/src/pages/canvas/use-canvas-media-tools.ts` | 按时间点截取视频画面、保存并创建图片节点 | 复用抽帧,不把现有按钮能力当成已经暴露给 Agent 的工具或完整拉片能力 | +| `web/src/constant/canvas.ts`、`web/src/components/canvas/canvas-script-node.tsx` | 已有分镜脚本节点、结构化镜头行及生产操作入口 | 优先补 Agent 对已有分镜数据的受控编辑,不另造同义节点 | + +当前在线助手是 Tool Calling 加前端函数分发,不经过 MCP。本机 Codex 路径才通过影策 MCP 和本机 Canvas Agent 桥接,二者不可混写。 + +## 4. 用户入口与场景要求 + +### 4.1 创作首页 + +- 复用现有创作输入、附件、素材引用、模型选择、Skills、结果和历史。 +- 支持普通生成与 Agent 创作。普通咨询、直接生成不强制走长流程。 +- Agent 模式支持通用创作、短剧、营销、电商等场景入口;用户可显式选择,模型也可提出场景建议。 +- 选择场景只影响本次任务的提示与默认配置,不清空用户已输入的素材和要求。 +- 历史记录可恢复对话、待回答卡片、已批准方案及真实任务状态。 + +### 4.2 短剧入口 + +- 支持上传或粘贴剧本、辅助创作和进入已有项目,接续现有章节、资产、分镜工作台。 +- 文件上传成功不等于正文解析成功;TXT、DOCX、PDF 的提取能力逐格式验证,扫描 PDF 等不能默认为可读。 +- 用户已给题材、时长、比例和风格时,直接复用,不固定再问同样问题。 +- 大纲、剧本与镜头方案确认后才创建本阶段画布内容,付费生成另按批准范围执行。 + +### 4.3 营销入口 + +- 输入支持商品、角色/模特等角色化素材及卖点、渠道、受众、创意方向。 +- 创意、Hook、风格和推荐 Skills 使用场景配置;模型按已有信息补问。 +- 输出可以是文案、海报、系列图或广告视频,不能默认所有营销任务都生成视频。 + +### 4.4 电商工具台 + +- 采用配置、预览、任务队列的布局,复用现有生成与素材能力。 +- 直接操作和 Agent 辅助共存,例如“帮我写提示词”“帮我规划一组商品图”。 +- 商品、模特、上装、下装等素材角色具有明确标识、数量与必选关系。 +- 服装上身需验证专用能力或多图编辑效果;不能仅改输入框就对外承诺精确试衣。 +- 队列默认筛选当前工具/会话相关任务,保留跳转全局任务中心的入口,不再维护一份任务真相。 + +## 5. 核心用户流程 + +```mermaid +flowchart TD + A[输入目标与素材] --> B[结合场景与上下文判断] + B --> C{需要多步骤创作吗} + C -->|否| D[普通回答或已有直接操作] + C -->|是| E[建立任务计划并补齐必要信息] + E --> F[对话展示需求摘要与大纲方案] + F --> G{用户确认方案} + G -->|修改| E + G -->|确认| H[创建或更新本阶段节点及连线] + H --> I{是否需要收费调用} + I -->|是| J[展示真实报价并等待授权] + J -->|批准| K[提交生成任务] + I -->|否| K + K --> L[真实状态和产物回写] + L --> M{后续阶段或修改} + M -->|已有批准覆盖的下一步| H + M -->|方案或费用范围变化| F + M -->|完成| N[交付并收起计划] +``` + +上图表示创作阶段关系,不表示任何文本模型调用免费。若首次理解、提问或方案生成需要计费,必须先取得相应的文本调用授权,见费用设计。 + +### 5.1 正常链路 + +1. 用户提交需求;关联素材和当前选择的场景配置版本。 +2. Agent 读取当前需求、历史答案、可用素材、相关技能和真实模型能力。 +3. 仅对缺失、冲突或显著影响执行的信息提问。信息足够时可直接给方案。 +4. 需求摘要可独立确认,也可与方案一起展示;不为了形式重复增加点击。 +5. 方案在对话中展示,并明确将新增/修改的内容、阶段和预期产物。 +6. 用户确认后才对画布执行本阶段操作;需要时创建空白画布容器并打开,容器创建也应在该确认之后。 +7. 收费生成先报价并取得授权;在已有批准范围内不反复弹同样确认。 +8. 任务排队、运行、失败和资源就绪状态写回原节点、计划和会话。 +9. 用户可修改、局部重做、进入专业工作台编辑或下载已有产物。 + +### 5.2 简单任务与普通对话 + +“这张图是什么风格”不建立制作计划;“把标题改成夏日限定”在明确请求范围内执行既有编辑;“做一套营销素材”才进入多步骤链路。用户直接提出的明确编辑指令可作为该操作的授权,不强制再生成大纲,但删除、覆盖和费用仍按影响范围处理。 + +## 6. 场景配置与 Skills + +### 6.1 配置职责 + +场景配置确定关注的信息、默认策略和可用范围;Skills 提供领域方法、模板和参考资料;系统规则负责权限、计费和能力约束。三者不能相互替代。 + +| 配置 | 内容 | 使用方 | +| --- | --- | --- | +| 基本信息 | 场景 ID、名称、简介、版本、启用状态 | 页面与会话 | +| 场景提示词 | 角色、目标、交付风格、提问原则 | 模型 | +| 需求字段 | 稳定 key、数据类型、说明、来源与依赖 | 模型与校验器 | +| 必要条件 | 在规划前或具体执行前需要哪些素材/参数 | 模型与执行校验 | +| 问答策略 | 模板/动态/混合、每批上限、自定义与跳过规则 | 模型与面板 | +| 默认值 | 可自动采用、需提示、需用户确认的默认值 | 模型与需求状态 | +| 参考内容 | 示例问题、建议选项、推荐 Skills | 模型与入口推荐 | +| 能力约束 | 支持的产物与工具类别 | 工具分发与真实能力接口 | + +首版不建设拖拽式问卷编辑器。采用内置配置加简单后台表单/JSON 校验编辑;首个开发阶段可先用版本化配置验证,发布面向运营使用前补启停、保存校验和版本记录。 + +### 6.2 同一份规则供模型和程序使用 + +```json +{ + "id": "ecommerce-marketing", + "version": 1, + "questionMode": "hybrid", + "questionPolicy": { + "maxQuestionsPerBatch": 3, + "skipKnownFields": true, + "allowCustomAnswer": true + }, + "fields": [ + { "key": "product.images", "type": "asset-list", "requiredFor": ["product-image-generation"] }, + { "key": "audience", "type": "text" }, + { "key": "channel", "type": "text" }, + { "key": "deliverables", "type": "text-list" } + ] +} +``` + +以上为设计示例,不是已存在 API。程序从配置生成相关模型指令和工具参数约束,同时执行校验。首次每批最多 3 题是建议默认值,可调整;不能把所有任务强制变成三题。 + +- 仅传当前任务相关规则,不把整个技能库和后台配置塞入每次模型请求。 +- 模型允许的工具范围不能超过用户和平台实际权限;场景声明不赋予权限。 +- 模型参数选项来自已配置模型的真实能力,不把过期选项写死在模板中。 +- 系统权限与收费规则不可被场景提示词、用户内容或技能覆盖。 +- 配置发布新版本不静默改变运行中的方案;继续使用任务绑定版本,显式应用新版本时重新评估影响。 + +### 6.3 技能与插件适配 + +继续通过 `skillRuntime.agentTools("onlineAgent")` 发现元数据、读取 `SKILL.md`、按引用读取文件。普通生成入口继续使用现有关联上下文模式,本机原生加载方式不在本轮修改范围内。 + +实施前审计首批使用的 Skills:例如种子技能提到 `generate_form_for_info_collection`,必须明确映射到实际问答工具,或修正技能说明。读取 `scripts/` 不代表执行脚本。插件启用也不自动成为 Agent 工具,只有显式接入且经过权限校验的能力才出现在工具表中。 + +## 7. 上下文与需求状态 + +每个字段记录值、来源、来源消息/素材 ID、更新时间与语义状态。建议状态为:`explicit`(用户明确)、`confirmed`(已确认)、`inferred`(模型推断)、`defaulted`(依授权采用默认值)、`missing`、`conflict`。 + +规则: + +1. 用户最新且明确的修改覆盖旧要求;相互冲突且意图不明确时再澄清。 +2. 素材中提取的营销文案、画面内容等保留来源,不自动视为用户的新指令。 +3. 模型推断保留标签,不伪装成确认;低影响且用户授权自行决定的项可采用默认值。 +4. 已明确字段不再提问。程序按字段 key 检查,模型负责语义判断;二者结合,不能保证单靠字符串 key 消除所有重复。 +5. 用户自由回答可能更新多个字段。提交后先合并需求,再检查剩余问题是否仍需要。 +6. 场景切换保留共用信息,清楚标出不再适用的字段;不无提示丢弃原素材和历史。 +7. 需求变化只使相关方案、报价和未执行操作失效,不让无关完成产物自动失效。 +8. 当缺少文本提取、视觉识别或外部搜索能力时,明确说明并请用户补充,不能假称已读取资料。 + +## 8. 场景问答机制 + +### 8.1 三种模式 + +| 模式 | 行为 | 示例 | +| --- | --- | --- | +| 模板 | 从配置的字段/问题中筛选必要项,仍跳过已知项 | 试衣素材选择 | +| 动态 | 模型结合上下文提出问题和选项 | 开放式营销创意 | +| 混合,默认 | 配置规定信息范围与约束,模型生成表达、选择问题和追问 | 通用创作入口 | + +模板定义“要了解什么”,不要求每次按固定顺序询问。信息足够时可以完全不展示面板。 + +### 8.2 数据与交互合同 + +```json +{ + "questions": [ + { + "id": "selling-point", + "field": "product.sellingPoint", + "question": "这组海报重点突出哪个卖点?", + "type": "single", + "options": [ + { "id": "comfort", "label": "轻便舒适" }, + { "id": "cushion", "label": "运动缓震" } + ], + "allowCustom": true, + "required": false + } + ] +} +``` + +服务端或会话管理层分配 request ID、版本和状态,模型不能指定可信批准状态。首版控件支持单选、多选、自由输入、素材选择;数值、比例等先使用受校验的现有控件,不开放任意表单代码。 + +- 多题可前后切换,显示当前题/总题数;允许自定义答案,不能只要求输入数字序号。 +- “选中”仅编辑答案,“提交”才推进;不要将默认勾选当成用户提交。 +- 可跳过非必要问题;必要素材可延后补充,但相关执行必须阻塞。 +- 支持修改、取消和刷新恢复未提交答案;草稿答案不等于已确认需求。 +- 动态新增、删除后续问题时保留其他答案,不突然清空已填写内容。 +- 前端渲染结构化数据,不执行模型返回的 HTML、JavaScript 或 React 组件。 + +### 8.3 工具循环衔接 + +模型调用 `ask_user_question` 后,持久化该请求及原始工具调用 ID,将会话置为等待回答。回答提交先校验版本和字段,再作为同一工具调用的结果进入上下文,恢复原任务,而非创建无关联的新会话。 + +等待期间不反复请求模型或消耗工具轮次。若问题均已被新上下文回答,返回已有答案并继续;如果用户取消,记录取消结果,不补写虚假的答案。同一请求只能成功提交一次,重复请求返回同一结果。 + +## 9. 大纲与方案确认 + +### 9.1 方案内容 + +方案包含需求摘要、核心创意/大纲、拟使用素材、预期产物、规格、步骤,以及将创建或修改的画布内容。可以先有完整计划,再按阶段细化和批准,避免要求所有细节一次确定。 + +| 场景 | 方案重点 | +| --- | --- | +| 短剧 | 剧情、角色/场景、分镜、镜头与视听要求 | +| 营销 | 受众、卖点、创意方向、文案、视觉与渠道产物 | +| 电商 | 商品、输入素材角色、主图/详情图/试穿图等制作规格 | + +方案展示可用 Markdown 加结构化摘要;执行内容单独做结构化校验。不能仅凭一段自由文本确认就允许模型生成任意后续写操作。 + +### 9.2 批准范围 + +- 按钮“确认方案,创建节点”:批准该阶段方案对应的节点和连线操作,不提交收费生成。 +- 按钮“确认并生成”:仅在操作和报价已经确定时提供,清晰显示总量、费用和范围,一次记录方案与费用两类批准。 +- 未确认时不创建本轮方案的节点,不创建隐藏占位草稿;已有画布和已有素材读取不受影响。 +- 文本框输入明确的“确认上述方案”等也可触发确认流程,但必须对应唯一待确认版本;歧义时不自动批准。费用授权首版使用明确费用卡提交。 +- 新增创作内容、变更输出、删除或覆盖已有内容超出范围时重新确认。 +- 仅自动布局或命名等方案内执行细节,在已批准范围内不逐工具再次弹窗。 + +方案有版本,批准有对应版本和内容指纹。批准后不得静默修改待执行内容;用户修改方案后,保留旧版本和已完成结果,明确新的执行差异。 + +## 10. 按需任务计划面板 + +计划是业务任务的可见步骤,不是模型内部思考过程,也不是工具调用日志。比如两张海报可以有“理解商品、确认创意、生成、筛选”四步,但某一步可能包含多个模型请求和工具调用。 + +| 状态 | 面板行为 | +| --- | --- | +| 普通聊天、简单明确编辑 | 不显示计划 | +| 多步骤创作任务 | Agent 创建计划后出现 | +| 等待回答、方案或费用批准 | 保留面板,当前步骤明确显示等待原因 | +| 执行中 | 更新真实完成数、当前项与任务链接 | +| 暂停、失败 | 保留进度,支持继续、重试或取消 | +| 完成、取消 | 自动收起,保留在会话历史中,用户可再次展开 | + +- 面板独立于正文但可折叠;具体停靠位置沿用现有画布浮层约定,在 UI 阶段验证,不强制长期占据一列。 +- 步骤 ID 稳定,允许调整后续步骤;增加/删除步骤后显示计划已更新,不冒充原总数一直未变。 +- 串行展示“当前第 2/6 步”;并行展示“已完成 2/6,运行中 2 项”,不要虚构严格串行。 +- 业务步骤建议使用 `pending`、`running`、`waiting_user`、`waiting_task`、`succeeded`、`failed`、`cancelled`、`skipped`;跳过不标成成功。 +- 模型提出计划和修改建议,程序依据方案保存、画布回执和后端任务更新受约束状态。 +- 创建完成依据真实节点/连线存在,生成完成依据任务成功且资源可用,交付完成依据实际输出文件可访问。 +- “生成中”的图片占位和空播放器不能作为完成证据;生成失败与资源回写失败分开展示。 + +## 11. Agent 操作画布 + +### 11.1 增量接入方式 + +继续使用 `CanvasAgentOp`、`applyCanvasAgentOps`、`verifyCanvasAgentOps` 和 `useCanvasAgentOperations`。先在现有画布助手增加新工具和确认状态,不提前迁出全部循环;首页需要共享时再提取无 JSX 的控制逻辑。 + +适配接口仅需要覆盖:读取当前快照、检查操作、执行一批操作、查询执行结果、定位相关节点。它是前端边界,不是新的 MCP 服务或通用插件执行平台。 + +新增业务节点沿用这条画布边界;定义发现、模板保存和执行能力绑定按第 11.5~11.8 节增补。界面和 Agent 共用字段校验与执行入口,避免两套行为。 + +### 11.2 写入合同 + +1. 执行批次关联会话、方案版本、批准记录、目标画布和稳定 operation ID。 +2. 提交前核对真实节点/素材 ID、输入能力、引用关系及批准范围。 +3. 创建节点时预分配稳定 ID,并把批次 ID 与结果映射持久化;遇到重复提交先查询,不重复分配新节点。 +4. 执行前校验当前 revision/stateHash,冲突则重新读取。仅布局变化可重新计算布局,内容或引用变化必须重新评估。 +5. 新节点和连接写入后执行结果复核,并区分“已显示”“本地已缓存”和“服务端已保存”。 +6. 将步骤与节点、连接、任务关联。定位按钮可聚焦本阶段内容,避免生成期间不断抢走用户视口。 +7. 用户删除或编辑正在生成的目标节点时,不强行恢复或覆盖;结果保存在任务/素材系统,提供手动放回画布的操作。 + +已存在撤销栈可复用,但撤销画布变化不等于取消上游生成或退费。手工编辑后的撤销边界遵循现有保护,不能把整个旧快照覆盖回去。 + +### 11.3 部分成功与异常窗口 + +浏览器画布操作与后端生成不在同一个事务中,不能承诺跨二者的原子执行。批次中部分节点已创建、任务未提交时,保留成功部分,标出失败项,只恢复缺失部分。 + +若操作已在浏览器执行但未收到保存回执,状态为“待核对”,重新打开后以批次 ID、稳定节点 ID 和服务端画布数据核对。未确认前不盲目重放。页面退出导致仅本地有改动时,显示本地恢复提示,不声称云端已经保存。 + +### 11.4 已有节点的辅助编辑 + +| 操作 | 示例 | 执行约束 | +| --- | --- | --- | +| 内容与名称更新 | 缩短文案、修改分镜描述、更新提示词、节点改名 | 先读真实节点,只更新允许编辑的字段 | +| 布局与辅助节点 | 调整位置和尺寸、整理连线、增补说明文本、调整背板 | 复用已有节点类型与布局算法,保留用户手工编辑 | +| 输入与生成参数 | 更换参考素材、调整模型或输出规格 | 校验素材角色、模型能力与下游影响;修改参数不自动提交生成 | +| 流程调整 | 增删节点、修改连接、插入一个处理步骤 | 校验端口与依赖,复杂或超出批准范围的调整先确认 | +| 局部重做 | 仅重做第二张海报或某个镜头 | 保留其他产物,收费任务按范围授权,失败只重试必要部分 | + +当前已有 `canvas_update_node`、`canvas_update_node_text` 和底层 `update_node` 等能力,但通用 patch 不代表每类节点的业务校验均已完整。新增接入对内置节点和插件节点分别建立可写字段约束,禁止模型修改节点身份、归属、可信任务终态或批准状态。 + +“把这个节点改名”属于明确授权的小操作,不强制出大纲或计划。“优化整套分镜并替换主角”先展示影响范围。仅修改提示词时,旧产物仍可保留并标注与当前参数不一致;下游已完成结果不自动删除或重新收费运行。 + +### 11.5 自定义节点的三种形态 + +| 形态 | 定义来源 | 使用方式 | +| --- | --- | --- | +| 现有节点配置/组合 | 内置节点、现有插件节点和工作流 | Agent 修改参数或组合节点,足以完成任务时不新增类型 | +| 项目自定义业务节点 | Agent 或用户提供结构化定义,绑定已接入能力 | 在当前项目确认后创建,可保存为个人模板复用 | +| 插件提供的节点类型 | 开发者或平台提供插件清单及所需执行/界面能力 | 通过现有插件注册机制扩展;需要新算法时先接入真实执行器 | + +节点定义描述一种可复用能力;节点实例保存本次的素材、参数、位置与运行关联。多个实例可以共享定义,但不能共享可变参数或误用彼此的任务结果。 + +项目定义提案先保存在会话方案中,未确认时不创建画布实例、不向全局注册表注入临时类型。这是对话中的结构化方案,不新增草稿画布或草稿节点。确认后保存项目可用定义并创建真实实例;保存个人模板、发布共享插件是独立操作,不因创建一个节点自动发生。 + +### 11.6 现有插件注册协议与扩展合同 + +现有调用链为:插件清单 `contributes.canvasNodes` → `registerPlugin` → `registerPluginCanvasNodes` → `canvasNodeDefinitionFromPlugin` → 节点注册表 → 画布菜单与内容渲染。它注册的是类型知识,不自动绑定生成执行器。 + +下面是符合当前 `PluginCanvasNodeContribution` 字段结构的贡献片段,需要放入完整插件清单的 `contributes`;不是已经发布的角色替换插件,也不能单独执行: + +```json +{ + "canvasNodes": [ + { + "id": "creative.character-replace", + "label": "角色替换", + "defaultTitle": "角色替换", + "defaultSize": { "width": 480, "height": 360 }, + "renderer": "declarative", + "acceptsInputKind": "image", + "showOutputConnection": true, + "schema": { + "type": "object", + "properties": { + "instruction": { "type": "string", "title": "替换要求" }, + "count": { "type": "integer", "title": "生成数量", "minimum": 1 } + } + } + } + ] +} +``` + +当前实例的通用插件字段存入 `metadata.pluginData`。通用渲染器展示字段和值,不自动实现 JSON Schema 全量校验、素材选择、执行按钮或结果图库;`renderer: "sandbox"` 目前为占位,不能作为首版执行路径。现有专用插件界面不能推导为任意插件都可动态渲染同样的交互。 + +建议在现有注册体系上补以下结构;具体字段名在实施时细化,不要求另建插件平台或修改不相关插件: + +| 合同 | 最少信息 | 程序责任 | +| --- | --- | --- | +| 身份与版本 | 类型 ID、版本、定义归属、项目或模板作用域 | 防止覆盖内置/其他插件类型;不同账号和项目隔离 | +| 输入端口 | 稳定端口 ID、素材角色、类型、数量、必填条件 | 区分原图/角色图/蒙版,校验资源可访问性与连接兼容性 | +| 参数 schema | 字段 key、类型、默认值、范围、可编辑性 | 同一规则用于用户表单、Agent 参数和执行前校验 | +| UI 配置 | 受支持控件、字段顺序、帮助文案、预览方式 | 使用现有设计系统渲染;不执行模型生成的界面代码 | +| 执行绑定 | 已注册能力或工作流 ID、版本、输入/参数/输出映射 | 解析真实执行器,经原有权限、授权、任务和计费链路调用 | +| 输出端口 | 稳定 ID、结果类型与数量、任务/资源映射 | 让真实产物进入素材引用与下游输入,不以描述文字冒充媒体 | +| Agent 元数据 | 作用、适用条件、必需输入、可编辑字段 | 提供可发现且可校验的调用说明,不能授予额外权限 | + +当前 `acceptsInputKind` 仅约束一种媒体类别,`showOutputConnection` 仅控制是否显示连接点。多角色、多端口及多结果需要同步端口渲染、连线校验、资源引用解析、容量计算与执行参数映射,不能只在 schema 增加字段。 + +执行绑定只允许引用已注册且当前可用的能力,不接受模型提供的任意脚本、请求 URL 或可信后端函数名。定义内不保存密钥、Cookie 或临时媒体下载凭证;素材保存资源引用,执行时按用户权限解析。 + +### 11.7 Agent 定义、创建和运行节点 + +```mermaid +flowchart TD + A[用户描述业务节点] --> B[发现现有节点及可用执行能力] + B --> C{现有配置或组合能否满足} + C -->|能| D[复用现有节点或提出业务节点定义] + C -->|不能| E[说明缺少的执行能力或插件] + D --> F[系统校验端口 参数 执行绑定与范围] + F --> G[在会话中展示并等待确认] + G --> H[保存项目定义并创建真实节点] + H --> I[根据实际输入准备执行和费用授权] + I --> J[调用现有任务或媒体处理能力] + J --> K[回写真实产物与步骤状态] +``` + +节点可以绑定单个执行器,也可以绑定组合流程。首版组合采用可检查的有限依赖图,校验引用存在、输入输出匹配及无循环;不开放任意代码或无上限循环。每个内部步骤有稳定 ID 和执行回执,复用创作计划与任务关联,不为自定义节点建立平行任务队列。 + +组合节点需要时展开内部步骤,普通单步节点不强制展示独立计划。收费步骤在执行前进入同一授权门槛,不能因包在一个节点里而隐藏内部调用、重试或费用。上游变化先标出受影响的待执行步骤;已生成结果保留,重新执行仍遵守范围与费用授权。 + +实例绑定定义版本或可恢复快照;刷新恢复时先加载有权限的定义,再渲染实例和对账任务。模板升级不静默改变旧实例,显式升级时展示差异并重新校验受影响的方案与报价。插件被停用或执行能力不可用时,保留参数、连线和已有产物,显示不可执行原因,不删除节点或自动切换收费模型。 + +项目级定义不直接混入全局无作用域的注册表;先解析当前用户、项目和版本,再加载相应定义。类型重复时明确拒绝或走显式版本更新,不能依赖当前 Map 写入覆盖作为冲突策略。 + +### 11.8 首批业务节点与复用优先级 + +| 业务需求 | 输入与输出 | 复用与缺口 | +| --- | --- | --- | +| 分镜编辑与生成 | 剧本/镜头资料 → 分镜行、分镜图或视频 | 已有分镜脚本节点,优先补字段级工具、资产引用和局部生产,不重复实现分镜表格 | +| 视频抽帧 | 视频、时间点或间隔 → 带时间信息的图片列表 | 复用 `captureVideoFrames` 与保存逻辑,补 Agent 入口、参数校验、取消与结果映射;现有函数按时间点截取,不等于自动镜头检测 | +| 视频拉片 | 视频、分析要求 → 镜头表、关键帧、分析报告 | 组合抽帧、可用视觉分析和结构化整理;间隔抽样不保证覆盖全部镜头,准确切镜需独立验证检测能力 | +| 角色替换 | 原图/视频、角色参考、可选蒙版 → 候选媒体 | 图片先验证现有编辑模型;视频需要专门能力与时序一致性验证,不能直接沿用图片效果承诺 | +| 场景替换 | 原素材、目标场景、保留主体要求 → 候选媒体 | 可复用同一替换节点定义的场景模式,校验背景与主体输入关系 | +| 道具替换 | 原素材、目标道具参考、位置/区域要求 → 候选媒体 | 复用元素编辑与蒙版能力;具体模型是否支持及保真效果独立验证 | + +角色、场景、道具若共享真实执行器与界面,优先定义一个“元素替换”节点加不同模式/模板,保留便于用户发现的名称和预设;只有执行或交互确实不同才拆节点类型。首批顺序建议为:已有分镜辅助编辑、抽帧、图片元素替换,随后扩展拉片分析和视频替换。 + +## 12. 生成报价、批准与计费 + +### 12.1 两类模型费用 + +| 类型 | 授权方式 | +| --- | --- | +| 理解需求、提问、规划等文本模型调用 | 首次发送前展示计费口径并批准本次任务有限调用/费用范围;后续在范围内继续,超额再确认 | +| 图片、视频、音频及收费处理任务 | 按明确批次展示报价,批准后执行 | + +文本授权必须在首次收费请求前完成,不能等模型已经收费分析后才问。固定价格展示确定金额;按 token 或浮动用量计费展示估算和可强制执行的上限,不能伪造准确总价。若所选计费方式无法限制授权范围,应收窄调用规模或阻塞执行。 + +用户自定义渠道可能不扣平台积分但产生外部费用。显示“外部渠道计费,平台无法准确报价”,明确批准操作与数量;不可显示“免费”或承诺平台能限制第三方最终账单。本机渠道和不可校验费用路径首版按现有能力单独标识,不自动纳入统一计费承诺。 + +### 12.2 批次授权合同 + +批准记录至少绑定用户、会话/任务、方案版本、规范化操作摘要、模型/规格/数量、报价版本及有效期、费用范围、幂等键和已消费项目。 + +- 金额由后端基于真实能力和计价规则计算,模型不能提交可信金额或 `approved=true`。 +- 用户批准后,后端重新验证参数、版本、资源归属、额度和插件启用状态。 +- 授权与子任务提交需具备事务/唯一约束或等价的一致性控制;重复 HTTP、双击、跨标签页不能重复消耗授权或产生订单。 +- 一批多项生成应记录每一项的提交结果;恢复时只提交未成功创建的子任务,不重做整个批次。 +- 新增付费重试、模型升级、数量增加或超出原范围时重新确认;状态不确定的账务先核对,不能简单再次扣费。 +- “自动执行工具”偏好不能关闭费用门槛,也不能扩大方案批准范围。 +- 仅增加可选 `approvalId` 且允许缺省收费不足以形成保证。必须梳理 Agent 实际可达的文本、媒体和插件收费入口,统一验证消费授权。 +- 后端无法仅凭不可信的 `source: agent` 区分手工/Agent 调用。受保护任务使用后端记录的执行上下文和统一授权入口;手工生成保留原交互,但接入同样可验证的用户确认语义。 + +首版复用现有报价、订单、计费状态和任务创建,不重写账务系统。前端已有 `runGenerationOperationOnce` 是内存并发合并,不等价于跨刷新、跨设备或后端扣费幂等。 + +## 13. 对现有代码的增量设计 + +### 13.1 优先原地调整 + +| 位置 | 改动 | +| --- | --- | +| `canvas-assistant-panel.tsx` | 接入场景上下文、问答/方案/计划工具,保留现有模型请求和工具分发 | +| `types/canvas.ts` 中助手类型 | 对新增结构化消息和关联数据做明确类型定义,避免全部塞入不可验证的 `detail: unknown` | +| `skill-runtime.ts` | 增加场景推荐/显式选择的衔接,维持渐进读取原则 | +| `use-canvas-agent-operations.ts` | 增加执行上下文与回执,复用原有操作及生成续接 | +| `web/src/lib/canvas/node-registry/`、`web/src/lib/plugins/plugin-types.ts` | 增补声明式业务节点合同、归属/版本校验;复用现有插件注册,不直接把项目提案全局注册 | +| `canvas-node-content.tsx` 及相关节点/连线组件 | 扩展受支持的编辑控件、端口和结果展示,保留专用节点实现 | +| `web/src/lib/canvas/canvas-agent-ops.ts` 与在线工具 schema | 对齐动态类型、可写字段、注册表默认值和操作复核,不能仅把类型强转为内置枚举 | +| `use-canvas-media-tools.ts` 与资源/生成适配 | 按需提取抽帧等已有功能的可调用入口,界面和 Agent 复用保存、任务和错误处理 | +| `pages/create/index.tsx` | 在第二阶段接入同会话 Agent 入口,保留直接生成和当前结果展示 | +| 生成任务/报价/计费服务 | 增补批准和幂等校验,不创建平行 Provider 请求层 | +| 短剧页面 | 接续现有项目上下文、镜头与素材,不复制业务流程 | + +### 13.2 适合新增的少量边界 + +- 问答合同、场景配置和校验纯函数,可放在 `web/src/lib/creation/`;初期页面专用的逻辑仍就近放置。 +- 问答卡、方案卡、费用确认卡和按需计划面板,跨入口复用后放到 `web/src/components/creation/`。 +- 创作执行记录的业务 API 客户端放 `web/src/services/api/`,使用现有 `apiClient` 和 `request`。 +- 首页与画布真正共享循环时,再提取到 `web/src/services/` 或共享 hook;不要先建立可插拔 Agent 平台。 +- 后端新增场景配置和执行记录相关逻辑遵循既有 handler → service → repository/model 分层。类名、表名和路由以实现前的数据模型检查为准。 +- 声明式业务节点的 schema、端口与映射校验放在现有画布/插件边界附近;动态表单仅在真实复用后提取。节点执行适配调用已有媒体或生成服务,不在节点组件内拼接 Provider 请求。 + +不新增独立微服务,不引入新队列,不迁移无关历史,不为概念上的九个职责强行建九个模块。 + +### 13.3 工具提案 + +| 工具 | 输入与作用 | 输出与等待 | +| --- | --- | --- | +| `update_creation_brief` | 更新需求字段并保留来源 | 校验后的需求版本,不允许伪造用户确认 | +| `ask_user_question` | 结构化问题与字段关联 | 已知答案或持久待回答请求 | +| `present_creation_proposal` | 大纲、产物和执行范围 | 方案版本与待确认状态 | +| `update_creation_plan` | 建立/调整步骤与依赖 | 校验后的计划,不允许伪造生成成功 | +| 节点/能力发现工具(新增) | 查询当前用户和项目可用的节点定义、端口、参数和执行能力 | 返回真实能力与不可用原因,供模型选择或组合 | +| 业务节点定义提案工具(新增) | 提交结构化定义、版本与执行映射 | 校验结果及待确认方案;未确认不注册类型或创建实例 | +| 现有画布读写工具 | 已批准范围内的真实操作 | 节点/连线回执及复核结果 | +| 现有生成工具加授权适配 | 规范化生成请求 | 系统生成的报价卡或已提交任务 ID | + +定义获批后的创建/更新尽量扩展现有画布工具;声明式节点执行通过明确适配入口调度既有能力,输入输出合同保持一致。保存个人模板或发布插件不与节点创建工具捆绑。 + +工具名为建议。费用卡也可在生成工具准备提交时由程序直接触发;必须有程序门槛,不依赖模型自觉调用某个“申请授权”工具。 + +## 14. 状态、持久化与接口设计 + +### 14.1 最小逻辑数据 + +以下为逻辑记录,不要求一条记录对应一张新表。先检查现有 Session、Message、Task 和账务结构,决定可扩展字段与必须独立持久化的批准/执行记录。 + +| 对象 | 关键内容 | 权威来源 | +| --- | --- | --- | +| 创作执行 `CreationRun` | 用户、会话、场景版本、可选画布与业务项目关联、状态、revision | 后端执行记录 | +| 需求 `Brief` | 字段、来源、语义状态、版本 | 已保存的用户信息和校验后的提取结果 | +| 问答 `QuestionRequest` | 工具调用 ID、问题、答案草稿、提交结果、版本 | 已提交结果由后端确认 | +| 方案 `Proposal` | 内容、结构化执行范围、版本、批准结果 | 后端保存及确认 | +| 计划 `Plan` | 步骤、依赖、关联任务/节点、更新记录 | 程序依据真实事件投影 | +| 批准 `Approval` | 方案/生成批准类型、范围、价格、额度、有效期 | 后端,不接受模型自行设置 | +| 操作回执 `OperationReceipt` | operation ID、稳定节点 ID、状态、执行/保存结果 | 校验后的画布和任务记录 | +| 产物 `ArtifactRef` | 资源 ID、task ID、node ID、用途 | 现有资源/素材/任务系统 | +| 业务节点定义(新增逻辑记录) | 类型 ID、版本、归属/作用域、schema、端口、执行映射与内容指纹 | 校验并确认后的项目定义或有权限的模板/插件 | +| 节点执行关联(扩展现有记录) | 节点实例、定义版本、输入/参数版本、内部步骤、批准与任务/资源 ID | 既有创作执行及操作回执,不建立另一套任务状态真相 | + +不能混用现有画布 `projectId` 与短剧业务 `domainProjectId`。新增边界显式区分画布、业务项目和会话,不使用空 ID、默认用户或默认项目兜底。 + +首版普通聊天历史可维持现有存储;新增创作执行、批准和待处理交互必须可服务端恢复。不把旧影视会话接口强行变成所有场景的通用循环,也不全面迁移全部历史。 + +自定义节点阶段补项目定义/个人模板的持久化,实例保存定义引用及可恢复版本,当前参数仍就近复用节点元数据。schema 变更采用显式版本处理,不强制迁移无关历史。费用批准同时绑定所用定义与执行映射版本,防止批准后替换内部工作流而扩大费用范围。 + +### 14.2 会话状态 + +建议使用:`idle`、`running`、`waiting_answer`、`waiting_proposal`、`waiting_payment`、`waiting_canvas`、`waiting_task`、`paused`、`completed`、`failed`、`cancelled`。这些是创作执行状态,不能直接替换现有生成 Task 的状态枚举。 + +模型请求结束不等于整个创作完成;前端等待、用户批准和外部任务终态分别触发后续转换。阻塞交互时释放请求资源,不保留一个永不结束的浏览器 Promise 作为唯一状态。 + +### 14.3 接口原则 + +建议提供:场景配置读取/管理、创作执行创建/查询、版本化更新、问答提交、方案确认、报价准备、费用批准、操作回执查询与提交、执行控制权获取/续期。具体 URL 在实现前与现有路由校准,不在本文伪称已有接口。 + +自定义节点阶段按实际复用增补定义/能力查询、项目定义确认保存、个人模板读取与保存;插件发布沿用现有管理边界。动态定义不能通过前端声称已校验来绕过后端执行、权限与费用校验。 + +- 业务接口继续返回 `{ code, data, msg }`,失败采用真实 HTTP 与业务错误。 +- 写入携带预期 revision 与幂等键。相同键相同请求返回原结果,不同请求冲突返回明确错误。 +- 所有读取、写入、批准和订阅在 service 校验用户与资源归属。 +- 原有文本任务 SSE 沿用事件游标和续传语义,不把任务 ID 当游标。 +- 不为每个卡片创建独立 WebSocket;优先复用现有请求、任务订阅和恢复接口。 +- 持久化结构变化时同步后端数据库专题文档;本文只是设计,不在本轮新增数据库迁移。 + +## 15. 运行恢复、并发与资源边界 + +### 15.1 刷新、关闭和重新进入 + +1. 保存会话、待交互请求、方案和执行回执;文本流可复用现有任务持久化。 +2. 关闭页面后 Agent 不再发起新工具调用。已接受的服务端任务可继续,本机渠道按各自连接能力处理。 +3. 重新进入先查询后端状态与画布快照,对账已提交任务和已执行操作,再恢复界面。 +4. 等待用户的状态继续等待;等待任务的状态可以继续观察。首版对中断中的主动执行显示“继续任务”,不在刷新后静默新增未核对的操作。 +5. 继续使用有效授权,不因刷新重新扣费;授权过期或参数变化再确认。查询失败时保留待核对状态,不把“没查到”当成“没执行”。 + +### 15.2 多标签页与多用户 + +- 同一创作执行只允许一个活动执行方;后端采用带过期时间的控制权/租约或等效机制,浏览器通知仅优化体验。 +- 接管产生新的执行代次,过期页面后续提交被拒绝;前端每次副作用前检查自己仍持有控制权。 +- 跨标签页的浏览器本地节点修改仍需画布保存版本校验和对账,租约不等于消除全部本地瞬时冲突。 +- 其他页面可以查看状态。账号切换清理用户 scope 下的 Query、会话和资源引用。 +- 复用后端已有用户任务并发限制;多业务步骤不意味着无限并行请求。 + +### 15.3 停止、失败与重试 + +“暂停 Agent”停止后续规划和提交,不等于上游任务已取消。对已提交任务显示单独取消状态;若供应商不支持取消,明确仍可能完成和计费。 + +失败分类至少包括:模型请求、问答校验、方案过期、画布冲突、保存失败、费用不足/报价失效、任务生成失败、资源回写失败。仅重做必要部分;生成已成功但资源回写失败时重试资源处理,不重新生成。 + +### 15.4 工具循环预算 + +现有 8 次循环先保持为保护,不简单删除上限。按“当前连续执行段”和“整个创作任务”分别记录请求、token/金额、任务数及失败次数;用户回答或任务完成后可开启新的执行段,但不能借此无限重置总预算。等待不消耗循环请求。 + +连续失败达到限制时记录原因并暂停,不能盲重试。业务计划可以多于或少于模型请求次数;面板不能用循环计数冒充制作进度。 + +## 16. UI 与交互约束 + +- 保留现有主题、布局与三层 token,不复制参考产品品牌和全局 CSS。 +- 面板优先嵌入现有助手空间,任务出现时按需显示;桌面可折叠、窄屏使用抽屉或底部展开,避免遮挡主要画布。 +- 支持键盘操作、焦点恢复、中文输入法、可见选中状态与减少动效设置。 +- 问答卡提交中禁重复提交;后端失败保留输入并允许重试,不清空答案。 +- 引导问题、用户选择和费用批准在历史中可追溯,不能只用短暂 toast 表示。 +- 节点状态、计划和任务队列使用相同 task ID,不各自维护相互矛盾的完成状态。 +- “已创建”“正在保存”“已保存”“生成中”“资源不可用”等文案对应真实阶段。 +- 自定义节点输入端口标明素材角色,编辑参数与执行按钮分开;输出列表能预览、引用和定位。缺少插件或能力时保留内容并展示原因,不出现无法解释的空白节点。 + +## 17. 分阶段交付 + +| 阶段 | 交付范围 | 完成标准 | +| --- | --- | --- | +| P0:合同与小范围接入 | 对齐首批场景字段、工具、技能、计费入口与数据关联;盘点分镜/抽帧/替换能力,定义最低持久化和授权合同 | 同一营销例子能映射到真实工具,区分已存在节点、可组合节点与缺失执行器 | +| P1:画布助手内闭环(内部里程碑) | 动态问答、方案确认、计划显示、确认后节点操作、已有节点辅助编辑、费用门槛、实际生成与回写 | 完成图像生成及局部修改;小编辑不强制计划;不得开放无授权收费,尚不能称为第一期完整交付 | +| P2:首页接续与短片交付(第一期发布) | 首页 Agent 入口、同会话转画布、刷新恢复、操作对账、单执行方控制、参考图到单段视频及预览;按需提取共享逻辑 | 主用例完成真实短片,营销海报作为跨场景回归;首页到画布不丢上下文,重复操作不重复提交,恢复不覆盖手工修改 | +| P3a:业务节点接入 | 复用节点注册,补声明式表单、角色化端口、执行绑定、Agent 发现/校验与定义持久化;接入抽帧和已验证的图片替换能力 | 一个自定义节点能确认创建、真实执行、刷新恢复并向下游提供结果;定义不能覆盖其他类型或绕过费用授权 | +| P3b:场景与模板扩展 | 营销场景、电商工具台、后台场景编辑、个人节点模板与有限组合流程;接续短剧工作台和拉片能力 | 多场景复用协议与循环;模板实例相互隔离,组合步骤按真实执行回执恢复 | +| P4:交付增强 | 基于已有时间线渲染补合成调用、字幕/配音关联和交付包 | 真实文件可预览下载;以实际执行器支持范围定义交付能力 | + +每阶段按最小充分范围验证再扩大。具体工期与数据库拆分在实施前估算,不用未经验证的复用百分比或用户容量作承诺。 + +P1/P2 共同构成第一期体验,不依赖完成所有自定义节点;详细设计中的 I0~I4 是这一范围内的开发顺序。单段短片生成与 P4 的时间线合成/交付包分别验收。P3a 先以单执行器和真实抽帧验证,再扩到 P3b 的组合流程。视频角色/场景/道具替换需能力验证后纳入具体阶段,不以完成节点注册作为交付依据。 + +## 18. 验收清单 + +| 编号 | 场景 | 可验证结果 | +| --- | --- | --- | +| A01 | 用户说明商品、平台、数量、受众 | 不再询问相同字段,仅补必要信息 | +| A02 | 用户信息完整或授权自行决定 | 可直接出方案,不强制问卷 | +| A03 | 自定义回答同时说明风格和渠道 | 更新两项需求,后续重复问题跳过 | +| A04 | 必要素材缺失或解析失败 | 明确阻塞原因,不编造已读取内容 | +| A05 | 切换场景或修改时长 | 保留共用信息,只更新相关方案及报价 | +| A06 | 尚未确认方案 | 本轮没有新增/修改画布节点,不产生隐藏草稿 | +| A07 | 确认“创建节点” | 仅创建批准范围节点/连线,不提交收费生成 | +| A08 | 确认并生成 | 展示明确范围和真实费用,批准记录同时覆盖对应动作 | +| A09 | 普通聊天或单一编辑 | 不出现多步骤计划,不强制大纲 | +| A10 | 多步骤任务、等待确认、完成 | 面板按需出现、真实等待、完成后收起且历史可展开 | +| A11 | 模型错误声称生成完成 | 真实任务未成功或资源不可用时不能显示成功 | +| A12 | 改模型、数量、规格或报价失效 | 原授权不可越界使用,重新报价确认 | +| A13 | 双击、重发、刷新、两标签页 | 不重复建节点、创建任务、扣费或恢复过期执行 | +| A14 | 一批两项仅一项提交成功 | 恢复只处理未提交项,已提交任务继续观察 | +| A15 | 用户在等待时编辑/删除节点 | 不覆盖或复活用户内容,产物仍可从任务/素材中找回 | +| A16 | 生成成功但节点写回失败 | 重试回写,不重新生成或收费 | +| A17 | 账务状态不确定 | 先核对,不自动发起新的付费重试 | +| A18 | 关闭页面再进入 | 已有任务结果可恢复,未批准步骤不自动执行 | +| A19 | 首页进入画布 | 同一会话、需求、方案、批准和任务映射保留 | +| A20 | 停止 Agent | 后续提交停止,上游任务取消情况单独如实显示 | +| A21 | 不同账号/无权限项目 | 读写、批准和订阅均拒绝越权,不泄露素材或凭证 | +| A22 | 深浅主题、窄屏、键盘、长问题 | 卡片可操作,焦点与画布滚动边界正常 | +| A23 | 插件/技能要求不存在的工具 | 给出不支持或替代方案,不伪造执行日志 | +| A24 | 电商试衣与短剧合成 | 只展示已验证能力;模板和模拟界面不能标为真实交付 | +| A25 | 修改名称、提示词、布局或辅助文本节点 | 按明确指令修改指定字段;不强制计划、不自动触发生成,不改变身份/归属/可信状态 | +| A26 | 编辑已有分镜或重新连接参考素材 | 校验真实镜头、资产和连接关系,只影响目标范围;旧产物保留并标注参数变化 | +| A27 | 提出尚不存在的业务节点 | 查找可用执行能力,结构化提案通过校验后展示;未确认无新画布实例或全局临时类型 | +| A28 | 缺少分析/替换模型或使用未知执行 ID | 明确不可执行原因,不用节点名称、提示词或任意 URL 伪造能力 | +| A29 | 注册插件/项目节点 | 名称、尺寸、字段和端口正确;重复类型 ID、跨归属覆盖或越权定义被拒绝 | +| A30 | 原图、角色图、蒙版连接与表单编辑 | 素材角色和数量校验有效;用户与 Agent 遵守同一参数规则,不能互换输入含义 | +| A31 | 自定义节点收费执行与重试 | 报价覆盖真实执行映射;批准后变更定义/范围需重新评估授权,重复提交不重复收费 | +| A32 | 抽帧节点执行 | 依据获批时间点/数量输出真实图片并保留来源时间,结果可供下游引用,取消/失败如实显示 | +| A33 | 组合拉片节点某步骤失败 | 保留已抽帧结果,仅恢复缺失步骤;抽样分析不声称完整切镜检测 | +| A34 | 保存模板并创建两个实例 | 定义可复用,素材、参数和任务相互独立;模板保存/共享不随节点创建自动发生 | +| A35 | 刷新、定义升级或插件停用 | 恢复对应版本;升级不静默改实例;停用后保留节点/产物并阻止新执行 | +| A36 | 修改节点参数或上游素材 | 标出受影响的后续步骤,不自动删除成功产物、不自动重新收费运行 | + +## 19. 验证与文档维护 + +实施时为状态转换、重复问题过滤、版本失效、收费授权、幂等、权限和断点对账编写有意义的专项测试。UI 验证覆盖关键入口、问答与确认、节点创建、真实任务联动以及明暗/窄屏,不用静态 diff 代替运行验证。 + +收费路径先用可控 Provider 和计费测试验证,再在明确授权额度内做真实端到端验证。用同一份商品资料完成“补问 → 两张海报方案 → 确认 → 建节点 → 批准生成 → 单张重做”,记录完成率、额外提问、重复执行、费用与恢复结果。 + +自定义节点阶段补端口兼容、输入映射、schema 受支持子集、定义归属与版本、输出资源引用及组合步骤恢复验证。采用“视频抽帧 → 拉片报告”和“原图/角色图 → 图片替换 → 结果引用”作为两条独立样例;只有实际模型能力验证通过才验收对应替换效果。节点能显示、注册成功或生成了 JSON 不代表执行链路验收通过。 + +数据库或 API 改动实施后同步对应专题;已实现但未由用户确认的内容写入待测试清单。当前仅编写需求设计文档,不把本设计写入“已实现功能”或“已完成测试”。 + +## 20. 评审时需要细化、但不改变目标的事项 + +- 首批电商工具选哪一种真实执行能力:商品主图/场景图可优先,试衣需单独验证。 +- 文本 Agent 调用的具体计费展示、默认批准预算及外部渠道费用提示。 +- 计划面板停靠位置、展开交互与窄屏行为。 +- 首批场景配置编辑采用哪些表单字段,哪些高级规则保留结构化编辑。 +- 会话与批准记录实际落在哪些已有/新增表、哪些接口需扩展,以及原有收费入口的授权接入范围。 +- 首批节点参数 schema 与控件子集、端口数量与输出列表约定、项目定义和个人模板的实际持久化位置。 +- 图片替换模型的实际输入能力与质量标准;拉片采用抽样分析还是额外接入切镜检测;视频替换单独评估。 + +这些是实施前需要落实的工程与产品细节,不是重新选择 Agent 框架或全面改造架构的理由。本需求的固定方向是:复用当前代码,先完成问答、确认、辅助编辑和真实生成闭环,再让 Agent 基于已接入能力定义业务节点、保存模板并编排可恢复的跨场景创作流程。 diff --git a/docs/content/docs/plugins/ai-art-critique.mdx b/docs/content/docs/plugins/ai-art-critique.mdx index 15ea1d93d..3090275c5 100644 --- a/docs/content/docs/plugins/ai-art-critique.mdx +++ b/docs/content/docs/plugins/ai-art-critique.mdx @@ -7,6 +7,8 @@ description: 面向图片创作的 AI 视觉分析、问题定位和修改建议 > 状态:MVP 已实现,当前审美标准为 `2026-08-v1`。 +执行入口已统一到 `art-critique-execution`:手动分析和 Agent 发起分析共用原插件面板。各阶段先取得后台真实报价,用户确认当前费用卡后才提交;后续阶段按已有结果另行报价。原图使用资源引用,一轮最多 9 个不同模型阶段。节点记录报价运行、模型及阶段任务 ID,图片和模型未变时可“继续上次分析”,复用已提交任务和结果,不自动付费重试失败阶段。停止观察不等于取消后台任务;关页后尚未提交的阶段不会自动运行。Agent 可通过 `canvas_start_art_critique` 准备报价,通过 `canvas_read_plugin_node` 读取报告;完整合同见[Agent 能力接入](../../../design/agent-capabilities.mdx)。 + ## 1. 先说结论 这个节点的定位不是“图片生成器”,而是一个视觉老师:用户把图片接进来,它负责看图、指出问题、解释原因,并给出可以执行的修改方向。 @@ -39,6 +41,7 @@ description: 面向图片创作的 AI 视觉分析、问题定位和修改建议 图片节点 -> 创建或连接“AI 审美批改”节点 -> 点击“开始批改” + -> 核对本批费用并确认(后续阶段按实际结果另行报价) -> AI 看图并返回结构化结果 -> 本地校验和整理结果 -> 在原图上绘制标注 diff --git a/docs/design/agent-capabilities.mdx b/docs/design/agent-capabilities.mdx new file mode 100644 index 000000000..3ac0d1b6d --- /dev/null +++ b/docs/design/agent-capabilities.mdx @@ -0,0 +1,70 @@ +--- +title: Agent 能力发现与插件动作 +description: 在线画布助手的节点发现、技能渐进读取和插件动作接入合同 +--- + +# Agent 能力发现与插件动作 + +在线画布助手通过 `canvas_list_capabilities` 查询实时节点注册表和有效启用的插件。内置节点及插件贡献不再依赖助手中的固定节点类型枚举。发现返回名称、来源、输入约束、默认数据和插件提供的动作;发现到声明不代表专用执行器已经存在。 + +## 技能自动发现 + +`canvas_list_skills` 支持 `query`、`offset`、`limit`,默认每页 40 条、最多 100 条,返回 `items`、`total`、`nextOffset`。助手先根据任务检索元数据,再读取 `SKILL.md`,按入口引用搜索、读取关联文件。无需用户手动选中技能;是否选用由模型结合任务判断,不保证每次都调用技能。 + +在线技能工具执行时重新取得当前已加入技能目录,复用账号隔离缓存和安装、移除时的缓存失效机制。插件文档可通过 `canvas_read_plugin` 按已发现 ID 读取。文档和技能说明不扩大用户授权,也不意味着浏览器可以执行技能包中的任意脚本。 + +## 专业短剧节点 + +Agent 拆镜与分镜节点底部输入共用 `generateScriptRows`:调用 `canvas_generate_storyboard` 后,由现有 `agent_storyboard_rows` 任务结合画风、角色、画布资产和短剧技能生成真实行。原始剧本保存在 `composerContent`;没有节点时可用 `canvas_create_storyboard` 创建明确标为待拆镜的空表,此时不得宣称镜头已完成。此入口用于已获准的搭建任务,不取消创作方案确认;通用方案直接交付镜头时仍要求完整 `script.shots`。 + +受管模型先用 `creation-runs` 准备真实报价,用户确认后批准并提交;外部自定义渠道沿用明确说明外部费用的提交确认,不伪造价格。整表重拆确认会说明替换镜头行、保留原媒体;取消或报价过期不提交。局部修改继续使用 `canvas_edit_storyboard`。专业拆镜完成后 Agent 继续读取真实表格检查,再进行后续操作;取消时停止本轮。单镜时长选项不表示总片长,总片长应在剧本要求中说明。 + +创作方案和通用工作流支持 `styleboard`、`story_input`、`script`,复用手动短剧入口的数据结构。画风节点初始为空,显示系统已有的选择入口;故事节点使用专用编辑卡片;分镜节点使用真实表格。 + +`script.shots` 必须逐镜填写正数 `durationSeconds` 和非空 `videoMotionPrompt`,`dialogue` 按需填写,最多 100 行,写入稳定行 ID。仅有正文或提示词的分镜方案会返回校验错误供模型修正;通用节点创建工具也校验真实分镜行,不能以空表代替交付。方案连到分镜节点时使用 `storyboard:context` 输入。仅创建这些节点不产生媒体生成项;图像、视频仍沿用既有方案和费用确认。 + +`canvas_get_context.storyboardReadiness` 与 `canvas_read_storyboard.readiness` 复用原分镜生成前置校验,返回画风状态、阻塞原因、真实行数量、未填完整的行 ID 和实际总时长。助手先检查这些状态;缺画风时读取真实目录,结合已确认风格与用户授权应用,只有关键取舍尚不明确时才追问。已有空表通过逐行编辑补齐,保留原行 ID 和素材关联;有足够剧本信息时直接编写镜头,不要求用户重复手动生成分镜。提示词引导不替代生成入口的业务校验,写入镜头也不代表媒体已生成。 + +`canvas_list_styles` 按 `source=system|user` 查询系统预设或当前用户保存的风格。`canvas_apply_style` 只接受该目录返回的真实 ID,复用页面的异步画风保存入口:关联项目时先保存项目设置,再应用画布节点;不关联项目时走原画布保存机制。异步返回时校验画布、项目及用户上下文,避免写到新页面。该动作是原画风选择逻辑,不冒充普通节点撤销能够回滚服务端项目设置;需要改回时重新应用原风格。 + +`canvas_read_storyboard` 返回真实行 ID、镜头内容及素材关联。`canvas_edit_storyboard` 支持 `append`、`update`、`remove`,按行 ID 操作;仅允许镜头文本与正数时长字段,不允许伪造任务状态或素材 ID。移除行保留生成素材,剩余行保留 ID 并重排序号。分镜编辑进入原画布操作、撤销与历史记录链路,不自动重新生成媒体。 + +## 插件动作接入 + +已在宿主注册的 `RegisteredPlugin` 可提供 `agentActions`: + +```ts +agentActions: [{ + id: "create-analysis-node", + description: "创建图片分析节点", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + buildOperations(input, snapshot) { + // 在此校验本动作入参,仅返回操作,禁止请求模型或产生其他副作用。 + return [{ type: "add_node", nodeType: "my-analysis-node" }]; + }, +}] +``` + +模型根据目录通过 `canvas_plugin_action` 提交 `pluginId`、`actionId`、`input`。动作构建器负责业务入参校验;宿主核对插件有效启用状态、动作存在性、`canvas.write` 权限、操作数量及生成操作的 `generation.run` 声明,再交给原画布执行链路。工具调用和结果沿用原会话记录。 + +构建器必须是纯函数:输入和快照以副本传入,返回 1 至 100 个操作,不自行网络请求、收费或写入资源。`inputSchema` 用于向模型说明合同,不替代动作构建器的校验。仅上传带节点声明的 JSON 插件不会凭空获得 JavaScript 执行器;执行器需要通过宿主支持的插件运行时接入。本接口没有增加任意远程代码执行能力。 + +插件还可提供 `readAgentNode(node, snapshot)`,将节点状态转换为有限的结构化结果,通过 `canvas_read_plugin_node` 读取。必须校验插件启用及 `canvas.read` 声明,不返回内嵌媒体和密钥。审美批改、肖像排查已接入 `prepare-input` 动作和状态读取;同一图片已有对应节点时复用,不重复创建。审美报告仅在当前唯一输入与报告指纹一致时返回,否则标为过期。 + +节点类型冲突会拒绝注册。插件停用后不再出现在能力目录,新建节点和调用插件动作都会失败,旧作品保留。未知节点类型不能降级为普通文本。 + +## 当前边界与后续接入 + +审美分析的手动入口和 `canvas_start_art_critique` 共用原插件面板及 `art-critique-execution`。Agent 准备输入后可打开分析并准备报价;已有当前图片的完整报告直接复用。只有用户明确要求重新分析时传 `retry=true`。打开面板后暂停本轮工具循环,同批后续操作不执行,避免等待费用确认期间重复调用模型;分析完成后用户可让 Agent 读取报告继续。这次接入使用原插件弹窗展示费用卡,尚未把费用卡嵌入聊天消息或完成后自动唤醒会话。 + +`ApprovedToolExecution` 复用已有 `creation-runs` 报价、批准和幂等执行接口。各阶段先准备真实请求,再展示费用;确认按钮只批准当时费用卡列出的提交 ID,后来到达的阶段继续等待。首批为场景理解及四个独立视觉分析,后续聚合、定位、复核和提示词按前面的实际结果报价,不预先虚构后续输入。按用量收费的金额是估算,不是封顶;自定义渠道另行标注外部费用。 + +各阶段通过现有后台文本任务运行,原图先准备为资源引用,任务正文不内嵌媒体。每轮最多 9 个不同阶段;节点保存 `billingRunId`、`lastRunId`、`lastRunModel` 和 `stageTaskIds`,任务元数据记录节点、执行和阶段标识。底层 pipeline 注入批准后的阶段执行器,不复制审美判断与解析逻辑。工具调用仍进入原会话历史;费用记录由服务端创作运行保存,节点保存恢复入口。 + +关闭面板或停止观察不代表后台任务取消或未收费。重新打开后,在图片、节点和模型未变时可选择“继续上次分析”:同阶段相同请求找回原报价或任务,已提交阶段沿用原任务 ID,已完成结果重新参与本地管线计算,未批准阶段继续等确认。报价过期且未提交时更新报价并重新确认;刷新后旧费用卡不能批准新报价。请求内容变化时后端拒绝复用,需重新开始,不静默新增收费阶段。 + +执行控制或报价错误会中止其他待确认阶段,不能被当作部分模型结果降级后宣称成功。已提交阶段的模型失败保留原管线的部分报告处理,不自动付费重试;整轮重新分析走新报价。当前仍是浏览器驱动的管线,关页后已提交后台任务继续,尚未提交的后续阶段不会自动运行。画布输入或账号变化、插件停用会阻止继续提交或回写旧结果。 + +本批接入在线画布助手。首页尚未改造成通用工具循环,本机 Agent 仍使用原技能包交付机制。现有插件只有声明而没有 `agentActions` 时,可发现贡献并创建节点,不宣称能自动执行它的专用分析、编辑或导出。 + +已补齐画风预设的模型选择与项目同步、分镜专用增删改,以及 Agent 发起审美分析报价。肖像排查涉及本机任务,仍使用插件自己的执行入口;后续接通该执行器,以及首页与本机端的同一能力目录。不得以任意 `metadata` 更新替代具有业务校验和费用语义的执行接口。 diff --git a/docs/design/creative-agent-dynamic-planning.mdx b/docs/design/creative-agent-dynamic-planning.mdx new file mode 100644 index 000000000..af80cea0f --- /dev/null +++ b/docs/design/creative-agent-dynamic-planning.mdx @@ -0,0 +1,404 @@ +--- +title: 通用创作 Agent 动态规划与画布协作需求设计 +description: 基于原 Agent 会话与现有画布能力,设计专业角色指导、动态问答、计划、分批执行及方案和费用确认 +--- + +# 通用创作 Agent 动态规划与画布协作需求设计 + +> 状态:待用户审阅,尚未实施。本文描述目标行为,不代表当前系统已经具备这些能力。 +> +> 第一批实施进度:用户批准先做有限灵活性调整,已接入自定义问题字段、专业角色指导、动态计划和手动请求结果分析;继续保留现有完整方案与费用合同。原型视觉同步应用于原 Agent 卡片与输入框上方计划条。本文其余任务级 run、方案分层、统一批次执行等仍是后续设计,不应视为已实现。 +> +> 本文是在第一期融合实现之上的调整设计。与旧设计有冲突时,以本文审阅通过的条目作为后续实施依据;未涉及的现有能力继续保留。配套旧原型仅供布局参考,不代表本方案的动态执行协议。 + +## 1. 产品目标 + +在当前画布的原 Agent 会话中,让用户通过自然语言、素材和问答卡片完成创作。Agent 根据任务扮演导演、设计师、电商美工、营销策划等专业角色,自主理解需求、读取画布、提出创意、规划步骤,并在授权范围内使用真实工具推进任务。 + +核心原则:创作路径由模型决定;真实能力、资源归属、执行结果、版本与费用授权由程序决定。 + +典型体验: + +1. 用户描述目标,Agent 读取当前画布与已有资料。 +2. 信息不足时按需提问;信息充分时直接给出创意方案。 +3. 用户修改或确认方案,确认前不创建画布节点。 +4. Agent 在批准范围内分批创建正式节点、整理布局与连线。 +5. 涉及收费生成时展示真实费用,等待明确确认。 +6. 工具完成后,Agent 根据结果决定继续、补问、修正或结束。 +7. 计划、问答、批准与结果保存在原历史会话中,刷新后可核对恢复。 + +以上是可能经过的环节,不是每次都要执行的固定七步。普通咨询、纯文案、修改节点、制作整套作品采用不同路径。 + +## 2. 范围与非目标 + +### 2.1 本次包含 + +- 原在线 Agent 的动态规划和执行闭环。 +- 专业角色提示词、按需加载已有 Skills 和可用插件能力。 +- 模型自定义问题、选项和需求字段。 +- 动态计划、可修改方案、按批次操作当前画布。 +- 统一画布写入与收费授权检查。 +- 失败反馈、有限自动修正、局部重做、历史及恢复。 +- 短剧、设计、电商、营销及跨场景任务的共同运行机制。 + +### 2.2 本次不包含 + +- 不替换为 Harness,不建设完整服务端画布 MCP。 +- 不新增独立智能创作聊天页面或第二套输入框。 +- 不创建草稿节点;确认前的方案只存在于会话中。 +- 首页接续继续暂缓,不扩大当前画布内的实施范围。 +- 不建设通用角色/问卷低代码编辑器、公开插件市场或任意脚本执行平台。 +- 不承诺模型能够临时发明可执行节点、编码安装插件或调用未注册工具。 +- 不将浏览器关闭后的持续 Agent 思考纳入本期。已提交后端任务可以继续,后续编排需页面恢复。 +- 不以本次改造直接承诺长剧、配音、字幕、剪辑合成全链路;具体交付受已接入能力约束。 +- 不实现多用户同时编辑同一画布的完整协作协议。 + +## 3. 当前代码依据与差距 + +| 当前入口 | 已有能力 | 本次要调整的限制 | +| --- | --- | --- | +| `web/src/components/canvas/canvas-assistant-panel.tsx` | 原会话、模型选择、Skills、画布工具循环、上下文和工具确认 | 控制逻辑集中在组件;原生成工具与新创作链路需要统一授权 | +| `web/src/components/canvas/canvas-creative-interaction.tsx` | 消息内问答、方案、报价、进度、历史 detail | 当前控制器按结构化消息创建,需转为同一任务跨多轮持续运行 | +| `web/src/lib/creation/creative-scenarios.ts` | 四类场景角色、字段、参考问法、方案章节与节点规则 | 场景字段与流程建议偏强,跨场景组合不自然 | +| `web/src/lib/creation/creative-agent-contract.ts` | 问题规范化、已知字段过滤、答案校验 | 未配置字段会被过滤,每轮硬性最多三题 | +| `web/src/lib/creation/creative-agent-tools.ts` | `creative_respond` 结构化输出 | 一个工具承担问答、方案、节点和生成配置,技术字段过早绑定 | +| `web/src/lib/creation/creative-agent-state.ts` | 方案、规格、依赖校验与计划展示 | 媒体节点必须立即配齐生成项;计划由固定阶段拼接 | +| `web/src/services/creative-agent-controller.ts` | 批准、画布提交、媒体依赖、报价与恢复 | 结果处理以既定流程为主,需要回到原 Agent 决策 | +| `web/src/services/api/creation-runs.ts` | run、租约、版本、审批、报价、任务与画布提交 API | 当前方案审批绑定具体 ops,不能直接视为可支持任意后续动作授权 | +| `backend/internal/service/creation.go`、`creation_canvas.go` | 权威批准、幂等、受限提交与任务执行 | 需要补充任务级计划、执行批次与批准范围之间的校验 | + +已观察到的实际失败:视频节点使用了图片模型;后续方案遗漏 `generationItems`。目前已补上失败方案反馈和手动修正入口,但这只是错误恢复,不等于本设计的动态运行闭环。 + +## 4. 方案选择 + +| 方案 | 优点 | 问题 | 结论 | +| --- | --- | --- | --- | +| 只丰富场景提示词和选项 | 改动最少 | 字段限制、固定计划和执行分支仍然存在 | 不足以达到目标 | +| 原 Agent 负责决策,现有控制器负责受控执行 | 复用会话、画布、任务、审批与计费,逐步调整合同 | 要统一不同工具的执行边界 | 推荐 | +| 替换整套 Agent 框架与画布接入 | 可重新定义运行架构 | 迁移大,无法替代产品侧权限、账务和资源工作 | 本期不采用 | + +## 5. 专业角色、Skills 与插件 + +### 5.1 角色配置从流程模板改为专业指导 + +建议角色配置保留:标识、展示名称、适用任务、专业目标、判断原则、质量检查点、参考案例和推荐 Skills。现有字段及问题参考作为建议,不作为唯一允许集合;节点规则中的真实业务约束保留为质量与素材要求。 + +| 角色 | 指导重点 | 不强制规定 | +| --- | --- | --- | +| 导演 | 人物动机、冲突、镜头叙事、节奏、连续性、现有剧本理解 | 题材、时长、角色数量、必须先做定妆图 | +| 视觉设计师 | 信息层级、构图、色彩、排版、品牌一致性 | 固定三种风格、固定海报结构 | +| 电商美工 | 商品真实性、购买动机、主图/详情/场景用途、卖点表达 | 必须做全套详情页、必须生成模特 | +| 营销策划 | 受众、传播目标、渠道、创意切入、行动引导 | 必须剧情化、必须制作视频 | + +用户可以直接指定角色,也可以由 Agent 推荐;角色变化不清空已有需求。商品广告片允许组合电商、美术和导演指导。角色组合仍由同一个 Agent 完成,本期不引入多 Agent 调度。 + +### 5.2 按需加载 + +- 初始上下文提供精简角色和 Skill 目录,相关内容按需读取。 +- 用户明确指定的角色/Skill 优先于系统推荐,仍不能绕过产品权限和收费规则。 +- Skill 提供方法与规范;插件必须有实际注册的工具,才能增加执行能力。 +- 工具目录声明输入、输出、读写影响、是否收费、可用条件及限制。 +- 未提供抽帧、元素替换、合成等工具时,Agent 应指出缺口或提出可执行替代,不创建假功能节点。 +- 现有原生节点与语义化工作流逐项审计后接入。第一批沿用已支持的文本、图片、视频;音频及其他专用节点按真实合同分步开放。 + +## 6. 上下文与需求记忆 + +### 6.1 每轮决策所需信息 + +1. 当前用户指令与最近对话。 +2. 结构化需求摘要:目标、事实、偏好、限制、待解决问题、自主决定授权。 +3. 当前画布 ID、真实节点、选区、关联资源与近期变化。 +4. 当前任务、动态计划、方案版本和已批准范围。 +5. 当前可用模型、工具、Skills 目录及所需能力详情。 +6. 最新工具结果、失败原因、未完成操作和未消费报价。 + +原始历史完整保存,模型上下文采用摘要和按需读取,不要求把全部节点、全部 Skills、全部历史每轮重复发送。 + +### 6.2 需求字段 + +保留目标、时长、数量、比例、素材等可执行标准字段,允许模型补充如“结尾情绪”“品牌气质”“包装允许变化范围”等自定义字段。 + +每个字段保存稳定标识、显示名称、值、来源、对应消息/节点、确定程度与修订关系。程序生成可信标识;模型可以建议字段名称,但不能直接写执行权限、账务字段或服务端状态。 + +“模型建议”“用户确认”“用户授权 Agent 选择”分开保存。用户同意自主创意不代表授权虚构商品参数、随意增加费用或改变交付目标。 + +同义问题先关联已有字段,避免换一种表述重复问。确需修改已确认内容时,应说明冲突并记录新的用户依据。 + +## 7. 动态问答需求 + +### 7.1 何时提问 + +- 缺失信息会影响交付目标、事实准确性、关键创意选择或可执行性。 +- 用户资料之间冲突,无法可靠判断。 +- 现有能力不支持目标,需要选择替代方案。 +- 当前结果引出了必须由用户决定的新问题。 + +不因场景模板存在字段就追问。可以合理建议的非关键偏好先给推荐,用户已授权自主选择时直接推进。 + +### 7.2 卡片交互 + +- 复用单选、多选、自由文本、素材选择和分页组件。 +- 问题标题、说明、候选项由模型结合当前任务生成。 +- 默认推荐每轮 1~3 题;允许确需一起决定时到 6 题。6 是防止长问卷的产品保护上限,不是要求凑满的题数。 +- 选项可以随场景、画布素材和已知需求变化,提供自由补充。 +- 支持“由你建议”;必需的真实商品事实、素材身份等不能以自主建议替代。 +- 用户可以不点击卡片,直接在原输入框回答;Agent 解析后更新原问题状态并记录来源。 +- 用户改变目标时旧问题标记为已替代,不自动删除历史答案。 +- 有能力产生缩略图选择时可以复用真实素材预览;不能为展示选项偷偷生成收费图片。 + +### 7.3 验收重点 + +已知 10 秒、横屏时不再问时长与比例;指定完整剧本时先读剧本;商品图与价格已提供时直接规划;没有任何必要问题时不显示问答卡。 + +## 8. 动态计划与 Plan 式体验 + +界面只参考第一期原型的交互与布局。配色、字体、圆角、阴影和控件状态以系统当前主题及主题 token 为准,不复制原型主题,不单独建立 Agent 皮肤。 + +### 8.1 计划的用途 + +计划描述用户可以理解的工作目标,例如“分析商品”“确定视觉方向”“制作首张图”“完成系列图”,而不是暴露 API 调用列表。 + +只有多步骤任务才显示紧凑计划条,展开后查看当前、已完成、等待和跳过步骤。普通聊天、单次咨询不显示计划。无需新增独立模式页面;规划/执行状态在原会话中自然体现。 + +### 8.2 计划可调整 + +- Agent 可以新增、重排、拆分、跳过未执行步骤,并用一句话说明有业务意义的变化。 +- 已执行记录不能通过重写计划抹掉;删除未开始步骤也保留修订记录。 +- 步骤总数显示当前计划数量,变更后允许从 2/5 变成 2/6,附“新增结果检查”等说明。 +- 步骤完成必须有依据:已展示方案、用户回答、成功工具结果或真实资源。 +- 失败、部分成功、等待用户和等待后端任务分别展示,不能统称处理中。 + +### 8.3 推荐状态 + +计划步骤采用待开始、进行中、等待用户、等待任务、完成、失败、跳过。整体任务继续尽量复用现有 CreationStatus;计划步骤状态单独保存,避免将业务阶段膨胀成大量 API 状态。 + +计划允许动态调整,不代表审批状态可以由模型自行改变。 + +## 9. 创意方案与执行批次 + +### 9.1 创意方案 + +用户确认的内容包括:目标与交付物、核心创意、主要内容、沿用素材、关键规格、本阶段准备创建/修改的节点范围、明显能力缺口与自主选择范围。 + +方案格式按场景调整:导演可以给剧本与镜头设计;设计师可以给构图与视觉方向;电商美工可以给逐张用途与卖点。复用 Markdown 与方案卡片,不强制所有场景显示同样章节。 + +方案可分阶段批准,例如先批准剧本与角色方向,角色图产生后再讨论分镜。无需一次规划整部作品的全部技术细节。 + +### 9.2 执行批次 + +每批次保存目标步骤、方案版本、操作清单、受影响节点、素材依赖、解析后的生成规格、预期结果和执行状态。 + +创意方案无需包含每一个未来任务的完整供应商参数。但确认前应做能力可行性检查,不允许明知无法生成 60 秒视频却承诺直接交付。 + +批准后可以创建本阶段正式节点,并将未准备好的生成参数标记为待配置。只有参数完整、依赖就绪、报价批准的生成项才能执行。 + +模型选择所需能力及效果偏好;程序从当前目录解析真实候选。存在显著价格、质量或时长差异时交用户决定,不能悄悄替换已批准模型。 + +### 9.3 批准范围如何约束动态动作 + +当前后端批准绑定具体 ops,后续不能直接扩展成“这个 run 已批准,所以所有操作都允许”。建议将批准记录同时绑定: + +- 方案版本和内容摘要。 +- 允许创建的业务对象及数量、类型。 +- 允许修改的已有节点 ID 与字段范围。 +- 交付规格、关键素材与禁止修改项。 +- 当前具体操作清单或由受限解析器生成的批次摘要。 + +后续批次必须由程序检查属于该范围;不能机械判断归属时重新展示批次确认,不让模型自行声称属于已批准范围。 + +首批实施优先采用可枚举的节点与字段清单。跨范围的大幅动态扩展需要新批准,避免过早建设复杂通用授权语言。 + +## 10. 确认与费用规则 + +| 操作 | 默认规则 | +| --- | --- | +| 读取当前画布、查看已授权素材、读取 Skill | 可自主进行 | +| 编写需求摘要、问题、方案、调整未执行计划 | 可进行文本推理,沿用当前文本计费方式 | +| 创建或修改方案中的正式节点 | 先批准方案或对应变更;批准范围内不逐节点重复问 | +| 用户明确要求移动/重命名指定节点 | 作为明确操作指令处理,遵循原工具确认偏好,不强制完整创意方案 | +| 改变数量、关键素材、时长、创意方向 | 对受影响部分重新确认 | +| 图片/视频及其他独立收费生成 | 真实报价批准后提交 | +| 删除或覆盖未在批准范围内的用户内容 | 展示目标与影响并确认 | +| 已消费生成的重做 | 新生成项与新报价,重新批准 | + +“继续”“你决定”不构成费用批准。原确认开关可以控制普通工具操作体验,不能关闭创作方案与收费的硬门槛。 + +文本模型用于问答、方案编写和执行后判断,不等到最终确认才调用。方案确认与费用确认针对画布执行及具体生成消费;界面不能声称所有规划过程免费。 + +费用卡绑定真实模型、规格、数量、参考输入、请求摘要、报价版本与有效期。条件变化使相应批准失效,未受影响的已完成任务保留。 + +批量费用批准只覆盖卡片列出的有限请求。依赖前序产物、尚无法形成确定请求的后续生成,在依赖就绪后报价;本期不引入无限预算授权。 + +## 11. 统一画布执行入口 + +### 11.1 复用原画布能力 + +原读取、搜索、选区、节点、连线、布局与后置校验继续复用。模型通过已有适配器使用当前页面画布,本期无需改为服务端 MCP。 + +写操作执行前检查:当前画布匹配、真实资源归属、目标节点存在、批准范围、当前状态版本、输入合同和必要依赖。 + +执行后记录操作 ID、受影响节点、真实结果、变更摘要及错误。通过当前节点实际状态确认变更,不以模型回复作为完成依据。 + +### 11.2 防止不同工具绕过确认 + +原 `canvas_apply_ops` 中的生成操作、`canvas_run_generation`、生成工具的 `autoRun`、工作流自动运行与新创作控制器都必须进入同一执行检查。 + +普通画布写入与收费生成可以采用不同执行器,但批准范围和费用提交的验证不能只写在提示词或前端按钮里。后端对创作生成请求强制关联有效批准;具体路由合同在实施时覆盖所有现有入口。 + +### 11.3 手动编辑与并发 + +执行前重读受影响节点。用户修改过内容时保留用户版本,重新计算差异;改变已批准的生成输入时重新报价。 + +同一页面继续串行化 Agent 提交与原自动保存。执行租约防止同一任务被多个执行器同时推进,但不宣称解决整个画布跨设备协作。 + +## 12. 工具结果驱动的循环 + +决策循环为:读取任务上下文 → 模型决定下一步 → 程序校验 → 必要时等待用户 → 执行已授权动作 → 保存结果 → 回到模型。 + +到达问答、方案或费用等待点时暂停执行。同一批模型回复中出现等待交互后,不能继续执行夹带的写入或收费操作。 + +批次完成后,Agent 可以继续、补问、更新尚未执行步骤或建议重做。成功产物必须保留;后续推理失败不能把整个作品标记为不存在。 + +视觉检查仅在当前模型及输入协议真正支持读取产物时进行。视频审阅可能需要可用抽帧或视频理解工具,缺失时只报告状态、尺寸、时长等实际检查结果。 + +## 13. 失败与恢复 + +| 失败类型 | 处理 | +| --- | --- | +| 工具 JSON、引用或能力选择错误 | 将具体错误和失败参数作为工具结果返回,允许最多两次自动修正;不创建节点或生成媒体 | +| 信息缺失或目标冲突 | 改为提问,不反复生成无效方案 | +| 没有匹配模型或工具 | 展示能力差距,询问替代,禁止编造模型 ID | +| 报价过期或输入改变 | 作废相应未消费报价,重新报价并确认 | +| 网络中断且提交结果未知 | 查询原操作/提交 ID,不新建重复任务 | +| 媒体任务失败 | 保留成功项,说明失败项;新尝试按原账务事实判断并重新报价 | +| 用户暂停 | 停止后续编排,明确已提交任务可能仍在执行 | +| 页面关闭 | 保存等待位置;重新进入后核对后端任务,再由用户继续 | + +自动纠正限于未执行参数,不自动重跑收费媒体。文本自动修正也消耗推理资源,达到限制后提供“修正方案”“修改要求”等操作,不无限循环。 + +## 14. 原会话与历史持久化 + +一个原聊天会话可以包含多个创作任务,一个任务可以跨多轮问答、多个方案版本和多个执行批次。不能继续把每条结构化消息都当成独立任务事实源。 + +原消息保存轻量内容快照与 task/run、interaction、proposal、step、batch 引用。服务端 run 保存当前执行事实;历史消息保留当时展示内容,不能全部渲染为最新方案。 + +需要持久化: + +- 用户输入、结构化答案和自主选择授权。 +- 需求摘要与来源、问题提交/替代状态。 +- 计划版本、步骤修订与执行依据。 +- 方案版本、用户批准范围、变更说明。 +- 操作批次、报价与批准、任务与产物引用。 +- 错误、自动纠正次数、暂停和恢复位置。 + +按用户 scope 隔离。UI 区分本地缓存与服务端保存;不将媒体大对象写进 localStorage,不在历史中保存密钥。 + +恢复先读服务端事实。已消费的批准不重放,旧方案确认按钮不可再次执行,失效报价不可继续扣费。旧单消息 run 历史只读展示;未完成旧任务按旧协议继续或明确结束后重新规划,不静默升级授权。 + +## 15. 概念数据合同 + +以下是职责草案,不代表立即增加同名数据库表: + +| 对象 | 核心内容 | 权威来源 | +| --- | --- | --- | +| CreativeTask | 会话、画布、需求摘要、角色、当前计划和等待点 | 程序管理,绑定现有 run | +| Requirement | 字段、值、来源、确定程度、修订 | 模型提取,程序校验 | +| QuestionInteraction | 问题、候选项、关联字段、答案、版本 | 模型提问,程序分配标识并保存提交 | +| AgentPlan / PlanStep | 步骤、依赖、修订、状态、完成证据 | 模型建议结构,程序维护实际状态 | +| CreativeProposal | 内容、交付、规格、素材、拟操作范围 | 模型提案,用户批准 | +| ExecutionBatch | 具体操作、目标节点、前置条件、方案版本 | 程序校验和执行 | +| Approval | 批准类型、范围、版本、有效性 | 用户动作与服务端记录 | +| Submission / Task | 真实请求、报价、消费及结果 | 复用现有后端 | + +标准字段和自定义需求分别存储;模型输出不直接合并进程序配置。权限与账务相关字段不接受模型赋值。 + +当前 CreationStatus 和 run.state 尽量复用。若批次幂等、批准范围需要索引与事务保证,则新增明确持久结构并同步数据库文档,不能只依靠无约束 JSON 提供安全保证。 + +## 16. 代码调整建议 + +| 模块 | 调整职责 | +| --- | --- | +| 原 `CanvasAssistantPanel` | 保留会话 UI,将新增决策循环协调逐步提取,避免继续堆进页面组件 | +| 场景配置 | 改为专业指导与推荐 Skill;常见字段作为建议 | +| 问答合同与卡片 | 动态字段、自然语言回答关联、可变题数、历史替代状态 | +| 计划合同与组件 | 持久化模型计划及修订,实际执行状态驱动展示 | +| 方案校验 | 分开创意可行性与执行参数完整性检查 | +| 创作控制器 | 聚焦租约、批准、报价、提交、幂等与恢复,工具结果交回原 Agent | +| 新增有限执行适配层 | 聚合现有工具的读写/费用分类、批准检查与执行回执;不另建模型 SDK | +| 后端 creation service | 批次、范围、版本与收费校验,复用现有 handler/service/repository 分层 | + +可以将模型交互区分为提问、更新计划、提出方案、提出执行批次四种明确动作;是否拆成四个工具在实施时结合供应商 schema 支持决定。不能再要求所有动作同时填写一份完整工作流。 + +## 17. 分期交付与人工验收 + +### A:动态规划与方案体验 + +交付角色指导、动态需求字段、动态计划、方案分层、原历史关联。未完成执行门槛改造前,不开放新的自主写入路径;不完整执行参数只用于规划展示。 + +验收: + +- 完整需求直接出方案,不补无关问题。 +- 用户通过普通输入框回答卡片,已回答字段不会再次提问。 +- 一个商品广告需求可同时采用电商与导演方法。 +- 问答数量和计划数量根据任务变化。 +- 缺少后续媒体参数不阻塞创意方案阅读,无法完成的目标有明确说明。 + +### B:受控的动态画布执行闭环 + +交付任务级 run、执行批次、统一写入与费用门槛、结果回到模型、历史恢复。A+B 构成本次推荐最小完整交付。 + +验收: + +- 方案确认前零新增节点、零媒体生成。 +- 批准范围内分批创建节点,不每个节点重复确认。 +- 从原工具、新创作入口及 autoRun 均不能绕过费用确认。 +- 用户改动相关节点后,旧请求不能覆盖新内容或继续消费旧报价。 +- 刷新、重复点击确认、网络超时均不重复创建或扣费。 +- 成功与失败混合批次保留成功项,只处理失败项。 + +### C:产物驱动与专业能力增强 + +按实际可用视觉/视频能力接入检查,扩展更多已存在的节点工具与 Skills。缺失工具单独立项,不纳入基础闭环的隐含承诺。 + +验收: + +- 根据真实产物提出下一步,而非固定跑完全部节点。 +- 只修改指定海报或镜头,其他产物保留。 +- 新增交付或重做产生新范围/费用确认。 +- 无视觉读取能力时不输出虚构的画面检查结论。 + +本轮仅设计,不运行自动测试。实施后按用户要求进行实际使用调试;上线前涉及审批、账务、幂等和资源归属的验证需单独记录,不以浏览器截图代替这些行为的验证。 + +## 18. 三类端到端审阅示例 + +### 短剧 + +用户给出剧本和角色图。Agent 先读现有素材,提出镜头与节奏方案,不重复问题材和角色。用户批准本阶段后创建剧本文本与必要镜头节点,引用已有角色图。实际视频时长超出模型上限时先解释并询问拆段或缩短,不私自改变。生成费用确认后制作,产物完成后再决定是否需要下一镜头或局部重做。 + +### 电商 + +用户提供商品图并要求两张促销海报。Agent 确认真实促销信息与不可改包装范围,提出两种构图。用户批准后创建对应节点及素材引用,按真实模型与数量报价。第一张完成后,可在可用视觉能力下检查商品保留情况,继续第二张或提出调整;调整已批准请求时重新报价。 + +### 营销与普通设计 + +用户只要活动文案,Agent 直接给文字,不附带收费图片。用户随后要求海报时在同一任务或明确关联的新任务中扩展方案。用户仅要求将已有标题节点左移并改名时,走具体操作确认规则,不重新询问受众、风格、尺寸。 + +## 19. 建议审阅的产品决策 + +以下给出明确推荐默认值,审阅时可直接修改: + +| 决策 | 推荐默认值 | +| --- | --- | +| 是否新增模式切换 | 不强制新增;原会话显示规划/执行状态 | +| 角色是否需要用户手动选 | 不需要,支持用户指定和模型按需组合 | +| 每轮问题数量 | 默认 1~3,确有需要最多 6,绝不凑题 | +| 参数错误自动修正 | 最多两次未执行参数纠正,不自动重跑媒体 | +| 画布节点何时创建 | 批准本阶段方案后创建正式节点 | +| 执行中是否可以改计划 | 可以;范围与费用变化单独重新确认 | +| 是否保留逐次媒体费用确认 | 保留,可批准明确列出的一个批次 | +| 本次最小交付 | A+B,C 按真实工具能力逐步加入 | +| 首页与跨画布 | 本次不扩大,当前画布内闭环优先 | + +## 20. 交付判断 + +本次完成的判断不是“可以生成更多问题”,而是同一个 Agent 能结合真实画布与用户反馈,选择不同创作路径、维护动态计划、在批准范围内执行,并在结果、错误或需求变化后合理调整;同时,确认、费用、历史和恢复仍然可追溯。 diff --git a/docs/design/creative-agent-phase-one-checkpoint.mdx b/docs/design/creative-agent-phase-one-checkpoint.mdx new file mode 100644 index 000000000..44c07da4b --- /dev/null +++ b/docs/design/creative-agent-phase-one-checkpoint.mdx @@ -0,0 +1,49 @@ +--- +title: 创作 Agent 第一期版本基线 +description: 已实现范围、用户体验反馈、代码核对与下一阶段边界 +--- + +# 创作 Agent 第一期版本基线 + +用户已反馈基本流程验证通过。本基线保存当前可用实现,作为后续工作台扩展和问题定位的对照;这不代表全部模型协议、费用异常路径及部署环境均已通过专项验收。 + +## 本期实际范围 + +- 原画布在线助手支持动态问答、正式方案、按需执行计划、节点操作、分批报价、生成与结果接续,复用原会话、输入框、模型和技能。 +- 普通画布工具按当前确认开关执行;模型承接当前任务授权,减少重复提问。结构化方案及费用仍以程序批准为准。 +- 历史问答、方案和报价折叠;引用显示类型与标题;首页/创作页和画布在线、本机助手均采用回车发送、Shift+回车换行。 +- 前端传输单份标准消息,后端路由后适配供应商;看图按需读取预览、原图或局部,派生图上传为资源,任务正文只保存资源引用。 +- 上下文按预算保留当前要求和执行事实,旧会话按完整组省略。运行事实保存在创作 run/submission 中,历史消息保留展示快照。 + +## 收尾代码核对 + +| 边界 | 当前依据与结论 | +| --- | --- | +| 历史恢复 | `CanvasCreativeInteraction` 从原消息读取快照和 runId,controller 加载服务端 run;历史只读,恢复连接不自动批准 | +| 生成批准 | `ExecuteCreationSubmission` 在事务内检查批准、过期、撤销、方案范围及价格签名,再创建任务和额度预留;已有 TaskID 返回原任务 | +| 修改 | `proposal-invalidate` 撤销未提交生成的执行项;已提交任务不会由文字修改自动取消 | +| 资源 | 看图消息通过 `referenceImages` 提供资源清单;后端按用户读取 ready 资源,执行时还原;创作 run/submission 纳入资源删除引用检查 | +| 撤销 | 原画布撤销最多保留十批;用户后续修改导致快照不匹配时清空撤销链,避免覆盖手工编辑;不取消已提交生成或退费 | +| 隔离 | 浏览器缓存使用用户 scope,controller 检查账号与生命周期;服务端 run、submission、画布和资源按当前用户查询 | + +以上为静态代码核对,不记为自动测试或浏览器全流程验证。本轮按用户要求未运行测试、类型检查、构建或收费模型请求。此前后端已通过开发启动编译,健康接口可用;它们仅证明启动入口可用。 + +## 保留的专项验收 + +- 四种模型协议的工具调用/结果及资源引用,尤其 Gemini 思考签名与协议插件。 +- 刷新、切换会话、断线、暂停后恢复,以及修改前后旧报价不能再次消费。 +- 原图复用、裁剪上传幂等、资源被引用时禁止删除、账号切换隔离。 +- 明暗主题、窄屏、中文输入法、引用/技能候选与发送状态。 +- PostgreSQL 迁移和部署验证。 + +## 版本与恢复边界 + +本次 Git 提交记录代码、设计和验收说明,不包含本地账号数据库、上传媒体、密钥、日志或构建产物。以该提交作为后续改动的对照点,必要时在独立检出中恢复代码,不覆盖正在使用的工作区。 + +本期数据库由 v8 增加到 v9。Git 回退不能恢复数据库、资源或账务;部署前应单独备份数据库和资源。数据库恢复或迁移回退需要另行核对影响,不随代码回退自动执行。 + +## 下一阶段 + +先设计首页进入画布时的同任务上下文接续、页面工具范围及角色/技能加载,再接通一个电商业务流程。当前仅首页发送快捷键统一,不宣称已实现首页到画布的同任务迁移。 + +本期不引入 Harness、完整服务端画布 MCP、动态插件节点注册、多人协作画布或完整长视频合成。这些能力继续作为独立阶段设计。 diff --git a/docs/design/creative-agent-phase-one-prototype.html b/docs/design/creative-agent-phase-one-prototype.html new file mode 100644 index 000000000..6a3c90084 --- /dev/null +++ b/docs/design/creative-agent-phase-one-prototype.html @@ -0,0 +1,235 @@ + + + + + + 影策 · 第一期创作 Agent 交互设计 + + + +
+
交互原型 · 示例数据不会调用模型或扣费
+
+
+
+ + + + + diff --git a/docs/design/creative-agent-phase-one.mdx b/docs/design/creative-agent-phase-one.mdx new file mode 100644 index 000000000..dfba54fe8 --- /dev/null +++ b/docs/design/creative-agent-phase-one.mdx @@ -0,0 +1,357 @@ +--- +title: 通用创作 Agent 第一期体验与实现设计 +description: 复核两段录屏及六张截图,以连续问答、方案确认、同会话画布执行和真实视频产出为第一期验收主线 +--- + +# 通用创作 Agent 第一期体验与实现设计 + +> 最新入口调整:按用户确认,首页本期暂缓,撤回新增独立创作界面;问答、方案、确认、执行和历史融合进原画布 Agent,会话和输入框不另建。下文首页接续属于暂缓设计,当前实现以[运行说明](../content/docs/backend/creative-agent-runtime.mdx)为准。 + +> 状态:第一期代码已接入,待联调与用户验收。本文细化[完整需求设计](../content/docs/overview/creative-agent-design.mdx),本期交付边界以本文为准。实现与待验收差异见[待测试](../content/docs/progress/pending-test.mdx)。未调用真实收费模型,不能将自动化测试通过等同于达到录屏效果。 + +配套[交互原型](creative-agent-phase-one-prototype.html)使用示例数据,仅供查看布局、卡片和状态转换,不证明真实生成或保存能力。原型中的价格、资源和节点状态均为演示。 + +原型顶部“评审状态”可以直接切换普通聊天、问答、方案、费用、执行、结果与失败状态,这是设计查看工具,不是产品中的权限入口。原型不实现真实模型语义理解、通用素材上传、后端恢复或收费校验;这些按本文验收脚本实施。HTML 文件独立保留,便于后续评审。 + +## 1. 第一期要交付的体验 + +用户从创作首页输入想法,Agent 结合上下文补问,生成可以查看和修改的创意方案。用户批准后,原会话接续到画布,Agent 创建本阶段真实节点与连线,按授权范围生成素材和视频,结果同时出现在会话和画布。进度可展开,用户可以修改节点或局部重做。 + +第一期以“一集短片试作”为主要验收场景,目标接近第一个视频中的 15 秒、9:16、悬疑短片体验;同时以“两张商品营销海报”验证同一套问答、确认和计划合同能用于其他场景。系统不固定悬疑、15 秒或六道题。 + +本期的可用版本必须覆盖首页到画布、真实视频生成和结果预览。开发可先完成画布内部能力,但不能把仅有问答卡或只有图片生成的中间成果称为已达到视频参考效果。具体视频模型必须先验证时长、比例和素材输入支持;能力不足时说明差距,由用户选择可执行替代,不静默缩短视频。 + +固定边界:沿用当前在线 Agent 和后端模型中转;不接入 Harness,不建设完整服务端画布 MCP,不增加草稿节点,不全面重构,不改变浏览器关闭后 Agent 无法继续推进的运行边界。 + +## 2. 参考资料复核 + +### 2.1 第一个视频:主要交互目标 + +参考文件为用户上传的“录屏2026-09-07 12.46.02.mov”,长度约 54 秒。以下时间是录屏播放位置,不是生成任务的耗时。 + +| 时间点 | 可直接观察到的内容 | 转化为本期需求 | +| --- | --- | --- | +| 00:00 | 左侧画布已有创意文本、道具图、角色图、场景图、视频;右侧为“搭建短剧工作流”会话 | 画布和会话并排,产物具有业务名称;历史能回看 | +| 00:06 | 题材卡,选项含标题/说明、已选高亮、已提交状态及 1/6 导航 | 单卡分页、清晰选择、提交回执、历史可浏览 | +| 00:10 | 时长卡显示 2/6,后面是“我已确认以上信息” | 回答按结构化信息汇总,一批提交后连续推进 | +| 00:14 | 6/6 为自由补充;模型汇总悬疑、15 秒、试做一集、9:16、写实等信息 | 自定义输入与选项共存;需求摘要参与后续规划 | +| 00:21 | Creative Design 产物卡,带故事板标签、查看分镜入口;随后为确认方案卡 | 方案是可打开的内容对象,不能只是长段聊天或空缩略图 | +| 00:28~00:35 | “确认方案,开始制作”“修改剧本”“换一个方向” | 确认、局部修改、方向调整三种明确分支 | +| 00:41 | 会话内展示竖屏视频画面及 0:15,带相关操作入口 | 真实结果可以预览,并与画布中的结果对应 | +| 00:53 | 结果卡后仍出现“服务器繁忙,请稍后重试” | 生成结果和后续助手响应分别记录,后续失败不能抹掉已成功产物 | + +证据边界:该录屏主要是滚动浏览已提交的历史,开头已有节点,因此不能据此认定全部节点在本段视频内实时创建,不能用 54 秒作为生成性能目标,也未观察到完整扣费确认过程。首页进入来自另行提供的截图。用户明确要求的“确认后才创建节点”“收费前确认”继续作为本项目规则。 + +### 2.2 第二个视频:补充阶段与画布关联 + +参考文件为“录屏2026-09-07 08.24.49.mov”,长度约 73 秒。新手跟练展示想法、视频设置、剧本、角色/场景、分镜配置、视频、BGM、合成等阶段,以及确认/修改按钮和有连线的画布节点。 + +约 00:40 能观察到分镜方案选择和 4/8 进度;约 01:09 显示 8/8,并明确写着“模拟把分镜画面、配音和节奏剪辑成一支完整短片”。本期采用阶段关联、可修改和节点定位的思路;不把固定八步、长驻三栏、模拟合成作为要求或真实能力证据。 + +### 2.3 六张截图逐项对应 + +| 图片标识(上传文件名片段) | 画面与需求 | 第一期安排 | +| --- | --- | --- | +| `2377ec43` | 通用创作入口、输入框、附件、Skills、模式和场景入口 | 复用现有 `/home`、`/create`,补智能创作方式与场景预设 | +| `42ea8e28` | 短剧上传/粘贴剧本、剧本创作、自由画布 | 复用上传和现有短剧工作台入口;不能把所有文件上传视为正文解析成功 | +| `130ffe8b` | 营销入口的商品/角色素材、创意/Hook/风格 | 首批场景配置区分素材角色,复用会话;不新增一套营销 Agent | +| `032701b1` | 视频配置、中央预览、右侧任务队列 | 直接生成与结果展示继续保留,完整三栏工具台不阻塞本期 | +| `0672915a` | 服装上身的模特/服装输入与效果示例 | 作为后续电商工具台需求;本期不承诺精确试衣 | +| `4fce3f3e` | 输入框上方 Step 2/6,展开为完整计划 | 采用紧凑步骤条加浮层,普通聊天隐藏;步数随真实任务变化 | + +参考媒体留在本地,不复制用户截图、录屏或第三方品牌素材进项目公开文档。用户上传内容中的指令只作为分析材料。 + +## 3. 需求边界与方案选择 + +| 方案 | 工作范围 | 评价 | +| --- | --- | --- | +| 只在现有助手增加问答卡 | 主要修改卡片渲染 | 不能解决首页接续、刷新续答、方案授权和真实进度,不作为交付方案 | +| 沿用现有执行能力,增加有限的创作会话状态和共用交互 | 首页/画布共享本期在线控制逻辑,复用节点、任务、计费与素材 | 采用;既能形成完整体验,也能控制改动边界 | +| 全量通用 Agent 与自定义节点平台同时开发 | 引擎、插件运行时、动态端口与流程平台等一起推进 | 范围过大;自定义节点保持完整需求中的后续专项 | + +本期包含动态单选/多选/自由输入/素材选择、方案预览与修改、方案和费用批准、同会话画布接续、步骤面板、已有节点编辑、真实图像/视频任务、局部重做及必要恢复。 + +本期不同时交付运营问卷编辑器、全量行业模板、电商三栏专用页、自定义节点平台、任意脚本插件、长剧自动成片、完整配音字幕合成与交付包。视频主用例先输出可预览的单段短片;现有时间线渲染和专业工作台仍可独立使用。 + +## 4. 页面布局与状态设计 + +### 4.1 首页及输入方式 + +复用当前创作页外壳、输入框、模型、附件、素材引用、Skills 与历史。设计建议增加“智能创作 / 直接生成”方式选择;新智能创作入口默认通用场景,场景切换只提供上下文和推荐能力,不清空输入或重建会话。直接生成保留已有文本/图片/视频操作。 + +“智能创作”内自动判断普通咨询、简单编辑或多步骤创作,不要求用户理解工具调用。输入不完整时补问;已经完整时直接规划。模型选择继续使用真实可用配置,不能把界面的“智能匹配”当作已配置了所有媒体模型。 + +首页会话在现有主区展开,不提前创建空节点或隐藏画布。问答、方案和详情均在会话空间内完成;已有画布发起的任务则在其右侧会话中完成相同交互。 + +### 4.2 八种可评审状态 + +| 状态 | 主区域 | 当前主操作 | 画布与进度 | +| --- | --- | --- | --- | +| S0 空闲 | 首页输入、场景入口、推荐技能 | 发送需求 | 无任务计划 | +| S1 理解/补问 | 简短说明与问答卡 | 回答并继续 | 多步骤任务有紧凑进度;无本轮节点 | +| S2 方案待确认 | 摘要、方案卡、详情 | 确认方案,创建节点 | 查看详情不建节点 | +| S3 画布接续 | 画布与同一会话 | 查看执行范围/准备生成 | 批准后创建本阶段节点与连线 | +| S4 费用待确认 | 数量、模型、规格、真实报价 | 批准本批生成 | 无对应获批任务时不显示生成中 | +| S5 执行 | 节点任务状态、会话产物 | 暂停后续执行/查看结果 | 紧凑进度可展开,依真实任务更新 | +| S6 完成 | 可播放/下载产物、结果摘要 | 局部修改或重做 | 计划收起,历史保留 | +| S7 异常/暂停 | 保留成功结果和待处理内容 | 针对当前失败继续或重试 | 区分生成失败、回写失败、助手响应失败 | + +### 4.3 画布侧栏与步骤条 + +画布使用现有右侧 `AssistantPanelColumn`,默认宽度建议约 440px,服从现有按视口限制的拖拽范围。顶部显示会话名称与必要控制,中部滚动消息,底部为步骤条和输入框。主画布保留当前工具、缩放和节点编辑入口。 + +步骤条示例:“第 3/6 步 · 等待你确认参考图”。点击向上展开步骤浮层,点击外部或 Escape 关闭并恢复焦点。浮层高度受会话区约束,不遮挡输入主操作;节点执行不持续抢用户视口。 + +普通聊天不出现步骤条。任务生成计划后显示;完成/取消后移入结果消息,可再次展开。并行任务显示“已完成 2/6,运行中 2 项”,不以连续编号制造串行假象。问题卡的“第几题”、业务计划步数、模型请求轮数分别表达。 + +小于 768px 的设计采用会话/画布切换或已有抽屉,同会话不重置;不强行压成三栏。主题、主操作、普通选中、焦点和减少动效均沿用项目 token。 + +## 5. 上下文问答的详细行为 + +### 5.1 已知信息先入需求摘要 + +场景规则提供稳定字段、字段解释、必需条件、允许默认值和推荐选项;模型结合会话、用户引用素材、已有画布选择及 Skills 提取信息。程序校验提取结果与待问字段,不仅依赖模型记忆最近几句聊天。 + +用户输入“做一集 15 秒、9:16 的写实悬疑短片”时,不再询问题材、时长、集数、比例或风格。若故事内容未确定,可询问创意方向或直接在用户允许自主决定的范围内给方案;不能为了演示强制补齐六题。 + +每个字段记录来源与状态。明确答案优先于推断,最新明确修改覆盖旧值。已上传但未解析的资料不能当作已知内容,素材身份不明时使用素材选择卡补齐用途。 + +### 5.2 问答卡合同 + +每批默认不超过 3 个必要问题,配置允许调整;录屏的 6 题是表现参考,不是固定题数。卡片一次展示一题,包含标题、必要性说明、选项标题/描述、自定义输入、上/下一题与批次提交。 + +- 单选点击仅选择;多选不自动跳页;下一题只切换界面,不代表批准执行。 +- 每题允许自定义时直接提供输入,不要求输入选项数字;可选题显示跳过。 +- 最后一题提供“提交并继续”,缺少必需输入定位到对应问题;不把默认选中或放置不操作当作回答。 +- 提交成功后显示“已提交”和答案摘要;可回看各题。修改通过新版本提交,不在旧批准记录中原地改答案。 +- 用户在普通输入框补充信息时,先合并需求,重新筛选未提交问题;保留其他输入,对已解决的题显示已由补充信息回答。 +- 必要信息存在冲突时只澄清冲突字段。已确认信息无需再次要求“确认摘要”,可直接进入方案。 + +设计示例(仅表示逻辑结构): + +```json +{ + "kind": "question_request", + "interactionId": "question-example-1", + "revision": 1, + "questions": [ + { + "id": "story-direction", + "field": "story.direction", + "type": "single", + "title": "你更想从哪个悬念开始?", + "options": [ + { "id": "elevator", "label": "电梯里的陌生楼层", "description": "密闭空间,结尾出现异常楼层" }, + { "id": "phone", "label": "来自自己的电话", "description": "一个电话打断日常,留下反转" } + ], + "allowCustom": true, + "required": false + } + ] +} +``` + +可信 ID、revision、状态和来源由程序维护;模型仅提出问题内容与字段关联。同一份场景规则参与提示构造与程序校验,所选场景和 Skills 必须显式进入当前消息组装点。 + +## 6. 方案、大纲与批准 + +### 6.1 方案卡要有可阅读的内容 + +方案卡显示标题、版本、摘要、产物数量/规格和“查看方案”。短片详情包括故事大纲、角色/场景/道具设定、镜头或叙事节奏、已有素材与待生成素材、输出视频规格。营销详情替换为受众、卖点、文案、视觉和交付清单,使用同一确认合同。 + +详情通过受控抽屉或会话内展开呈现 Markdown 与结构化列表;无需新增故事板编辑器或在确认前创建 Script 节点。“方案 v1”是会话内容对象,不称为草稿画布。缩略图若没有真实素材,使用明确的内容图标,不伪造已生成图片。 + +短片示例方案可描述:主角进入电梯,指示灯跳到不存在的楼层,门缝出现异样手影,最后以主角反应收尾。具体文字由模型生成,方案应说明哪些画面来自同一段视频,不能把文本中的多个叙事节奏误称为已完成的多个视频镜头。 + +### 6.2 三个分支 + +| 操作 | 行为 | +| --- | --- | +| 确认方案,创建节点 | 批准明确的本阶段节点、连线和参数准备;不隐含收费任务批准 | +| 修改方案 | 指定要改的内容,生成新版本并标示受影响部分;保留未受影响的要求 | +| 换个方向 | 保留比例、时长、素材等共用信息,替换创意方向;旧未执行批准失效 | + +报价与执行参数完整时可以提供“确认并生成”,一次提交记录方案与费用两类批准。报价尚不确定时不提供这个按钮,不以一句“开始制作”允许未限定的后续付费调用。 + +一期先允许对话修改方案、复用已有节点编辑;不额外实现完整富文本协作编辑器。已批准方案的文字、参数或执行映射不能静默变化;新的用户输入到来时,取消尚未使用的旧执行段并评估影响。 + +## 7. 首页接续与画布实际操作 + +### 7.1 同一运行记录跨页面 + +新增智能创作使用同一个 `CreationRun`(设计名)。首页和画布分别投影同一需求、待交互、方案、计划和任务引用,不把两个旧聊天列表互相复制。保留旧直接生成历史与旧画布会话,不进行全面迁移。 + +确认后若尚无画布,按稳定操作键创建一个实际画布,关联 run,再导航到已有画布路由。URL 只携带不含凭证的关联 ID;接收方按当前账号读取并校验。画布关联成功但跳转失败时可重试打开,不重新建画布;关联失效则进入待核对状态。 + +首页卸载前终止其主动执行段,画布挂载后获取控制权并恢复。节点写入需要挂载后的真实画布适配器;首页不能使用虚构快照或空项目 ID 提前执行。画布未就绪时显示“正在打开画布”,不标成已创建节点。 + +### 7.2 第一期复用哪些节点 + +| 内容 | 节点映射 | 更新方式 | +| --- | --- | --- | +| 创意大纲 | 现有 Markdown/文本节点 | 确认后写入方案内容,后续修改按范围更新 | +| 分镜表(确有需要时) | 已有 Script 节点 | 经现有镜头、风格和资产校验;不为单段视频强制造一个空表格 | +| 角色、场景、道具参考 | 现有 Image 节点及业务元数据 | 生成状态与真实资源回写;优先复用用户已有可用素材 | +| 短片输出 | 现有 Video 节点 | 任务与产物引用;生成失败与资源加载失败分开 | +| 辅助说明/分组 | 现有文本/背板 | 仅在布局和理解需要时创建 | + +确认后可以创建等待执行的正式媒体节点,它们不是确认前的草稿层。所有节点要有真实用途、提示词/参数和对应状态,不用多个空文本卡片冒充工作流。 + +布局按“方案 → 参考素材 → 视频/图片结果”安排,同一批素材可并行排列。连线必须表达真实依赖;媒体模型支持几个参考输入、各自角色是什么,以能力校验结果为准,不只在画面上把所有图连接过去。必要时用户选择参考组合或另批生成合成参考图,新增费用需授权。 + +每批预分配稳定节点/连接 ID,验证输入、归属和批准范围后执行。结果回执分别记录内存已变更、本地已缓存和服务端已保存。用户拖动或编辑后重新读取快照,避免旧异步结果覆盖手工内容;失败只核对和恢复缺失部分。 + +## 8. 生成授权与分阶段推进 + +短片示例采用:文本理解/问答/规划 → 本阶段节点创建 → 参考素材生成 → 必要的素材选择 → 视频生成 → 可用结果交付。已有素材可以跳过参考生成;已明确选中的素材不再强制确认一次。 + +### 8.1 三种确认各自负责什么 + +| 确认 | 批准内容 | 触发时机 | +| --- | --- | --- | +| 问答提交 | 用户的需求与选择 | 信息缺失时,不代表节点写入或费用批准 | +| 方案批准 | 指定版本下的节点和执行范围 | 创作写入前;明确的小编辑可以直接由用户指令授权 | +| 费用批准 | 模型、规格、数量、计价依据、允许费用与操作范围 | 实际收费请求前;已批准范围内不重复弹相同确认 | + +文本模型本身可能收费。首个收费请求前通过发送区的计费提示或费用卡批准有限调用范围,随后持续补问和规划。首批验证建议使用可确定计价的模型;按 token 计价只有在能执行上限时显示“最多”,否则明确估算与可执行限制。自定义渠道显示外部费用归属,不承诺平台能封顶第三方账单。 + +媒体费用卡显示“生成哪些产物、各几项、使用什么模型、什么规格、确定金额或估算、已批准数量”。参考图与视频参数尚未共同确定时分批授权;若范围和报价都明确,可以一次批准并按依赖推进。 + +### 8.2 后端强校验与重复提交 + +复用现有报价、`CreateTask` 与账务服务,在收费 admission 增加批准范围核验与稳定执行键。当前报价没有完整快照标识,设计需保存价格版本/依据、有效期和规范化参数;报价变化重新确认。 + +同一 run/批准/执行项重复提交应返回原任务;同一幂等键参数不同则冲突。批次先记录获批子项,逐项消费,部分成功只补未提交项。不能依靠按钮 disabled、内存 Map、`confirmTools` 或请求中的 `source: agent` 实现此保证。 + +通用工具执行器必须执行相同授权检查,模型没有直接调用旧收费入口的旁路;普通手工生成也必须具有可验证的用户提交语义。账务状态不确定时复用现有核对/重试门槛,不自动新建付费任务。 + +## 9. 计划、日志与真实状态 + +短片可以采用“了解需求、确认创意、准备参考素材、确认视频参数、生成短片、整理结果”六步作为示例,但模型可以按任务简化、拆分和调整,程序负责有效状态。 + +| 事实 | 计划/界面表现 | +| --- | --- | +| 问答等待 | 等待你补充,停止下一阶段副作用 | +| 方案待确认 | 等待你确认方案,不显示正在创建 | +| 提交生成获后端 task ID | 排队中或生成中,不提前打勾 | +| Provider 成功但资源未就绪 | 处理中,尚不可复用 | +| 资源与节点/会话引用保存完成 | 生成步骤成功,可预览/引用 | +| 视频已成功,后续助手总结失败 | 视频继续可用,提示总结失败,可单独继续 | +| 用户暂停或某一项失败 | 保留已完成部分,标明等待/失败项 | + +会话可以展示简短活动摘要,如“读取 2 份参考资料”“已创建 5 个节点”,依据真实工具回执生成,详情默认折叠。产品主流程不展示命令、原始 JSON 或技能文件路径,不伪造“执行命令”“写入文件”来模仿参考。业务计划不是模型思考过程。 + +## 10. 最小代码改动边界 + +| 现有代码/符号 | 核查结果 | 本期改动 | +| --- | --- | --- | +| `web/src/pages/create/index.tsx` | `CreationConversation` 是直接生成历史 | 增加智能创作入口,接新 run;保留当前直接生成路径 | +| `web/src/components/canvas/canvas-assistant-panel.tsx` | 循环、工具、确认与 UI 集中;首轮强制工具调用 | 按模式选择工具;保留本机路径;提取本期首页/画布真实共享的在线控制逻辑 | +| `buildToolAgentMessages` | 保留最近 8 条普通消息,未保留完整跨回合工具上下文 | 显式拼场景规则、brief、方案及当前协议段;避免仅依赖聊天截断 | +| `pendingToolContextRef`、`approveOnlineTool` | 关键续接上下文只在内存;刷新后可报上下文不完整 | 持久化待交互和可恢复工具段,支持版本化提交 | +| `executeOnlineToolCalls` | 主要按错误中断工具批次 | 增加等待结果与屏障,阻止提问/待批准之后的写调用 | +| `web/src/lib/canvas/canvas-asset-handoff.ts` | 当前只转 asset,非整会话 | 新 run 关联采用明确交接;原素材转画布功能继续复用 | +| `web/src/pages/canvas/use-canvas-agent-operations.ts` | 已有操作及生成消费;默认 `resumeAgent` 为空实现 | 接真实继续规划回调、执行代次与停止信号 | +| `web/src/services/canvas-generation-consumer.ts` | 已有生成结果消费与持久化 | 复用任务/资源回写和消费键,补 run/step 关联 | +| `backend/internal/model/models_task.go` | 现有 Session 含影视会话与 CanvasOps 语义 | 不直接把旧 Session 变成全场景状态机;新增有限创作运行记录 | +| `backend/internal/service/task_creation.go`、`logical_model_quote.go`、`task_billing.go` | 已有收费入口,缺本期批准/请求幂等合同 | 扩展准入与价格快照,复用既有任务、账务和 Provider | + +建议只形成少量新增边界:`web/src/lib/creation/` 的合同/纯校验,`web/src/components/creation/` 的共用问答/方案/费用/计划卡片,本期在线控制器,以及 `web/src/services/api/` 的创作运行接口。后端按 handler → service → repository/model 扩展。控制器提取仅覆盖实际共享部分,不为本机渠道、插件平台或全部历史重写架构。 + +## 11. 状态与恢复合同 + +逻辑结构建议如下,不要求每种记录独立建表;正式表和迁移在实施任务中对照现有数据库细化。 + +| 记录 | 最少字段 | +| --- | --- | +| CreationRun | 用户/会话关联、可选 canvasId 与 domainProjectId、场景版本、status、revision、executionEpoch、brief、proposal、plan、预算引用 | +| Interaction | interactionId、类型、revision、原 toolCallId、完整当前工具批次/已执行结果、待答问题或方案、提交结果与状态 | +| Approval | 用户、run、方案版本、范围指纹、报价依据与有效期、额度、获批子项及消费结果 | +| OperationReceipt | 稳定操作键、画布/节点/连接 ID、stepId、任务/资源 ID、本地/服务端保存结果、待核对状态 | + +会话展示消息与模型协议消息可关联但不等同。持久化只保存必要的可恢复协议段和资源引用,不保存 API Key、Cookie、临时签名链接或整份包含凭证的模型配置;恢复时重新解析合法资源与当前配置。 + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> running: 用户提交且满足收费授权 + running --> waiting_answer: 缺少必要信息 + waiting_answer --> running: 同一交互提交成功 + running --> waiting_proposal: 方案准备完成 + waiting_proposal --> running: 修改方案 + waiting_proposal --> waiting_canvas: 方案批准 + waiting_canvas --> waiting_payment: 画布就绪且需要费用授权 + waiting_payment --> waiting_task: 批准并提交任务 + waiting_canvas --> waiting_task: 已有有效费用授权并提交 + waiting_task --> running: 结果就绪后继续规划 + running --> completed: 交付条件满足 + running --> paused: 用户暂停或请求异常 + waiting_task --> paused: 任务或回写失败 + paused --> running: 对账后用户继续 + completed --> [*] +``` + +图示为主要路径,实际取消从任意未结束状态可进入 cancelled;任务终态使用现有 Task 枚举,不被创作 run 状态替代。等待素材选择、方案或费用时均释放本轮模型请求资源。 + +执行规则: + +1. 同一模型响应包含“提问 + 创建节点 + 生成”时,交互工具形成执行屏障。此前合法只读结果保留,后续副作用暂不执行;答复后重新评估原写调用,过期内容不得重放。协议中的每个 toolCallId 最终都要有合法结果或取消结果。 +2. 刷新先恢复 run、交互、批准与任务状态,再对账画布操作。等待用户继续等待,已提交任务继续观察;不因恢复自动新建未知状态的任务。 +3. 当前 run 只允许一个执行方持有控制权;切换页面/标签页增加执行代次,旧回调不能继续提交。后端验证代次与批准,浏览器在节点写入前重新检查控制权和画布版本。 +4. 暂停取消新的规划/提交;上游任务取消单独展示。成功生成但回写失败只恢复回写,成功视频后总结失败只继续总结。 +5. 现有连续 8 次模型循环仍是保护,等待不消耗请求次数;新回答可开新段,但整任务调用/金额/失败预算持续累计,不能无限重置。 +6. 关闭页面后不发起下一阶段,已接受的后端任务可继续;再进入显示结果和“继续任务”。退出时保存失败需明确区分仅本地恢复与云端已保存。 + +## 12. 第一期实施顺序与完成标准 + +| 内部步骤 | 具体结果 | 放行条件 | +| --- | --- | --- | +| I0 能力核对 | 确定可用文本、参考图和短片视频模型;对齐场景字段、费用与 run 合同 | 15 秒/比例/参考输入可验证;不满足时明确产品差距 | +| I1 交互与持久化 | 问答、方案、计划及待交互记录,普通聊天分支 | 重复问题被过滤,刷新可继续回答,未批准不写画布 | +| I2 画布与授权 | 方案批准、稳定节点/连接、报价批准、任务幂等与回写 | 一批真实图像任务可完成、可恢复、无未授权收费 | +| I3 首页同会话接续 | 共享本期在线控制逻辑,首页到画布、单执行方控制 | 不丢答案/方案/批准,刷新/重复跳转不重复创建 | +| I4 视频与完整验收 | 短片生成、预览、局部修改/重做;营销海报回归 | 真实短片可访问,辅助编辑可用,两场景同一合同 | + +I1~I3 是内部中间结果;本期发布以 I4 的完整链路通过为准。没有真实模型验证时,只能交付界面/协议能力并明确视频尚未验收,不以原型替代。 + +## 13. 验收脚本 + +### 13.1 主用例:接近第一个视频 + +1. 在创作首页输入“帮我搭建短剧工作流,先试做一集”,完成首段文本费用授权。 +2. Agent 补问缺失条件。选择悬疑、15 秒、9:16、写实,并可自由补充故事要求;已说“试做一集”不再询问集数。 +3. 提交后显示需求摘要与可阅读的创意方案,查看详情、修改一个情节;此时本轮画布节点为零。 +4. 确认最新方案,进入画布,右侧保留原对话、答案和方案;看到有意义的文本/素材/视频节点及真实依赖连线。 +5. 按真实报价批准参考素材任务,观察排队、生成与可用状态。使用已有素材的情况应跳过对应生成。 +6. 在视频所需参考/参数明确后批准视频费用,生成期间可以展开步骤、定位节点或暂停后续执行。 +7. 获得实际可播放的竖屏短片,会话和画布指向同一资源;完成后步骤收起,用户可下载。 +8. 要求“只修改这个提示词”时仅更新字段;要求重做视频时重新评估费用授权,保留其他成功结果。 + +### 13.2 必需回归与异常 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| V01 | 输入已包含题材、时长、集数、比例、风格 | 跳过这些问题;不强制六题 | +| V02 | 自定义回答同时填多个字段 | 更新多个字段,后续已解决问题被移除或标记已回答 | +| V03 | 普通咨询/明确改名 | 无制作计划,无强制大纲;不因首轮工具要求而写画布 | +| V04 | 未确认方案但查看分镜详情 | 无本轮节点、连线或隐藏草稿 | +| V05 | 方案/参数改变后点击旧确认 | 拒绝过期版本,展示有效版本 | +| V06 | 同一模型响应同时提问和写画布 | 等待屏障阻止后续副作用 | +| V07 | 问答/确认中刷新 | 恢复同一交互与协议上下文,无“工具上下文不完整” | +| V08 | 重复批准、重复跳转、双标签页 | 不重复建画布/节点/任务或扣费,过期执行方不能写入 | +| V09 | 一批两项只提交成功一项 | 返回已成功任务,仅补未提交项 | +| V10 | Provider 成功,素材回写失败 | 不显示整步成功,不重新生成,只重试资源回写 | +| V11 | 视频可用但助手总结失败 | 视频仍可预览/下载,单独继续总结 | +| V12 | 用户在等待时移动、编辑、删除节点 | 不覆盖手工内容;生成产物仍可从任务/素材找到 | +| V13 | 超预算、报价过期、账户切换、越权资源 | 阻塞对应写入/收费,不兜底默认额度或用户 | +| V14 | 不支持 15 秒或多参考输入的模型 | 提示实际能力与可选方案,不静默变更规格 | +| V15 | 两张营销海报 | 复用同一问答/方案/授权/计划,使用营销字段,不出现短剧专属必问题 | +| V16 | 深浅主题、窄屏、长选项、中文输入、键盘 | 卡片和步骤可操作,滚动不误缩放画布,提交失败保留输入 | + +运行验证需使用受控 Provider 和账务测试,随后在明确获批的真实额度内完成端到端验证。只做静态检查不能宣称上述用例通过。 + +## 14. 评审结论与实施前事项 + +本设计已选定首页同会话接续、右侧助手、分页问答、可阅读方案卡、紧凑步骤条、确认后创建现有节点、分批费用批准和真实短片交付的主线。自定义节点与插件注册扩展继续保留在完整需求,排在基础闭环之后。 + +进入实施前需核实部署中可用的模型与计价配置、短片参考输入限制,以及实际 run/批准记录落表方式。界面原型的数值和默认选项仅用于评审;最终 UI 使用真实能力、报价和用户上下文。 diff --git a/docs/index.md b/docs/index.md index ca1c2681c..420341222 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,15 @@ ## 设计沉淀 +- [创作 Agent 第一期版本基线](design/creative-agent-phase-one-checkpoint.mdx):用户基本体验验收反馈、已实现范围、执行与资源边界核对、待验收项及代码恢复说明。 + +- [通用创作 Agent 动态规划与画布协作需求设计](design/creative-agent-dynamic-planning.mdx):基于原会话的专业角色、动态问答与计划、创意方案和执行批次分离、统一画布与费用授权、历史恢复及分期验收(待审阅,未实施)。 + +- [通用创作 Agent 第一期体验与实现设计](design/creative-agent-phase-one.mdx):复核两段录屏与六张截图,细化首页连续问答、可修改方案卡、确认后画布操作、按需步骤条、费用授权和真实单段短片交付,附[交互原型](design/creative-agent-phase-one-prototype.html)(第一期代码已接入,待联调验收)。 +- [智能创作会话与执行](content/docs/backend/creative-agent-runtime.mdx):实际代码入口、API、审批与任务幂等、画布保存和恢复边界。 + +- [通用创作 Agent 需求与设计](content/docs/overview/creative-agent-design.mdx):复用现有在线助手、画布、Skills、插件与生成任务,涵盖动态问答、方案确认、按需计划、费用授权、已有节点辅助编辑、自定义业务节点、插件注册与执行绑定;包含分镜、拉片/抽帧、元素替换用例及分阶段验收;不接入 Harness、不建设服务端画布 MCP、不增加草稿节点(需求与设计评审稿,未实施)。 + - [插件平台与市场演进调研](design/plugin-platform-and-marketplace-research.md):插件机制代码审计、对外回应、外部 SDK 与隔离运行时、独立插件验收、受控目录到公开市场的分阶段方案(调研建议,未实施)。 - [站点及外观与皮肤主题设计合同](design/site-appearance-and-skins.mdx):品牌一致性、登录页与邮件、SEO/备案、三层皮肤令牌、无闪屏启动顺序和验收边界。 diff --git a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx index 5049ea75e..201320fe7 100644 --- a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx +++ b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx @@ -1,10 +1,10 @@ +import { canvasThemes } from "@/lib/canvas-theme"; import { Button, Tag } from "antd"; import { AppModal } from "@/components/ui/product/app-modal"; import { EmptyState } from "@/components/ui/product/empty-state"; import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { canvasThemes } from "@/lib/canvas-theme"; import { ART_CRITIQUE_SCHEMA_VERSION, ART_CRITIQUE_PLUGIN_ID, @@ -22,9 +22,12 @@ import { type ArtCritiqueReport, } from "@/lib/art-critique/contracts"; import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, layoutArtCritiqueLabels, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation"; -import { runArtCritiquePipeline } from "@/lib/art-critique/pipeline"; +import { executeArtCritique } from "@/services/art-critique-execution"; +import { ApprovedToolExecution, type ApprovedToolExecutionState } from "@/services/approved-tool-execution"; +import { CreativeQuoteCard } from "@/components/creation/creative-agent-cards"; +import { getActiveUserScope } from "@/lib/user-scope"; import { useCopyText } from "@/hooks/use-copy-text"; -import { imageToDataUrl, resolveImageUrl } from "@/services/image-storage"; +import { resolveImageUrl } from "@/services/image-storage"; import { modelOptionLabel, useEffectiveConfig } from "@/stores/use-config-store"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -33,6 +36,9 @@ import { IconButton } from "@/components/ui/base/buttons"; import { Callout } from "@/components/ui/product/callout"; type AiArtCritiqueModalProps = { + startRequestId?: string; + restartRequested?: boolean; + onRunningChange?: (running: boolean) => void; node: CanvasNodeData | null; upstreamNodes: CanvasNodeData[]; open: boolean; @@ -56,16 +62,14 @@ type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; type AiArtCritiqueView = "overview" | "detail"; type ArtCritiquePromptStatus = "ready" | "pending" | "unavailable"; -export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState }: AiArtCritiqueModalProps) { +export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState, startRequestId, restartRequested, onRunningChange }: AiArtCritiqueModalProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const effectiveConfig = useEffectiveConfig(); const selectedCritiqueModel = effectiveConfig.textModel.trim(); const configuredCritiqueModelLabel = selectedCritiqueModel ? modelOptionLabel(effectiveConfig, selectedCritiqueModel) : "未配置文本/视觉理解模型"; - const installations = usePluginStore((state) => state.installations); - const input = useMemo(() => upstreamNodes.find(isArtCritiqueImageInput), [upstreamNodes]); + const enabled = usePluginStore((state) => state.pluginStates[ART_CRITIQUE_PLUGIN_ID]?.effectiveEnabled ?? (ART_CRITIQUE_PLUGIN_ID in state.runtimeStatuses ? state.runtimeStatuses[ART_CRITIQUE_PLUGIN_ID] === "enabled" : Boolean(state.installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID)?.enabled))); + const input = useMemo(() => { const images = upstreamNodes.filter(isArtCritiqueImageInput); return images.length === 1 ? images[0] : undefined; }, [upstreamNodes]); const state = node?.metadata?.artCritique || createDefaultArtCritiqueState(); - const installation = installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID); - const enabled = installation?.enabled === true; const currentFingerprint = input ? artCritiqueSourceFingerprint(input) : ""; const stale = Boolean(state.report && currentFingerprint && state.report.sourceFingerprint !== currentFingerprint); const visibleState: ArtCritiqueNodeState = stale && state.status !== "running" ? { ...state, status: "stale" } : state; @@ -79,7 +83,11 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat const [hoveredIssueId, setHoveredIssueId] = useState(null); const [draftReportVisible, setDraftReportVisible] = useState(false); const abortRef = useRef(null); - const preparedImageRef = useRef<{ fingerprint: string; dataUrl: string } | null>(null); + const approvalRef = useRef(null); + const [payment, setPayment] = useState({ busy: false }); + const startedRequestRef = useRef(undefined); + const currentRunTargetRef = useRef(""); + currentRunTargetRef.current = `${getActiveUserScope()}:${node?.id || ""}:${currentFingerprint}`; useEffect(() => { if (!open || !input) { @@ -111,6 +119,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat }, [node?.id, currentFingerprint, open]); useEffect(() => () => abortRef.current?.abort(), []); + useEffect(() => () => abortRef.current?.abort(), [node?.id, currentFingerprint]); + useEffect(() => { if (!enabled) abortRef.current?.abort(); }, [enabled]); const issues = visibleState.report?.issues || []; const reportOptions = visibleState.report?.options || []; @@ -144,23 +154,35 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat setView("overview"); }; - const runReview = async () => { + const runReview = async (resume = false) => { + if (abortRef.current) return; if (!node || !input) { - setLocalError("请先连接一张已有图片"); + setLocalError("请连接且仅连接一张已有图片"); return; } if (!enabled) { setLocalError("插件尚未启用,请先到插件管理中开启 AI 审美批改"); return; } + if (resume && (!state.billingRunId || state.sourceFingerprint !== artCritiqueSourceFingerprint(input) || state.lastRunModel !== selectedCritiqueModel)) { + setLocalError("原分析的图片或模型已变化,请开始新的分析"); + return; + } setView("overview"); setSelectedIssueId(null); setHoveredIssueId(null); setDraftReportVisible(false); const sourceFingerprint = artCritiqueSourceFingerprint(input); - const runId = `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const runId = resume && state.lastRunId ? state.lastRunId : `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + let billingRunId = resume ? state.billingRunId : undefined; const controller = new AbortController(); abortRef.current = controller; + onRunningChange?.(true); + const runTarget = currentRunTargetRef.current; + const runScope = getActiveUserScope(); + const publishState = (next: ArtCritiqueNodeState) => { + if (getActiveUserScope() === runScope && currentRunTargetRef.current === runTarget && abortRef.current === controller) onUpdateState(node.id, { ...next, billingRunId, lastRunModel: selectedCritiqueModel }); + }; const baseState: ArtCritiqueNodeState = { ...state, schemaVersion: ART_CRITIQUE_SCHEMA_VERSION, @@ -169,41 +191,41 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat sourceNodeId: input.id, sourceFingerprint, lastRunId: runId, + stageTaskIds: resume ? state.stageTaskIds : {}, errorCode: undefined, errorMessage: undefined, updatedAt: new Date().toISOString(), }; - onUpdateState(node.id, baseState); + publishState(baseState); setRunning(true); setActiveStage("preparing"); setLocalError(""); let latestStage: ArtCritiquePipelineStage = "preparing"; let runningReport: ArtCritiqueReport | undefined = state.report; + const stageTaskIds: Record = resume ? { ...state.stageTaskIds } : {}; + setPayment({ busy: false }); + const approval = new ApprovedToolExecution({ + clientKey: `art-critique:${runId}`, runId: billingRunId, + identity: { kind: "art-critique", nodeId: node.id, sourceFingerprint, model: selectedCritiqueModel }, signal: controller.signal, + onRun: (id) => { billingRunId = id; publishState({ ...baseState, stageTaskIds: { ...stageTaskIds } }); }, + onState: (next) => { if (currentRunTargetRef.current === runTarget) setPayment(next); }, + }); + approvalRef.current = approval; try { - const source = input.metadata?.content || input.metadata?.previewContent || ""; - const cachedImage = preparedImageRef.current; - const dataUrl = - cachedImage?.fingerprint === sourceFingerprint - ? cachedImage.dataUrl - : await imageToDataUrl({ - dataUrl: source.startsWith("data:") ? source : undefined, - url: source.startsWith("data:") ? undefined : source, - storageKey: input.metadata?.storageKey, - name: input.title, - mimeType: input.metadata?.mimeType, - }); - if (!dataUrl) throw new Error("无法读取输入图片"); - if (!controller.signal.aborted) preparedImageRef.current = { fingerprint: sourceFingerprint, dataUrl }; - const report = await runArtCritiquePipeline( - effectiveConfig, - { dataUrl, title: input.title, sourceFingerprint }, - { + const report = await executeArtCritique({ + nodeId: node.id, runId, source: input, config: effectiveConfig, signal: controller.signal, + submitStage: approval.execute, + onTaskCreated: (stage, taskId) => { + stageTaskIds[stage] = taskId; + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, analysisStage: latestStage, updatedAt: new Date().toISOString() }); + }, onStage: (analysisStage) => { latestStage = analysisStage; setActiveStage(analysisStage); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, ...(runningReport ? { report: { ...runningReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) } } : {}), analysisStage, updatedAt: new Date().toISOString(), @@ -213,19 +235,20 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat if (controller.signal.aborted || abortRef.current !== controller) return; runningReport = draftReport; setDraftReportVisible(true); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, status: "running", analysisStage: latestStage, report: { ...draftReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) }, updatedAt: new Date().toISOString(), }); }, - }, - ); + }); setDraftReportVisible(false); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, status: "completed", analysisStage: "completed", report: { ...report, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) }, @@ -236,22 +259,31 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat if (controller.signal.aborted) { setActiveStage(undefined); setDraftReportVisible(false); - onUpdateState(node.id, { ...baseState, status: "idle", analysisStage: undefined, errorCode: undefined, errorMessage: undefined, updatedAt: new Date().toISOString() }); + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: "analysis_interrupted", errorMessage: "分析观察已停止;已提交阶段保留在任务中心,不会自动重复提交。", updatedAt: new Date().toISOString() }); return; } const errorMessage = error instanceof Error ? error.message : "AI 批改失败,请稍后重试"; setActiveStage("failed"); setDraftReportVisible(false); setLocalError(errorMessage); - onUpdateState(node.id, { ...baseState, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() }); + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() }); } finally { + approval.dispose(); + if (approvalRef.current === approval) { approvalRef.current = null; setPayment({ busy: false }); } if (abortRef.current === controller) abortRef.current = null; setRunning(false); + onRunningChange?.(false); } }; const progressStage = activeStage || visibleState.analysisStage || "preparing"; const displayedModelLabel = visibleState.report?.modelLabel || configuredCritiqueModelLabel; + useEffect(() => { + if (!open || !node || !startRequestId || running || startedRequestRef.current === startRequestId) return; + startedRequestRef.current = startRequestId; + void runReview(Boolean(!restartRequested && state.billingRunId && state.status !== "completed" && state.sourceFingerprint === currentFingerprint && state.lastRunModel === selectedCritiqueModel)); + }, [open, node?.id, startRequestId, running]); + const modelStatusLabel = running ? "正在使用" : visibleState.report?.modelLabel ? "报告使用" : selectedCritiqueModel ? "待使用" : "需要配置"; const modelStatusColor = selectedCritiqueModel || visibleState.report?.modelLabel ? theme.node.muted : "var(--status-warning)"; const modelInlineLabel = selectedCritiqueModel || visibleState.report?.modelLabel ? `${modelStatusLabel} · ${displayedModelLabel}` : "请到设置中选择支持图片理解的文本模型"; @@ -336,6 +368,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat