diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index fe142fea5..050e03297 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -1,19 +1,24 @@ package main import ( + "context" "errors" "fmt" "log" "os" "reflect" + "sort" + "strconv" "strings" "time" + "unicode/utf8" "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/logger" + "gorm.io/gorm/schema" ) type tableMigration struct { @@ -50,17 +55,17 @@ func main() { // PostgreSQL 的 DDL 参与事务;任一表复制或核对失败都会回滚整个新库结构。 if err := target.Transaction(func(tx *gorm.DB) error { - tableCount, err := publicTableCount(tx) + tableNames, err := publicTableNames(tx) if err != nil { return err } - copyRows := tableCount == 0 + copyRows := len(tableNames) == 0 if copyRows { if err := database.MigrateSchema(tx); err != nil { return fmt.Errorf("创建目标表结构:%w", err) } - } else if tableCount != int64(len(migrations())) { - return fmt.Errorf("PostgreSQL public schema 已有 %d 张表,拒绝覆盖或补写", tableCount) + } else if err := validateTargetTableNames(tableNames); err != nil { + return err } total := 0 @@ -77,6 +82,9 @@ func main() { } else { log.Printf("目标库已有完整迁移结果,未重复写入:%d 张表,%d 行", len(migrations()), total) } + if err := database.ReconcilePrefixedIDSequences(tx); err != nil { + return fmt.Errorf("校准可读 ID 序列:%w", err) + } return nil }); err != nil { log.Fatal(err) @@ -94,10 +102,52 @@ func verifySQLite(db *gorm.DB) error { return nil } -func publicTableCount(db *gorm.DB) (int64, error) { - var count int64 - err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&count).Error - return count, err +func publicTableNames(db *gorm.DB) ([]string, error) { + var names []string + err := db.Raw(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name + `).Scan(&names).Error + return names, err +} + +// validateTargetTableNames compares the existing PostgreSQL schema against the +// same business-table list used by Models() and migrations(). +// schema_migrations is created by database.MigrateSchema and is intentionally +// not part of the row-copy list, so it is allowed as the only system table. +func validateTargetTableNames(actual []string) error { + expected := make(map[string]struct{}, len(migrations())) + for _, migration := range migrations() { + expected[migration.name] = struct{}{} + } + + actualSet := make(map[string]struct{}, len(actual)) + for _, name := range actual { + actualSet[name] = struct{}{} + } + missing := make([]string, 0) + for name := range expected { + if _, ok := actualSet[name]; !ok { + missing = append(missing, name) + } + } + extra := make([]string, 0) + for name := range actualSet { + if name == "schema_migrations" { + continue + } + if _, ok := expected[name]; !ok { + extra = append(extra, name) + } + } + sort.Strings(missing) + sort.Strings(extra) + if len(missing) > 0 || len(extra) > 0 { + return fmt.Errorf("PostgreSQL public schema 与当前模型清单不一致,缺少表:%s,额外表:%s;拒绝覆盖或补写", strings.Join(missing, ", "), strings.Join(extra, ", ")) + } + return nil } func migrateTable[T any](name string) tableMigration { @@ -112,6 +162,9 @@ func migrateTable[T any](name string) tableMigration { if err := source.Order(primaryKey).Find(&sourceRows).Error; err != nil { return 0, err } + if err := validateExplicitStringLengths(source, name, sourceRows); err != nil { + return 0, err + } if copyRows && len(sourceRows) > 0 { if err := target.CreateInBatches(&sourceRows, 100).Error; err != nil { return 0, err @@ -130,6 +183,102 @@ func migrateTable[T any](name string) tableMigration { } } +// validateExplicitStringLengths catches values that SQLite accepts but +// PostgreSQL would reject for a GORM size:N column. SQLite does not enforce +// VARCHAR(N), so this check must happen before the first target insert. +func validateExplicitStringLengths[T any](db *gorm.DB, tableName string, rows []T) error { + statement := &gorm.Statement{DB: db} + if err := statement.Parse(new(T)); err != nil { + return err + } + if statement.Schema == nil { + return fmt.Errorf("表 %s 缺少 GORM schema", tableName) + } + + for rowIndex := range rows { + rowValue := reflect.ValueOf(rows[rowIndex]) + key := primaryKeyValue(statement.Schema.PrimaryFields, rowValue) + for _, field := range statement.Schema.Fields { + limit, ok := explicitStringSize(field) + if !ok || field.Serializer != nil || !field.Creatable || !field.Readable { + continue + } + value, zero := field.ValueOf(context.Background(), rowValue) + if zero { + continue + } + text, ok := stringValue(value) + if !ok { + continue + } + actual := utf8.RuneCountInString(text) + if actual <= limit { + continue + } + return fmt.Errorf("表 %s 第 %d 行(主键 %s)列 %s 长度 %d,超过 size:%d", tableName, rowIndex+1, key, field.DBName, actual, limit) + } + } + return nil +} + +func explicitStringSize(field *schema.Field) (int, bool) { + // This adapter keeps the validation logic independent from GORM's inferred + // default size (often 255). Only an explicit size:N tag is a PostgreSQL + // contract we can prove from the model. + sizeText, ok := field.TagSettings["SIZE"] + if !ok || strings.TrimSpace(sizeText) == "" { + return 0, false + } + if strings.EqualFold(strings.TrimSpace(field.TagSettings["TYPE"]), "text") { + return 0, false + } + if field.IndirectFieldType.Kind() != reflect.String { + return 0, false + } + limit, err := strconv.Atoi(sizeText) + if err != nil || limit < 0 { + return 0, false + } + return limit, true +} + +func stringValue(value any) (string, bool) { + if value == nil { + return "", false + } + rv := reflect.ValueOf(value) + for rv.Kind() == reflect.Interface || rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "", false + } + rv = rv.Elem() + } + if rv.Kind() != reflect.String { + return "", false + } + return rv.String(), true +} + +func primaryKeyValue(fields []*schema.Field, rowValue reflect.Value) string { + if len(fields) == 0 { + return "<无主键>" + } + parts := make([]string, 0, len(fields)) + for _, field := range fields { + value, zero := field.ValueOf(context.Background(), rowValue) + if zero || value == nil { + parts = append(parts, field.DBName+"=<空>") + continue + } + if text, ok := stringValue(value); ok { + parts = append(parts, field.DBName+"="+text) + continue + } + parts = append(parts, field.DBName+"="+fmt.Sprint(value)) + } + return strings.Join(parts, ",") +} + func primaryKeyColumn[T any](db *gorm.DB) (string, error) { statement := &gorm.Statement{DB: db} if err := statement.Parse(new(T)); err != nil { diff --git a/backend/internal/app/cloud_agent_approval_preview.go b/backend/internal/app/cloud_agent_approval_preview.go index 7250e897f..ff32664d8 100644 --- a/backend/internal/app/cloud_agent_approval_preview.go +++ b/backend/internal/app/cloud_agent_approval_preview.go @@ -331,7 +331,7 @@ func cloudAgentApprovalCallHash(call cloudAgentCall) string { // server revalidates the prepared dependency hash below, which deliberately // ignores layout-only edits such as moving a node. arguments := call.Function.Arguments - if call.Function.Name == "generate_media" { + if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" { var object map[string]any if err := json.Unmarshal([]byte(arguments), &object); err == nil { delete(object, "snapshotHash") diff --git a/backend/internal/app/cloud_agent_approval_settings_test.go b/backend/internal/app/cloud_agent_approval_settings_test.go index 7a90a70a2..e6d3d0e20 100644 --- a/backend/internal/app/cloud_agent_approval_settings_test.go +++ b/backend/internal/app/cloud_agent_approval_settings_test.go @@ -24,7 +24,7 @@ func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) { if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil { t.Fatal(err) } - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -39,7 +39,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T for _, change := range []string{"move", "presentation", "prompt", "resource", "connection", "locked", "task", "unknown_metadata"} { t.Run(change, func(t *testing.T) { s, db, a := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -112,7 +112,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T func TestCloudAgentMediaApprovalReusesPreparedInputsAfterModelRetry(t *testing.T) { s, db, a := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -170,7 +170,7 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) { a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1" a.ReferenceNodeIDs = []string{"cat"} - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } diff --git a/backend/internal/app/cloud_agent_canvas_events_test.go b/backend/internal/app/cloud_agent_canvas_events_test.go index 00e795266..29a81e715 100644 --- a/backend/internal/app/cloud_agent_canvas_events_test.go +++ b/backend/internal/app/cloud_agent_canvas_events_test.go @@ -31,7 +31,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te for _, status := range []model.TaskStatus{model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled} { t.Run(string(status), func(t *testing.T) { s, db, args := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -128,7 +128,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) { s, _, args := agentMediaFixture(t) - run, state := agentMediaRun(t, s, args, "auto") + run, state := agentMediaRun(t, s, args, "request_approval") args.SourceNodeID = "cat" if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err == nil || !strings.Contains(err.Error(), "referenceNodeIds") { t.Fatalf("image source must point to the correct field: %v", err) @@ -226,7 +226,7 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) { func TestCloudAgentMissingCanvasStillCheckpointsMediaFailure(t *testing.T) { s, db, args := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } diff --git a/backend/internal/app/cloud_agent_diagnostics_test.go b/backend/internal/app/cloud_agent_diagnostics_test.go index 64f72efaf..6171d2df8 100644 --- a/backend/internal/app/cloud_agent_diagnostics_test.go +++ b/backend/internal/app/cloud_agent_diagnostics_test.go @@ -148,7 +148,7 @@ func TestCloudAgentMediaAdmissionReportsTaskBinding(t *testing.T) { func TestCloudAgentCancellationRecordsSourceOnce(t *testing.T) { s, _, args := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } diff --git a/backend/internal/app/cloud_agent_generation_contract.go b/backend/internal/app/cloud_agent_generation_contract.go index 341b81422..492af0749 100644 --- a/backend/internal/app/cloud_agent_generation_contract.go +++ b/backend/internal/app/cloud_agent_generation_contract.go @@ -14,10 +14,18 @@ func cloudAgentGenerationSpec(a cloudAgentMediaArgs, refs map[string]any, resolv } options := contract.Options{} if a.Mode == "image" || a.Mode == "video" { - options.Size = &a.Size + // A blank option means "use the selected model's declared default". Do + // not turn absence into an explicit empty value: task admission is where + // the model capability contract resolves defaults and validates support. + if strings.TrimSpace(a.Size) != "" { + options.Size = &a.Size + } } if a.Mode == "video" { - options.DurationSeconds, options.GenerateAudio = &a.Duration, a.VideoGenerateAudio + if a.Duration > 0 { + options.DurationSeconds = &a.Duration + } + options.GenerateAudio = a.VideoGenerateAudio if a.Quality != "" { options.Resolution = &a.Quality } diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 95cb20ad5..bbda82b4d 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "time" "unicode/utf8" @@ -19,8 +20,12 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) { if err != nil { return nil, err } + logicalModels, err := s.PublicLogicalModels(intent) + if err != nil { + return nil, err + } items := []map[string]any{} - for _, m := range catalog.Models { + for _, m := range logicalModels { if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) { items = append(items, map[string]any{"name": m.Name, "capability": m.Capability, "selection": map[string]any{"logicalModelId": m.ID}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilitySpec, "profiles": m.CapabilityProfiles, "defaults": m.DefaultOptions}) } @@ -28,7 +33,7 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) { for _, channel := range catalog.Channels { for _, m := range channel.Models { if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) { - items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig}) + items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig, "defaults": m.DefaultOptions}) } } } @@ -109,6 +114,9 @@ type cloudAgentMediaPlan struct { Args cloudAgentMediaArgs CallID string TransientReferences map[string]cloudAgentTransientReference + // Prepared is populated by the auto path after its dry admission. Approval + // mode keeps the same admission in state.Approval.Prepared. + Prepared *cloudAgentPreparedMedia `json:"-"` } // A resumed draft's incoming edges must describe the new approved inputs, @@ -161,7 +169,11 @@ func (s *Service) cloudAgentMediaModelName(a cloudAgentMediaArgs) (string, error if err != nil { return "", err } - for _, m := range catalog.Models { + logicalModels, err := s.PublicLogicalModels(nil) + if err != nil { + return "", err + } + for _, m := range logicalModels { if a.LogicalModelID != "" && m.ID == a.LogicalModelID && m.Available && normalizeCapability(m.Capability) == normalizeCapability(a.Mode) { return m.Name, nil } @@ -422,17 +434,13 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime if utf8.RuneCountInString(a.Prompt) > 16000 { return BadAuthRequest(fmt.Sprintf("提示词共%d字符,超过16000字符上限;请先告知用户,不要擅自删改关键内容", utf8.RuneCountInString(a.Prompt))) } - if (mode == "image" || mode == "video") && strings.TrimSpace(a.Size) == "" { - return BadAuthRequest("请填写模型支持的具体画幅;用户授权默认时沿用参考图比例或目录默认画幅,无需重复询问") - } if a.Duration < 0 { return BadAuthRequest("生成时长不能为负数") } switch mode { case "video": - if a.Duration == 0 { - return BadAuthRequest("视频生成必须明确 durationSeconds") - } + // 0 means omitted. The selected model's capability defaults are resolved + // during task admission; an explicit unsupported value still fails there. case "image", "audio": if a.Duration != 0 { return BadAuthRequest("只有视频生成允许设置 durationSeconds") @@ -540,14 +548,36 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state * if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil { return CreateTaskRequest{}, nil, cloudAgentJSONArgumentError(err) } - if err := validateCloudAgentModelSelection(call.Function.Arguments, a); err != nil { + a.Mode = strings.ToLower(strings.TrimSpace(a.Mode)) + defaultedModel, err := s.applyCloudAgentProjectDefaultModel(run, state, call.Function.Arguments, &a) + if err != nil { + return CreateTaskRequest{}, nil, err + } + selectionArguments := call.Function.Arguments + if defaultedModel { + selectionFields := map[string]string{} + if a.LogicalModelID != "" { + selectionFields["logicalModelId"] = a.LogicalModelID + } else { + selectionFields["channelId"], selectionFields["channelModelKey"] = a.ChannelID, a.ChannelModelKey + } + encoded, encodeErr := json.Marshal(selectionFields) + if encodeErr != nil { + return CreateTaskRequest{}, nil, encodeErr + } + selectionArguments = string(encoded) + } + if err := validateCloudAgentModelSelection(selectionArguments, a); err != nil { return CreateTaskRequest{}, nil, err } - a.Mode = strings.ToLower(strings.TrimSpace(a.Mode)) a.DraftRunID = run.ID if state.Approval != nil && state.Approval.Prepared != nil && state.Approval.CallHash == cloudAgentApprovalCallHash(call) { a.Prepared = state.Approval.Prepared + } else if state.AutoPreparedMedia != nil && + state.AutoPreparedCallHash == cloudAgentApprovalCallHash(call) && + time.Now().Before(state.AutoPreparedMedia.Quote.ExpiresAt) { + a.Prepared = state.AutoPreparedMedia } if err := s.fillCloudAgentMediaSnapshotHash(run.UserID, state.Request.CanvasID, &a); err != nil { return CreateTaskRequest{}, nil, err @@ -593,7 +623,52 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state * for id, ref := range state.TransientReferences { transientSnapshot[id] = ref } - return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot}, nil + return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot, Prepared: a.Prepared}, nil +} + +// applyCloudAgentResolvedMediaDefaults makes the result of the dry admission +// explicit in both the plan and the request that will be submitted. The +// regular task admission already owns default resolution; this helper only +// mirrors that resolved contract back to the Agent state so auto execution does +// not need to fail once for size/duration and then ask the model to repair it. +func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAgentMediaPlan, task *model.Task) error { + if req == nil || plan == nil || task == nil { + return BadAuthRequest("媒体生成准入结果无效,未提交生成任务") + } + var input map[string]any + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + return err + } + config, ok := input["config"].(map[string]any) + if !ok { + return BadAuthRequest("媒体生成未解析出模型配置,未提交生成任务") + } + + plan.Args.Size = stringValue(config["size"]) + if plan.Args.Mode == "image" { + plan.Args.Quality = stringValue(config["quality"]) + } + if plan.Args.Mode == "video" { + plan.Args.Quality = stringValue(config["vquality"]) + if raw := stringValue(config["videoSeconds"]); raw != "" { + seconds, err := strconv.Atoi(raw) + if err != nil || seconds < 0 { + return BadAuthRequest("模型目录返回的默认视频时长无效,未提交生成任务") + } + plan.Args.Duration = seconds + } + if raw := stringValue(config["videoGenerateAudio"]); raw != "" { + audio, err := strconv.ParseBool(raw) + if err != nil { + return BadAuthRequest("模型目录返回的默认音频参数无效,未提交生成任务") + } + plan.Args.VideoGenerateAudio = &audio + } + } + plan.Args.Prepared = nil + req.Input = input + req.Prompt = stringValue(input["prompt"]) + return nil } func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error { diff --git a/backend/internal/app/cloud_agent_media_test.go b/backend/internal/app/cloud_agent_media_test.go index 3f1438968..9212c198f 100644 --- a/backend/internal/app/cloud_agent_media_test.go +++ b/backend/internal/app/cloud_agent_media_test.go @@ -83,8 +83,19 @@ func agentMediaRun(t *testing.T, s *Service, a cloudAgentMediaArgs, permission s func approveAgentMediaDraft(t *testing.T, s *Service, runID string) { t.Helper() run, err := s.CloudAgentRun("user", runID) - if err != nil || run.Approval == nil || run.Status != "waiting_approval" { - t.Fatalf("media must wait for approval even in auto mode: %v", err) + if err != nil { + t.Fatal(err) + } + if run.Status != "waiting_approval" || run.Approval == nil { + stored, loadErr := s.repo.CloudAgent("user", runID) + state, decodeErr := cloudAgentDecode(stored) + if loadErr != nil { + decodeErr = loadErr + } + if decodeErr != nil || state.MediaTaskID == "" { + t.Fatalf("media generation was neither approved nor auto-submitted: status=%s task=%s err=%v", run.Status, state.MediaTaskID, decodeErr) + } + return } if err := s.DecideCloudAgentApproval("user", runID, run.Approval.ID, "approve", ""); err != nil { t.Fatal(err) @@ -196,7 +207,6 @@ func TestCloudAgentMediaReferenceAndSnapshotGuards(t *testing.T) { {"existing node", func(v *cloudAgentMediaArgs) { v.NodeID = "cat" }}, {"mixed selection", func(v *cloudAgentMediaArgs) { v.LogicalModelID = "made-up" }}, {"duration budget", func(v *cloudAgentMediaArgs) { v.Duration = 25 }}, - {"missing aspect ratio", func(v *cloudAgentMediaArgs) { v.Size = "" }}, } { t.Run(test.name, func(t *testing.T) { bad := a @@ -234,7 +244,7 @@ func TestCloudAgentImageCreatesReferencedNode(t *testing.T) { a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1" a.ReferenceNodeIDs = []string{"cat"} - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -399,7 +409,7 @@ func TestCloudAgentMediaFillsMissingSnapshotHash(t *testing.T) { func TestCloudAgentMediaFailedTaskUpdatesNode(t *testing.T) { s, db, a := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -545,7 +555,7 @@ func TestCloudAgentMediaDraftReuseLifecycle(t *testing.T) { func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) { s, db, a := agentMediaFixture(t) - run, state := agentMediaRun(t, s, a, "auto") + run, state := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentTool(run, &state); err != nil { t.Fatal(err) } @@ -564,7 +574,7 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) { } a.SnapshotHash = cloudAgentCanvasHash(doc) a.ReferenceNodeIDs = []string{"hero"} - next, nextState := agentMediaRun(t, s, a, "auto", "resume-draft-next-turn") + next, nextState := agentMediaRun(t, s, a, "request_approval", "resume-draft-next-turn") if next.ID == run.ID { t.Fatal("expected a distinct run") } @@ -605,74 +615,130 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) { } } -func TestCloudAgentAutoMediaDraftRequiresExplicitApproval(t *testing.T) { - for _, decision := range []string{"approve", "reject", "cancel"} { - t.Run(decision, func(t *testing.T) { - s, db, a := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, a, "auto") - var ordersBefore int64 - db.Model(&model.BillingOrder{}).Count(&ordersBefore) - if err := s.advanceCloudAgentByID("user", run.ID); err != nil { - t.Fatal(err) - } - run, _ = s.repo.CloudAgent("user", run.ID) - state, _ := cloudAgentDecode(run) - if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ModelName != "视频测试" || state.MediaTaskID != "" { - t.Fatal("auto mode bypassed approval or omitted selected model") - } - canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") - doc, _ := creationDocument(canvas.PayloadJSON) - nodes, _ := creationObjects(doc["nodes"]) - meta, _ := nodes[a.NodeID]["metadata"].(map[string]any) - if meta["status"] != "idle" || meta["taskId"] != nil || meta["size"] != "9:16" || meta["agentDraftRunId"] != run.ID || len(creationMaps(doc["connections"])) != 3 { - t.Fatalf("missing uncharged draft with references: %+v", meta) - } - // Re-entering the tool while awaiting a decision must remain a no-op. - if err := s.advanceCloudAgentTool(run, &state); err != nil { - t.Fatal(err) - } - var count, orders int64 - db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) - db.Model(&model.BillingOrder{}).Count(&orders) - if count != 0 || orders != ordersBefore { - t.Fatal("draft created a task or charge") - } - // Another run may not take over this draft, even with the latest snapshot. - foreign := a - foreign.DraftRunID, foreign.SnapshotHash = "another-run", cloudAgentCanvasHash(doc) - if _, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", foreign); err == nil { - t.Fatal("foreign draft overwrite accepted") - } - if decision == "cancel" { - if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil { - t.Fatal(err) - } - } else { - // A local focus/zoom autosave cannot change the approved content. - doc["viewport"], doc["updatedAt"] = map[string]any{"x": -900, "y": -500, "k": 0.8}, "later" - raw, _ := json.Marshal(doc) - if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(raw)).Error; err != nil { - t.Fatal(err) - } - for range 2 { - if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil { - t.Fatal(err) - } - } - if err := s.advanceCloudAgentByID("user", run.ID); err != nil { - t.Fatal(err) - } - } - db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) - db.Model(&model.BillingOrder{}).Count(&orders) - if decision == "approve" { - if count != 1 { - t.Fatal("explicit approval did not create exactly one task") - } - } else if count != 0 || orders != ordersBefore { - t.Fatal("reject/cancel submitted generation") - } - }) +func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) { + s, db, a := agentMediaFixture(t) + // Omitted size and duration should be filled by the selected model's catalog + // defaults during admission instead of producing a repair turn. + a.Size = "" + a.Duration = 0 + run, _ := agentMediaRun(t, s, a, "auto") + var ordersBefore int64 + if err := db.Model(&model.BillingOrder{}).Count(&ordersBefore).Error; err != nil { + t.Fatal(err) + } + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + run, _ = s.repo.CloudAgent("user", run.ID) + state, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + if run.Status == "waiting_approval" || state.Approval != nil { + t.Fatalf("auto mode unexpectedly requested approval: status=%s approval=%+v", run.Status, state.Approval) + } + if state.MediaTaskID == "" { + t.Fatalf("auto mode did not submit a media task: %+v", state.Events) + } + for _, event := range state.Events { + if event.Type == "approval_requested" { + t.Fatal("auto mode emitted an approval event") + } + } + task, err := s.repo.TaskForUser("user", state.MediaTaskID) + if err != nil { + t.Fatal(err) + } + var input canvasGenerationInput + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + t.Fatal(err) + } + if input.Config.Size == "" || input.Config.VideoSeconds == "" { + t.Fatalf("model defaults were not resolved: %+v", input.Config) + } + canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") + doc, _ := creationDocument(canvas.PayloadJSON) + nodes, _ := creationObjects(doc["nodes"]) + meta, _ := nodes[a.NodeID]["metadata"].(map[string]any) + if meta["status"] != "loading" || meta["taskId"] != task.ID || meta["agentDraftRunId"] != nil { + t.Fatalf("auto submission did not finalize the draft: %+v", meta) + } + var tasks, orders int64 + if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.BillingOrder{}).Count(&orders).Error; err != nil { + t.Fatal(err) + } + if tasks != 1 || orders != ordersBefore+1 { + t.Fatalf("auto submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore) + } + // Re-entering a submitted call is idempotent and must not reserve/submit a + // second generation. + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil { + t.Fatal(err) + } + if tasks != 1 { + t.Fatalf("re-entering auto media duplicated task: %d", tasks) + } + + // Admission failures are still non-billed tool failures and do not create an + // approval or a draft task. + invalidService, invalidDB, invalidArgs := agentMediaFixture(t) + invalidArgs.ChannelModelKey = "removed-model" + invalidRun, _ := agentMediaRun(t, invalidService, invalidArgs, "auto") + var invalidOrdersBefore int64 + if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrdersBefore).Error; err != nil { + t.Fatal(err) + } + if err := invalidService.advanceCloudAgentByID("user", invalidRun.ID); err != nil { + t.Fatal(err) + } + invalidRun, _ = invalidService.repo.CloudAgent("user", invalidRun.ID) + invalidState, err := cloudAgentDecode(invalidRun) + if err != nil { + t.Fatal(err) + } + var invalidTasks, invalidOrders int64 + if err := invalidDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&invalidTasks).Error; err != nil { + t.Fatal(err) + } + if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrders).Error; err != nil { + t.Fatal(err) + } + if invalidTasks != 0 || invalidOrders != invalidOrdersBefore || invalidState.Approval != nil { + t.Fatalf("invalid auto admission created side effects: tasks=%d orders=%d before=%d approval=%+v", invalidTasks, invalidOrders, invalidOrdersBefore, invalidState.Approval) + } + + approvalService, approvalDB, approvalArgs := agentMediaFixture(t) + approvalRun, _ := agentMediaRun(t, approvalService, approvalArgs, "request_approval") + var approvalOrdersBefore int64 + if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrdersBefore).Error; err != nil { + t.Fatal(err) + } + if err := approvalService.advanceCloudAgentByID("user", approvalRun.ID); err != nil { + t.Fatal(err) + } + approvalRun, _ = approvalService.repo.CloudAgent("user", approvalRun.ID) + approvalState, err := cloudAgentDecode(approvalRun) + if err != nil { + t.Fatal(err) + } + if approvalRun.Status != "waiting_approval" || approvalState.Approval == nil { + t.Fatalf("request_approval stopped bypassing approval: status=%s approval=%+v", approvalRun.Status, approvalState.Approval) + } + var approvalTasks, approvalOrders int64 + if err := approvalDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&approvalTasks).Error; err != nil { + t.Fatal(err) + } + if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrders).Error; err != nil { + t.Fatal(err) + } + if approvalTasks != 0 || approvalOrders != approvalOrdersBefore { + t.Fatalf("request_approval submitted before decision: tasks=%d orders=%d before=%d", approvalTasks, approvalOrders, approvalOrdersBefore) } } @@ -692,7 +758,7 @@ func TestCloudAgentMediaPromptCountsUnicodeCharacters(t *testing.T) { func TestCloudAgentMediaCancellationUpdatesNode(t *testing.T) { s, _, a := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, a, "auto") + run, _ := agentMediaRun(t, s, a, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } diff --git a/backend/internal/app/cloud_agent_media_writeback_test.go b/backend/internal/app/cloud_agent_media_writeback_test.go index 0052a71e3..3a8402c40 100644 --- a/backend/internal/app/cloud_agent_media_writeback_test.go +++ b/backend/internal/app/cloud_agent_media_writeback_test.go @@ -13,7 +13,7 @@ func TestCloudAgentMediaCleanupUsesPersistedTaskTarget(t *testing.T) { for _, missingTarget := range []bool{false, true} { t.Run(map[bool]string{false: "known target", true: "unknown target"}[missingTarget], func(t *testing.T) { s, db, args := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } @@ -81,7 +81,7 @@ func TestCloudAgentMediaCompletionKeepsGenerationAndWritebackFailures(t *testing } { t.Run(test.name, func(t *testing.T) { s, db, args := agentMediaFixture(t) - run, _ := agentMediaRun(t, s, args, "auto") + run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } diff --git a/backend/internal/app/cloud_agent_model_selection.go b/backend/internal/app/cloud_agent_model_selection.go index 91998a249..dc8afab8f 100644 --- a/backend/internal/app/cloud_agent_model_selection.go +++ b/backend/internal/app/cloud_agent_model_selection.go @@ -2,12 +2,104 @@ package app import ( "encoding/json" + "fmt" "strings" + + "infinite-canvas/backend/internal/model" ) // Keep cross-field validation server-side rather than adding provider-specific // root schema combinators. The same contract is advertised by both media tools. -const cloudAgentModelSelectionDescription = "模型选择必填:将 model_list 返回的一种 selection 展开到顶层参数,必须提供非空 logicalModelId,或同时提供非空 channelId 和 channelModelKey,二者互斥。未使用的选择字段省略或传空字符串,不得传 null 或仅含空白的字符串。缺失、混用或不完整均在提交前拒绝,不会自动选择或切换模型。" +const cloudAgentModelSelectionDescription = "模型选择:复制 model_list 的 selection 到顶层。完全省略模型字段时,仅使用当前项目对应能力的可用默认模型。显式选择必须提供 logicalModelId,或同时提供 channelId 与 channelModelKey,二者互斥。未使用字段省略或传空字符串,不得传 null/空白;无可用默认、混用或不完整均拒绝,不会随机选模。" + +// applyCloudAgentProjectDefaultModel only fills a selection when the Agent did +// not send any model-selection field at all. An explicitly empty, partial, or +// null selection remains invalid so a malformed tool call cannot silently turn +// into a different model. +func (s *Service) applyCloudAgentProjectDefaultModel(run *model.CloudAgentExecution, state *cloudAgentRuntime, raw string, a *cloudAgentMediaArgs) (bool, error) { + if s == nil || s.repo == nil || run == nil || state == nil || a == nil || cloudAgentModelSelectionProvided(raw) { + return false, nil + } + a.Mode = strings.ToLower(strings.TrimSpace(a.Mode)) + if a.Mode != "image" && a.Mode != "video" || strings.TrimSpace(state.Request.CanvasID) == "" { + return false, nil + } + canvas, err := s.repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + if err != nil { + return false, err + } + if strings.TrimSpace(canvas.ProjectID) == "" { + return false, nil + } + project, err := s.repo.ProjectForUser(run.UserID, canvas.ProjectID) + if err != nil { + return false, err + } + defaultModel := strings.TrimSpace(project.DefaultImageModel) + if a.Mode == "video" { + defaultModel = strings.TrimSpace(project.DefaultVideoModel) + } + if defaultModel == "" { + return false, nil + } + + if channelID, channelModelKey, ok := splitCloudAgentChannelModel(defaultModel); ok { + catalog, catalogErr := s.ModelCatalog(nil) + if catalogErr != nil { + return false, catalogErr + } + for _, channel := range catalog.Channels { + if channel.ID != channelID { + continue + } + for _, channelModel := range channel.Models { + if channelModel.ModelKey == channelModelKey && channelModel.Available && normalizeCapability(channelModel.Capability) == a.Mode { + a.ChannelID, a.ChannelModelKey = channelID, channelModelKey + return true, nil + } + } + } + return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode)) + } + + logicalModels, err := s.PublicLogicalModels(nil) + if err != nil { + return false, err + } + for _, logicalModel := range logicalModels { + if logicalModel.ID == defaultModel && logicalModel.Available && normalizeCapability(logicalModel.Capability) == a.Mode { + a.LogicalModelID = defaultModel + return true, nil + } + } + return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode)) +} + +func cloudAgentModelSelectionProvided(raw string) bool { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil || fields == nil { + return true + } + for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} { + if _, ok := fields[field]; ok { + return true + } + } + return false +} + +func splitCloudAgentChannelModel(value string) (string, string, bool) { + channelID, channelModelKey, ok := strings.Cut(strings.TrimSpace(value), "::") + channelID, channelModelKey = strings.TrimSpace(channelID), strings.TrimSpace(channelModelKey) + return channelID, channelModelKey, ok && channelID != "" && channelModelKey != "" +} + +func cloudAgentModeLabel(mode string) string { + if mode == "video" { + return "视频" + } + return "图片" +} func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error { // Go's JSON decoder accepts null for string fields; the tool contract does @@ -33,7 +125,7 @@ func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error { case a.LogicalModelID != "": return nil case a.ChannelID == "" && a.ChannelModelKey == "": - return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:必须复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey,不会自动选择模型") + return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:请复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey;若项目未配置当前能力的可用默认模型,不能自动猜测") case a.ChannelID == "": return cloudAgentFieldError("channelId", "required", "系统渠道模型选择不完整:缺少 channelId,请复制 model_list 的完整 selection") case a.ChannelModelKey == "": diff --git a/backend/internal/app/cloud_agent_model_selection_test.go b/backend/internal/app/cloud_agent_model_selection_test.go index ffab34bbc..f1df18fbe 100644 --- a/backend/internal/app/cloud_agent_model_selection_test.go +++ b/backend/internal/app/cloud_agent_model_selection_test.go @@ -5,10 +5,23 @@ import ( "errors" "strings" "testing" + "time" + "gorm.io/gorm" "infinite-canvas/backend/internal/model" ) +func cloudAgentProjectForDefaultModel(t *testing.T, db *gorm.DB, modelRef string) { + t.Helper() + project := &model.Project{ID: "agent-default-project", UserID: "user", Name: "Agent 默认模型项目", Type: "short-drama", Status: model.ProjectStatusActive, DefaultImageModel: modelRef, DefaultVideoModel: modelRef} + if err := db.Create(project).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", "agent-canvas", "user").Update("project_id", project.ID).Error; err != nil { + t.Fatal(err) + } +} + func TestCloudAgentModelSelectionRejectsBeforeCanvasRead(t *testing.T) { for _, tc := range []struct{ name, args, field, issue string }{ {"missing", `{}`, "logicalModelId", "required"}, @@ -62,6 +75,54 @@ func TestCloudAgentModelSelectionAcceptsOnlyCompleteSelections(t *testing.T) { } } +func TestCloudAgentProjectDefaultChannelModelFillsOnlyOmittedSelection(t *testing.T) { + s, db, _ := agentMediaFixture(t) + cloudAgentProjectForDefaultModel(t, db, "channel::seedance-test") + run := &model.CloudAgentExecution{ID: "run-default-channel", UserID: "user"} + state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}} + args := cloudAgentMediaArgs{Mode: "video"} + used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video"}`, &args) + if err != nil || !used || args.ChannelID != "channel" || args.ChannelModelKey != "seedance-test" || args.LogicalModelID != "" { + t.Fatalf("default channel selection = used:%v args:%+v err:%v", used, args, err) + } + + args = cloudAgentMediaArgs{Mode: "video"} + used, err = s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video","channelId":"","channelModelKey":""}`, &args) + if err != nil || used { + t.Fatalf("explicit empty selection was silently defaulted: used:%v args:%+v err:%v", used, args, err) + } + if err := validateCloudAgentModelSelection(`{"mode":"video","channelId":"","channelModelKey":""}`, args); err == nil { + t.Fatal("explicit empty selection unexpectedly passed validation") + } +} + +func TestCloudAgentProjectDefaultLogicalModelFillsWhenAvailable(t *testing.T) { + s, db, _ := agentMediaFixture(t) + cloudAgentProjectForDefaultModel(t, db, "logical-image") + s.routeCatalogTTL = time.Hour + imageSpec := CapabilitySpec{Version: 1, Capability: "image"} + s.routeCatalog = &routeCatalogSnapshot{ + LoadedAt: time.Now(), Ordered: []string{"logical-image"}, Models: map[string]cachedLogicalModel{ + "logical-image": { + Model: model.LogicalModel{ID: "logical-image", Name: "默认逻辑图片", Capability: "image", Enabled: true, PricePolicy: "unified"}, + ProductSpec: imageSpec, + Routes: []cachedLogicalRoute{{ + Route: model.LogicalModelRoute{ID: "logical-image-route", Enabled: true, Weight: 1}, + CapabilitySpec: imageSpec, + ChannelModel: model.ChannelModel{ID: "video-cm", ChannelID: "channel", ModelKey: "seedance-test", Capability: "image"}, + }}, + }, + }, + } + run := &model.CloudAgentExecution{ID: "run-default-logical", UserID: "user"} + state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}} + args := cloudAgentMediaArgs{Mode: "image"} + used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"image"}`, &args) + if err != nil || !used || args.LogicalModelID != "logical-image" || args.ChannelID != "" || args.ChannelModelKey != "" { + t.Fatalf("default logical selection = used:%v args:%+v err:%v", used, args, err) + } +} + func TestCloudAgentMediaMalformedJSONKeepsStrictFeedback(t *testing.T) { for _, raw := range []string{`{"unknown":"private-sentinel"}`, `{"channelId":3}`, `{} {}`, `null`} { call := cloudAgentCall{} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 59265d495..c055a9acf 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -85,31 +85,36 @@ type cloudAgentApproval struct { Reason string `json:"reason,omitempty"` } type cloudAgentRuntime struct { - RuntimeRunID string `json:"-"` - Request CloudAgentRequest `json:"request"` - Policy cloudAgentPolicySnapshot `json:"policy"` - ParentID string `json:"parentId,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"` - TextHistory []providerTextMessage `json:"textHistory,omitempty"` - Skills []cloudAgentSkill `json:"skills"` - Profile cloudAgentProfileSnapshot `json:"profile"` - ProfileReads map[string]bool `json:"profileReads,omitempty"` - ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"` - ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"` - readCacheExecution bool `json:"-"` - Canonical canonicalAgentRequest `json:"canonical"` - ActiveTaskID string `json:"activeTaskId"` - ActiveTextDraft string `json:"activeTextDraft,omitempty"` - MediaTaskID string `json:"mediaTaskId,omitempty"` - TaskIDs []string `json:"taskIds"` - Step int `json:"step"` - Generations int `json:"generations"` - VideoSeconds int `json:"videoSeconds"` - Calls []cloudAgentCall `json:"calls"` - CallIndex int `json:"callIndex"` - ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"` - Approval *cloudAgentApproval `json:"approval,omitempty"` + RuntimeRunID string `json:"-"` + Request CloudAgentRequest `json:"request"` + Policy cloudAgentPolicySnapshot `json:"policy"` + ParentID string `json:"parentId,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"` + TextHistory []providerTextMessage `json:"textHistory,omitempty"` + Skills []cloudAgentSkill `json:"skills"` + Profile cloudAgentProfileSnapshot `json:"profile"` + ProfileReads map[string]bool `json:"profileReads,omitempty"` + ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"` + ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"` + readCacheExecution bool `json:"-"` + Canonical canonicalAgentRequest `json:"canonical"` + ActiveTaskID string `json:"activeTaskId"` + ActiveTextDraft string `json:"activeTextDraft,omitempty"` + MediaTaskID string `json:"mediaTaskId,omitempty"` + TaskIDs []string `json:"taskIds"` + Step int `json:"step"` + Generations int `json:"generations"` + VideoSeconds int `json:"videoSeconds"` + Calls []cloudAgentCall `json:"calls"` + CallIndex int `json:"callIndex"` + ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"` + Approval *cloudAgentApproval `json:"approval,omitempty"` + // AutoPreparedMedia is the durable admission checkpoint for permissionMode=auto. + // It prevents a worker restart between dry admission and billed submission from + // re-running admission or creating the draft node a second time. + AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"` + AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"` Decisions map[string]string `json:"decisions"` DecisionSettings map[string]string `json:"decisionSettings,omitempty"` DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"` @@ -436,10 +441,22 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent return errors.New("Agent runtime active task is not in task history") } if state.MediaTaskID != "" { - if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || state.Calls[state.CallIndex].Function.Name != "generate_media" { + if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || (state.Calls[state.CallIndex].Function.Name != "generate_media" && state.Calls[state.CallIndex].Function.Name != "image_layer_split") { return errors.New("Agent runtime media task is not attached to current call") } } + if state.AutoPreparedMedia != nil { + if state.AutoPreparedCallHash == "" || state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + return errors.New("Agent runtime auto media preparation is invalid") + } + current := state.Calls[state.CallIndex] + if current.Function.Name != "generate_media" && current.Function.Name != "image_layer_split" { + return errors.New("Agent runtime auto media preparation is not attached to a media call") + } + if state.AutoPreparedCallHash != cloudAgentApprovalCallHash(current) { + return errors.New("Agent runtime auto media preparation does not match current call") + } + } if state.Decisions == nil || state.Events == nil { return errors.New("Agent runtime maps are missing") } @@ -714,7 +731,14 @@ func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgent return nil, err } for _, order := range orders { - out.SpentCredits += float64(order.AmountMicrocredits) / float64(CreditScale) + // AmountMicrocredits is the reservation/quote, not necessarily the amount + // finally charged. A run can reserve 100000 microcredits and settle at + // 100000 microcredits (= 0.1 credits), or be refunded altogether. Never + // expose the reservation as spend, otherwise the Agent claims a charge + // that did not happen and budget/usage copy diverges from billing. + if order.Status == model.BillingStatusSettled { + out.SpentCredits += float64(order.ActualAmountMicrocredits) / float64(CreditScale) + } } return out, nil } @@ -1520,7 +1544,8 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止") } allowed := cloudAgentToolAllowed(state.Request, call.Function.Name) - if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval == nil { + mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" + if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil { var plan *cloudAgentMediaPlan var modelName string var mediaRequest CreateTaskRequest @@ -1536,20 +1561,99 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c if err != nil { return s.cloudAgentMediaError(run, state, "admission", false, false, err) } - // Dry admission validates the selected model and prompt limits without a task or charge. - mediaPreparation = &creationTaskPreparation{} - req.creationPrepare = mediaPreparation - preparedTask, err = s.CreateTask(run.UserID, req) - if err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) + mediaRequest, plan = req, prepared + // A durable auto checkpoint already contains the exact admitted input and + // quote. Reusing it avoids a second dry admission after a worker restart. + if plan.Prepared == nil { + // Dry admission validates the selected model and prompt limits without a task or charge. + mediaPreparation = &creationTaskPreparation{} + req.creationPrepare = mediaPreparation + preparedTask, err = s.CreateTask(run.UserID, req) + if err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + // Resolve model-owned defaults once during the dry admission. Both the + // eventual task and the canvas draft must use this exact resolved input; + // otherwise auto mode reports a false tool failure for omitted size or + // duration and makes the model spend another turn repairing its own call. + if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + mediaRequest, plan = req, prepared } - mediaRequest = req - plan = prepared modelName, err = s.cloudAgentMediaModelName(plan.Args) if err != nil { return s.cloudAgentMediaError(run, state, "admission", false, false, err) } } + if plan != nil && state.Request.PermissionMode == "auto" { + if plan.Prepared != nil { + // The draft and quote were already checkpointed. Reuse them after a + // worker restart instead of dry-admitting and mutating the canvas again. + latest, err := s.repo.CloudAgent(run.UserID, run.ID) + if err != nil { + return err + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + return err + } + return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) + } + // Auto media is a direct, server-admitted write. Prepare the draft and + // its immutable quote in one checkpoint transaction, then release the + // lock before enqueueCloudAgentTask performs the billed submission. + s.storageMu.Lock() + err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { + return err + } + canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + if err != nil { + return err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return err + } + plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) + raw, err := json.Marshal(plan.Args) + if err != nil { + return err + } + call.Function.Arguments = string(raw) + state.Calls[state.CallIndex] = call + preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order) + if err != nil { + return err + } + plan.Prepared = preparedMedia + state.AutoPreparedMedia = preparedMedia + state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call) + return cloudAgentSave(current, state) + }) + s.storageMu.Unlock() + if err != nil { + latest, readErr := s.repo.CloudAgent(run.UserID, run.ID) + if readErr != nil || latest.Revision != run.Revision { + return err + } + fresh, decodeErr := cloudAgentDecode(latest) + if decodeErr != nil { + return decodeErr + } + return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err) + } + latest, err := s.repo.CloudAgent(run.UserID, run.ID) + if err != nil { + return err + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + return err + } + return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) + } s.storageMu.Lock() defer s.storageMu.Unlock() return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { @@ -1785,11 +1889,17 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c var prepared *cloudAgentPreparedMedia approvalID, generationID := "", "" if media != nil { - if state.Approval == nil || state.Approval.Prepared == nil { + prepared = media.Prepared + if prepared == nil && state.Approval != nil { + prepared = state.Approval.Prepared + if state.Approval != nil { + approvalID = state.Approval.ID + } + } + if prepared == nil { return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务")) } - prepared = state.Approval.Prepared - approvalID, generationID = state.Approval.ID, prepared.GenerationID + generationID = prepared.GenerationID refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared) if refErr != nil { return s.cloudAgentMediaError(run, state, "admission", false, false, refErr) @@ -1890,16 +2000,39 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c return err } if media != nil && prepared != nil { - if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil { - return err + if approvalID != "" { + if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil { + return err + } + } else { + // Auto execution has no approval owner to transfer from. Attach the + // resource lease directly to the billed task before it can run. + ids := make([]string, 0, len(prepared.ResourceSignatures)) + for id := range prepared.ResourceSignatures { + ids = append(ids, id) + } + if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil { + return err + } } } + if media != nil { + // The prepared auto admission is single-use. Once the billed task and + // draft node are committed, a later retry must observe the task state, + // not reuse the old quote or create a second task. + state.AutoPreparedMedia = nil + state.AutoPreparedCallHash = "" + } state.TaskIDs = append(state.TaskIDs, task.ID) if media != nil { state.MediaTaskID = task.ID state.Generations++ state.VideoSeconds += media.Args.Duration - state.event(run.ID, "generation_task_created", map[string]any{"toolName": "generate_media", "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"}) + toolName := "generate_media" + if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" { + toolName = state.Calls[state.CallIndex].Function.Name + } + state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"}) } else { state.ActiveTaskID = task.ID // 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。 @@ -1954,6 +2087,10 @@ func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cl return cloudAgentSave(current, state) } cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err) + // Any admission failure advances the call into the repair path. Do not let + // a prepared quote from the failed attempt leak into the corrected call. + state.AutoPreparedMedia = nil + state.AutoPreparedCallHash = "" if submitted { state.MediaTaskID = "" } diff --git a/backend/internal/app/cloud_agent_task_facts.go b/backend/internal/app/cloud_agent_task_facts.go index faabccfc3..0a888408e 100644 --- a/backend/internal/app/cloud_agent_task_facts.go +++ b/backend/internal/app/cloud_agent_task_facts.go @@ -53,9 +53,29 @@ type cloudAgentTaskBillingFacts struct { ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits,omitempty"` ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits,omitempty"` RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits,omitempty"` + ReservedCredits float64 `json:"reservedCredits"` + ActualCredits float64 `json:"actualCredits"` + RefundedCredits float64 `json:"refundedCredits"` + ChargeState string `json:"chargeState"` + ChargeSummary string `json:"chargeSummary"` StateAvailable bool `json:"stateAvailable"` } +func cloudAgentChargeState(status model.BillingStatus) (string, string) { + switch status { + case model.BillingStatusSettled: + return "settled", "已结算,实际扣费以 actualCredits 为准" + case model.BillingStatusRefunded: + return "refunded", "已退款,本次不计入已消费" + case model.BillingStatusReserved, model.BillingStatusRunning: + return "pending", "仅预授权/执行中,尚未确认实际扣费" + case model.BillingStatusUncertain: + return "uncertain", "扣费状态待确认,不得称为已扣费" + default: + return "unknown", "扣费状态未知,不得称为已扣费" + } +} + func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map[string]any { if task == nil { return map[string]any{"taskStatus": "unavailable", "taskSubmitted": false, "submissionOutcome": "unavailable"} @@ -98,6 +118,10 @@ func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map facts.Billing.ReservedAmountMicrocredits = order.ReservedAmountMicrocredits facts.Billing.ActualAmountMicrocredits = order.ActualAmountMicrocredits facts.Billing.RefundedAmountMicrocredits = order.RefundedAmountMicrocredits + facts.Billing.ReservedCredits = float64(order.ReservedAmountMicrocredits) / float64(CreditScale) + facts.Billing.ActualCredits = float64(order.ActualAmountMicrocredits) / float64(CreditScale) + facts.Billing.RefundedCredits = float64(order.RefundedAmountMicrocredits) / float64(CreditScale) + facts.Billing.ChargeState, facts.Billing.ChargeSummary = cloudAgentChargeState(order.Status) facts.Billing.StateAvailable = true } } diff --git a/backend/internal/app/cloud_agent_task_facts_test.go b/backend/internal/app/cloud_agent_task_facts_test.go index 2503524ca..909faccc9 100644 --- a/backend/internal/app/cloud_agent_task_facts_test.go +++ b/backend/internal/app/cloud_agent_task_facts_test.go @@ -83,3 +83,33 @@ func TestCloudAgentTaskDiagnosticRejectsMismatchedBillingOrderFacts(t *testing.T t.Fatalf("cross-owner billing state leaked: %#v", billing) } } + +func TestCloudAgentTaskDiagnosticSeparatesReservationFromActualCharge(t *testing.T) { + s, db, _, _ := creationTestService(t) + task := &model.Task{ + ID: "task-settled", UserID: "user", ProjectID: "canvas", Status: model.TaskStatusSucceeded, + BillingOrderID: "order-settled", InputJSON: `{}`, + } + order := &model.BillingOrder{ + ID: "order-settled", UserID: "user", TaskID: task.ID, Status: model.BillingStatusSettled, + ReservedAmountMicrocredits: 100_000, ActualAmountMicrocredits: 100_000, + } + if err := db.Create(task).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(order).Error; err != nil { + t.Fatal(err) + } + + facts := cloudAgentTaskDiagnostic(s.repo, task) + billing, ok := facts["billing"].(map[string]any) + if !ok { + t.Fatalf("billing facts = %#v", facts["billing"]) + } + if billing["reservedAmountMicrocredits"] != float64(100_000) || billing["actualAmountMicrocredits"] != float64(100_000) { + t.Fatalf("microcredit facts = %#v", billing) + } + if billing["reservedCredits"] != 0.1 || billing["actualCredits"] != 0.1 || billing["chargeState"] != "settled" { + t.Fatalf("credit facts = %#v", billing) + } +} diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index 8a6898526..5bb30fa55 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -509,7 +509,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "topic", "category", "situation") } if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 { - add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;这是生成型操作,必须进入现有媒体审批与计费链路,不能直接执行。", map[string]any{ + add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;request_approval 进入界面独立审批,auto 由服务端准入成功后直接提交,不会再次弹出模型生成审批。", map[string]any{ "prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), "quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")}, }, "prompt", "nodeId", "title", "referenceNodeIds") @@ -582,7 +582,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma }, "snapshotHash") } if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 { - add("generate_media", "提交媒体生成:先准备草稿、引用和独立审批,再提交收费任务(auto也需审批)。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{ + add("generate_media", "提交媒体生成:先准备草稿和引用;request_approval 需要用户确认,auto 在服务端完成模型、能力、价格、预算和资源校验后直接提交,不会再次弹出模型生成审批。auto 只在准入成功后提交。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{ "mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"), "logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), "durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"}, diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index acf55e0ba..289738a12 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -46,6 +46,7 @@ type PublicChannelModel struct { Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"` + DefaultOptions map[string]any `json:"defaultOptions,omitempty"` PriceTiers []PublicChannelModelPriceTier `json:"priceTiers"` PricingMode string `json:"pricingMode"` DisplayPrice *int64 `json:"displayPrice,omitempty"` @@ -181,6 +182,17 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod return PublicChannelModel{}, fmt.Errorf("投影渠道模型能力配置失败:%w", err) } } + var defaultOptions map[string]any + if normalized != nil { + spec, specErr := CapabilitySpecFromModelCapabilityConfig(normalized, cm.Capability) + if specErr != nil { + return PublicChannelModel{}, fmt.Errorf("投影渠道模型默认参数失败:%w", specErr) + } + defaultOptions, err = channelModelDefaultOptions(*cm, spec) + if err != nil { + return PublicChannelModel{}, err + } + } return PublicChannelModel{ ID: cm.ID, @@ -194,6 +206,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod Capability: cm.Capability, Protocol: cm.Protocol, CapabilityConfig: capabilityConfig, + DefaultOptions: defaultOptions, PriceTiers: publicTiers, PricingMode: pricingMode, DisplayPrice: displayPrice, diff --git a/backend/internal/app/project.go b/backend/internal/app/project.go index c2494b02b..0b5de215c 100644 --- a/backend/internal/app/project.go +++ b/backend/internal/app/project.go @@ -503,10 +503,13 @@ func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int if kind != model.ProjectUnitKindChapter && kind != model.ProjectUnitKindEpisode { return model.ProjectUnit{}, BadAuthRequest("不支持的项目单元类型") } - title := strings.TrimSpace(req.Title) + title := model.NormalizeProjectUnitTitle(req.Title) if title == "" { return model.ProjectUnit{}, BadAuthRequest("章节标题不能为空") } + if !model.ValidProjectUnitTitle(title) { + return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符") + } if position < 0 { position = 0 } @@ -524,7 +527,10 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri return model.ProjectUnit{}, err } sourceChanged := unit.SourceText != req.SourceText - if title := strings.TrimSpace(req.Title); title != "" { + if title := model.NormalizeProjectUnitTitle(req.Title); title != "" { + if !model.ValidProjectUnitTitle(title) { + return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符") + } unit.Title = title } unit.SourceText = req.SourceText diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index f75619b91..b028da9ec 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 37 +const CurrentSchemaVersion int64 = 38 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -25,6 +25,7 @@ const creationRuntimeChecksum = "sha256:creation-runtime-v10-20260909" const authNotificationsChecksum = "sha256:auth-notifications-v35-20260924" const cloudAgentGeminiCacheChecksum = "sha256:cloud-agent-gemini-cache-v36-20260924" const cloudAgentGeminiCacheIdentityChecksum = "sha256:cloud-agent-gemini-cache-identity-v37-20260925" +const prefixedIDSequenceReconcileChecksum = "sha256:prefixed-id-sequence-reconcile-v38-20260926" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -125,6 +126,14 @@ var schemaMigrations = []migration{ return tx.AutoMigrate(&model.CloudAgentGeminiCache{}) }}, {version: 37, name: "cloud_agent_gemini_cache_identity", checksum: cloudAgentGeminiCacheIdentityChecksum, apply: migrateCloudAgentGeminiCacheIdentity}, + {version: 38, name: "prefixed_id_sequence_reconcile", checksum: prefixedIDSequenceReconcileChecksum, apply: migratePrefixedIDSequenceReconcile}, +} + +func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.IDSequence{}); err != nil { + return fmt.Errorf("创建可读 ID 序列表:%w", err) + } + return reconcilePrefixedIDSequences(tx) } func migrateCloudAgentGeminiCacheIdentity(tx *gorm.DB) error { diff --git a/backend/internal/database/prefixed_ids.go b/backend/internal/database/prefixed_ids.go new file mode 100644 index 000000000..04c96f991 --- /dev/null +++ b/backend/internal/database/prefixed_ids.go @@ -0,0 +1,224 @@ +package database + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// prefixedIDDefinition is the single source of truth for the readable IDs that +// are allocated from id_sequences. The business table is used to reconcile a +// sequence after a SQLite -> PostgreSQL copy or an interrupted write. +type prefixedIDDefinition struct { + prefix string + model any +} + +var prefixedIDDefinitions = []prefixedIDDefinition{ + {prefix: "CHANNEL", model: &model.ModelChannel{}}, + {prefix: "MODEL", model: &model.ChannelModel{}}, + {prefix: "PTIER", model: &model.ChannelModelPriceTier{}}, + {prefix: "ATTEMPT", model: &model.RouteAttempt{}}, + {prefix: "LMODEL", model: &model.LogicalModel{}}, + {prefix: "REVISION", model: &model.LogicalModelRevision{}}, + {prefix: "ROUTE", model: &model.LogicalModelRoute{}}, +} + +// ReconcilePrefixedIDSequences advances all known readable-ID sequences to at +// least the largest valid business ID. It never moves a sequence backwards. +// Soft-deleted rows are included deliberately: their IDs remain reserved. +func ReconcilePrefixedIDSequences(db *gorm.DB) error { + if db == nil { + return errors.New("database handle is nil") + } + return db.Transaction(func(tx *gorm.DB) error { + return reconcilePrefixedIDSequences(tx) + }) +} + +func reconcilePrefixedIDSequences(tx *gorm.DB) error { + for _, definition := range prefixedIDDefinitions { + if !tx.Migrator().HasTable(definition.model) { + continue + } + maximum, err := maxPrefixedBusinessID(tx, definition) + if err != nil { + return fmt.Errorf("读取 %s 业务 ID:%w", definition.prefix, err) + } + if err := ensureSequenceRow(tx, definition.prefix); err != nil { + return fmt.Errorf("初始化 %s 序列:%w", definition.prefix, err) + } + item, err := lockedSequence(tx, definition.prefix) + if err != nil { + return fmt.Errorf("读取 %s 序列:%w", definition.prefix, err) + } + if item.Value >= maximum { + continue + } + if err := tx.Model(&model.IDSequence{}).Where("name = ?", item.Name).Updates(map[string]any{ + "value": maximum, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("校准 %s 序列到 %d:%w", definition.prefix, maximum, err) + } + } + return nil +} + +// AllocatePrefixedID allocates the next readable ID. For known prefixes it +// first reconciles the sequence with the business table and checks the +// candidate against that table, so stale sequences and soft-deleted rows +// cannot produce duplicate primary keys. +func AllocatePrefixedID(db *gorm.DB, prefix string) (string, error) { + if db == nil { + return "", errors.New("database handle is nil") + } + var allocated string + if err := db.Transaction(func(tx *gorm.DB) error { + var err error + allocated, err = allocatePrefixedID(tx, prefix) + return err + }); err != nil { + return "", err + } + return allocated, nil +} + +func allocatePrefixedID(tx *gorm.DB, rawPrefix string) (string, error) { + prefix := normalizeIDPrefix(rawPrefix) + if prefix == "" || len(prefix) > 16 { + return "", errors.New("invalid id prefix") + } + if err := ensureSequenceRow(tx, prefix); err != nil { + return "", err + } + item, err := lockedSequence(tx, prefix) + if err != nil { + return "", err + } + + definition, known := prefixedIDDefinitionFor(prefix) + candidateValue := item.Value + if known { + if !tx.Migrator().HasTable(definition.model) { + return "", fmt.Errorf("ID 前缀 %s 对应的业务表不存在", prefix) + } + maximum, err := maxPrefixedBusinessID(tx, definition) + if err != nil { + return "", fmt.Errorf("读取 %s 业务 ID:%w", prefix, err) + } + if candidateValue < maximum { + candidateValue = maximum + } + } + if candidateValue == math.MaxInt64 { + return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix) + } + + for { + candidateValue++ + candidate := fmt.Sprintf("%s_%06d", prefix, candidateValue) + if !known { + if err := updateSequence(tx, item.Name, candidateValue); err != nil { + return "", err + } + return candidate, nil + } + var count int64 + if err := tx.Unscoped().Model(definition.model).Where("id = ?", candidate).Count(&count).Error; err != nil { + return "", fmt.Errorf("检查 %s 候选 ID:%w", prefix, err) + } + if count != 0 { + if candidateValue == math.MaxInt64 { + return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix) + } + continue + } + if err := updateSequence(tx, item.Name, candidateValue); err != nil { + return "", err + } + return candidate, nil + } +} + +func normalizeIDPrefix(raw string) string { + return strings.ToUpper(strings.TrimSpace(raw)) +} + +func prefixedIDDefinitionFor(prefix string) (prefixedIDDefinition, bool) { + for _, definition := range prefixedIDDefinitions { + if definition.prefix == prefix { + return definition, true + } + } + return prefixedIDDefinition{}, false +} + +func ensureSequenceRow(tx *gorm.DB, prefix string) error { + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.IDSequence{ + Name: "id:" + prefix, + UpdatedAt: time.Now(), + }).Error +} + +func lockedSequence(tx *gorm.DB, prefix string) (model.IDSequence, error) { + query := tx.Model(&model.IDSequence{}).Where("name = ?", "id:"+prefix) + if tx.Dialector.Name() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var item model.IDSequence + if err := query.First(&item).Error; err != nil { + return model.IDSequence{}, err + } + return item, nil +} + +func updateSequence(tx *gorm.DB, name string, value int64) error { + return tx.Model(&model.IDSequence{}).Where("name = ?", name).Updates(map[string]any{ + "value": value, + "updated_at": time.Now(), + }).Error +} + +func maxPrefixedBusinessID(tx *gorm.DB, definition prefixedIDDefinition) (int64, error) { + var ids []string + if err := tx.Unscoped().Model(definition.model).Select("id").Pluck("id", &ids).Error; err != nil { + return 0, err + } + prefix := definition.prefix + "_" + var maximum int64 + for _, id := range ids { + if !strings.HasPrefix(id, prefix) { + continue + } + digits := strings.TrimPrefix(id, prefix) + if digits == "" { + continue + } + for _, character := range digits { + if character < '0' || character > '9' { + digits = "" + break + } + } + if digits == "" { + continue + } + value, err := strconv.ParseInt(digits, 10, 64) + if err != nil { + continue + } + if value > maximum { + maximum = value + } + } + return maximum, nil +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 4fe92e954..37d280dd1 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -10,11 +10,22 @@ import ( var projectUnitHTMLTagPattern = regexp.MustCompile(`<[^>]+>`) +const ProjectUnitTitleMaxRunes = 240 + func ProjectUnitWordCount(sourceText string) int { plainText := projectUnitHTMLTagPattern.ReplaceAllString(sourceText, "") return utf8.RuneCountInString(strings.TrimSpace(html.UnescapeString(plainText))) } +func NormalizeProjectUnitTitle(value string) string { + return strings.TrimSpace(value) +} + +func ValidProjectUnitTitle(value string) bool { + title := NormalizeProjectUnitTitle(value) + return title != "" && utf8.RuneCountInString(title) <= ProjectUnitTitleMaxRunes +} + const AssetIDMaxLength = 80 type Resource struct { diff --git a/backend/internal/prompts/agent-media-policy.md b/backend/internal/prompts/agent-media-policy.md index aafbfedd6..2a449463d 100644 --- a/backend/internal/prompts/agent-media-policy.md +++ b/backend/internal/prompts/agent-media-policy.md @@ -1,16 +1,16 @@ --- id: cloud-agent-media -version: 3 +version: 4 --- # 影策 Cloud Agent 媒体策略 生成前读取节点能力和当前画布,向 model_list 传实际 mode 与 referenceNodeIds,按真实参考素材筛选模型。多图生视频不能选择仅文生视频模型,也不能丢弃参考图来迁就模型。返回的 options / profiles 是配置能力,不凭模型名称猜测;同一配置必须同时满足参考数量、时长、画幅和音频要求,不能拼接不同 profiles 的能力。空候选时说明能力缺口,不编造可用模型;素材或模式改变后旧的筛选结果不再适用。 -用户要求生成且参数齐备时,本轮直接调用 generate_media 进入独立审批,不把仅创建节点、连线或填写提示词说成已提交。用户仅要求改词或准备草稿时不要擅自生成。已有未提交草稿应优先续用原节点:无任务的空白媒体占位节点、当前运行草稿、已结束且清理完成运行的遗留草稿均需最新快照和新的审批。另一运行仍在处理的草稿先完成或取消原运行;已有任务或成品不可覆盖。不要通过不断换新 ID 逃避错误,不把所有旧节点都误认为不可操作。 +用户要求生成且参数齐备时,本轮直接调用 generate_media。request_approval 会进入界面独立审批;auto 会由服务端完成模型、能力、价格、预算和资源准入后直接提交,不会再产生一张模型生成审批卡。不要把仅创建节点、连线或填写提示词说成已提交。用户仅要求改词或准备草稿时不要擅自生成。已有未提交草稿应优先续用原节点:无任务的空白媒体占位节点、当前运行草稿、已结束且清理完成运行的遗留草稿均需最新快照;request_approval 需要新的审批,auto 由服务端重新准入。另一运行仍在处理的草稿先完成或取消原运行;已有任务或成品不可覆盖。不要通过不断换新 ID 逃避错误,不把所有旧节点都误认为不可操作。 媒体生成先区分参数选择与收费授权。用户明确指定的模型、画幅、时长、音频必须遵守;用户说“随便”“你决定”“按默认”时允许安全默认,不要求四项逐个确认:从当前目录选择支持参考方式、参数且预算可覆盖的模型,优先用户最近明确使用且仍可用的模型,否则选择价格清楚的低成本可用项。画幅优先匹配参考图原始宽高比且必须在模型支持范围内,否则用目录默认;时长用目录默认,不编造;模型不支持音频且用户未要求声音时直接用默片。 -用户对上一条明确推荐方案说“可以”“开始吧”,即接受该方案的参数,不要求回复特定确认口令。只有引用对象不明确、用户硬性要求与能力冲突、无有效默认值或预算不足时,一次性询问真正缺少的事项。选择后简短说明实际参数和默认来源,并立即创建草稿交给审批卡,不添加额外文字确认轮次。 +用户对上一条明确推荐方案说“可以”“开始吧”,即接受该方案的参数,不要求回复特定确认口令。只有引用对象不明确、用户硬性要求与能力冲突、无有效默认值或预算不足时,一次性询问真正缺少的事项。选择后简短说明实际参数和默认来源;request_approval 创建草稿交给审批卡,auto 直接提交并用 task_get/generation 核实结果,不添加额外文字确认轮次。 -创建媒体草稿及连线不等于收费授权;所有模式都必须等待界面独立审批。“随便”“开始”和默认参数都不能跳过审批。已提交的收费任务失败不得自动重提、换模型或盲重试;把失败原因告诉用户并结束本步,不要把整轮对话停掉。 +创建媒体草稿及连线不等于收费授权;request_approval 必须等待界面独立审批,auto 只有在服务端准入成功后才提交,不能再等待或伪造审批事件。“随便”“开始”和默认参数都不能绕过服务端准入。已提交的收费任务失败不得自动重提、换模型或盲重试;把失败原因告诉用户并结束本步,不要把整轮对话停掉。查询计费时,authorizedChargeMicrocredits / chargeLimitMicrocredits 是预授权或上限,不是实际消费;只有 billing.status=settled 时才使用 actualCredits(或 actualAmountMicrocredits)描述已扣费,reservedCredits 不能当作已扣费。 diff --git a/backend/internal/prompts/agent_policy_test.go b/backend/internal/prompts/agent_policy_test.go index 45349b8e1..41183b457 100644 --- a/backend/internal/prompts/agent_policy_test.go +++ b/backend/internal/prompts/agent_policy_test.go @@ -10,12 +10,21 @@ func TestLoadAgentPoliciesUsesDocumentMetadata(t *testing.T) { if err != nil { t.Fatal(err) } - if system.ID != "cloud-agent-system" || system.Version != 10 || media.ID != "cloud-agent-media" || media.Version != 3 { + if system.ID != "cloud-agent-system" || system.Version != 10 || media.ID != "cloud-agent-media" || media.Version != 4 { t.Fatalf("unexpected policy metadata: system=%+v media=%+v", system, media) } if strings.Contains(system.Text, "id: cloud-agent-system") || !strings.HasPrefix(system.Text, "# 影策 Cloud Agent") { t.Fatalf("metadata leaked into compiled policy body: %q", system.Text) } + for _, phrase := range []string{ + "auto 会由服务端完成模型、能力、价格、预算和资源准入后直接提交", + "request_approval 必须等待界面独立审批", + "authorizedChargeMicrocredits / chargeLimitMicrocredits 是预授权或上限,不是实际消费", + } { + if !strings.Contains(media.Text, phrase) { + t.Fatalf("media policy lost required contract phrase %q", phrase) + } + } } func TestParsePolicyDocumentRejectsInvalidMetadata(t *testing.T) { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index f743c96ab..b081094e3 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -73,32 +74,7 @@ func (r *Repository) ReleaseTaskLease(id string, owner string) error { // NextPrefixedID 在数据库事务中递增序列,避免 UUID/父子字符串拼接导致的不可读和不可排序 ID。 // prefix 只决定展示前缀,关联关系仍由独立外键维护。 func (r *Repository) NextPrefixedID(prefix string) (string, error) { - return r.nextPrefixedID(r.db, prefix) -} - -func (r *Repository) nextPrefixedID(db *gorm.DB, prefix string) (string, error) { - prefix = strings.ToUpper(strings.TrimSpace(prefix)) - if prefix == "" || len(prefix) > 16 { - return "", errors.New("invalid id prefix") - } - sequence := "id:" + prefix - var item model.IDSequence - err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.IDSequence{Name: sequence, UpdatedAt: time.Now()}).Error; err != nil { - return err - } - if err := tx.Model(&model.IDSequence{}).Where("name = ?", sequence).Updates(map[string]any{ - "value": gorm.Expr("value + ?", 1), - "updated_at": time.Now(), - }).Error; err != nil { - return err - } - return tx.First(&item, "name = ?", sequence).Error - }) - if err != nil { - return "", err - } - return fmt.Sprintf("%s_%06d", prefix, item.Value), nil + return database.AllocatePrefixedID(r.db, prefix) } func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) { diff --git a/backend/internal/repository/skill_packages.go b/backend/internal/repository/skill_packages.go index 3726149af..40aa1c668 100644 --- a/backend/internal/repository/skill_packages.go +++ b/backend/internal/repository/skill_packages.go @@ -51,9 +51,9 @@ func (r *Repository) SkillFiles(versionID string) ([]model.SkillFile, error) { return files, err } -func (r *Repository) SkillsForPackageEnsure(userSource int) ([]model.Skill, error) { +func (r *Repository) SkillsForPackageEnsure() ([]model.Skill, error) { var skills []model.Skill - err := r.db.Where("status = ? AND (current_version_id = '' OR source <> ?)", 1, userSource).Find(&skills).Error + err := r.db.Where("status = ?", 1).Find(&skills).Error return skills, err } diff --git a/backend/internal/skills/skill_packages.go b/backend/internal/skills/skill_packages.go index cc6487898..3d8fef03c 100644 --- a/backend/internal/skills/skill_packages.go +++ b/backend/internal/skills/skill_packages.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "log" "mime" "mime/multipart" "net/http" @@ -104,6 +105,14 @@ type skillPackageArchive struct { TotalBytes int64 } +type skillPackageSnapshot struct { + skill *model.Skill + version *model.SkillVersion + files []model.SkillFile + legacy bool + contents map[string][]byte +} + type githubSkillSpec struct { Owner string Repo string @@ -112,27 +121,54 @@ type githubSkillSpec struct { } func (s *Service) EnsureSkillPackages() error { - skills, err := s.repo.SkillsForPackageEnsure(skillSourceUser) + skills, err := s.repo.SkillsForPackageEnsure() if err != nil { return err } for index := range skills { skill := &skills[index] - // Legacy fields are only fallbacks for package metadata. Bound them at - // this migration boundary without rewriting source fields or relaxing uploads. - archive, err := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500)) - if err != nil { - return fmt.Errorf("迁移技能 %s 文件包失败: %w", skill.ID, err) - } - if skill.CurrentVersionID != "" && skill.ContentHash == archive.ContentHash { + version, files, healthErr := s.skillPackageHealth(skill) + if healthErr == nil { + if err := s.syncSkillPackageMetadata(skill, version, files); err != nil { + return fmt.Errorf("同步技能 %s 文件包元数据失败: %w", skill.ID, err) + } + // User-installed ZIP/GitHub skills are authoritative in skill_files and + // must never be replaced by the legacy instruction column at startup. + if skill.Source == skillSourceUser { + continue + } + if strings.TrimSpace(skill.Instruction) == "" { + continue + } + archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500)) + if archiveErr != nil { + log.Printf("技能 %s 的内置正文无法刷新文件包:%v", skill.ID, archiveErr) + continue + } + if version.ContentHash == archive.ContentHash { + continue + } + if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil { + return fmt.Errorf("刷新技能 %s 文件包失败: %w", skill.ID, err) + } continue } - sourceType := "builtin" - if skill.Source == skillSourceUser { - sourceType = "markdown" + + // A legacy row may contain only skills.instruction. Rebuild that single + // entry when package metadata, files, or the archive is missing. If no + // source body exists, keep the service available and leave a diagnostic + // instead of failing every user request during boot. + if strings.TrimSpace(skill.Instruction) == "" { + log.Printf("技能 %s 文件包不完整且没有 legacy instruction,跳过自动修复:%v", skill.ID, healthErr) + continue } - if err := s.addSkillArchiveVersion(skill, archive, sourceType, "", "", "", "", false); err != nil { - return fmt.Errorf("保存技能 %s 文件包失败: %w", skill.ID, err) + archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500)) + if archiveErr != nil { + log.Printf("技能 %s 文件包损坏且 legacy instruction 无法重建:%v", skill.ID, archiveErr) + continue + } + if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil { + return fmt.Errorf("修复技能 %s 文件包失败: %w", skill.ID, err) } } return nil @@ -422,25 +458,19 @@ func (s *Service) persistSkillArchive(skillID string, versionID string, archive } func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) { - skill, err := s.visibleSkill(userID, skillID) + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) if err != nil { return nil, err } - files, err := s.repo.SkillFiles(skill.CurrentVersionID) - if err != nil { - return nil, err - } - items := make([]SkillPackageFileItem, 0, len(files)) - for _, file := range files { + items := make([]SkillPackageFileItem, 0, len(snapshot.files)) + for _, file := range snapshot.files { items = append(items, skillFileItem(file)) } return items, nil } func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) { - skill, version, file, content, err := s.readSkillPackageFile(userID, skillID, filePath) - _ = skill - _ = version + _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) if err != nil { return nil, err } @@ -455,7 +485,7 @@ func (s *Service) SkillPackageFile(userID string, skillID string, filePath strin } func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) { - _, _, file, content, err := s.readSkillPackageFile(userID, skillID, filePath) + _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) if err != nil { return nil, "", "", err } @@ -463,21 +493,13 @@ func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath st } func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) { - skill, err := s.visibleSkill(userID, skillID) + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) if err != nil { return nil, err } - version, err := s.repo.SkillVersion(skill.CurrentVersionID) - if err != nil { - return nil, err - } - files, err := s.repo.SkillFiles(version.ID) - if err != nil { - return nil, err - } - bundle := &SkillPackageBundle{SkillID: skill.ID, Name: skill.Name, Description: skill.Description, VersionID: version.ID, Version: version.VersionLabel, ContentHash: version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(files))} - for _, file := range files { - content, err := s.readSkillArchiveEntry(version, file.Path) + bundle := &SkillPackageBundle{SkillID: snapshot.skill.ID, Name: snapshot.skill.Name, Description: snapshot.skill.Description, VersionID: snapshot.version.ID, Version: snapshot.version.VersionLabel, ContentHash: snapshot.version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(snapshot.files))} + for _, file := range snapshot.files { + content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) if err != nil { return nil, err } @@ -487,7 +509,7 @@ func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPacka } func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) { - skill, err := s.visibleSkill(userID, skillID) + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) if err != nil { return nil, err } @@ -495,21 +517,13 @@ func (s *Service) SearchSkillPackage(userID string, skillID string, query string if query == "" || utf8.RuneCountInString(query) > 120 { return nil, kernel.BadAuthRequest("搜索关键词必须为 1-120 个字符") } - version, err := s.repo.SkillVersion(skill.CurrentVersionID) - if err != nil { - return nil, err - } - files, err := s.repo.SkillFiles(version.ID) - if err != nil { - return nil, err - } needle := strings.ToLower(query) results := make([]SkillFileSearchResult, 0, 20) - for _, file := range files { + for _, file := range snapshot.files { if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes { continue } - content, err := s.readSkillArchiveEntry(version, file.Path) + content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) if err != nil { return nil, err } @@ -525,31 +539,230 @@ func (s *Service) SearchSkillPackage(userID string, skillID string, query string return results, nil } -func (s *Service) readSkillPackageFile(userID string, skillID string, filePath string) (*model.Skill, *model.SkillVersion, *model.SkillFile, []byte, error) { - skill, err := s.visibleSkill(userID, skillID) +func (s *Service) readSkillPackageSnapshotFile(userID string, skillID string, filePath string) (*skillPackageSnapshot, *model.SkillFile, []byte, error) { + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) if err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, err } filePath, err = normalizeSkillPath(filePath) if err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, err + } + for index := range snapshot.files { + if snapshot.files[index].Path != filePath { + continue + } + content, err := s.readSkillPackageSnapshotEntry(snapshot, filePath) + return snapshot, &snapshot.files[index], content, err + } + return nil, nil, nil, kernel.BadAuthRequest("技能文件不存在") +} + +func (s *Service) loadSkillPackageSnapshot(userID string, skillID string) (*skillPackageSnapshot, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, err } version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return legacySkillPackageSnapshot(skill) + } if err != nil { - return nil, nil, nil, nil, err + return nil, err + } + if version.SkillID != skill.ID { + return nil, fmt.Errorf("技能 %s 当前版本归属不一致", skill.ID) } files, err := s.repo.SkillFiles(version.ID) if err != nil { - return nil, nil, nil, nil, err + return nil, err } - for index := range files { - if files[index].Path != filePath { + if len(files) == 0 { + return legacySkillPackageSnapshot(skill) + } + if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { + return nil, err + } + return &skillPackageSnapshot{skill: skill, version: version, files: files}, nil +} + +func (s *Service) readSkillPackageSnapshotEntry(snapshot *skillPackageSnapshot, filePath string) ([]byte, error) { + if snapshot.contents != nil { + content, ok := snapshot.contents[filePath] + if !ok { + return nil, kernel.BadAuthRequest("技能文件不存在") + } + return content, nil + } + return s.readSkillArchiveEntry(snapshot.version, filePath) +} + +func legacySkillPackageSnapshot(skill *model.Skill) (*skillPackageSnapshot, error) { + if strings.TrimSpace(skill.Instruction) == "" { + return nil, fmt.Errorf("技能 %s 缺少当前版本、文件清单和 legacy instruction", skill.ID) + } + content := []byte(skill.Instruction) + files := map[string][]byte{"SKILL.md": content} + digest := sha256.Sum256(content) + hash, total := contentHashAndSize(files) + versionID := strings.TrimSpace(skill.CurrentVersionID) + versionLabel := strings.TrimSpace(skill.VersionLabel) + if versionLabel == "" { + versionLabel = "legacy" + } + version := &model.SkillVersion{ID: versionID, SkillID: skill.ID, VersionLabel: versionLabel, ContentHash: hash, EntryPath: "SKILL.md", FileCount: 1, TotalBytes: total} + file := model.SkillFile{SkillVersionID: versionID, Path: "SKILL.md", Kind: "markdown", MimeType: skillFileMime("SKILL.md", content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} + return &skillPackageSnapshot{skill: skill, version: version, files: []model.SkillFile{file}, legacy: true, contents: files}, nil +} + +func skillSourceTypeForRepair(skill *model.Skill) string { + if value := strings.TrimSpace(skill.SourceType); value != "" { + return value + } + if skill.Source == skillSourceUser { + return "markdown" + } + return "builtin" +} + +func (s *Service) skillPackageHealth(skill *model.Skill) (*model.SkillVersion, []model.SkillFile, error) { + if strings.TrimSpace(skill.CurrentVersionID) == "" { + return nil, nil, gorm.ErrRecordNotFound + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if err != nil { + return nil, nil, err + } + if version.SkillID != skill.ID { + return nil, nil, fmt.Errorf("当前版本归属技能不一致") + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, nil, err + } + if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { + return nil, nil, err + } + return version, files, nil +} + +func (s *Service) syncSkillPackageMetadata(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { + changed := skill.CurrentVersionID != version.ID || skill.VersionLabel != version.VersionLabel || skill.ContentHash != version.ContentHash || skill.FileCount != len(files) || skill.TotalBytes != version.TotalBytes + if !changed { + return nil + } + skill.CurrentVersionID = version.ID + skill.VersionLabel = version.VersionLabel + skill.ContentHash = version.ContentHash + skill.FileCount = len(files) + skill.TotalBytes = version.TotalBytes + return s.repo.SaveSkill(skill) +} + +func validateSkillPackageSnapshot(s *Service, skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { + if len(files) == 0 || version.FileCount != len(files) || version.TotalBytes < 0 { + return fmt.Errorf("技能 %s 文件元数据不完整", skill.ID) + } + if skill.ContentHash != "" && skill.ContentHash != version.ContentHash { + return fmt.Errorf("技能 %s 内容摘要与当前版本不一致", skill.ID) + } + if skill.FileCount != 0 && skill.FileCount != len(files) { + return fmt.Errorf("技能 %s 文件数量与当前版本不一致", skill.ID) + } + if skill.TotalBytes != 0 && skill.TotalBytes != version.TotalBytes { + return fmt.Errorf("技能 %s 文件大小与当前版本不一致", skill.ID) + } + if version.PackageKey == "" { + return fmt.Errorf("技能 %s 当前版本缺少 ZIP 包", skill.ID) + } + contents, err := readSkillArchiveEntries(s.dataDir, version.PackageKey) + if err != nil { + return fmt.Errorf("读取技能 %s ZIP 包失败: %w", skill.ID, err) + } + byPath := make(map[string]model.SkillFile, len(files)) + var total int64 + for _, file := range files { + if strings.TrimSpace(file.Path) == "" { + return fmt.Errorf("技能 %s 文件清单存在空路径", skill.ID) + } + if version.EntryPath != "" && version.EntryPath != "SKILL.md" { + return fmt.Errorf("技能 %s 当前入口不是 SKILL.md", skill.ID) + } + if _, exists := byPath[file.Path]; exists { + return fmt.Errorf("技能 %s 文件清单包含重复路径 %s", skill.ID, file.Path) + } + byPath[file.Path] = file + content, ok := contents[file.Path] + if !ok { + return fmt.Errorf("技能 %s ZIP 缺少数据库文件 %s", skill.ID, file.Path) + } + digest := sha256.Sum256(content) + if file.Size != int64(len(content)) || file.SHA256 != hex.EncodeToString(digest[:]) { + return fmt.Errorf("技能 %s 文件 %s 元数据与 ZIP 不一致", skill.ID, file.Path) + } + total += int64(len(content)) + } + if _, ok := byPath["SKILL.md"]; !ok { + return fmt.Errorf("技能 %s 文件清单缺少 SKILL.md", skill.ID) + } + if len(contents) != len(files) || total != version.TotalBytes { + return fmt.Errorf("技能 %s ZIP 文件数量或大小与版本元数据不一致", skill.ID) + } + hash, _ := contentHashAndSize(contents) + if version.ContentHash == "" || hash != version.ContentHash { + return fmt.Errorf("技能 %s ZIP 内容摘要与版本元数据不一致", skill.ID) + } + return nil +} + +func readSkillArchiveEntries(dataDir string, packageKey string) (map[string][]byte, error) { + absolute := filepath.Join(dataDir, "skill-packages", filepath.FromSlash(packageKey)) + reader, err := zip.OpenReader(absolute) + if err != nil { + return nil, err + } + defer reader.Close() + if len(reader.File) > maxSkillPackageFiles+64 { + return nil, kernel.BadAuthRequest("技能包文件数量异常") + } + contents := make(map[string][]byte, len(reader.File)) + var total int64 + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { continue } - content, err := s.readSkillArchiveEntry(version, filePath) - return skill, version, &files[index], content, err + filePath, err := normalizeSkillPath(entry.Name) + if err != nil { + return nil, err + } + if _, exists := contents[filePath]; exists { + return nil, kernel.BadAuthRequest("技能包包含重复文件路径") + } + if entry.UncompressedSize64 > maxSkillFileBytes { + return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + file, err := entry.Open() + if err != nil { + return nil, err + } + content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if len(content) > maxSkillFileBytes { + return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + total += int64(len(content)) + if total > maxSkillPackageBytes { + return nil, kernel.BadAuthRequest("技能包解压后不能超过 20MB") + } + contents[filePath] = content } - return nil, nil, nil, nil, kernel.BadAuthRequest("技能文件不存在") + return contents, nil } func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) { @@ -722,6 +935,20 @@ func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string return files, nil } +func contentHashAndSize(files map[string][]byte) (string, int64) { + hash := sha256.New() + var total int64 + for _, filePath := range sortedSkillPaths(files) { + content := files[filePath] + total += int64(len(content)) + hash.Write([]byte(filePath)) + hash.Write([]byte{0}) + hash.Write(content) + hash.Write([]byte{0}) + } + return hex.EncodeToString(hash.Sum(nil)), total +} + func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) { if _, ok := files["SKILL.md"]; !ok { return skillPackageArchive{}, kernel.BadAuthRequest("技能包入口必须是 SKILL.md") @@ -735,17 +962,8 @@ func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 { return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制") } - hash := sha256.New() - var total int64 - for _, filePath := range sortedSkillPaths(files) { - content := files[filePath] - total += int64(len(content)) - hash.Write([]byte(filePath)) - hash.Write([]byte{0}) - hash.Write(content) - hash.Write([]byte{0}) - } - return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hex.EncodeToString(hash.Sum(nil)), TotalBytes: total}, nil + hash, total := contentHashAndSize(files) + return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hash, TotalBytes: total}, nil } func encodeSkillArchive(files map[string][]byte) ([]byte, error) { diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 35a34f211..90f969666 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -140,33 +140,11 @@ export function AgentChatMessage({ const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming); const color = item.role === "error" ? "#ef4444" : theme.node.text; if (item.reasoning) { - return ( -