From 5f359f2b1df32d1ef2d7dcea65f3b8afbcfde8d3 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:19:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20-=20=E6=96=B0=E5=A2=9E=E6=93=8D=E4=BD=9C=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E3=80=81=E9=A2=84=E6=BC=94=E4=BF=9D=E5=AD=98=E4=B8=8E?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=81=A2=E5=A4=8D=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/migrate-sqlite-postgres/main.go | 1 + backend/internal/app/canvas_bridge.go | 73 +- .../internal/app/cloud_agent_canvas_events.go | 5 +- .../app/cloud_agent_canvas_events_test.go | 18 +- .../internal/app/cloud_agent_canvas_state.go | 15 +- backend/internal/app/cloud_agent_media.go | 160 +- .../app/cloud_agent_previs_mutation.go | 13 +- backend/internal/app/cloud_agent_undo_test.go | 5 +- backend/internal/app/resource_delete.go | 2 +- backend/internal/canvas/canvas_history.go | 2 +- backend/internal/canvas/host.go | 4 + backend/internal/canvas/operations.go | 460 ++++ backend/internal/canvas/operations_test.go | 310 +++ backend/internal/canvas/user_data.go | 185 +- backend/internal/database/migrations.go | 5 +- backend/internal/database/schema.go | 1 + backend/internal/handler/openapi.yaml | 131 +- backend/internal/handler/user_data.go | 104 +- backend/internal/model/models_canvas_sync.go | 18 + .../repository/canvas_mutation_receipts.go | 47 + backend/internal/repository/repository.go | 28 + .../structured_storage_usage_test.go | 89 + backend/internal/service/aliases_types.go | 1 + .../content/docs/backend/backend-database.mdx | 19 +- .../docs/backend/canvas-operations-sync.mdx | 69 + docs/content/docs/backend/code-map.mdx | 4 +- docs/content/docs/backend/http-api.mdx | 31 +- docs/content/docs/backend/meta.json | 3 +- docs/content/docs/overview/features.mdx | 24 +- docs/content/docs/progress/pending-test.mdx | 73 +- .../canvas/canvas-version-compare-modal.tsx | 45 - .../canvas/previs/canvas-previs-workbench.tsx | 99 +- .../previs/use-previs-save-coordinator.ts | 86 +- web/src/lib/canvas/canvas-storage-revision.ts | 23 +- .../lib/canvas/previs/previs-diagnostics.ts | 63 +- .../lib/canvas/previs/previs-save-wiring.ts | 98 +- web/src/lib/canvas/previs/previs-save.ts | 247 +-- .../pages/canvas/canvas-project-top-bar.tsx | 60 +- web/src/pages/canvas/canvas-sync-status.css | 1 - web/src/pages/canvas/canvas-sync-status.tsx | 265 +-- .../pages/canvas/canvas-version-history.css | 250 --- .../pages/canvas/canvas-version-history.tsx | 408 ---- .../pages/canvas/canvas-version-preview.tsx | 230 -- web/src/pages/canvas/project.tsx | 1953 +++++++++-------- .../canvas/use-canvas-project-lifecycle.ts | 90 +- web/src/services/agent-canvas-sync.ts | 7 +- web/src/services/api/user-data.ts | 46 +- .../services/canvas-generation-consumer.ts | 9 +- web/src/services/canvas-missing-resources.ts | 114 - web/src/services/canvas-sync-drafts.ts | 56 - web/src/services/user-data-sync.ts | 705 +++--- web/src/stores/canvas/use-canvas-store.ts | 25 +- web/src/stores/use-asset-store.ts | 4 +- web/src/stores/use-sync-progress-store.ts | 8 +- web/src/types/canvas.ts | 1 - web/test/agent-canvas-refresh.test.ts | 344 --- web/test/canvas-operations-build.test.ts | 52 + web/test/canvas-persistence-loss.test.ts | 112 + web/test/canvas-remote-revision.test.ts | 531 ----- .../director-diagnostics-recorder.test.ts | 151 -- web/test/director-diagnostics.test.ts | 186 -- web/test/director-save-coordinator.test.ts | 995 --------- web/test/director-save-wiring.test.ts | 264 --- 63 files changed, 3235 insertions(+), 6193 deletions(-) create mode 100644 backend/internal/canvas/operations.go create mode 100644 backend/internal/canvas/operations_test.go create mode 100644 backend/internal/model/models_canvas_sync.go create mode 100644 backend/internal/repository/canvas_mutation_receipts.go create mode 100644 backend/internal/repository/structured_storage_usage_test.go create mode 100644 docs/content/docs/backend/canvas-operations-sync.mdx delete mode 100644 web/src/components/canvas/canvas-version-compare-modal.tsx delete mode 100644 web/src/pages/canvas/canvas-version-history.css delete mode 100644 web/src/pages/canvas/canvas-version-history.tsx delete mode 100644 web/src/pages/canvas/canvas-version-preview.tsx delete mode 100644 web/src/services/canvas-missing-resources.ts delete mode 100644 web/src/services/canvas-sync-drafts.ts delete mode 100644 web/test/agent-canvas-refresh.test.ts create mode 100644 web/test/canvas-operations-build.test.ts create mode 100644 web/test/canvas-persistence-loss.test.ts delete mode 100644 web/test/canvas-remote-revision.test.ts delete mode 100644 web/test/director-diagnostics-recorder.test.ts delete mode 100644 web/test/director-diagnostics.test.ts delete mode 100644 web/test/director-save-coordinator.test.ts delete mode 100644 web/test/director-save-wiring.test.ts diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 335034ef3..1f1e4a82a 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -441,6 +441,7 @@ func migrations() []tableMigration { migrateTable[model.WorkflowStepTask]("workflow_step_tasks"), migrateTable[model.ProductionTaskLink]("production_task_links"), migrateTable[model.CanvasProject]("canvas_projects"), + migrateTable[model.CanvasMutationReceipt]("canvas_mutation_receipts"), migrateTable[model.CanvasSnapshot]("canvas_snapshots"), migrateTable[model.CanvasSnapshotResource]("canvas_snapshot_resources"), migrateTable[model.CanvasShare]("canvas_shares"), diff --git a/backend/internal/app/canvas_bridge.go b/backend/internal/app/canvas_bridge.go index 33be91aaf..13826170d 100644 --- a/backend/internal/app/canvas_bridge.go +++ b/backend/internal/app/canvas_bridge.go @@ -2,9 +2,7 @@ package app import ( "encoding/json" - "errors" "io" - "time" "yingce/backend/internal/assets" "yingce/backend/internal/canvas" @@ -13,15 +11,17 @@ import ( ) type ( - CanvasShareRequest = canvas.CanvasShareRequest - CanvasShareStatus = canvas.CanvasShareStatus - PublicCanvasShare = canvas.PublicCanvasShare - AssetsSyncRequest = canvas.AssetsSyncRequest - CanvasHistoryList = canvas.CanvasHistoryList - UserDataSummary = canvas.UserDataSummary - UserDataSnapshot = canvas.UserDataSnapshot - CanvasLibrarySummary = canvas.CanvasLibrarySummary - CanvasLibraryPage = canvas.CanvasLibraryPage + CanvasShareRequest = canvas.CanvasShareRequest + CanvasShareStatus = canvas.CanvasShareStatus + PublicCanvasShare = canvas.PublicCanvasShare + AssetsSyncRequest = canvas.AssetsSyncRequest + CanvasHistoryList = canvas.CanvasHistoryList + UserDataSummary = canvas.UserDataSummary + UserDataSnapshot = canvas.UserDataSnapshot + CanvasLibrarySummary = canvas.CanvasLibrarySummary + CanvasLibraryPage = canvas.CanvasLibraryPage + CanvasOperationsRequest = canvas.CanvasOperationsRequest + CanvasOperation = canvas.CanvasOperation ) type canvasHost struct { @@ -68,6 +68,23 @@ func (h canvasHost) WithStorageLock(fn func() error) error { return fn() } +func (h canvasHost) CanvasQuotaCheck(userID string) (func(*repository.Repository, bool, int64) error, error) { + if h.svc == nil { + return func(*repository.Repository, bool, int64) error { return nil }, nil + } + policy, err := h.svc.RuntimePolicy() + if err != nil { + return nil, err + } + return func(repo *repository.Repository, creating bool, deltaBytes int64) error { + usage, err := repo.UserStructuredStorageUsage(userID) + if err != nil { + return err + } + return validateStructuredStorageQuotaWithPolicy(usage, "canvas", creating, deltaBytes, policy.Resource) + }, nil +} + func (h canvasHost) StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error { if h.svc == nil { return nil @@ -76,7 +93,7 @@ func (h canvasHost) StructuredQuota(userID, kind string, creating bool, deltaByt if err != nil { return err } - usage, err := h.svc.repo.UserStorageUsage(userID) + usage, err := h.svc.repo.UserStructuredStorageUsage(userID) if err != nil { return err } @@ -91,7 +108,7 @@ func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, b if err != nil { return err } - usage, err := h.svc.repo.UserStorageUsage(userID) + usage, err := h.svc.repo.UserStructuredStorageUsage(userID) if err != nil { return err } @@ -222,6 +239,10 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U return s.canvasDomain().UpsertUserCanvasProject(userID, raw) } +func (s *Service) ApplyUserCanvasOperations(userID, canvasID string, req CanvasOperationsRequest) (UserDataSummary, error) { + return s.canvasDomain().ApplyUserCanvasOperations(userID, canvasID, req) +} + func (s *Service) RepairUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) { return s.canvasDomain().RepairUserCanvasProject(userID, raw) } @@ -250,12 +271,28 @@ func saveCreationCanvasWithHistory(repo *repository.Repository, project *model.C if before.PayloadJSON != previous || before.Revision != project.Revision { return repository.ErrCreationConflict } - project.UpdatedAt = time.Now().UTC() - err = canvas.SaveDocumentWithHistory(repo, before, project, "automatic") - if errors.Is(err, repository.ErrCanvasRevisionConflict) { - return repository.ErrCreationConflict + beforeDoc, err := creationDocument(before.PayloadJSON) + if err != nil { + return err } - return err + desiredDoc, err := creationDocument(project.PayloadJSON) + if err != nil { + return err + } + operations := canvas.DiffCanvasDocuments(beforeDoc, desiredDoc) + if len(operations) == 0 { + return nil + } + baseRevision := project.Revision + request := canvas.CanvasOperationsRequest{BaseRevision: &baseRevision, Operations: operations, TrustedServerMutation: true} + request.MutationID = "creation-canvas:" + project.ID + ":" + canvas.CanvasOperationsDigest(request) + result, err := canvas.New(repo, nil).ApplyUserCanvasOperations(project.UserID, project.ID, request) + if err != nil { + return err + } + project.Revision = result.Revision + project.UpdatedAt = result.UpdatedAt + return nil } func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessage, error) { diff --git a/backend/internal/app/cloud_agent_canvas_events.go b/backend/internal/app/cloud_agent_canvas_events.go index dd40aac02..66597f6db 100644 --- a/backend/internal/app/cloud_agent_canvas_events.go +++ b/backend/internal/app/cloud_agent_canvas_events.go @@ -240,7 +240,10 @@ func cloudAgentPatchCoversDocument(before, after map[string]any) bool { content := func(doc map[string]any) map[string]any { result := map[string]any{} for key, value := range doc { - if key != "nodes" && key != "connections" && key != "updatedAt" && key != "viewport" { + if key != "nodes" && key != "connections" && key != "id" && key != "revision" && key != "createdAt" && key != "updatedAt" && key != "viewport" { + if (key == "title" || key == "projectId") && value == "" { + continue + } result[key] = value } } diff --git a/backend/internal/app/cloud_agent_canvas_events_test.go b/backend/internal/app/cloud_agent_canvas_events_test.go index b61f632b4..34d9456a3 100644 --- a/backend/internal/app/cloud_agent_canvas_events_test.go +++ b/backend/internal/app/cloud_agent_canvas_events_test.go @@ -96,9 +96,9 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te } previousRevision = delta["revision"] } - history, err := s.CanvasHistory("user", "agent-canvas") - if err != nil || len(history.Snapshots) == 0 || float64(history.CurrentRevision) != previousRevision { - t.Fatalf("Agent save did not retain history/revision: %+v %v", history, err) + canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") + if err != nil || float64(canvas.Revision) != previousRevision { + t.Fatalf("Agent save did not retain current revision: %+v %v", canvas, err) } changes = creationMaps(completion["nodes"]) if len(changes) != 1 || len(creationMaps(completion["connections"])) != 0 { @@ -126,6 +126,18 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te } } +func TestCloudAgentPatchCoversProjectedRowMetadata(t *testing.T) { + before := map[string]any{"nodes": []any{}, "connections": []any{}} + after := map[string]any{"id": "canvas", "revision": float64(2), "createdAt": "2026-10-11T00:00:00Z", "updatedAt": "2026-10-11T01:00:00Z", "title": "", "projectId": "", "nodes": []any{}, "connections": []any{}} + if !cloudAgentPatchCoversDocument(before, after) { + t.Fatal("database row metadata and empty projected fields should not force a refresh") + } + after["title"] = "新标题" + if cloudAgentPatchCoversDocument(before, after) { + t.Fatal("a changed canvas title must still force a refresh") + } +} + func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) { s, _, args := agentMediaFixture(t) run, state := agentMediaRun(t, s, args, "request_approval") diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index d4e32e68e..e9742a522 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -58,8 +58,19 @@ func cloudAgentNodeSnapshotMatches(doc map[string]any, nodeID, snapshotHash stri func cloudAgentCanvasHash(doc map[string]any) string { content := make(map[string]any, len(doc)) for key, value := range doc { - if key != "viewport" && key != "updatedAt" { - content[key] = value + switch key { + case "id", "viewport", "updatedAt", "revision", "createdAt": + continue + case "title", "projectId": + if text, ok := value.(string); ok && strings.TrimSpace(text) == "" { + continue + } + } + content[key] = value + } + for _, key := range []string{"nodes", "connections"} { + if value, exists := content[key]; !exists || value == nil { + content[key] = []any{} } } return creationHash(content) diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index e7bd28f83..f87805aa3 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -11,6 +11,7 @@ import ( "unicode/utf8" "gorm.io/gorm" + "yingce/backend/internal/canvas" "yingce/backend/internal/model" "yingce/backend/internal/repository" ) @@ -92,27 +93,29 @@ func (s *Service) cloudAgentModelIntent(userID, canvasID, arguments string) (*Mo } type cloudAgentMediaArgs struct { - Prepared *cloudAgentPreparedMedia `json:"-"` - DraftRunID string `json:"-"` - CharacterVersions map[string]string `json:"-"` - CharacterLabels []string `json:"-"` - Mode string `json:"mode"` - Prompt string `json:"prompt"` - LogicalModelID string `json:"logicalModelId"` - ChannelID string `json:"channelId"` - ChannelModelKey string `json:"channelModelKey"` - Duration int `json:"durationSeconds"` - Size string `json:"size"` - Quality string `json:"quality"` - VideoGenerateAudio *bool `json:"videoGenerateAudio"` - VideoStartFrameNodeID string `json:"videoStartFrameNodeId"` - VideoEndFrameNodeID string `json:"videoEndFrameNodeId"` - SnapshotHash string `json:"snapshotHash"` - NodeID string `json:"nodeId"` - Title string `json:"title"` - SourceNodeID string `json:"sourceNodeId"` - ReferenceNodeIDs []string `json:"referenceNodeIds"` - ReferenceTransientIDs []string `json:"referenceTransientIds"` + Prepared *cloudAgentPreparedMedia `json:"-"` + DraftRunID string `json:"-"` + CharacterVersions map[string]string `json:"-"` + CharacterLabels []string `json:"-"` + Mode string `json:"mode"` + Prompt string `json:"prompt"` + LogicalModelID string `json:"logicalModelId"` + ChannelID string `json:"channelId"` + ChannelModelKey string `json:"channelModelKey"` + Duration int `json:"durationSeconds"` + Size string `json:"size"` + Quality string `json:"quality"` + VideoGenerateAudio *bool `json:"videoGenerateAudio"` + VideoWatermark *bool `json:"videoWatermark"` + VideoArkPrivateAssetUpload *bool `json:"videoArkPrivateAssetUpload"` + VideoStartFrameNodeID string `json:"videoStartFrameNodeId"` + VideoEndFrameNodeID string `json:"videoEndFrameNodeId"` + SnapshotHash string `json:"snapshotHash"` + NodeID string `json:"nodeId"` + Title string `json:"title"` + SourceNodeID string `json:"sourceNodeId"` + ReferenceNodeIDs []string `json:"referenceNodeIds"` + ReferenceTransientIDs []string `json:"referenceTransientIds"` } type cloudAgentMediaPlan struct { @@ -537,8 +540,8 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime if a.Duration != 0 { return cloudAgentFieldError("durationSeconds", "invalid_value", "只有视频生成允许设置 durationSeconds;图片或音频生成请省略该字段") } - if a.VideoGenerateAudio != nil { - return cloudAgentFieldError("videoGenerateAudio", "invalid_value", "videoGenerateAudio 仅适用于视频生成;图片或音频生成请省略该字段") + if a.VideoGenerateAudio != nil || a.VideoWatermark != nil || a.VideoArkPrivateAssetUpload != nil { + return cloudAgentFieldError("videoGenerateAudio", "invalid_value", "视频参数仅适用于视频生成;图片或音频生成请省略该字段") } } if len(a.ReferenceNodeIDs) > 16 { @@ -711,6 +714,9 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state * if a.ChannelID != "" { config["channelId"], config["channelModelKey"], config["model"] = a.ChannelID, a.ChannelModelKey, a.ChannelModelKey } + if a.Mode == "video" && a.VideoArkPrivateAssetUpload != nil { + config["videoArkPrivateAssetUpload"] = strconv.FormatBool(*a.VideoArkPrivateAssetUpload) + } metadata := map[string]any{"nodeId": a.NodeID, "source": "cloud_agent"} // 首尾帧是结构化输入,不能只写进提示词;复用画布提交任务时的字段。 if a.VideoStartFrameNodeID != "" { @@ -805,6 +811,20 @@ func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAge } plan.Args.VideoGenerateAudio = &audio } + if raw := stringValue(config["videoWatermark"]); raw != "" { + watermark, err := strconv.ParseBool(raw) + if err != nil { + return BadAuthRequest("模型目录返回的默认水印参数无效,未提交生成任务") + } + plan.Args.VideoWatermark = &watermark + } + if raw := stringValue(config["videoArkPrivateAssetUpload"]); raw != "" { + privateUpload, err := strconv.ParseBool(raw) + if err != nil { + return BadAuthRequest("模型目录返回的默认参考图同步参数无效,未提交生成任务") + } + plan.Args.VideoArkPrivateAssetUpload = &privateUpload + } } plan.Args.Prepared = nil req.Input = input @@ -812,7 +832,7 @@ func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAge return nil } -func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error { +func saveCloudAgentDocument(repo *repository.Repository, project *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error { doc["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) raw, err := json.Marshal(doc) if err != nil { @@ -821,16 +841,98 @@ func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasPro if len(raw) > 8<<20 { return BadAuthRequest("画布大小超限") } - usage, err := repo.UserStorageUsage(canvas.UserID) + usage, err := repo.UserStorageUsage(project.UserID) if err != nil { return err } - if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(raw)-len(canvas.PayloadJSON)), policy.Resource); err != nil { + if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(raw)-len(project.PayloadJSON)), policy.Resource); err != nil { return err } - before := canvas.PayloadJSON - canvas.PayloadJSON = string(raw) - return saveCreationCanvasWithHistory(repo, canvas, before) + beforeDoc, err := creationDocument(project.PayloadJSON) + if err != nil { + return err + } + operations := canvas.DiffCanvasDocuments(beforeDoc, doc) + if len(operations) == 0 { + return nil + } + baseRevision := project.Revision + request := canvas.CanvasOperationsRequest{BaseRevision: &baseRevision, Operations: operations, TrustedServerMutation: true} + request.MutationID = "agent-canvas:" + project.ID + ":" + canvas.CanvasOperationsDigest(request) + _, err = canvas.New(repo, nil).ApplyUserCanvasOperations(project.UserID, project.ID, request) + if err != nil { + return err + } + latest, err := repo.CanvasProjectForUser(project.UserID, project.ID) + if err != nil { + return err + } + *project = *latest + persistedDoc, err := creationDocument(latest.PayloadJSON) + if err != nil { + return err + } + for key := range doc { + delete(doc, key) + } + for key, value := range persistedDoc { + doc[key] = value + } + return nil +} + +func rebaseCloudAgentDocument(base, desired, latest map[string]any) map[string]any { + result := make(map[string]any, len(latest)) + for key, value := range latest { + result[key] = value + } + for _, key := range []string{"nodes", "connections"} { + result[key] = rebaseCloudAgentCollection(base[key], desired[key], latest[key]) + } + for key, value := range desired { + if key == "nodes" || key == "connections" || key == "revision" || key == "updatedAt" || key == "createdAt" || key == "id" { + continue + } + if !jsonValuesEqual(base[key], value) { + result[key] = value + } + } + return result +} + +func rebaseCloudAgentCollection(baseValue, desiredValue, latestValue any) []map[string]any { + baseItems, desiredItems, latestItems := creationMaps(baseValue), creationMaps(desiredValue), creationMaps(latestValue) + baseByID, desiredByID := map[string]map[string]any{}, map[string]map[string]any{} + for _, item := range baseItems { + baseByID[stringValue(item["id"])] = item + } + for _, item := range desiredItems { + desiredByID[stringValue(item["id"])] = item + } + result := make([]map[string]any, 0, len(latestItems)+len(desiredItems)) + seen := map[string]bool{} + for _, item := range latestItems { + id := stringValue(item["id"]) + if desired, changed := desiredByID[id]; changed && !jsonValuesEqual(baseByID[id], desired) { + result = append(result, desired) + } else if _, removed := baseByID[id]; !removed || desiredByID[id] != nil { + result = append(result, item) + } + seen[id] = true + } + for _, item := range desiredItems { + id := stringValue(item["id"]) + if !seen[id] && !jsonValuesEqual(baseByID[id], item) { + result = append(result, item) + } + } + return result +} + +func jsonValuesEqual(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) } // createCloudAgentMediaNode 为指定用户的画布保存计划中的媒体节点及引用连线。 diff --git a/backend/internal/app/cloud_agent_previs_mutation.go b/backend/internal/app/cloud_agent_previs_mutation.go index c0910b63b..5c3ae9c5a 100644 --- a/backend/internal/app/cloud_agent_previs_mutation.go +++ b/backend/internal/app/cloud_agent_previs_mutation.go @@ -1947,15 +1947,20 @@ func applyCloudAgentPrevisMutation(repo *repository.Repository, userID, canvasID if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil { return nil, err } + afterCanvasHash := cloudAgentCanvasHash(plan.Document) + afterSceneHash := plan.ResultSnapshotHash + if scene, ok := findPrevisScene(creationMaps(plan.Document["previsScenes"]), plan.SceneID); ok { + afterSceneHash = creationHash(scene) + } if len(recorder) > 0 && recorder[0] != nil { preview := plan.Preview - if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: plan.Operation, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: plan.AfterSnapshotHash, BeforeJSON: plan.BeforeJSON, Preview: &preview}); err != nil { + if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: plan.Operation, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: afterCanvasHash, BeforeJSON: plan.BeforeJSON, Preview: &preview}); err != nil { return nil, err } } - resultSnapshotHash := plan.ResultSnapshotHash + resultSnapshotHash := afterSceneHash if resultSnapshotHash == "" { - resultSnapshotHash = plan.AfterSnapshotHash + resultSnapshotHash = afterCanvasHash } - return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "nodeId": plan.NodeID, "shotId": plan.ShotID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": plan.AfterSnapshotHash, "committed": true, "summary": plan.Preview.Description}, nil + return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "nodeId": plan.NodeID, "shotId": plan.ShotID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": afterCanvasHash, "committed": true, "summary": plan.Preview.Description}, nil } diff --git a/backend/internal/app/cloud_agent_undo_test.go b/backend/internal/app/cloud_agent_undo_test.go index 57bfd62b5..6662b13db 100644 --- a/backend/internal/app/cloud_agent_undo_test.go +++ b/backend/internal/app/cloud_agent_undo_test.go @@ -61,8 +61,9 @@ func TestUndoCloudAgentCanvasRestoresLatestMutationAndIsIdempotent(t *testing.T) t.Fatalf("unexpected undo result: %+v", result) } restored, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") - if restored.PayloadJSON != canvas.PayloadJSON { - t.Fatal("undo did not restore the exact before snapshot") + restoredDoc, err := creationDocument(restored.PayloadJSON) + if err != nil || cloudAgentCanvasHash(restoredDoc) != beforeHash { + t.Fatal("undo did not restore the semantic before snapshot") } result, err = s.UndoCloudAgentCanvas("user", run.ID, call.ID, beforeHash, "重复请求") if err != nil || result["accepted"] != true { diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index 19ac415a4..ac36f4903 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -46,7 +46,7 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur // 删除语义(产品约定,勿收紧): // - purge=true 是用户在素材库确认后的「彻底删除」(单个 DELETE、批量删除、清空回收站), // 不因画布、任务、项目或画布历史快照仍引用而拒绝;旧引用随之失效,画布保存时由 - // repairMissingResources 修复断链。 + // 由统一 operations 提交前的资源完整性校验修复断链。 // - purge=false 是内部普通删除:回收站(archived)素材同样直接删除,其余素材保留引用保护。 // - 无论哪种,被其他素材复用的资源与物理对象都保留,物理删除一律经 Outbox 异步执行。 deleteReferencedResources := purge diff --git a/backend/internal/canvas/canvas_history.go b/backend/internal/canvas/canvas_history.go index 0515065d6..c8d9869c0 100644 --- a/backend/internal/canvas/canvas_history.go +++ b/backend/internal/canvas/canvas_history.go @@ -40,7 +40,7 @@ func (s *Service) RepairUserCanvasProject(userID string, raw json.RawMessage) (U } func canvasRevisionConflict() error { - return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,已停止覆盖;请保留本地草稿并加载最新版本") + return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,未确认操作会保留并按原请求自动重放") } type CanvasHistoryList struct { diff --git a/backend/internal/canvas/host.go b/backend/internal/canvas/host.go index 37da61f81..c4bb6f89f 100644 --- a/backend/internal/canvas/host.go +++ b/backend/internal/canvas/host.go @@ -14,6 +14,7 @@ type Host interface { PrepareResourceDelivery(userID string, resource *model.Resource, options assets.AccessOptions, rangeHeader string) (*assets.ResourceDelivery, error) WithStorageLock(fn func() error) error StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error + CanvasQuotaCheck(userID string) (func(*repository.Repository, bool, int64) error, error) StructuredReplacementQuota(userID, kind string, count int, bytes int64) error DeleteUserAssetWithResources(userID, assetID string, purge bool) error PurgeUserAssetsWithResources(userID string, assetIDs []string) error @@ -36,6 +37,9 @@ func (nopHost) WithStorageLock(fn func() error) error { } return fn() } +func (nopHost) CanvasQuotaCheck(string) (func(*repository.Repository, bool, int64) error, error) { + return func(*repository.Repository, bool, int64) error { return nil }, nil +} func (nopHost) StructuredQuota(string, string, bool, int64) error { return nil } func (nopHost) StructuredReplacementQuota(string, string, int, int64) error { return nil } func (nopHost) DeleteUserAssetWithResources(string, string, bool) error { return nil } diff --git a/backend/internal/canvas/operations.go b/backend/internal/canvas/operations.go new file mode 100644 index 000000000..8bb8efde6 --- /dev/null +++ b/backend/internal/canvas/operations.go @@ -0,0 +1,460 @@ +package canvas + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" +) + +var canvasOperationFields = []string{"title", "projectId", "chatSessions", "activeChatId", "starterMode", "appearance", "backgroundMode", "showImageInfo", "previsScenes", "timeline"} +var canvasOptionalOperationFields = map[string]struct{}{"projectId": {}, "starterMode": {}, "appearance": {}, "timeline": {}} + +// CanvasOperationsRequest is the durable write envelope shared by the editor, +// Agent and media callbacks. MutationID is the retry identity: a lost response +// must be replayed with the same ID and payload. +type CanvasOperationsRequest struct { + MutationID string `json:"mutationId"` + BaseRevision *int64 `json:"baseRevision,omitempty"` + Operations []CanvasOperation `json:"operations"` + TrustedServerMutation bool `json:"-"` +} + +var ( + ErrCanvasOperationConflict = errors.New("画布操作冲突") + ErrCanvasObjectMissing = errors.New("画布对象不存在") +) + +type CanvasOperation struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Node json.RawMessage `json:"node,omitempty"` + Connection json.RawMessage `json:"connection,omitempty"` + Field string `json:"field,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + Patch map[string]json.RawMessage `json:"patch,omitempty"` + Before map[string]json.RawMessage `json:"before,omitempty"` + BeforeValue json.RawMessage `json:"beforeValue,omitempty"` + Expected json.RawMessage `json:"expected,omitempty"` +} + +func CanvasOperationsDigest(req CanvasOperationsRequest) string { + raw, _ := json.Marshal(req) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// DiffCanvasDocuments turns a full document into object-scoped operations. +// Existing nodes are patched by leaf path so concurrent writers can merge +// unrelated fields without replacing an entire node. +func DiffCanvasDocuments(before, after map[string]any) []CanvasOperation { + operations := make([]CanvasOperation, 0) + beforeNodes := indexedCanvasObjects(before["nodes"]) + afterNodes := indexedCanvasObjects(after["nodes"]) + for _, node := range orderedCanvasObjects(after["nodes"]) { + id := canvasString(node["id"]) + previous, exists := beforeNodes[id] + if !exists { + operations = append(operations, CanvasOperation{Type: "create_node", Node: jsonRawValue(node)}) + continue + } + patch, previousValues := diffCanvasObject(previous, node, "") + if len(patch) > 0 { + operations = append(operations, CanvasOperation{Type: "patch_node", ID: id, Patch: patch, Before: previousValues}) + } + } + for _, node := range orderedCanvasObjects(before["nodes"]) { + id := canvasString(node["id"]) + if _, exists := afterNodes[id]; !exists { + operations = append(operations, CanvasOperation{Type: "remove_node", ID: id, Expected: jsonRawValue(node)}) + } + } + beforeEdges := indexedCanvasObjects(before["connections"]) + afterEdges := indexedCanvasObjects(after["connections"]) + for _, edge := range orderedCanvasObjects(after["connections"]) { + id := canvasString(edge["id"]) + if previous, exists := beforeEdges[id]; !exists { + operations = append(operations, CanvasOperation{Type: "create_connection", Connection: jsonRawValue(edge)}) + } else if !jsonEqual(previous, edge) { + operations = append(operations, CanvasOperation{Type: "update_connection", ID: id, Connection: jsonRawValue(edge), Expected: jsonRawValue(previous)}) + } + } + for _, edge := range orderedCanvasObjects(before["connections"]) { + id := canvasString(edge["id"]) + if _, exists := afterEdges[id]; !exists { + operations = append(operations, CanvasOperation{Type: "remove_connection", ID: id, Expected: jsonRawValue(edge)}) + } + } + for _, field := range canvasOperationFields { + if !jsonEqual(before[field], after[field]) { + operations = append(operations, CanvasOperation{Type: "set_field", Field: field, Value: jsonRawValue(after[field]), BeforeValue: jsonRawValue(before[field])}) + } + } + return operations +} + +func orderedCanvasObjects(value any) []map[string]any { + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var values []map[string]any + if json.Unmarshal(raw, &values) != nil { + return nil + } + return values +} + +func indexedCanvasObjects(value any) map[string]map[string]any { + result := map[string]map[string]any{} + for _, item := range orderedCanvasObjects(value) { + if id := canvasString(item["id"]); id != "" { + result[id] = item + } + } + return result +} + +func canvasString(value any) string { + text, _ := value.(string) + return text +} + +func diffCanvasObject(before, after map[string]any, prefix string) (map[string]json.RawMessage, map[string]json.RawMessage) { + patch, previous := map[string]json.RawMessage{}, map[string]json.RawMessage{} + keys := map[string]bool{} + for key := range before { + keys[key] = true + } + for key := range after { + keys[key] = true + } + ordered := make([]string, 0, len(keys)) + for key := range keys { + ordered = append(ordered, key) + } + sort.Strings(ordered) + for _, key := range ordered { + path := key + if prefix != "" { + path = prefix + "." + key + } + oldValue, oldExists := before[key] + newValue, newExists := after[key] + if oldExists && newExists { + oldObject, oldOK := oldValue.(map[string]any) + newObject, newOK := newValue.(map[string]any) + if oldOK && newOK { + nested, nestedBefore := diffCanvasObject(oldObject, newObject, path) + for k, v := range nested { + patch[k] = v + } + for k, v := range nestedBefore { + previous[k] = v + } + continue + } + if jsonEqual(oldValue, newValue) { + continue + } + } + if oldExists { + previous[path] = jsonRawValue(oldValue) + } else { + previous[path] = json.RawMessage("null") + } + if newExists { + patch[path] = jsonRawValue(newValue) + } else { + patch[path] = json.RawMessage("null") + } + } + return patch, previous +} + +func setCanvasPath(target map[string]any, path string, raw json.RawMessage) error { + parts := strings.Split(path, ".") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + return errors.New("节点字段路径无效") + } + current := target + for _, part := range parts[:len(parts)-1] { + next, ok := current[part].(map[string]any) + if !ok { + next = map[string]any{} + current[part] = next + } + current = next + } + key := parts[len(parts)-1] + if string(raw) == "null" { + delete(current, key) + return nil + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + current[key] = value + return nil +} + +func canvasOperationConflict(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrCanvasOperationConflict, fmt.Sprintf(format, args...)) +} + +func canvasObjectMissing(id string) error { + return fmt.Errorf("%w: 节点 %q 已删除,迟到更新未应用", ErrCanvasObjectMissing, id) +} + +func canvasPathValue(target map[string]any, path string) (any, bool) { + parts := strings.Split(path, ".") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + return nil, false + } + var current any = target + for _, part := range parts { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + value, exists := object[part] + if !exists { + return nil, false + } + current = value + } + return current, true +} + +func canvasExpectedValueMatches(actual any, exists bool, expected json.RawMessage) bool { + if len(expected) == 0 { + return true + } + if string(expected) == "null" { + return !exists || actual == nil + } + if !exists { + return false + } + var expectedValue any + if json.Unmarshal(expected, &expectedValue) != nil { + return false + } + return jsonEqual(actual, expectedValue) +} + +func canvasObjectExpectedMatches(actual map[string]any, expected json.RawMessage) bool { + if len(expected) == 0 { + return true + } + var expectedValue map[string]any + if json.Unmarshal(expected, &expectedValue) != nil { + return false + } + return jsonEqual(actual, expectedValue) +} + +func applyCanvasOperations(payload map[string]json.RawMessage, operations []CanvasOperation) error { + nodes := orderedCanvasObjects(rawJSON(payload["nodes"])) + connections := orderedCanvasObjects(rawJSON(payload["connections"])) + for _, op := range operations { + switch op.Type { + case "create_node": + var node map[string]any + if err := json.Unmarshal(op.Node, &node); err != nil || canvasString(node["id"]) == "" { + return fmt.Errorf("节点操作缺少有效节点") + } + id := canvasString(node["id"]) + if indexOfCanvasObject(nodes, id) >= 0 { + return canvasOperationConflict("节点 %q 已存在,显式新增不能覆盖已有节点", id) + } + nodes = append(nodes, node) + case "patch_node": + if strings.TrimSpace(op.ID) == "" { + return fmt.Errorf("节点补丁缺少 ID") + } + if len(op.Patch) == 0 || len(op.Before) == 0 { + return fmt.Errorf("节点补丁缺少字段基线") + } + index := indexOfCanvasObject(nodes, op.ID) + if index < 0 { + return canvasObjectMissing(op.ID) + } + for path, expected := range op.Before { + if path == "id" { + return fmt.Errorf("节点 ID 不可修改") + } + actual, exists := canvasPathValue(nodes[index], path) + if !canvasExpectedValueMatches(actual, exists, expected) { + return canvasOperationConflict("节点 %q 的字段 %q 已被其他写入修改", op.ID, path) + } + } + keys := make([]string, 0, len(op.Patch)) + for key := range op.Patch { + if key == "id" { + return fmt.Errorf("节点 ID 不可修改") + } + keys = append(keys, key) + } + sort.Strings(keys) + for _, path := range keys { + if err := setCanvasPath(nodes[index], path, op.Patch[path]); err != nil { + return err + } + } + case "remove_node": + if strings.TrimSpace(op.ID) == "" { + return fmt.Errorf("删除节点缺少 ID") + } + if len(op.Expected) == 0 { + return fmt.Errorf("删除节点缺少对象基线") + } + index := indexOfCanvasObject(nodes, op.ID) + if index < 0 { + continue + } + if !canvasObjectExpectedMatches(nodes[index], op.Expected) { + return canvasOperationConflict("节点 %q 已被其他写入修改,拒绝删除", op.ID) + } + nodes = append(nodes[:index], nodes[index+1:]...) + next := connections[:0] + for _, edge := range connections { + if canvasString(edge["fromNodeId"]) != op.ID && canvasString(edge["toNodeId"]) != op.ID { + next = append(next, edge) + } + } + connections = next + case "create_connection": + var edge map[string]any + if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" { + return fmt.Errorf("连线操作缺少有效连线") + } + fromID := canvasString(edge["fromNodeId"]) + toID := canvasString(edge["toNodeId"]) + if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 { + return fmt.Errorf("连线端点节点不存在") + } + id := canvasString(edge["id"]) + if indexOfCanvasObject(connections, id) >= 0 { + return canvasOperationConflict("连线 %q 已存在,显式新增不能覆盖已有连线", id) + } + connections = append(connections, edge) + case "update_connection": + var edge map[string]any + if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" { + return fmt.Errorf("连线更新操作缺少有效连线") + } + if strings.TrimSpace(op.ID) == "" || op.ID != canvasString(edge["id"]) { + return fmt.Errorf("连线操作 ID 与对象 ID 不一致") + } + if len(op.Expected) == 0 { + return fmt.Errorf("连线更新缺少对象基线") + } + fromID := canvasString(edge["fromNodeId"]) + toID := canvasString(edge["toNodeId"]) + if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 { + return fmt.Errorf("连线端点节点不存在") + } + index := indexOfCanvasObject(connections, op.ID) + if index < 0 { + return canvasObjectMissing("连线 " + op.ID) + } + if !canvasObjectExpectedMatches(connections[index], op.Expected) { + return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝覆盖", op.ID) + } + connections[index] = edge + case "remove_connection": + if strings.TrimSpace(op.ID) == "" { + return fmt.Errorf("删除连线缺少 ID") + } + if len(op.Expected) == 0 { + return fmt.Errorf("删除连线缺少对象基线") + } + index := indexOfCanvasObject(connections, op.ID) + if index < 0 { + continue + } + if !canvasObjectExpectedMatches(connections[index], op.Expected) { + return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝删除", op.ID) + } + connections = append(connections[:index], connections[index+1:]...) + case "set_field": + if !allowedCanvasOperationField(op.Field) { + return fmt.Errorf("不支持的画布字段操作: 字段 %q", op.Field) + } + // Older clients serialized an undefined optional field by omitting the + // property entirely. Treat that wire form as JSON null so the original + // mutation ID and payload can be replayed safely after an upgrade. The + // expected-value check below still rejects the write when the server + // field is present, so this compatibility path cannot overwrite a value. + value := op.Value + if len(value) == 0 { + if _, optional := canvasOptionalOperationFields[op.Field]; !optional { + return fmt.Errorf("画布字段操作缺少 value: 字段 %q", op.Field) + } + value = json.RawMessage("null") + } + beforeValue := op.BeforeValue + if len(beforeValue) == 0 { + if _, optional := canvasOptionalOperationFields[op.Field]; !optional { + return fmt.Errorf("画布字段操作缺少 beforeValue: 字段 %q", op.Field) + } + beforeValue = json.RawMessage("null") + } + if !canvasExpectedValueMatches(rawJSON(payload[op.Field]), payload[op.Field] != nil, beforeValue) { + return canvasOperationConflict("画布字段 %q 已被其他写入修改", op.Field) + } + if string(value) == "null" { + delete(payload, op.Field) + } else { + payload[op.Field] = append(json.RawMessage(nil), value...) + } + default: + return fmt.Errorf("不支持的画布操作") + } + } + payload["nodes"], _ = json.Marshal(nodes) + payload["connections"], _ = json.Marshal(connections) + return nil +} + +func rawJSON(raw json.RawMessage) any { + if len(raw) == 0 { + return []any{} + } + var value any + if json.Unmarshal(raw, &value) != nil { + return []any{} + } + return value +} + +func jsonRawValue(value any) json.RawMessage { + raw, _ := json.Marshal(value) + return raw +} + +func jsonEqual(left, right any) bool { return reflect.DeepEqual(left, right) } + +func indexOfCanvasObject(items []map[string]any, id string) int { + for i, item := range items { + if canvasString(item["id"]) == id { + return i + } + } + return -1 +} + +func allowedCanvasOperationField(field string) bool { + for _, allowed := range canvasOperationFields { + if field == allowed { + return true + } + } + return false +} diff --git a/backend/internal/canvas/operations_test.go b/backend/internal/canvas/operations_test.go new file mode 100644 index 000000000..222a4ff4f --- /dev/null +++ b/backend/internal/canvas/operations_test.go @@ -0,0 +1,310 @@ +package canvas + +import ( + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strings" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +func TestApplyCanvasOperationsReplaysSameMutationAndRejectsDifferentPayload(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ + ID: "canvas", UserID: "user", Title: "初始", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧","prompt":"原始"}}],"connections":[]}`, + }); err != nil { + t.Fatal(err) + } + request := CanvasOperationsRequest{ + MutationID: "mutation-1", + Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{ + "metadata.title": json.RawMessage(`"新"`), + }, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}, + } + first, err := svc.ApplyUserCanvasOperations("user", "canvas", request) + if err != nil { + t.Fatal(err) + } + if first.Revision != 2 { + t.Fatalf("first revision = %d, want 2", first.Revision) + } + second, err := svc.ApplyUserCanvasOperations("user", "canvas", request) + if err != nil { + t.Fatal(err) + } + if second.Revision != first.Revision { + t.Fatalf("replay revision = %d, want %d", second.Revision, first.Revision) + } + receipt, err := svc.repo.CanvasMutationReceiptForUser("user", "canvas", "mutation-1") + if err != nil { + t.Fatal(err) + } + if receipt.Revision != first.Revision || receipt.PayloadDigest == "" || receipt.ResultJSON == "" { + t.Fatalf("invalid receipt: %#v", receipt) + } + + request.Operations[0].Patch["metadata.title"] = json.RawMessage(`"冲突"`) + _, err = svc.ApplyUserCanvasOperations("user", "canvas", request) + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("different payload replay error = %v, want conflict", err) + } +} + +func TestApplyCanvasOperationsCreatesOnlyExplicitCanvas(t *testing.T) { + svc := newCanvasOperationTestService(t) + request := CanvasOperationsRequest{MutationID: "create-canvas", Operations: []CanvasOperation{ + {Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"新画布"`)}}, + {Type: "create_node", Node: json.RawMessage(`{"id":"node","type":"text","metadata":{"content":"hello"}}`)}, + }} + result, err := svc.ApplyUserCanvasOperations("user", "canvas", request) + if err != nil { + t.Fatal(err) + } + if result.Revision != 1 { + t.Fatalf("created revision = %d, want 1", result.Revision) + } + stored, err := svc.repo.CanvasProjectForUser("user", "canvas") + if err != nil { + t.Fatal(err) + } + if stored.Title != "新画布" || !strings.Contains(stored.PayloadJSON, `"node"`) { + t.Fatalf("created canvas payload = %s", stored.PayloadJSON) + } + if _, err := svc.ApplyUserCanvasOperations("user", "missing", CanvasOperationsRequest{MutationID: "missing-update", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"late"`)}, Before: map[string]json.RawMessage{"title": json.RawMessage(`null`)}}}}); err == nil { + t.Fatal("implicit canvas creation was accepted") + } +} +func TestApplyCanvasOperationsDoesNotRecreateDeletedNode(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ + ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧"}}],"connections":[]}`, + }); err != nil { + t.Fatal(err) + } + remove := CanvasOperationsRequest{MutationID: "remove-node", Operations: []CanvasOperation{{Type: "remove_node", ID: "node", Expected: json.RawMessage(`{"id":"node","metadata":{"title":"旧"}}`)}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", remove); err != nil { + t.Fatal(err) + } + late := CanvasOperationsRequest{MutationID: "late-update", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{ + "metadata.title": json.RawMessage(`"迟到"`), + }, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", late); err == nil { + t.Fatal("late update after deletion was accepted") + } else { + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("late update error = %v, want conflict", err) + } + } + stored, err := svc.UserCanvasProject("user", "canvas") + if err != nil { + t.Fatal(err) + } + var payload struct { + Nodes []map[string]any `json:"nodes"` + } + if err := json.Unmarshal(stored, &payload); err != nil { + t.Fatal(err) + } + if len(payload.Nodes) != 0 { + t.Fatalf("late update recreated deleted node: %s", stored) + } +} + +func TestApplyCanvasOperationsRejectsConnectionWithMissingEndpoint(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ + ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"from"}],"connections":[]}`, + }); err != nil { + t.Fatal(err) + } + request := CanvasOperationsRequest{MutationID: "bad-connection", Operations: []CanvasOperation{{ + Type: "create_connection", Connection: json.RawMessage(`{"id":"edge","fromNodeId":"from","toNodeId":"missing"}`), + }}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", request); err == nil { + t.Fatal("missing connection endpoint was accepted") + } +} + +func TestApplyCanvasOperationsRejectsStaleFieldAndAllowsDisjointField(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ + ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧","prompt":"原始"}}],"connections":[]}`, + }); err != nil { + t.Fatal(err) + } + first := CanvasOperationsRequest{MutationID: "title-change", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{ + "metadata.title": json.RawMessage(`"标题一"`), + }, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", first); err != nil { + t.Fatal(err) + } + stale := CanvasOperationsRequest{MutationID: "stale-title", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{ + "metadata.title": json.RawMessage(`"标题二"`), + }, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil { + t.Fatal("stale same-field update was accepted") + } else { + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("stale same-field error = %v, want conflict", err) + } + } + disjoint := CanvasOperationsRequest{MutationID: "prompt-change", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{ + "metadata.prompt": json.RawMessage(`"新的提示词"`), + }, Before: map[string]json.RawMessage{"metadata.prompt": json.RawMessage(`"原始"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", disjoint); err != nil { + t.Fatal(err) + } +} + +func TestApplyCanvasOperationsRejectsStaleCanvasField(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", Title: "旧标题", PayloadJSON: `{"nodes":[],"connections":[],"title":"旧标题"}`}); err != nil { + t.Fatal(err) + } + first := CanvasOperationsRequest{MutationID: "field-one", Operations: []CanvasOperation{{Type: "set_field", Field: "title", Value: json.RawMessage(`"新标题"`), BeforeValue: json.RawMessage(`"旧标题"`)}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", first); err != nil { + t.Fatal(err) + } + stale := CanvasOperationsRequest{MutationID: "field-two", Operations: []CanvasOperation{{Type: "set_field", Field: "title", Value: json.RawMessage(`"另一个标题"`), BeforeValue: json.RawMessage(`"旧标题"`)}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil { + t.Fatal("stale canvas field update was accepted") + } else { + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("stale canvas field error = %v, want conflict", err) + } + } +} + +// 客户端读到的 projectId/title 来自数据库列投影;payload 里缺省该字段时, +// 以投影值作为 beforeValue 的请求必须被接受,而不是误报字段冲突。 +func TestApplyCanvasOperationsChecksFieldBaseAgainstProjection(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", Title: "自由画布 1", PayloadJSON: `{"nodes":[],"connections":[]}`}); err != nil { + t.Fatal(err) + } + request := CanvasOperationsRequest{MutationID: "projection-base", Operations: []CanvasOperation{ + {Type: "set_field", Field: "projectId", Value: json.RawMessage(`null`), BeforeValue: json.RawMessage(`""`)}, + {Type: "set_field", Field: "title", Value: json.RawMessage(`"改名"`), BeforeValue: json.RawMessage(`"自由画布 1"`)}, + }} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", request); err != nil { + t.Fatalf("projection base rejected: %v", err) + } + stored, err := svc.repo.CanvasProjectForUser("user", "canvas") + if err != nil { + t.Fatal(err) + } + if stored.Title != "改名" || stored.ProjectID != "" { + t.Fatalf("stored title=%q projectId=%q", stored.Title, stored.ProjectID) + } + stale := CanvasOperationsRequest{MutationID: "projection-stale", Operations: []CanvasOperation{ + {Type: "set_field", Field: "title", Value: json.RawMessage(`"再改"`), BeforeValue: json.RawMessage(`"自由画布 1"`)}, + }} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil { + t.Fatal("stale title base accepted") + } +} + +func TestApplyCanvasOperationsAcceptsLegacyOmittedOptionalFieldValues(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}); err != nil { + t.Fatal(err) + } + // Before the frontend encoded undefined as null, JSON.stringify omitted + // beforeValue when an optional field was absent. The server must replay that + // original request against an absent field without weakening the precondition. + legacyCreate := CanvasOperationsRequest{MutationID: "legacy-optional-field", Operations: []CanvasOperation{{ + Type: "set_field", Field: "timeline", Value: json.RawMessage(`{"tracks":[]}`), + }}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", legacyCreate); err != nil { + t.Fatalf("legacy optional field update failed: %v", err) + } + + stored, err := svc.repo.CanvasProjectForUser("user", "canvas") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stored.PayloadJSON, `"timeline":{"tracks":[]}`) { + t.Fatalf("legacy optional field was not stored: %s", stored.PayloadJSON) + } + + legacyDelete := CanvasOperationsRequest{MutationID: "legacy-optional-field-delete", Operations: []CanvasOperation{{ + Type: "set_field", Field: "timeline", BeforeValue: json.RawMessage(`{"tracks":[]}`), + }}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", legacyDelete); err != nil { + t.Fatalf("legacy optional field deletion failed: %v", err) + } + stored, err = svc.repo.CanvasProjectForUser("user", "canvas") + if err != nil { + t.Fatal(err) + } + if strings.Contains(stored.PayloadJSON, `"timeline"`) { + t.Fatalf("legacy optional field was not deleted: %s", stored.PayloadJSON) + } + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", CanvasOperationsRequest{MutationID: "missing-required-field-base", Operations: []CanvasOperation{{ + Type: "set_field", Field: "title", Value: json.RawMessage(`"标题"`), + }}}); err == nil { + t.Fatal("missing required field precondition was accepted") + } +} + +func TestApplyCanvasOperationsDoesNotRecreateDeletedCanvas(t *testing.T) { + svc := newCanvasOperationTestService(t) + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", CanvasOperationsRequest{MutationID: "create", Operations: []CanvasOperation{{Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"初始"`)}}}}); err != nil { + t.Fatal(err) + } + if err := svc.DeleteUserCanvasProject("user", "canvas"); err != nil { + t.Fatal(err) + } + late := CanvasOperationsRequest{MutationID: "late-after-canvas-delete", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"迟到"`)}, Before: map[string]json.RawMessage{"title": json.RawMessage(`null`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", late); err == nil { + t.Fatal("late update recreated deleted canvas") + } + createAgain := CanvasOperationsRequest{MutationID: "explicit-recreate", Operations: []CanvasOperation{{Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"明确新建"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", createAgain); err != nil { + t.Fatalf("explicit canvas creation after deletion failed: %v", err) + } +} + +func TestApplyCanvasOperationsRequiresObjectPreconditions(t *testing.T) { + svc := newCanvasOperationTestService(t) + if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","title":"旧"}],"connections":[{"id":"edge","fromNodeId":"node","toNodeId":"node","label":"旧"}]}`}); err != nil { + t.Fatal(err) + } + withoutPatchBase := CanvasOperationsRequest{MutationID: "missing-patch-base", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"新"`)}}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutPatchBase); err == nil { + t.Fatal("patch without field precondition was accepted") + } + withoutRemoveBase := CanvasOperationsRequest{MutationID: "missing-remove-base", Operations: []CanvasOperation{{Type: "remove_node", ID: "node"}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutRemoveBase); err == nil { + t.Fatal("remove without object precondition was accepted") + } + withoutConnectionBase := CanvasOperationsRequest{MutationID: "missing-connection-base", Operations: []CanvasOperation{{Type: "update_connection", ID: "edge", Connection: json.RawMessage(`{"id":"edge","fromNodeId":"node","toNodeId":"node","label":"新"}`)}}} + if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutConnectionBase); err == nil { + t.Fatal("connection update without object precondition was accepted") + } +} + +func newCanvasOperationTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "canvas-operations.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasMutationReceipt{}, &model.CanvasShare{}, &model.CanvasUnitLink{}, &model.Task{}, &model.Resource{}, &model.Asset{}); err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + return New(repository.New(db), nil) +} diff --git a/backend/internal/canvas/user_data.go b/backend/internal/canvas/user_data.go index 8f101dca9..470dc6656 100644 --- a/backend/internal/canvas/user_data.go +++ b/backend/internal/canvas/user_data.go @@ -234,6 +234,181 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U return s.upsertUserCanvasProjectWithHistory(userID, raw, "automatic") } +type canvasMutationReceiptResult struct { + Summary UserDataSummary `json:"summary"` + Document json.RawMessage `json:"document"` +} + +func decodeCanvasMutationReceipt(raw string, result *UserDataSummary) error { + var stored canvasMutationReceiptResult + if err := json.Unmarshal([]byte(raw), &stored); err == nil && stored.Summary.ID != "" { + *result = stored.Summary + return nil + } + return json.Unmarshal([]byte(raw), result) +} + +// ApplyUserCanvasOperations applies only the objects changed by the caller to the +// latest server document. The request is idempotent by mutation ID and commits +// the canvas row plus its receipt in one database transaction. +func (s *Service) ApplyUserCanvasOperations(userID, canvasID string, req CanvasOperationsRequest) (UserDataSummary, error) { + if canvasID == "" || len(canvasID) > 80 || req.MutationID == "" || len(req.MutationID) > 160 || len(req.Operations) == 0 || len(req.Operations) > 500 { + return UserDataSummary{}, kernel.BadAuthRequest("画布操作请求 ID、操作内容或批次大小无效") + } + if req.BaseRevision != nil && (*req.BaseRevision < 0 || *req.BaseRevision >= 9007199254740991) { + return UserDataSummary{}, kernel.BadAuthRequest("画布基线版本无效") + } + quota, err := s.host.CanvasQuotaCheck(userID) + if err != nil { + return UserDataSummary{}, err + } + digest := CanvasOperationsDigest(req) + var result UserDataSummary + err = s.host.WithStorageLock(func() error { + return s.repo.Transaction(func(tx *repository.Repository) error { + if err := tx.LockCanvasMutation(userID, canvasID); err != nil { + return err + } + candidate := &model.CanvasMutationReceipt{ID: kernel.NewID(), UserID: userID, CanvasID: canvasID, MutationID: req.MutationID, PayloadDigest: digest} + created, err := tx.TryCreateCanvasMutationReceipt(candidate) + if err != nil { + return err + } + if !created { + receipt, err := tx.CanvasMutationReceiptForUser(userID, canvasID, req.MutationID) + if err != nil { + return err + } + if receipt.PayloadDigest != digest { + return canvasMutationError("canvas_mutation_id_reused", "同一画布请求 ID 对应的操作内容不一致") + } + return decodeCanvasMutationReceipt(receipt.ResultJSON, &result) + } + existing, err := tx.CanvasProjectForUser(userID, canvasID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + existing = nil + } + isCreate := existing == nil + if isCreate && (len(req.Operations) == 0 || req.Operations[0].Type != "create_canvas") { + return kernel.NotFound("画布不存在或已删除") + } + if !isCreate { + for _, operation := range req.Operations { + if operation.Type == "create_canvas" { + return canvasOperationConflict("画布 %q 已存在,不能重复创建", canvasID) + } + } + } + if req.BaseRevision != nil && *req.BaseRevision > 0 && isCreate { + return canvasMutationError("canvas_invalid_base", "新画布不能使用已有版本作为基线") + } + now := time.Now().UTC() + var payload map[string]json.RawMessage + var operations []CanvasOperation + if isCreate { + create := req.Operations[0] + if create.ID != "" && create.ID != canvasID { + return kernel.BadAuthRequest("画布创建操作的 ID 与请求路径不一致") + } + payload = map[string]json.RawMessage{ + "id": jsonRawValue(canvasID), + "nodes": json.RawMessage(`[]`), + "connections": json.RawMessage(`[]`), + } + for field, value := range create.Patch { + if !allowedCanvasOperationField(field) { + return kernel.BadAuthRequest("不支持的新画布字段") + } + payload[field] = append(json.RawMessage(nil), value...) + } + operations = req.Operations[1:] + } else { + // 客户端的 beforeValue 来自 GET 返回的投影(title/projectId 等以数据库列为准), + // 字段基线校验必须对同一份投影进行,否则 projectId 列为 "" 而 payload 缺省时会误判冲突。 + base, err := canvasProjectPayload(*existing) + if err != nil { + return err + } + if err := json.Unmarshal(base, &payload); err != nil { + return err + } + operations = req.Operations + } + if err := applyCanvasOperations(payload, operations); err != nil { + if errors.Is(err, ErrCanvasObjectMissing) { + return canvasMutationError("canvas_object_missing", err.Error()) + } + if errors.Is(err, ErrCanvasOperationConflict) { + return canvasMutationError("canvas_field_conflict", err.Error()) + } + return kernel.BadAuthRequest(err.Error()) + } + delete(payload, "revision") + delete(payload, "remoteContentHash") + payload["id"] = jsonRawValue(canvasID) + payload["updatedAt"] = jsonRawValue(now) + raw, err := json.Marshal(payload) + if err != nil { + return err + } + if !req.TrustedServerMutation { + if err := New(tx, nil).ValidateCanvasMediaAssets(userID, raw); err != nil { + return err + } + } + project, err := canvasProjectFromJSONWithValidation(userID, raw, !req.TrustedServerMutation) + if err != nil { + return err + } + if project.ID != canvasID { + return kernel.BadAuthRequest("画布 ID 与请求路径不一致") + } + if existing != nil { + project.ID, project.CreatedAt, project.Revision, project.UpdatedAt = existing.ID, existing.CreatedAt, existing.Revision, now + } else { + project.CreatedAt, project.Revision, project.UpdatedAt = now, 0, now + } + var existingPayload string + if existing != nil { + existingPayload = existing.PayloadJSON + } + if err := quota(tx, existing == nil, int64(len(raw)-len(existingPayload))); err != nil { + return err + } + if err := tx.UpsertCanvasProject(&project); err != nil { + return err + } + result = UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision, + SaveAudit: &CanvasSaveAudit{NodesBefore: canvasNodeCount(existingPayload), NodesAfter: canvasNodeCount(project.PayloadJSON)}} + document, err := canvasProjectPayload(project) + if err != nil { + return err + } + encoded, err := json.Marshal(canvasMutationReceiptResult{Summary: result, Document: document}) + if err != nil { + return err + } + candidate.ResultJSON, candidate.Revision = string(encoded), project.Revision + return tx.FinalizeCanvasMutationReceipt(candidate) + }) + }) + if errors.Is(err, repository.ErrCanvasRevisionConflict) { + retry := canvasMutationError("canvas_concurrent_write", "画布正在更新,将自动重放原请求") + retry.Retryable = true + return UserDataSummary{}, retry + } + return result, err +} + +func canvasMutationError(reason kernel.ErrorReason, message string) *kernel.AppError { + err := kernel.NewAppError(http.StatusConflict, message) + err.Reason = reason + return err +} + func (s *Service) upsertUserCanvasProjectWithHistory(userID string, raw json.RawMessage, reason string) (UserDataSummary, error) { var version struct { Revision *int64 `json:"revision"` @@ -378,8 +553,14 @@ func AssetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { } func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProject, error) { - if err := ValidateSyncedPayload(raw, "画布"); err != nil { - return model.CanvasProject{}, err + return canvasProjectFromJSONWithValidation(userID, raw, true) +} + +func canvasProjectFromJSONWithValidation(userID string, raw json.RawMessage, validate bool) (model.CanvasProject, error) { + if validate { + if err := ValidateSyncedPayload(raw, "画布"); err != nil { + return model.CanvasProject{}, err + } } var payload struct { ID string `json:"id"` diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index b781999b6..9193ac4d5 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 49 +const CurrentSchemaVersion int64 = 50 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -163,6 +163,9 @@ var schemaMigrations = []migration{ } return nil }}, + {version: 50, name: "canvas_mutation_receipts", checksum: "sha256:canvas-mutation-receipts-v50-20261011", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.CanvasMutationReceipt{}) + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 39cd79990..ed801d1d8 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -102,6 +102,7 @@ func Models() []any { &model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, + &model.CanvasMutationReceipt{}, &model.CanvasShare{}, &model.PromptTemplate{}, &model.UserPromptCustomization{}, diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 11d3517f0..33d5d091e 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -376,99 +376,74 @@ paths: responses: "200": description: 信封 data.project 包含完整画布及服务端 revision + "304": + description: If-None-Match 命中当前 revision,正文为空 "404": description: 画布不存在或不属于当前用户 - put: + delete: tags: [canvas] - summary: 按 revision 保存画布内容 - description: 新建传 revision 0,更新传读取到的版本;成功原子加 1。忽略客户端 viewport 和 remoteContentHash,updatedAt 由服务端生成。 + summary: 显式删除当前用户画布 + description: 删除后迟到的媒体回写和编辑操作不会重新创建画布;只有新的 create_canvas operation 可以显式创建同 ID 画布。 + responses: + "200": + description: 画布已删除,信封 data.id 返回画布 ID + "401": + description: 未登录 + "404": + description: 画布不存在或不属于当前用户 + /canvas-projects/{id}/operations: + parameters: + - in: path + name: id + required: true + schema: { type: string } + patch: + tags: [canvas] + summary: 按对象或字段变更写入画布 + description: | + 请求只提交对象级或字段级 operations,不提交整批画布快照。 + mutationId 是一次请求的幂等身份;响应丢失、断网、刷新或后台重试时必须复用同一个 mutationId 与原始 operations。 + 更新、删除和连接操作要求目标存在并通过对象基线校验;同一字段冲突不会覆盖远端修改。 requestBody: required: true content: application/json: schema: type: object - required: [project] + required: [mutationId, operations] properties: - project: - type: object - required: [id, revision] - properties: - id: { type: string } - revision: { type: integer, format: int64, minimum: 0 } - additionalProperties: true + mutationId: + type: string + minLength: 1 + maxLength: 160 + description: 一次写入请求的幂等身份 + baseRevision: + type: integer + format: int64 + minimum: 0 + description: 生成 operations 时已确认的画布 revision,用于诊断和响应 + operations: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [type] + additionalProperties: true + description: create_canvas、create_node、patch_node、remove_node、create_connection、update_connection、remove_connection 或 set_field responses: "200": - description: 信封 data.project 返回摘要及递增后的 revision + description: 信封 data.project 返回当前画布摘要,data.mutationId 返回原 mutationId "400": - description: 请求、版本或素材引用不合法 + description: 请求、字段、对象基线或素材引用格式不合法 + "401": + description: 未登录 + "404": + description: 画布不存在或不属于当前用户,且请求未包含显式 create_canvas "409": - description: 版本或资源状态冲突,原内容未被覆盖;保留草稿后加载最新版 + description: 对象并发冲突或同一 mutationId 对应的请求摘要不同;原事务不会被覆盖 "428": - description: 缺少 revision,需刷新客户端并重新读取 - /canvas-projects/{id}/history: - get: - tags: [canvas] - summary: 读取当前画布版本与最近 20 份历史摘要 - parameters: - - in: path - name: id - required: true - schema: { type: string } - responses: - "200": - description: 信封 data 包含 currentRevision 和 snapshots;每项含 id、revision、title、nodeCount、connectionCount、payloadBytes、reason、contentUpdatedAt、createdAt,不含正文 - "404": - description: 画布不存在或无权访问 - /canvas-projects/{id}/history/{snapshotId}: - get: - tags: [canvas] - summary: 按需读取历史正文 - parameters: - - in: path - name: id - required: true - schema: { type: string } - - in: path - name: snapshotId - required: true - schema: { type: string } - responses: - "200": - description: 信封 data 包含 snapshot 摘要和 project 正文 - "404": - description: 画布或快照不存在、已过期或无权访问 - /canvas-projects/{id}/history/{snapshotId}/restore: - post: - tags: [canvas] - summary: 备份当前内容并将历史内容恢复为新版本 - parameters: - - in: path - name: id - required: true - schema: { type: string } - - in: path - name: snapshotId - required: true - schema: { type: string } - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [revision] - properties: - revision: { type: integer, format: int64, minimum: 1 } - responses: - "200": - description: 信封 data.project 返回恢复后的摘要及新 revision;当前 ID、归属与项目关联保留 - "404": - description: 画布或快照不存在、已过期或无权访问 - "409": - description: 当前版本或素材状态已变化,恢复未执行 - "428": - description: 缺少当前 revision + description: 缺少执行所需的对象基线 /public/welcome: get: tags: [meta] diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index a299e1253..2ebbcbe63 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "net/http" "strconv" "strings" @@ -557,59 +556,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } okCanvasProject(c, project) }) - r.GET("/canvas-projects/:id/history", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - result, err := svc.CanvasHistory(user.ID, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/canvas-projects/:id/history/:snapshotId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - snapshot, err := svc.CanvasHistorySnapshot(user.ID, c.Param("id"), c.Param("snapshotId")) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"snapshot": snapshot, "project": json.RawMessage(snapshot.PayloadJSON)}) - }) - r.POST("/canvas-projects/:id/history/:snapshotId/restore", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - policy, available := loadRuntimePolicy(c, svc) - if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) { - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024) - var req struct { - Revision *int64 `json:"revision"` - } - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, service.BadAuthRequest("恢复请求格式错误")) - return - } - project, err := svc.RestoreCanvasHistory(user.ID, c.Param("id"), c.Param("snapshotId"), req.Revision) - if err != nil { - failService(c, err) - return - } - slog.Info("canvas_restore", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", c.Param("id"), "snapshot", c.Param("snapshotId"), "base_revision", *req.Revision, "revision", project.Revision) - ok(c, gin.H{"project": project}) - }) - r.PUT("/canvas-projects/:id", func(c *gin.Context) { + r.PATCH("/canvas-projects/:id/operations", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) @@ -620,58 +567,17 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) - var req struct { - Project json.RawMessage `json:"project"` - RepairMissingResources bool `json:"repairMissingResources"` - } + var req service.CanvasOperationsRequest if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) + fail(c, http.StatusBadRequest, service.BadAuthRequest("画布操作请求格式错误")) return } - var identity struct { - ID string `json:"id"` - } - if json.Unmarshal(req.Project, &identity) != nil || identity.ID != c.Param("id") { - fail(c, http.StatusBadRequest, service.BadAuthRequest("画布 ID 与请求路径不一致")) - return - } - var audit struct { - Revision *int64 `json:"revision"` - Nodes []json.RawMessage `json:"nodes"` - Connections []json.RawMessage `json:"connections"` - } - _ = json.Unmarshal(req.Project, &audit) - baseRevision := int64(-1) - if audit.Revision != nil { - baseRevision = *audit.Revision - } - var project service.UserDataSummary - if req.RepairMissingResources { - project, err = svc.RepairUserCanvasProject(user.ID, req.Project) - } else { - project, err = svc.UpsertUserCanvasProject(user.ID, req.Project) - } - defer func() { - nodesBefore, nodesAfter := -1, len(audit.Nodes) - if project.SaveAudit != nil { - nodesBefore, nodesAfter = project.SaveAudit.NodesBefore, project.SaveAudit.NodesAfter - } - // Metadata only: never log prompts, media URLs, cookies, or the canvas payload. - // 自动保存非常频繁:常规保存只在 debug 输出;节点减少(可能丢数据)或保存失败才提升级别。 - level := slog.LevelDebug - switch { - case c.Writer.Status() >= http.StatusBadRequest: - level = slog.LevelWarn - case nodesBefore >= 0 && nodesAfter < nodesBefore: - level = slog.LevelInfo - } - slog.Log(c.Request.Context(), level, "canvas_save", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", identity.ID, "base_revision", baseRevision, "revision", project.Revision, "nodes_before", nodesBefore, "nodes_after", nodesAfter, "connections", len(audit.Connections), "status", c.Writer.Status()) - }() + project, err := svc.ApplyUserCanvasOperations(user.ID, c.Param("id"), req) if err != nil { failService(c, err) return } - ok(c, gin.H{"project": project}) + ok(c, gin.H{"project": project, "mutationId": req.MutationID}) }) r.DELETE("/canvas-projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) diff --git a/backend/internal/model/models_canvas_sync.go b/backend/internal/model/models_canvas_sync.go new file mode 100644 index 000000000..e41d412cb --- /dev/null +++ b/backend/internal/model/models_canvas_sync.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// CanvasMutationReceipt makes a client retry observable without making the +// canvas document carry an ever-growing list of request IDs. The receipt is a +// recovery hint; the mutation itself remains idempotent because applying the +// same desired values is a no-op. +type CanvasMutationReceipt struct { + ID string `json:"id" gorm:"primaryKey;size:80"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:1"` + CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:2"` + MutationID string `json:"mutationId" gorm:"size:160;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:3"` + PayloadDigest string `json:"payloadDigest" gorm:"size:64;index"` + ResultJSON string `json:"resultJson" gorm:"type:text"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} diff --git a/backend/internal/repository/canvas_mutation_receipts.go b/backend/internal/repository/canvas_mutation_receipts.go new file mode 100644 index 000000000..d6f52f097 --- /dev/null +++ b/backend/internal/repository/canvas_mutation_receipts.go @@ -0,0 +1,47 @@ +package repository + +import ( + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "yingce/backend/internal/model" +) + +// LockCanvasMutation serializes all operations for an existing canvas, including +// deletion. On SQLite this first write also avoids a deferred read transaction +// trying to upgrade its stale snapshot to a write lock. +func (r *Repository) LockCanvasMutation(userID, canvasID string) error { + return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID). + UpdateColumn("revision", gorm.Expr("revision")).Error +} + +func (r *Repository) CanvasMutationReceiptForUser(userID, canvasID, mutationID string) (*model.CanvasMutationReceipt, error) { + var receipt model.CanvasMutationReceipt + err := r.db.First(&receipt, "user_id = ? AND canvas_id = ? AND mutation_id = ?", userID, canvasID, mutationID).Error + return &receipt, err +} + +func (r *Repository) TryCreateCanvasMutationReceipt(receipt *model.CanvasMutationReceipt) (bool, error) { + if receipt.CreatedAt.IsZero() { + receipt.CreatedAt = time.Now().UTC() + } + result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "canvas_id"}, {Name: "mutation_id"}}, DoNothing: true}).Create(receipt) + return result.RowsAffected == 1, result.Error +} + +func (r *Repository) FinalizeCanvasMutationReceipt(receipt *model.CanvasMutationReceipt) error { + if receipt.ResultJSON == "" { + return errors.New("canvas mutation receipt requires a committed result") + } + result := r.db.Model(&model.CanvasMutationReceipt{}).Where("id = ? AND payload_digest = ?", receipt.ID, receipt.PayloadDigest). + Updates(map[string]any{"result_json": receipt.ResultJSON, "revision": receipt.Revision}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("canvas mutation receipt was not finalized") + } + return nil +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 2dcb93df8..2a78886ef 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -55,6 +55,14 @@ func (r *Repository) WithContext(ctx context.Context) *Repository { return &Repository{db: r.db.WithContext(ctx)} } +// Transaction exposes a repository scoped to one database transaction. Domain +// services use it when a canvas mutation and its receipt must commit together. +func (r *Repository) Transaction(fn func(*Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return fn(New(tx)) + }) +} + func (r *Repository) Dialect() string { return r.db.Dialector.Name() } @@ -95,6 +103,26 @@ func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) { return usage, err } +// UserStructuredStorageUsage reads only the counters and payload bytes used by +// asset/canvas structured-data quotas. Keeping task and API-log accounting out of +// this path avoids scanning unrelated large history rows on every canvas write. +func (r *Repository) UserStructuredStorageUsage(userID string) (UserStorageUsage, error) { + var usage UserStorageUsage + query := ` + SELECT + (SELECT COUNT(*) FROM assets WHERE user_id = ?) AS asset_count, + (SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM assets WHERE user_id = ?) AS asset_bytes, + (SELECT COUNT(*) FROM canvas_projects WHERE user_id = ?) AS canvas_count, + (SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM canvas_projects WHERE user_id = ?) AS canvas_bytes + ` + if r.Dialect() == "postgres" { + query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(") + query = strings.ReplaceAll(query, ", '') AS BLOB))", ", ''))") + } + err := r.db.Raw(query, userID, userID, userID, userID).Scan(&usage).Error + return usage, err +} + // Create 是低层兼容入口;业务写路径应优先使用带领域约束的显式方法。 func (r *Repository) Create(value any) error { return r.db.Create(value).Error diff --git a/backend/internal/repository/structured_storage_usage_test.go b/backend/internal/repository/structured_storage_usage_test.go new file mode 100644 index 000000000..0f3395557 --- /dev/null +++ b/backend/internal/repository/structured_storage_usage_test.go @@ -0,0 +1,89 @@ +package repository + +import ( + "errors" + "path/filepath" + "testing" + + "yingce/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestUserStructuredStorageUsage(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "quota.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + // History tables are deliberately absent: canvas quota must not read them. + if err := db.AutoMigrate(&model.Asset{}, &model.CanvasProject{}); err != nil { + t.Fatal(err) + } + repo := New(db) + assetPayload := `{"title":"素材 🎬"}` + canvasPayload := `{"title":"画布","nodes":[]}` + assets := []model.Asset{ + {ID: "asset", UserID: "owner", PayloadJSON: assetPayload}, + {ID: "empty", UserID: "owner"}, + {ID: "null", UserID: "owner"}, + {ID: "foreign-asset", UserID: "other", PayloadJSON: assetPayload}, + } + canvases := []model.CanvasProject{ + {ID: "canvas", UserID: "owner", PayloadJSON: canvasPayload}, + {ID: "foreign-canvas", UserID: "other", PayloadJSON: canvasPayload}, + } + if err := db.Create(&assets).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Asset{}).Where("id = ?", "null").UpdateColumn("payload_json", nil).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&canvases).Error; err != nil { + t.Fatal(err) + } + want := UserStorageUsage{AssetCount: 3, AssetBytes: int64(len(assetPayload)), CanvasCount: 1, CanvasBytes: int64(len(canvasPayload))} + assertUsage := func(repo *Repository, userID string, expected UserStorageUsage) { + t.Helper() + got, err := repo.UserStructuredStorageUsage(userID) + if err != nil { + t.Fatal(err) + } + if got != expected { + t.Fatalf("usage for %s = %+v, want %+v", userID, got, expected) + } + } + assertUsage(repo, "owner", want) + assertUsage(repo, "missing", UserStorageUsage{}) + assertUsage(repo, "other", UserStorageUsage{AssetCount: 1, AssetBytes: int64(len(assetPayload)), CanvasCount: 1, CanvasBytes: int64(len(canvasPayload))}) + + rollback := errors.New("rollback quota probe") + err = repo.Transaction(func(tx *Repository) error { + project := model.CanvasProject{ID: "uncommitted", UserID: "owner", PayloadJSON: canvasPayload} + if err := tx.UpsertCanvasProject(&project); err != nil { + return err + } + inside := want + inside.CanvasCount++ + inside.CanvasBytes += int64(len(canvasPayload)) + assertUsage(tx, "owner", inside) + return rollback + }) + if !errors.Is(err, rollback) { + t.Fatalf("transaction error = %v", err) + } + assertUsage(repo, "owner", want) + if err := db.Migrator().DropTable(&model.Asset{}); err != nil { + t.Fatal(err) + } + if _, err := repo.UserStructuredStorageUsage("owner"); err == nil { + t.Fatal("database error must not become zero usage") + } +} diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 1596e35af..7438f20f0 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -89,6 +89,7 @@ type ( BulkDisableUsersResult = app.BulkDisableUsersResult CanvasLibraryPage = app.CanvasLibraryPage CanvasLibrarySummary = app.CanvasLibrarySummary + CanvasOperationsRequest = app.CanvasOperationsRequest CanvasHistoryList = app.CanvasHistoryList CanvasShareRequest = app.CanvasShareRequest CanvasShareStatus = app.CanvasShareStatus diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 8385bb7ea..275c07690 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -31,9 +31,7 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex ## 结构迁移与版本 -当前结构版本为 49。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 - -版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 +当前结构版本为 50。版本 50 新增 `canvas_mutation_receipts`,按 `(user_id, canvas_id, mutation_id)` 唯一记录画布 operations 请求摘要、确定性结果和提交 revision,供响应丢失后的幂等重放使用;receipt 与画布内容、revision 在同一事务提交。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 42,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。 @@ -68,19 +66,20 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 -## 画布版本与快照 +## 画布 operations 与幂等回执 | 表 | 用途 | 关键边界 | | --- | --- | --- | -| `canvas_projects` | 当前画布内容及递增 `revision`(非空,默认 1) | 客户端新建传 0;后续写入按用户、ID、原版本原子校验,成功才加 1 | -| `canvas_snapshots` | 结构 JSON、版本、标题、节点/连线数、字节数、内容时间和备份时间 | `(canvas_id, revision)` 唯一;列表只读摘要,正文按需加载;不复制媒体文件 | -| `canvas_snapshot_resources` | 历史快照引用的资源 | `(snapshot_id, resource_id)` 联合主键;资源外键禁止删除被引用记录 | +| `canvas_projects` | 当前画布内容及递增 `revision` | 所有正常画布写入都通过 operations 服务更新;对象删除后迟到更新不能隐式重建 | +| `canvas_mutation_receipts` | 保存 `(user_id, canvas_id, mutation_id)`、请求摘要、确定性结果和提交 revision | 唯一请求身份与画布内容在同一事务提交;同 ID 不同摘要返回冲突 | -浏览器、云端 Agent 和创作任务共用保存事务。内容变化时备份旧内容,普通备份最短间隔 5 分钟,每画布最多保留 20 份;恢复前强制备份当前内容。版本号每次成功写入递增,快照按间隔采样,两者不是一一对应。画布关联项目或解除关联同样推进版本,使旧页面的全量保存失效。 +`canvas_mutation_receipts` 不保存整批历史版本,只保存一次请求的恢复结果。服务端在事务内锁定画布、创建或读取 receipt、校验字段/对象基线、应用 operations、递增 revision、写入结果并完成 receipt;事务失败时不留下可重放的半成品。客户端可在响应丢失、断网或刷新后用原始 `mutationId` 和原始 payload 重放。 -恢复历史保留当前画布 ID、归属和项目关联,按当前版本做 CAS 后形成新版本;不会把 revision 改回历史值。快照写入、引用保护、恢复和保留数量清理在同一事务内完成。资源删除检查与异步物理删除任务均检查历史引用(包括同对象的资源别名),仍被历史引用的媒体延后回收;删除画布时清理该画布快照及引用。 +画布操作支持 `create_canvas`、`create_node`、`patch_node`、`remove_node`、`create_connection`、`update_connection`、`remove_connection`、`set_field`。创建操作显式创建对象;更新和删除要求对象存在,并以 `before`、`expected` 或 `beforeValue` 做字段/对象级并发校验。删除节点同时删除其连接,迟到的媒体回写返回对象缺失冲突。`viewport` 是本地视图状态,不进入远端 operations。 -升级不改变已有画布正文,也不会凭空产生升级前历史。前后端应一起升级:旧客户端缺少 revision 的保存返回 428,需刷新客户端。 +旧的画布版本历史、恢复和整批 PUT 不属于当前用户写入合同;数据库迁移保留既有历史表以兼容已部署数据,但新写入不依赖它们。Agent、媒体回写、预演台、创作提交及导入路径都复用同一 operations 服务。 + +画布/素材结构化配额使用 `UserStructuredStorageUsage`,仅聚合当前用户的 `assets` 和 `canvas_projects` 数量及 `payload_json` 字节数。SQLite 使用 `length(CAST(... AS BLOB))`,PostgreSQL 使用 `octet_length(...)`,避免中文按字符计数;空值按零字节处理。operations 通过事务 repository 读取用量后再写入,不增加缓存或配额表,不变更 schema。完整 `UserStorageUsage` 仍用于需要任务及日志统计的路径,画布保存不再为未使用的任务/日志指标扫描大字段。 ## 已部署 PostgreSQL 的素材 bytes 修复 diff --git a/docs/content/docs/backend/canvas-operations-sync.mdx b/docs/content/docs/backend/canvas-operations-sync.mdx new file mode 100644 index 000000000..5bf3ecfff --- /dev/null +++ b/docs/content/docs/backend/canvas-operations-sync.mdx @@ -0,0 +1,69 @@ +--- +title: 画布 operations 同步协议 +description: 画布对象级写入、并发校验、幂等重放与删除边界 +--- + +# 画布 operations 同步协议 + +## 请求合同 + +画布写入使用 `PATCH /api/canvas-projects/:id/operations`。请求体为: + +```json +{ + "mutationId": "客户端生成的稳定请求 ID", + "baseRevision": 12, + "operations": [ + { + "type": "patch_node", + "id": "node-1", + "patch": { "metadata.prompt": "新的提示词" }, + "before": { "metadata.prompt": "旧的提示词" } + } + ] +} +``` + +`mutationId` 是重试身份,不是版本号。请求超时、响应丢失、断网、刷新或后台自动重试时,必须使用相同的 `mutationId`、`baseRevision` 和原始 `operations`。修改原始 payload 后复用 ID 会返回 `canvas_mutation_id_reused`。 + +`baseRevision` 是生成请求时的已确认版本,用于记录和诊断。真正的并发校验在操作本身:字段补丁使用 `before`,对象更新或删除使用 `expected`,顶层字段使用 `beforeValue`。不同字段可以合并;同一字段或同一对象的基线不匹配时返回冲突。可选顶层字段不存在时,客户端必须在 JSON 中将 `value` 或 `beforeValue` 编码为 `null`,不能让 `undefined` 被序列化为缺失属性。服务端仅兼容早期客户端对这些可选字段省略属性的请求,并按 `null` 参与同样的基线校验。 + +## 操作类型 + +- `create_canvas`:只在画布不存在时创建,第一项必须是该操作。 +- `create_node`:显式新增节点,已有 ID 不会被覆盖。 +- `patch_node`:对已有节点按字段路径更新,节点缺失或字段基线不匹配都会失败。 +- `remove_node`:按完整对象基线删除节点,并删除关联连接。 +- `create_connection`:显式新增连接,两个端点必须存在。 +- `update_connection`:按连接对象基线更新,连接缺失或端点缺失都会失败。 +- `remove_connection`:按连接对象基线删除。 +- `set_field`:更新白名单中的画布字段,并校验 `beforeValue`;字段不存在时用 JSON `null` 表示删除或缺失基线。 + +只有 create 操作可以创建实体。删除后的迟到编辑、媒体回写或 Agent 更新得到 `canvas_object_missing`,不会隐式重建节点、连接或画布。画布删除后的迟到请求同样不会重建画布;只有新的 `create_canvas` 才能显式重新创建。 + +## 事务与 receipt + +服务端事务按以下顺序处理请求:锁定画布写入、创建或读取 receipt、核对请求摘要、读取当前画布、应用 operations、校验媒体引用、递增 revision、写入画布和完成 receipt。画布内容、revision、receipt 的结果 JSON 在同一事务提交,任一步失败都会回滚。 + +`canvas_mutation_receipts` 使用 `(user_id, canvas_id, mutation_id)` 唯一约束。相同 ID 和相同摘要返回之前保存的确定性结果,不再次应用操作;相同 ID 和不同摘要返回冲突。receipt 不替代当前画布,也不保存整批版本历史。 + +结构化容量校验仍在同一事务内使用事务 repository 读取实时用量,但只统计当前账号的素材/画布数量和 JSON 字节数。此路径使用 `UserStructuredStorageUsage`,不扫描任务正文、结果或 API 请求/响应日志;任务与日志配额继续使用原有完整统计。容量上限、创建数量限制、存储锁、CAS 和 receipt 原子提交不变,也不引入可能过期的配额缓存。 + +## 浏览器 journal 与刷新 + +浏览器在发送请求前把以下内容写入账号隔离的本地 journal: + +- 已确认的画布基线; +- 原始 operations 请求及 mutationId; +- 生成请求时的本地投影; +- 创建时间。 + +响应成功后删除对应未确认请求,并把服务端返回的完整画布保存为新基线。响应丢失时保留 journal,刷新后使用原始请求重放;请求发出后产生的新编辑不会改写原请求,而是在确认后从新基线生成下一批 operations。服务端完整读取或 Agent 刷新只能更新已确认基线,不能覆盖仍未确认的本地请求之后的修改。 + +`viewport` 是本地视图状态,不进入 operations。媒体文件先完成资源登记,再提交引用画布的 operations,避免产生只有节点没有资源记录的远端状态。 + +## Agent 和媒体回写 + +普通编辑、Agent、媒体生成终态、预演台、创作提交、LibTV/TapNow 导入都必须经过同一 operations 服务。Agent 事件里的 `canvasPatch` 只是客户端通知:小变更携带有界对象增量;删除、重排、顶层字段变化或事件过大时使用 `requiresRefresh: true`,客户端重新读取后继续处理未确认 journal。 + +媒体终态只更新仍存在且任务绑定未变化的节点。目标节点已删除、任务绑定已改变或资源不可用时,任务记录保留,画布不创建幽灵节点。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index b384a677a..e9c544c45 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -65,8 +65,8 @@ description: 从产品功能快速定位主要代码入口 | 页面装配和跨域动作协调 | `web/src/pages/canvas/project.tsx` | | 画布领域规则和生成协议 | `web/src/lib/canvas/canvas-project-domain.ts`、`canvas-project-generation.ts`、`produced-model.ts` | | 项目恢复、持久化和项目级动作 | `web/src/pages/canvas/use-canvas-project-lifecycle.ts` | -| 云端版本校验与冲突草稿 | `web/src/services/user-data-sync.ts`、`canvas-sync-drafts.ts`、`web/src/lib/canvas/canvas-content.ts`、`web/src/pages/canvas/canvas-sync-status.tsx` | -| 历史树与独立只读预览 | `web/src/pages/canvas/canvas-version-history.tsx`、`canvas-version-preview.tsx`;后端 `internal/canvas/canvas_history.go` 与 `repository/canvas_history.go`,Agent 写入桥接在 `app/canvas_bridge.go` | +| 画布 operations 同步与本地 journal | `web/src/services/user-data-sync.ts`、`web/src/services/api/user-data.ts`、`web/src/lib/canvas/canvas-content.ts`、`web/src/pages/canvas/canvas-sync-status.tsx`、`web/src/stores/use-sync-progress-store.ts` | +| 内部资源保护与维护快照 | `backend/internal/canvas/canvas_history.go`、`repair_resources.go`、`backend/internal/repository/canvas_history.go`;仅供资源完整性维护和审计,不提供用户历史树、预览或恢复入口 | | Patch 历史、生成任务和重试控制 | `web/src/pages/canvas/use-canvas-history.ts`、`use-canvas-generation.ts`、`use-canvas-generation-executor.ts`、`use-canvas-generation-retry.ts` | | 文本、图片、视频和音频生成执行 | `web/src/pages/canvas/canvas-*-generation-executor*.ts` | | 云端 Agent、助手面板、导演台和全局快捷键控制 | `web/src/components/canvas/canvas-cloud-agent-panel.tsx`、`web/src/pages/canvas/use-canvas-assistant-visibility.ts`、`use-canvas-director.ts`、`use-canvas-keyboard.ts`;画布操作契约在 `web/src/lib/canvas/canvas-operation-contract.ts` | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 14717faec..1687ffddb 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -71,19 +71,19 @@ handle 放错端点、格式错误或行 ID 不存在时,工具错误回执提 文本生成任务的 `metadata.edit = "layer-planning"` 使用图片参考时,服务端从当前用户归属的就绪资源读取实际像素并转换为内嵌图片,清除附带的旧 URL。图片数量与字节数沿用文本模型能力及资源读取上限;不允许其他用户资源或客户端内嵌内容替换资源文件。普通生成的签名 URL 策略保持不变,任务端点、账务与数据库结构沿用已有合同。 -## 画布资源完整性与 409 +## 画布资源完整性与 operations 写入 -画布写入在保存历史快照前校验当前文档中的资源引用;引用须属于当前用户且处于 `ready`。引用收集器递归遍历受支持的资源字段,因此 `nodes[节点 ID].metadata.videoPreview.storageKey` 等嵌套派生封面也会参与检查。历史快照保存会一并校验新版本引用;若资源在保存期间缺失或状态变化,事务回滚,画布 revision 不递增。 +画布 operations 应用前校验当前文档中的资源引用;引用须属于当前用户且处于 `ready`。引用收集器递归遍历受支持的资源字段,因此 `nodes[节点 ID].metadata.videoPreview.storageKey` 等嵌套派生封面也会参与检查。维护写入会保留必要的损坏文档审计信息;若资源在事务期间缺失或状态变化,事务回滚,画布 revision 不递增。 -HTTP 409 不能一概解释为 revision 冲突:普通版本冲突使用 `reason: conflict`;资源完整性失败使用 `reason: canvas_history_resources_missing`,并在 `details` 中返回 `resourceIds` 和 `missingResources`(含资源 ID、JSON 路径、节点 ID、引用类型及 `source`)。客户端应按 `reason` 分流,不解析 `msg` 猜测原因。显式资源修复写入仅允许历史 preimage 存在缺失资源,不允许新画布仍引用缺失资源,也不绕过 revision CAS。 +HTTP 409 不能一概解释为并发冲突:对象或字段基线冲突使用 `reason: conflict`;资源完整性失败使用 `reason: canvas_history_resources_missing`,并在 `details` 中返回 `resourceIds` 和 `missingResources`(含资源 ID、JSON 路径、节点 ID、引用类型及 `source`)。客户端应按 `reason` 分流,不解析 `msg` 猜测原因。普通 operations 不会绕过资源校验或 revision 语义,维护修复也不能把缺失主媒体写回为有效画布。 -运维扫描/修复仅处理当前画布中可安全清除的失效 `videoPreview`;不删除主媒体、不扫描并改写所有历史快照,也不替代备份。使用目标数据库对应的新版本二进制,先在维护窗口备份并执行只读扫描: +运维扫描/修复仅处理当前画布中可安全清除的失效 `videoPreview`;不删除主媒体、不扫描并改写所有维护记录,也不替代备份。使用目标数据库对应的新版本二进制,先在维护窗口备份并执行只读扫描: ```sh CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-resources ``` -复核 JSON 报告后,只有 `repairable` 项可由明确授权的操作员再次运行 `repair-canvas-resources --apply`。命令要求显式配置 `CANVAS_BACKEND_DATA_DIR` 或 `DATABASE_URL`,逐画布按版本 CAS 保存并保留原始历史;有未解决主媒体/素材绑定问题时以非零退出,不会自动恢复或重传。发生并发编辑时停止并重新扫描,不能用强制覆盖代替恢复有效历史或重新上传。 +复核 JSON 报告后,只有 `repairable` 项可由明确授权的操作员再次运行 `repair-canvas-resources --apply`。命令要求显式配置 `CANVAS_BACKEND_DATA_DIR` 或 `DATABASE_URL`,逐画布按当前 revision CAS 保存并保留维护审计记录;有未解决主媒体/素材绑定问题时以非零退出,不会自动重传或重建节点。发生并发编辑时停止并重新扫描,不能用整批快照替换或绕过 operations 校验。 ## 生成成功后的重试保存 @@ -213,24 +213,25 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 上传、工作流结构查询、取消查询和取消请求也不参与财务归属,即使日志被标记为可计费;它们不能代替生成请求承载订单金额。订单是否已结算以账单状态为准,保留人工核账后结算订单的统计能力。 -## 画布保存、历史与恢复 +## 画布保存与 operations 同步 -画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 +画布写入统一使用 `PATCH /canvas-projects/:id/operations`。请求只携带对象级或字段级变更,不提交整批画布快照;`mutationId` 是一次写入的幂等身份,客户端在响应丢失、断网、刷新或后台重试时必须复用同一个 ID 和原始 `operations`。`baseRevision` 记录生成请求时的已确认基线版本,用于诊断和响应;字段/对象的 `before`、`expected`、`beforeValue` 是实际并发校验,允许不同字段合并,拒绝同字段覆盖和缺失对象更新。可选顶层字段缺失时必须显式编码为 JSON `null`;服务端仅对早期客户端省略这些可选字段的 `value` 或 `beforeValue` 按 `null` 兼容处理,并继续执行基线校验。 | 接口 | 行为 | | --- | --- | -| `GET /canvas-projects/:id` | 返回当前内容及权威版本号;响应包含基于数据库 `revision` 的弱 `ETag` 和 `Cache-Control: private, no-cache`。请求可用 `If-None-Match` 做条件读取,版本未变化时返回无正文的 `304`。 | -| `GET /canvas-projects/:id/history` | 返回 `snapshots` 摘要与 `currentRevision`,不批量读取历史正文 | -| `GET /canvas-projects/:id/history/:snapshotId` | 按需返回 `snapshot` 摘要和 `project` 历史正文 | -| `POST /canvas-projects/:id/history/:snapshotId/restore` | 请求 `{ revision }` 为当前画布版本,成功返回新版本摘要;恢复前强制备份当前内容 | +| `GET /canvas-projects/:id` | 返回当前完整画布及权威 `revision`;响应包含基于数据库版本的弱 `ETag` 和 `Cache-Control: private, no-cache`。请求可用 `If-None-Match` 做条件读取,版本未变化时返回 `304`。 | +| `PATCH /canvas-projects/:id/operations` | 接收 `{ mutationId, baseRevision?, operations[] }`,返回当前项目摘要和 `mutationId`。支持 `create_canvas`、`create_node`、`patch_node`、`remove_node`、`create_connection`、`update_connection`、`remove_connection`、`set_field`。 | +| `DELETE /canvas-projects/:id` | 显式删除画布。删除后迟到的更新不会重新创建画布;只有新的 `create_canvas` 操作可以显式创建同 ID 画布。 | -历史接口先验证画布归属;无权访问、已删除画布或不存在的快照返回 404。恢复沿用画布写入限流和素材校验,在同一事务完成版本校验、备份、内容写入及历史资源保护。恢复保留当前画布 ID 和项目归属,生成新版本,不倒退版本号。升级前的内容无法补建历史。 +`create_node`、`create_connection` 和 `create_canvas` 是唯一的创建语义;更新、删除和连接操作都要求目标存在并通过对象基线校验。节点删除会同步移除关联连接,迟到媒体回写或编辑更新返回结构化冲突,不会复活已删除节点。请求摘要与确定性结果写入 `canvas_mutation_receipts`,画布内容、版本递增和 receipt 在同一数据库事务完成;同一 `mutationId` 和相同摘要返回原结果,摘要不同返回 `canvas_mutation_id_reused`。 -平移和缩放只持久化浏览器视口,内容 PUT 忽略客户端 `viewport` 和 `remoteContentHash`。前端遇到版本冲突(409/428)会先核对云端并尝试合并;无法合并时保留账号隔离的本地草稿、暂停该画布自动提交,并提供加载最新版和版本记录查看入口。载入最新版前草稿写入失败时停止替换,当前编辑内容保持不变;其他画布仍可保存。 +前端先把未确认请求和请求基线写入账号隔离的本地 journal,再发 HTTP 请求。journal 保存已确认基线、原始请求、请求生成时的本地投影和创建时间;刷新后只重放原请求,随后把请求之后的新编辑按新的基线生成下一批 operations。服务端返回完整画布时,客户端只合并仍未确认的本地 mutations,视口仍属于本地视图状态,不进入远端 operations。 -单画布读取会先查询归属用户的版本元数据,再判断 `If-None-Match`;命中时不会读取大型画布正文。未命中时返回完整项目 JSON。正文达到 1 KiB 且客户端声明接受 gzip 时响应使用 gzip 压缩,并带 `Vary: Accept-Encoding`;因此客户端应以 HTTP 状态码区分 `200` 正文和 `304` 未变化,不要把 `304` 当作业务错误。 +云端 Agent、媒体生成终态回写、预演台、创作提交以及 LibTV/TapNow 导入都复用同一 operations 服务。Agent 事件中的 `canvasPatch` 只是一种有界通知;过大、删除或无法完整表达的变更使用 `requiresRefresh: true`,客户端重新读取后按未确认请求继续重放。用户界面不提供版本记录、加载云端版、覆盖云端或手动重试入口,失败请求由后台自动重放并在同步状态中显示当前状态。 -云端 Agent 与创作任务的写入共用版本和历史事务;Agent `canvasPatch` 携带 `baseRevision`、`revision`,客户端只接受连续增量,跳过重复事件,缺口或同字段冲突时保留草稿并重新核对。删除、重排或顶层内容变化无法由增量完整表示时,事件携带 `requiresRefresh: true` 且不附 `canvasPatch`,客户端完整核对后才推进基线;撤销事件同样触发核对。保存日志仅记录请求/链路 ID、画布/用户 ID、版本、节点数量变化及 HTTP 状态,不记录正文、提示词或媒体 URL。 +平移和缩放只持久化浏览器视口,不生成远端 operations。保存日志仅记录请求/链路 ID、画布/用户 ID、版本、节点数量变化及 HTTP 状态,不记录正文、提示词或媒体 URL。 + +operations 的结构化配额在提交事务内实时统计账号的素材/画布数量和 JSON 字节数,不为画布保存扫描任务正文及 API 请求/响应日志。接口请求、响应、容量上限及错误语义均不变;性能排查应分别记录成功写入、同请求重放和冲突请求的耗时,冲突请求提前退出不能代表完整提交耗时。 ## 欢迎页开放配置 diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json index 0ef1f564d..bf273da30 100644 --- a/docs/content/docs/backend/meta.json +++ b/docs/content/docs/backend/meta.json @@ -13,6 +13,7 @@ "newapi-channel-2", "system-update", "protocol-plugins", - "canvas-data-structure" + "canvas-data-structure", + "canvas-operations-sync" ] } diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 8a951c977..572d59bfd 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -129,15 +129,15 @@ AutoDL 视频模型在“能力与参数 → 画面规格”直接多选当前 技能库保留原有头部和技能广场共享分类;在“我的技能 / 我创建的”视图增加左侧个人技能库分类及技能数量。用户可为已加入或自建技能创建、分配个人分类;管理员也可创建对所有用户可见、可选、可用于筛选的分类。个人分类仅影响当前用户的技能库和画布 Agent,不改动技能广场共享分类。删除分类只解除相关归类,技能仍保留。画布 Agent 的“我的技能 / 已启用”列表可按个人或平台技能库分类快速筛选,市场页仍按共享分类浏览。 -## 画布保存保护与版本记录 +## 画布 operations 同步与本地恢复 -旧页面或旧缓存不能覆盖云端新内容。每次内容保存核对服务端版本,发生冲突会停止该画布自动提交并保留本地草稿;平移、缩放和打开画布不触发整份内容保存。画布顶部显示保存状态,可保留草稿后加载云端最新版。 +画布内容按字段和对象生成 operations,节点、连线和顶层业务字段分别提交;平移、缩放等 viewport 只保存在本地。每次请求携带唯一 `mutationId` 和可选基线版本,服务端在同一事务中校验对象存在性、字段基线、资源归属并保存回执。可选顶层字段不存在时用 JSON `null` 表示,避免 `undefined` 被序列化为缺失属性。相同请求安全重放,响应丢失、断网、刷新或服务端暂时不可用时,浏览器保留已确认基线与未确认请求,后台使用原 ID 和原 payload 自动继续处理。结构化配额只聚合当前用户素材与画布数据,不扫描任务和 API 日志大字段,容量上限与事务语义不变。 -「版本记录」在桌面右侧按日期展开历史树,小屏使用抽屉。点击版本进入独立只读预览,可以缩放查看或下载;返回当前画布后继续编辑。恢复历史会先保存本地草稿、备份当前云端内容,再创建一个新版本。历史只保存画布结构和媒体引用,不复制视频文件;普通快照最短间隔 5 分钟,每个画布保留最近 20 份,因此每次递增的版本号不一定都有快照。历史引用的媒体受到删除保护。本地绘图笔画不在云端历史中,草稿和历史导出不混入当前笔画。 +不同字段的并发修改可以合并;同一字段或同一对象的基线不一致会返回冲突,当前本地修改继续保留。删除后的迟到节点、连线或媒体更新不会重新创建对象,只有显式新增 operation 可以创建实体。界面只显示同步状态,不提供版本记录、加载云端版、覆盖云端或手动恢复入口。 -此功能不提供多人实时协同或自动合并两个浏览器的完整画布;版本保护用于阻止静默覆盖。历史从升级后开始积累。 +此合同不提供多人实时协同;它保证局部变更、幂等重放和删除保护,避免旧缓存静默覆盖新内容。内部历史表仍用于资源完整性维护和审计,不作为用户编辑入口。 -![右侧版本记录与独立只读画布预览(示例数据)](/images/canvas-version-history.png) +相关实现见后端 [画布 operations 同步](/docs/backend/canvas-operations-sync)、`web/src/services/user-data-sync.ts`、`web/src/services/api/user-data.ts` 和 `web/src/pages/canvas/canvas-sync-status.tsx`。 ## 画布智能引用 @@ -365,18 +365,18 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph - `web/src/lib/canvas/canvas-starter.ts`:空画布起点状态决策。 - `web/src/stores/canvas/use-canvas-store.ts`:按画布保存自由起点选择。 -## 画布手动保存与强制覆盖 +## 画布同步与离线恢复 -画布左上角菜单提供「保存」与「强制覆盖保存」两个手动入口。「保存」与 ⌘/Ctrl+S 等价:先写入浏览器本地缓存,再触发云端同步;云端失败时保留本地结果并排队自动重试,适合平板等无键盘设备手动落盘。 +画布编辑会先写入浏览器本地持久化,再由统一同步队列提交 operations。浏览器在线时自动保存;断网、响应丢失或服务端暂时不可用时,未确认请求会保留在本地 journal,恢复连接后按原 `mutationId` 和原操作内容自动重放。同步状态会区分保存中、已完成、待处理和错误,用户无需选择本地版或云端版,也无需手动强制覆盖。 -「强制覆盖保存」用于云端同步反复失败(如提示“画布媒体与素材库记录不一致”)时的显式恢复,操作前有确认弹窗。执行时以本地画布为准覆盖云端版本:先按服务端素材记录校验所有画布媒体节点与时间线片段声明的素材绑定,不一致时重绑到引用同一资源的素材(缺失则按节点新建),再按“素材先于画布”的顺序整体推送;素材远端版本冲突时采纳远端为基线继续用本地覆盖。后端“画布资源必须被其素材记录引用”的不变式保持不变,强制覆盖通过前端对齐绑定完成,不绕过校验。 +媒体素材先完成资源归属和就绪校验,再提交引用画布的 operations。服务端返回并发或资源冲突时,本地修改继续保留,系统等待下一次基线校验;删除后的迟到更新会被拒绝,不会凭空创建节点。相关同步细节见 [画布 operations 同步](/docs/backend/canvas-operations-sync)。 ### 相关实现 -- `web/src/services/canvas-asset-repair.ts`:媒体与素材的资源身份归一化、增量自动修复和强制重绑。 -- `web/src/services/user-data-sync.ts`:强制覆盖同步入口与素材冲突基线策略。 -- `web/src/pages/canvas/use-canvas-project-lifecycle.ts`:手动保存与强制保存流程。 -- `web/src/pages/canvas/canvas-project-top-bar.tsx`:菜单入口。 +- `web/src/stores/canvas/use-canvas-store.ts`:本地画布持久化与 journal。 +- `web/src/services/user-data-sync.ts`:operations diff、自动保存、回执重放和远端删除保护。 +- `web/src/services/canvas-asset-repair.ts`:提交前媒体引用与素材记录校验。 +- `web/src/pages/canvas/canvas-sync-status.tsx`:同步状态显示。 ## 画布外观 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 7ef486099..22ddb9783 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -15,9 +15,19 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 画布素材缺失时的本地强制覆盖 -- 已实现:云端保存返回 `canvas_history_resources_missing` 时,画布同步面板提供“用本地版强制覆盖云端”。操作会先保存本地草稿,探测资源状态,清理确认已删除或不存在的资源定位,保留节点布局、提示词和连线后再提交;普通自动保存仍执行完整素材校验。 -- [x] 专项测试覆盖嵌套资源定位收集、失效媒体字段清理、素材绑定移除以及画布性能回归;前端构建、后端画布/处理器/仓储测试通过。 -- [ ] 使用已登录浏览器打开具体画布,点击强制覆盖并刷新确认:失效图片节点仍保留为可重新上传的空节点,本地草稿可从版本记录恢复。 +## 画布 operations 自动恢复验收 + +- 已实现:云端画布写入统一使用字段/对象 operations;本地 journal 保存未确认请求,响应丢失、断网、刷新和重试都复用原 `mutationId` 与 payload。服务端 receipt 与画布变更同事务提交,相同请求安全重放,摘要不同返回冲突。 +- [x] 专项测试覆盖字段合并、同字段冲突、显式创建、对象不存在、幂等重放以及删除后迟到更新不重新创建节点;前端类型检查、生产构建和后端全量测试通过。 +- [ ] 使用已登录浏览器打开同一画布的两个标签页:分别修改不同字段应自动合并;修改同一字段应显示同步错误并保留本地输入,恢复后自动重放。 +- [ ] 在浏览器开发者工具中模拟断网、刷新和响应丢失,确认本地 journal 保留原请求并在恢复连接后完成;检查同步状态图标的 pending/error/done 颜色。 +- [ ] 让 Agent 或媒体任务在节点删除后返回迟到更新,确认任务回执保留但不会重新创建幽灵节点。 + +- 已实现:云端画布写入统一使用字段/对象 operations;本地 journal 保存未确认请求,响应丢失、断网、刷新和重试都复用原 `mutationId` 与 payload。服务端 receipt 与画布变更同事务提交,相同请求安全重放,摘要不同返回冲突。 +- [x] 专项测试覆盖字段合并、同字段冲突、显式创建、对象不存在、幂等重放以及删除后迟到更新不重新创建节点;前端类型检查、生产构建和后端全量测试通过。 +- [ ] 使用已登录浏览器打开同一画布的两个标签页:分别修改不同字段应自动合并;修改同一字段应显示同步错误并保留本地输入,恢复后自动重放。 +- [ ] 在浏览器开发者工具中模拟断网、刷新和响应丢失,确认本地 journal 保留原请求并在恢复连接后完成;检查同步状态图标的 pending/error/done 颜色。 +- [ ] 让 Agent 或媒体任务在节点删除后返回迟到更新,确认任务回执保留但不会重新创建幽灵节点。 ## Agent 只读循环软收敛 @@ -198,14 +208,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - 画布编辑器左侧图标菜单与展开内容改用一块低圆角、半透明磨砂表面;删除双重标题、硬分隔线和搜索框边框。侧栏现在浮在真实画布上方,打开/折叠不再改变画布视口宽度;顶栏和左下缩放控件避开侧栏。工作区下拉菜单及共享单选下拉去掉高亮描边,用背景和阴影建立层次,键盘焦点保留低对比提示。仍待登录态真实页面验收明暗主题、画布节点透视、窄屏抽屉、菜单键盘导航和弹层层级。 - [ ] 在已登录的真实画布和用户工作区以浏览器 100% 缩放验收明暗主题、侧栏展开/折叠、顶部/底部工具栏、Agent 历史与设置、输入区和标签横向滚动;再检查窄屏与触屏下是否截断或遮挡。当前本地独立浏览器会跳转登录页,未将静态检查视为视觉验收。 -## 画布资源断链、前端发布兼容与画布热点 +## 画布资源校验、前端发布兼容与画布热点 -- 已实现:统一递归识别持久资源字段(含嵌套 `videoPreview`);保存前和历史快照保存时校验当前资源归属/ready 状态,资源缺失返回带路径和 ID 的结构化 409。显式修复保留坏版本历史但要求新版本完整且 revision CAS 成功。后台 `repair-canvas-resources` 默认只读,仅可清除确认缺失的派生视频预览;主媒体问题报告为 unresolved。 -- 已实现:哈希 chunk 采用长缓存,缺失资源不缓存;HTML 显式 `no-cache, must-revalidate`。静态容器发布保留当前与上一发布 chunk,并在保留窗口内清理更旧资源;Vite preload 失败只自动恢复一次,之后交由路由错误页,用户手动重试仍可用。已有数据卷不会凭空恢复首次部署前已删的 chunk。 -- 已实现:同步显式 repair/force 请求不会被正在运行的普通同步吞掉;素材元数据请求按账号 scope/generation 隔离迟到响应。画布内容 hash 对 viewport/revision-only 副本复用;视口更新走轻量路径;loading 节点共享画布级 ticker,页面隐藏时暂停该 ticker。 -- [ ] 先备份隔离/测试数据库,验证 `repair-canvas-resources` dry-run、`--apply`、重复运行幂等、并发 revision 冲突、只清派生 preview、不改主媒体;检查 unresolved 后人工恢复历史或重传。未扫描或改写生产数据库。 +- 已实现:统一递归识别持久资源字段(含嵌套 `videoPreview`);operations 提交前校验资源归属和 ready 状态,资源缺失返回带路径和 ID 的结构化 409。未确认请求保留在 journal,不能通过覆盖或历史恢复绕过资源合同;主媒体问题继续报告为 unresolved。 +- 已实现:哈希 chunk 采用长缓存,缺失资源不缓存;HTML 显式 `no-cache, must-revalidate`。静态容器发布保留当前与上一发布 chunk,并在保留窗口内清理更旧资源;Vite preload 失败只自动恢复一次,之后交由路由错误页。已有数据卷不会凭空恢复首次部署前已删的 chunk。 +- 已实现:媒体元数据请求按账号 scope/generation 隔离迟到响应;画布内容 hash 对 viewport-only 副本复用;视口更新走轻量路径;loading 节点共享画布级 ticker,页面隐藏时暂停该 ticker。 +- [ ] 先备份隔离/测试数据库,验证资源缺失返回结构化冲突、同一 mutationId 重放幂等、字段基线冲突和删除后迟到更新拒绝;确认事务失败不会留下半条 receipt。未扫描或改写生产数据库。 - [ ] 使用发布测试卷连续发布三版以上:旧 tab 延迟触发旧 chunk 后仍可加载;验证 HTML 与哈希资源 Cache-Control、旧 chunk 404 不被 SPA fallback 改写,以及浏览器一次性恢复不循环。当前没有 CDN/Ingress 线上响应证据,Firefox MIME 具体响应类型未确认。 -- [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线草稿、登录切换和错误重试行为。尚未做真实浏览器/CDN 验收。 +- [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线 journal、登录切换和自动重放行为。尚未做真实浏览器/CDN 验收。 - [ ] 使用 React Profiler 与长时间运行的大画布记录任务更新、节点数/可见数、主线程长任务和页面隐藏行为。当前只做结构性降热点,不推断整画布必然全量重渲染或已达到某个帧率。 ## 完整 AI 拆图 @@ -618,14 +628,13 @@ MiniMax Hailuo Video V2 / H3 插件更新至 `2.0.1`,创建和查询均优先 - [ ] 使用 Artbox 的 NewAPI 通道 2 保存“视频 Token”,完成无用量视频任务后确认公式结算、10% 余量退回、来源标注;重复轮询/回调不重复扣费。 - [ ] PostgreSQL 部署验证 schema v25;新代码未启动或未迁移时不要用旧后端保存非 Ark Token 价格。历史无公式快照的收费订单缺少用量仍进入待核对。 -## Agent 历史事件重放导致画布反复冲突 +## Agent 事件校准与 operations journal 恢复 -旧会话的画布增量可能缺少版本号。页面刷新重放事件时,原实现先标记冲突;后续完整快照即使确认内容一致,也提前返回而不清除冲突。现在增量拒绝只触发快照校准,由完整快照确认真实冲突;内容一致时更新同步基线并恢复已同步状态。云端仍为已核对的同一基线时,保留待保存的本地编辑,不误判为并发修改。真实冲突继续保留草稿并阻止覆盖,不重放无版本增量,不修改历史数据。 +旧会话的画布增量可能缺少版本号或发生断流。现在增量缺口只触发完整画布校准;客户端保留已确认基线和未确认 operations,请求恢复时复用原 `mutationId` 与原始 payload。不同字段可合并,同字段或已删除对象不会被静默覆盖或重建。 -- [x] 47 项画布版本、Agent 增量合并、事件调度与刷新测试通过,覆盖加载云端、保存、重建会话后三次重放旧事件,以及版本缺口、未核对离线草稿与真实冲突保护。 -- [x] 前端 `bun run build` 通过;同时补齐 Agent 会话预览缺少的 `markdownPlainText` 导入。 -- [x] 隔离浏览器使用实际前端同步模块与 IndexedDB、模拟云端 API,执行保存后三次整页刷新和旧事件重放:均保持已同步,未增加草稿或重复提交。不访问真实账号数据。 -- [ ] 登录真实账号验证问题画布:加载云端、保存、刷新后不再因旧 Agent 事件回到冲突;已有草稿仍可读取。自动化浏览器当前未登录,尚未完成该账号的页面验收。 +- [x] 画布 operations、Agent 增量合并、事件调度与刷新测试通过,覆盖版本缺口、响应丢失、未确认请求重放、删除后迟到更新和幂等回执。 +- [x] 前端类型检查与生产构建通过;隔离浏览器使用实际同步模块与 IndexedDB 模拟刷新恢复,未产生重复提交。 +- [ ] 登录真实账号验证断网、刷新和 Agent 事件缺口:同步面板自动恢复,未确认操作只提交一次,删除节点后迟到结果不复活节点。 ## 画布 Agent 读取参数纠错 @@ -716,18 +725,13 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 - [ ] 同一张已录入方舟素材 ID 的参考图用于 Agent Plan / 方舟 Seedance 视频生成时,仍可走 `asset://` 并跳过重复上传。 - [ ] 官方 `volcengine-ark-image` / `volcengine-ark-video` 行为不变。 -## 画布保存保护与版本记录 +## 画布 operations 持久化与恢复验收 -已增加版本 CAS、冲突草稿、视口与内容分离、打开时核对云端、历史快照和恢复,以及右侧历史树和独立只读预览。上游云端 Agent 与创作任务使用同一版本和历史事务。专项测试覆盖并发旧写入、断档增量、草稿写入失败、恢复冲突、历史权限、保留数量和历史媒体删除保护。Schema 顺延为 v23,不占用已发布的 v16 Agent 记忆版本。 +画布写入已统一为字段级、对象级 operations。客户端先把未确认请求写入账号隔离 journal,服务端在同一事务中提交画布 mutation 与 `canvas_mutation_receipts`;响应丢失、断网、刷新和后台重试都复用原 `mutationId` 与原始 payload。内部维护快照只用于资源保护和审计,不作为用户可见的历史树、预览或恢复入口。 -隔离 SQLite/PostgreSQL 的迁移与版本/快照测试已通过;前端构建、类型检查与 lint 通过。真实 Chrome 的组件测试(合成数据与模拟接口)通过了明暗主题、桌面预览缩放、草稿查看、窄屏抽屉、恢复确认与无内容写请求检查;完整登录态和真实生成渠道仍按下列清单验收。 - -- [ ] 双标签页编辑同一画布:旧页平移/缩放不发内容 PUT,旧页内容保存收到冲突后保留草稿,另一画布仍可正常保存。 -- [ ] 出现「版本冲突 · 未同步」后点「加载云端最新版本」:画布不整页卸掉,状态变为已保存或等待保存;平移/缩放不能再次卡死加载。 -- [ ] 在有未同步编辑时加载最新版或恢复历史,确认本地草稿可下载,恢复后版本递增且恢复前内容仍可找到。 -- [ ] 明暗主题、桌面和窄屏下检查右侧历史树、草稿页、预览缩放和恢复确认;预览期间快捷键和拖拽不能修改当前编辑器,返回当前画布后可继续生成和编辑。 -- [ ] 使用真实受控模型验收 Agent 生成任务完成与本地编辑同时发生时的冲突提示;自动化测试不调用收费渠道。 -- [ ] 既有部署执行 schema 23 迁移并刷新前端,确认旧画布正文保留;历史只从升级后积累,不能恢复升级前已被覆盖的内容。 +- [x] 后端覆盖显式创建、字段合并、对象基线、幂等回执、原子事务以及删除后迟到更新不重建节点。 +- [x] 前端覆盖未确认请求 journal、刷新恢复、自动重放、远端完整快照与本地未确认 mutation 合并。 +- [ ] 双标签页、断网、响应丢失、刷新和 Agent 媒体回写的真实浏览器验收:状态自动恢复,同字段冲突不覆盖远端修改,删除节点后迟到结果不复活节点。 ## 首页常驻滚动通知(2026-09-18) @@ -958,12 +962,11 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 - 已补充后端回归用例,覆盖任务状态、输入引用、画布引用、删除事务 Outbox、历史记录保留和自动清理边界;测试包编译通过,本地数据库测试因 `CGO_ENABLED=0` 无法初始化 SQLite,尚未验证通过。 - 待部署验收:未绑定画布的生成图片可单个及批量彻底删除;被画布或其他任务输入引用时仍提示来源。删除后任务历史中的原文件不再可用,共享素材仍保留其资源。 -## 画布手动保存与强制覆盖保存 +## 画布统一 operations 写入与媒体保护 -- 画布左上角菜单新增「保存」(与 ⌘/Ctrl+S 等价,本地落盘后同步云端,适配平板无键盘场景)与「强制覆盖保存」(确认弹窗后以本地内容覆盖云端,并自动修复画布媒体与素材库的绑定)。 -- 根因修复:同节点原地重生成(如视频节点改提示词后重新生成)走兜底写回路径时,节点保留旧 assetId 却指向新资源,后端画布媒体校验拒绝后,自动修复被节点自身声明掩盖,形成“自动重试但永远失败”。现节点声明不再覆盖素材库真实绑定,错误配对会在下一次自动同步时自愈。 -- 强制覆盖语义:按服务端素材记录(与本地缓存合并)校验所有画布媒体节点与时间线片段的素材绑定,不一致时重绑到引用同一资源的素材或按节点新建,再按“素材先于画布”整体推送;素材远端版本冲突时采纳远端为基线继续用本地覆盖。后端不变式与接口未改动。 -- 已通过 `web/test/canvas-asset-repair.test.ts`(8 例)与前端生产构建。待浏览器实测:改提示词原地重生成视频后不再出现“画布媒体与素材库记录不一致”的持续报错;出现历史遗留报错时「强制覆盖保存」能立即恢复且后续自动同步不再报错;平板设备上「保存」菜单可用。 +- 画布手工编辑、Agent、媒体终态回写、预演台、创作提交和导入均通过统一 operations 服务提交;不存在整批快照覆盖入口。 +- 素材和画布按“资源先确认、画布后提交”顺序同步。资源缺失或绑定不一致时保留本地内容并进入自动恢复队列,不通过用户选择云端覆盖本地,也不重建来源不明的媒体或插件节点。 +- 已通过 operations、资源引用、幂等重放和删除顺序专项测试;真实浏览器仍需验证断网恢复、媒体回写和刷新后的状态投影。 ## 欢迎页开放开关 @@ -1852,14 +1855,12 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 - [ ] 在存储资源页尝试删除当前浅色 Logo、深色 Logo、视频和封面,确认显示“外观”引用并保留文件;解除绑定后可正常删除。 - [ ] 将公开配置接口模拟为超时或 5xx,确认 4 秒内结束等待并完整回退内置外观,应用仍可进入。 -## 画布导入同步、素材回收站与历史 +## 画布导入同步、素材回收站与删除保护 -- [x] 画布压缩包导入会先校验媒体完整性,并统一通过远端同步入口上传资源;失败时保持脏状态和自动重试,不再把浏览器本地 storage key 误报为已同步。 +- [x] 画布压缩包导入先校验媒体完整性,资源上传和画布写入统一进入 operations journal;失败时保留未确认请求并自动重放,不把浏览器本地 storage key 标记为云端事实。 - [x] 素材回收站使用独立归档状态;彻底删除复用服务端引用校验与资源删除 Outbox,自动过期清理不会旁路业务引用。 -- [x] 已删除画布历史改为按用户 scope 存入 IndexedDB;删除失败不记录历史,共享素材不会因删除单个画布被错误归档。 -- [ ] 使用两个账号分别删除画布并切换登录,确认历史记录和回收站素材不串账号;刷新后记录仍在,清理历史只影响当前账号。 -- [ ] 删除仍被其他画布或项目引用的回收站素材,确认服务端拒绝并显示引用来源;解除引用后彻底删除,确认资源记录进入 Outbox 并最终清理物理对象。 -- [ ] 导入包含图片、视频、音频的真实画布压缩包,分别模拟上传中断和服务端保存失败,确认进度遮罩最终关闭、页面可离开且恢复网络后自动重试。 +- [x] 画布删除后迟到的媒体或编辑更新不会重新创建画布或节点;只有显式 create operation 可以创建实体。 +- [ ] 使用两个账号、两个标签页和断网恢复验收导入、删除、刷新与同步状态;确认来源未知的插件包和媒体修改不被清理。 ## 邮箱找回密码 diff --git a/web/src/components/canvas/canvas-version-compare-modal.tsx b/web/src/components/canvas/canvas-version-compare-modal.tsx deleted file mode 100644 index 326136151..000000000 --- a/web/src/components/canvas/canvas-version-compare-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Button, Modal } from "antd"; -import { Check, Star } from "lucide-react"; - -import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image"; -import { producedModelLabel } from "@/lib/canvas/produced-model"; -import { canvasThemes } from "@/lib/canvas-theme"; -import { useEffectiveConfig } from "@/stores/use-config-store"; -import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; -import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; - -export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimary, onFocus }: { open: boolean; versions: CanvasNodeData[]; onClose: () => void; onSetPrimary: (nodeId: string) => void; onFocus: (nodeId: string) => void }) { - const theme = canvasThemes[useActiveTheme()]; - const config = useEffectiveConfig(); - const modalWidth = Math.min(1180, Math.max(440, 112 + versions.length * 340)); - return ( - -
- {versions.map((node) => { - const producedModelText = producedModelLabel(config, node.metadata?.producedModel); - return ( -
-
-
{node.metadata?.versionLabel || "-"}{node.title}
- {node.metadata?.versionPrimary ? 主版本 : null} -
- -
- {producedModelText ? : null} - -
提示词
{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}
- -
-
- ); - })} -
-
- ); -} - -function Info({ label, value }: { label: string; value: string }) { - return
{label}{value}
; -} diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.tsx b/web/src/components/canvas/previs/canvas-previs-workbench.tsx index da3d8c9b2..6312fb126 100644 --- a/web/src/components/canvas/previs/canvas-previs-workbench.tsx +++ b/web/src/components/canvas/previs/canvas-previs-workbench.tsx @@ -24,7 +24,7 @@ import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumano import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement"; import { uploadMediaFile } from "@/services/file-storage"; import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync"; -import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react"; +import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react"; import { PrevisCanvasDock } from "./previs-canvas-dock"; import { Dropdown } from "antd"; import { nanoid } from "nanoid"; @@ -45,7 +45,7 @@ import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path"; import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder"; import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session"; import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview"; -import { describePrevisSaveStatus, shouldOfferPrevisDraftRecovery, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring"; +import { describePrevisSaveStatus, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring"; import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator"; import "./canvas-previs-workbench.css"; import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; @@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({ onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise; }) { - const { message, modal } = App.useApp(); + const { message } = App.useApp(); const theme = canvasThemes[useActiveTheme()]; const effectiveCanvasId = useMemo(() => { const explicit = canvasId?.trim(); @@ -183,11 +183,7 @@ export function CanvasPrevisWorkbench({ }, [onChange, onFlush]); const closingRef = useRef(false); - const recoveryPromptedRef = useRef(null); - const [retrying, setRetrying] = useState(false); const handledPreviewRequestsRef = useRef(new Set()); - - // 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。 const saveController = usePrevisSaveCoordinator({ sceneId: scene?.id ?? null, initialScene: scene, @@ -198,25 +194,6 @@ export function CanvasPrevisWorkbench({ saveControllerRef.current = saveController; const saveIndicator = describePrevisSaveStatus(saveController.progress); - const retrySave = async () => { - setRetrying(true); - try { - if (await saveController.retry()) { - recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true }); - message.success("已保存到项目"); - return; - } - // 只有真的存在合法本地候选才敢说草稿已保留。 - const draftStored = Boolean(saveController.restoreCandidate()); - recordPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true }); - if (!draftStored) recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision }); - if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试"); - else message.error("远端和本地都未保存,请不要关闭预演台并继续重试"); - } finally { - setRetrying(false); - } - }; - const writeDraft = useCallback((next: PrevisScene | null) => { draftRef.current = next; setDraft(next); @@ -232,7 +209,7 @@ export function CanvasPrevisWorkbench({ onChangeRef.current(current); }, []); - /** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */ + /** 真实 canonical 提交:先交给 coordinator,再镜像到项目。 */ const commitDraft = useCallback(() => { const current = draftRef.current; if (!current || initializedSceneIdRef.current !== current.id) return; @@ -282,40 +259,6 @@ export function CanvasPrevisWorkbench({ message.info("Agent 已更新预演场景"); }, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]); - // 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。 - useEffect(() => { - if (!open || !scene) return; - if (recoveryPromptedRef.current === scene.id) return; - recoveryPromptedRef.current = scene.id; - - const controller = saveControllerRef.current; - const candidate = controller?.restoreCandidate() ?? null; - if (!controller || !candidate) return; - if (!shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene: scene })) return; - - modal.confirm({ - title: "发现未保存的本地草稿", - content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`, - okText: "恢复草稿", - cancelText: "放弃草稿", - closable: false, - mask: { closable: false }, - keyboard: false, - onOk: () => { - if (!controller.restoreDraft(candidate)) { - message.error("草稿恢复失败,已保留当前场景"); - return; - } - writeDraft(candidate.scene); - message.success("已恢复本地草稿并写回项目"); - }, - onCancel: () => { - if (controller.discardDraft()) message.success("已放弃本地草稿"); - else message.error("草稿删除失败,下次打开可能仍会提示"); - }, - }); - }, [message, modal, open, scene, writeDraft]); - const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null; const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null; useEffect(() => { @@ -522,31 +465,9 @@ export function CanvasPrevisWorkbench({ onClose(); return; } - if (decision.kind === "blocked") { - recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored }); - message.error(decision.message); - closingRef.current = false; - return; - } - - // 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。 - modal.confirm({ - title: "远端保存失败", - content: decision.message, - okText: "仍然离开", - cancelText: "留在预演台", - closable: false, - mask: { closable: false }, - keyboard: false, - // 预演视口持续占用主线程时,zoom 离场要等 rAF 和 transitionend。 - // 动画停在可见态后,点「留在预演台」确认框也不会消失。 - transitionName: "", - maskTransitionName: "", - onOk: () => onClose(), - onCancel: () => { - closingRef.current = false; - }, - }); + recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision }); + message.error(decision.message); + closingRef.current = false; })(); }; @@ -1325,12 +1246,6 @@ export function CanvasPrevisWorkbench({ {saveIndicator.label} - {saveIndicator.retryable ? ( - - ) : null} {onboardingScope ? ( - - onMediaPerformanceModeChange(key as CanvasMediaPerformanceMode), - items: [ - { key: "auto", label: "自动性能" }, - { key: "quality", label: "画质优先" }, - { key: "performance", label: "性能优先" }, - ], - }} - > - - - ) : null} - {missingResources ? ( - - ) : null} - - ) : localError ? ( - - ) : null} - {conflict ? ( -
- - -
- ) : ( - - )} -
- {onOpenVersions ? ( - - ) : null} -
- - - } - > - - + + {showErrorIcon ? : saving || cloudError || localError ? : } + ); } diff --git a/web/src/pages/canvas/canvas-version-history.css b/web/src/pages/canvas/canvas-version-history.css deleted file mode 100644 index df8bda2f4..000000000 --- a/web/src/pages/canvas/canvas-version-history.css +++ /dev/null @@ -1,250 +0,0 @@ -.canvas-version-sidebar { - width: 350px; - flex: 0 0 350px; - min-height: 0; - border-left: 1px solid var(--border); -} -.canvas-editor-shell { - container-type: inline-size; -} -@container (max-width: 1050px) { - .canvas-topbar-tools-cluster > :not(:has(.canvas-topbar-version-button)):not(.canvas-topbar-agent-button) { - display: none !important; - } -} -@container (max-width: 650px) { - .canvas-topbar-project-picker { - display: none !important; - } - .canvas-topbar-project-cluster { - max-width: calc(100% - 180px); - } - .canvas-topbar { - gap: 8px; - padding-inline: 10px; - } - .canvas-sync-status-label { - display: none; - } - .canvas-topbar-project-context { - display: none; - } -} -.canvas-version-panel { - height: 100%; - display: flex; - flex-direction: column; - background: var(--sidebar); - color: var(--foreground); - font-size: var(--fs-label); - overscroll-behavior: contain; -} -.canvas-version-header { - display: flex; - align-items: center; - gap: 10px; - min-height: 64px; - padding: 0 18px; -} -.canvas-version-header h2 { - flex: 1; - font-size: var(--fs-body); - font-weight: 600; -} -.canvas-version-tabs { - display: flex; - margin: 0 18px 14px; - padding: 3px; - gap: 3px; - background: var(--muted); - border-radius: var(--radius-lg); -} -.canvas-version-tabs button { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - gap: 6px; - padding: 9px 4px; - color: var(--muted-foreground); - border-radius: var(--radius-md); -} -.canvas-version-tabs button[aria-selected="true"] { - background: var(--background); - color: var(--foreground); -} -.canvas-version-count, -.canvas-version-tag { - font-size: var(--fs-tiny); - color: var(--muted-foreground); - font-variant-numeric: tabular-nums; -} -.canvas-version-tag { - border-radius: var(--radius-sm); - background: var(--muted); - padding: 2px 6px; - white-space: nowrap; -} -.canvas-version-list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 0 18px 20px; - overscroll-behavior: contain; -} -.canvas-version-current { - width: 100%; - display: flex; - align-items: center; - gap: 10px; - text-align: left; - border: 1px solid var(--border); - padding: 12px; - border-radius: var(--radius-lg); -} -.canvas-version-current > span:nth-child(2) { - flex: 1; - min-width: 0; -} -.canvas-version-current strong, -.canvas-version-current small { - display: block; -} -.canvas-version-dot { - display: grid; - place-items: center; - width: 22px; - height: 22px; - border: 1px solid var(--border); - border-radius: 50%; -} -.canvas-version-current[aria-pressed="true"], -.canvas-version-item[aria-pressed="true"] { - background: var(--accent); - border-color: var(--control-focus-ring); -} -.canvas-version-panel small { - display: block; - margin-top: 5px; - color: var(--muted-foreground); - font-size: var(--fs-tiny); - line-height: 1.6; -} -.canvas-version-hint { - color: var(--muted-foreground); - font-size: var(--fs-tiny); - line-height: 1.8; - margin: 10px 0; -} -.canvas-version-error { - color: var(--destructive); - line-height: 1.8; - margin: 10px 0; -} -.canvas-version-empty { - padding: 35px 0; - text-align: center; -} -.canvas-version-group { - margin-top: 15px; -} -.canvas-version-group summary { - cursor: pointer; - list-style: none; - display: flex; - align-items: center; - gap: 7px; - color: var(--muted-foreground); - padding: 6px 0; -} -.canvas-version-group summary::-webkit-details-marker { - display: none; -} -.canvas-version-group:not([open]) summary > svg { - transform: rotate(-90deg); -} -.canvas-version-group summary > span:nth-child(2) { - flex: 1; -} -.canvas-version-branches { - border-left: 1px solid var(--border); - margin: 6px 0 0 7px; - padding-left: 16px; -} -.canvas-version-item { - position: relative; - width: 100%; - display: block; - text-align: left; - padding: 12px; - border: 1px solid transparent; - border-radius: var(--radius-lg); - margin-bottom: 6px; -} -.canvas-version-branches .canvas-version-item::before { - content: ""; - position: absolute; - left: -21px; - top: 19px; - width: 7px; - height: 7px; - border: 1px solid var(--muted-foreground); - border-radius: 50%; - background: var(--sidebar); -} -.canvas-version-branches .canvas-version-item[aria-pressed="true"]::before { - background: var(--foreground); - border-color: var(--foreground); -} -.canvas-version-item:hover, -.canvas-version-current:hover { - background: var(--accent); -} -.canvas-version-item-title { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; -} -.canvas-version-item-title > .canvas-version-tag { - margin-left: auto; -} -.canvas-version-reason { - display: block; - margin-top: 8px; -} -.canvas-version-draft-note { - color: var(--muted-foreground); - line-height: 1.8; - margin: 0 0 15px; -} -.canvas-version-draft { - border-color: var(--border); - margin-bottom: 10px; -} -.canvas-version-draft time { - display: block; - color: var(--muted-foreground); - font-size: var(--fs-tiny); - margin-top: 10px; -} -.canvas-version-footer { - flex-shrink: 0; - border-top: 1px solid var(--border); - padding: 16px 18px; -} -.canvas-version-actions { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 14px; -} -.canvas-version-panel button:focus-visible, -.canvas-version-group summary:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} -.canvas-version-panel button:disabled { - cursor: not-allowed; - opacity: 0.5; -} diff --git a/web/src/pages/canvas/canvas-version-history.tsx b/web/src/pages/canvas/canvas-version-history.tsx deleted file mode 100644 index 802512d5a..000000000 --- a/web/src/pages/canvas/canvas-version-history.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import { AppDrawer } from "@/components/ui/product/app-drawer"; -import { EmptyState } from "@/components/ui/product/empty-state"; -import { App, Button, Grid, Spin } from "antd"; -import { Check, ChevronDown, Cloud, FileClock, History, RefreshCw, X } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data"; -import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts"; -import { getActiveUserScope } from "@/lib/user-scope"; -import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; -import "./canvas-version-history.css"; - -export type CanvasVersionPreviewState = { - key: string; - label: string; - date: string; - kind: "cloud" | "draft"; - snapshot?: CanvasHistoryEntry; - project?: CanvasProject; - error?: string; -}; - -export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotId: string, revision: number) => Promise) { - const { message, modal } = App.useApp(); - const desktop = Boolean(Grid.useBreakpoint().lg); - const scope = getActiveUserScope(); - const contextRef = useRef({ projectId, scope }); - contextRef.current = { projectId, scope }; - const isCurrentContext = () => contextRef.current.projectId === projectId && contextRef.current.scope === scope && getActiveUserScope() === scope; - const draftCount = useSyncProgressStore((state) => state.syncingProjects[projectId]?.draftCount); - const [open, setOpen] = useState(false); - const [listOpen, setListOpen] = useState(true); - const [tab, setTab] = useState<"cloud" | "draft">("cloud"); - const [entries, setEntries] = useState([]); - const [drafts, setDrafts] = useState([]); - const [currentRevision, setCurrentRevision] = useState(); - const [preview, setPreview] = useState(null); - const [loading, setLoading] = useState(false); - const [draftLoading, setDraftLoading] = useState(false); - const [restoring, setRestoring] = useState(false); - const [confirming, setConfirming] = useState(false); - const [error, setError] = useState(""); - const [draftError, setDraftError] = useState(""); - const [reload, setReload] = useState(0); - const previewRequest = useRef(null); - const returnToCurrent = useCallback(() => { - previewRequest.current?.abort(); - setPreview(null); - }, []); - const close = useCallback(() => { - if (!restoring) { - setOpen(false); - returnToCurrent(); - } - }, [restoring, returnToCurrent]); - - useEffect(() => { - setOpen(false); - setTab("cloud"); - returnToCurrent(); - }, [projectId, scope, returnToCurrent]); - - useEffect(() => { - if (!open) return; - const controller = new AbortController(); - setLoading(true); - setError(""); - setEntries([]); - setCurrentRevision(undefined); - void listCanvasHistory(projectId, controller.signal) - .then((result) => { - if (controller.signal.aborted || !isCurrentContext()) return; - setEntries(result.snapshots); - // Keep the revision observed with this list as the restore precondition. - setCurrentRevision(result.currentRevision); - }) - .catch((cause) => { - if (!controller.signal.aborted && isCurrentContext()) setError(cause instanceof Error ? cause.message : "历史版本读取失败"); - }) - .finally(() => { - if (!controller.signal.aborted && isCurrentContext()) setLoading(false); - }); - return () => controller.abort(); - }, [open, projectId, reload, scope]); - - useEffect(() => { - if (!open) return; - let active = true; - setDraftLoading(true); - setDraftError(""); - setDrafts([]); - void readCanvasSyncDrafts(projectId, scope) - .then((items) => { - if (active && isCurrentContext()) setDrafts(items.sort((a, b) => b.savedAt.localeCompare(a.savedAt))); - }) - .catch(() => { - if (active && isCurrentContext()) setDraftError("本地草稿读取失败,请刷新重试"); - }) - .finally(() => { - if (active && isCurrentContext()) setDraftLoading(false); - }); - return () => { - active = false; - }; - }, [open, projectId, reload, scope, draftCount]); - - useEffect( - () => () => { - previewRequest.current?.abort(); - }, - [open, projectId, scope], - ); - - const selectVersion = (entry: CanvasHistoryEntry) => { - previewRequest.current?.abort(); - const controller = new AbortController(); - previewRequest.current = controller; - const selection: CanvasVersionPreviewState = { key: entry.id, label: `版本 ${entry.revision}`, date: entry.contentUpdatedAt, kind: "cloud", snapshot: entry }; - setPreview(selection); - setListOpen(false); - void getCanvasHistoryEntry(projectId, entry.id, controller.signal) - .then(({ project }) => { - if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, project }); - }) - .catch((cause) => { - if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, error: cause instanceof Error ? cause.message : "预览读取失败,请重新选择版本" }); - }); - }; - - const restore = () => { - const selected = preview?.snapshot; - if (!selected || !preview.project || currentRevision === undefined || restoring) return; - setConfirming(true); - modal.confirm({ - title: `恢复版本 ${selected.revision} 的内容?`, - content: "恢复前会备份当前云端内容,并保留本地草稿。恢复后会生成一个新版本,画布的项目归属保持当前设置。", - okText: "恢复此版本", - cancelText: "取消", - afterClose: () => setConfirming(false), - onOk: async () => { - if (!isCurrentContext()) { - message.error("画布或账号已切换,请重新打开版本记录"); - return; - } - setRestoring(true); - try { - await onRestore(selected.id, currentRevision); - returnToCurrent(); - setReload((value) => value + 1); - message.success("已恢复内容并保存为新版本"); - } catch (cause) { - const detail = cause instanceof Error ? cause.message : "恢复失败,请重试"; - setError(detail); - message.error(detail); - } finally { - setRestoring(false); - } - }, - }); - }; - - return { - open, - listOpen, - desktop, - tab, - entries, - drafts, - currentRevision, - preview, - loading, - draftLoading, - restoring, - confirming, - error, - draftError, - show: () => { - setOpen(true); - setListOpen(true); - }, - toggle: () => { - if (open && (desktop || listOpen)) close(); - else { - setOpen(true); - setListOpen(true); - } - }, - hideList: () => { - if (!restoring) { - setListOpen(false); - if (!preview) setOpen(false); - } - }, - close, - changeTab: (next: "cloud" | "draft") => { - setTab(next); - returnToCurrent(); - }, - refresh: () => { - returnToCurrent(); - setReload((value) => value + 1); - }, - selectVersion, - selectDraft: (draft: CanvasSyncDraft) => { - previewRequest.current?.abort(); - setPreview({ key: draft.id, label: "本地草稿", date: draft.savedAt, kind: "draft", project: draft.project }); - setListOpen(false); - }, - returnToCurrent, - restore, - }; -} - -export type CanvasVersionHistoryController = ReturnType; - -export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) { - const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, error, draftError } = history; - if (!open) return null; - const groups = new Map(); - for (const entry of entries) { - const day = new Date(entry.contentUpdatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" }); - groups.set(day, [...(groups.get(day) || []), entry]); - } - const content = ( -
-
- -

版本记录

-
-
{ - if (restoring || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = event.key === "Home" ? "cloud" : event.key === "End" ? "draft" : tab === "cloud" ? "draft" : "cloud"; - history.changeTab(next); - event.currentTarget.querySelector(`[data-tab="${next}"]`)?.focus(); - }} - > - - -
-
- {tab === "cloud" ? ( - <> - -

查看旧版不会修改当前画布

- {error ? ( -

- {error} -

- ) : null} - {loading ? ( -
- -
- ) : !entries.length && !error ? ( - - ) : null} - {[...groups].map(([day, items]) => ( -
- - - {day} - {items.length} - -
- {items.map((entry) => ( - - ))} -
-
- ))} - - ) : ( - <> -

发生冲突或恢复版本时,系统自动保留的本地内容,可在此查看。

- {draftError ? ( -

- {draftError} -

- ) : null} - {draftLoading ? ( -
- -
- ) : !drafts.length && !draftError ? ( - - ) : null} - {drafts.map((draft) => ( - - ))} - - )} -
-
- {preview ? ( - <> -
- {preview.label} - 只读预览 -
- {new Date(preview.date).toLocaleString("zh-CN")} - {preview.project ? ( - - {preview.project.nodes.length} 个节点 · {preview.project.connections.length} 条连线 - - ) : null} - {preview.project?.nodes.some((node) => node.type === "drawing") ?

绘图仅保留已上传的预览,不含本机历史笔画。

: null} - {preview.kind === "cloud" ? ( -
- -
- ) : null} - - ) : ( -

{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿保存在此浏览器中,查看草稿不会修改当前画布。"}

- )} -
-
- ); - - return history.desktop ? ( - - ) : ( - - {content} - - ); -} - -function formatTime(date: string) { - return new Date(date).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); -} diff --git a/web/src/pages/canvas/canvas-version-preview.tsx b/web/src/pages/canvas/canvas-version-preview.tsx deleted file mode 100644 index 94c4d934d..000000000 --- a/web/src/pages/canvas/canvas-version-preview.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { EmptyState } from "@/components/ui/product/empty-state"; -import { Button, Spin } from "antd"; -import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { CanvasViewport } from "@/components/canvas/canvas-viewport"; -import { CanvasNode } from "@/components/canvas/canvas-node"; -import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; -import { ConnectionPath } from "@/components/canvas/canvas-connections"; -import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context"; -import { CanvasNodeGraphContext } from "@/components/canvas/canvas-node-graph-context"; -import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame"; -import { getCanvasNodesBounds, viewportAtScale, viewportForBounds } from "@/lib/canvas/canvas-viewport"; -import { resolveCanvasAppearance } from "@/lib/canvas/canvas-appearance"; -import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; -import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; -import { resolveMediaUrl } from "@/services/file-storage"; -import type { CanvasNodeData, ViewportTransform } from "@/types/canvas"; -import type { CanvasVersionPreviewState } from "./canvas-version-history"; - -const noAction = () => undefined; -const readOnlyActions = {}; -const previewNodeId = (id: string) => `version-preview:${id}`; - -export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { preview: CanvasVersionPreviewState; onReturn: () => void; onShowVersions: () => void }) { - const containerRef = useRef(null); - const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); - const [selectedId, setSelectedId] = useState(); - const [localMedia, setLocalMedia] = useState>({}); - const [mediaError, setMediaError] = useState(""); - const theme = useActiveTheme(); - const project = preview.project; - useEffect(() => { - const node = project?.nodes.find((item) => item.id === selectedId); - const key = node?.metadata?.storageKey; - if (!node || !key || resourceIdFromStorageKey(key) || !["video", "audio"].includes(node.type)) return; - let active = true; - setMediaError(""); - void resolveMediaUrl(key, node.metadata?.content) - .then((url) => { - if (active) setLocalMedia((current) => ({ ...current, [node.id]: url })); - }) - .catch(() => { - if (active) setMediaError("媒体暂时无法读取,请重新打开此版本重试"); - }); - return () => { - active = false; - }; - }, [project, selectedId]); - const nodes = useMemo( - () => - (project?.nodes || []).map((node) => { - const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey); - return resourceId && ["image", "video", "audio"].includes(node.type) - ? { ...node, metadata: { ...node.metadata, content: resourceFileUrl(resourceId) } } - : localMedia[node.id] - ? { ...node, metadata: { ...node.metadata, content: localMedia[node.id] } } - : node; - }), - [project, localMedia], - ); - const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); - const connections = useMemo( - () => - (project?.connections || []).flatMap((connection) => { - const resolved = resolveFrameConnection(connection, nodes); - return resolved ? [{ connection, ...resolved }] : []; - }), - [project, nodes], - ); - const graph = useMemo(() => { - const byId = new Map(nodes.map((node) => [node.id, node])); - return { - getUpstreamNodes: (id: string) => - (project?.connections || []) - .filter((connection) => previewNodeId(connection.toNodeId) === id) - .flatMap((connection) => { - const node = byId.get(connection.fromNodeId); - return node ? [node] : []; - }), - }; - }, [nodes, project]); - const fit = useCallback(() => { - const rect = containerRef.current?.getBoundingClientRect(); - const sourceNodes = project?.nodes || []; - const bounds = getCanvasNodesBounds(sourceNodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, sourceNodes))); - if (rect && bounds) setViewport(viewportForBounds(bounds, rect, { padding: 72 })); - }, [project]); - useLayoutEffect(() => { - fit(); - }, [fit]); - const zoom = (scale: number) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) setViewport((current) => viewportAtScale(current, rect, scale)); - }; - - return ( -
-
- -
- {preview.label} · 只读预览 -

{new Date(preview.date).toLocaleString("zh-CN")}

-
- - -
- {preview.error ? ( -
-

{preview.error}

- -
- ) : !project ? ( -
- -
- -
- ) : ( -
- {/* No editor callbacks, drawing cache key, or action context may cross this boundary. */} - - - event.preventDefault()} - onDrop={(event) => event.preventDefault()} - onCanvasDeselect={() => setSelectedId(undefined)} - > - - {connections.map(({ connection, from, to }) => ( - - ))} - - {visibleNodes.map((node) => - isFrameNode(node) ? ( - child.parentId === node.id)} - scale={viewport.k} - isSelected={false} - isDropTarget={false} - readOnly - onMouseDown={noAction} - onResize={noAction} - onToggleCollapsed={noAction} - onFolderStyleChange={noAction} - onTitleChange={noAction} - onContextMenu={(event) => event.preventDefault()} - /> - ) : ( - setSelectedId(node.id)} - onHoverStart={noAction} - onHoverEnd={noAction} - onConnectStart={noAction} - onResize={noAction} - onContentChange={noAction} - onContextMenu={(event) => event.preventDefault()} - /> - ), - )} - - - - {!nodes.length ? ( -
- -
- ) : null} - {mediaError ? ( -

- {mediaError} -

- ) : null} -
-
-
- )} -
- ); -} - -function renderReadOnlyContent(node: CanvasNodeData) { - const rows = node.metadata?.storyboard?.rows; - return ( -
- {node.title} -
- {rows?.length - ? rows.map((row) => ( -
-

- 镜头 {row.shotNumber} · {row.durationSeconds}s -

-

{row.plotDescription}

-

{row.dialogue}

-
- )) - : node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || "此节点没有文本内容"} -
-
- ); -} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 4d2cbf66c..18869df73 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,4 +1,6 @@ import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel"; +import "@/lib/plugins/builtin/ai-art-critique"; +import "@/lib/plugins/builtin/media-conversion"; import { useCanvasImageLayerGroups } from "./use-canvas-image-layer-groups"; import { useCanvasImageLayerMaterials } from "./use-canvas-image-layer-materials"; import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; @@ -21,7 +23,6 @@ import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; -import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; import { filterCanvasDisplayConnections, persistCanvasHideNodeConnections, readCanvasHideNodeConnections } from "@/lib/canvas/canvas-connection-visibility"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference"; @@ -77,7 +78,6 @@ import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generat import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table"; import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { CanvasPrevisNodePanel } from "@/components/canvas/previs/canvas-previs-node-panel"; -import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal"; import { useFocusMode } from "@/hooks/use-focus-mode"; import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention"; import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; @@ -110,10 +110,8 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { cancelGenerationTask } from "@/services/api/task-center"; -import { CanvasSyncStatus } from "./canvas-sync-status"; -import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history"; -import { CanvasVersionPreview } from "./canvas-version-preview"; import { CanvasTopBar } from "./canvas-project-top-bar"; +import { CanvasSyncStatus } from "./canvas-sync-status"; import { LibTVImportDialog } from "./components/libtv-import-dialog"; import { TapNowImportDialog } from "./components/tapnow-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; @@ -163,7 +161,6 @@ import { type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, - type CanvasMediaPerformanceMode, type StoryboardColumn, type StoryboardShotCount, type StoryboardShotDuration, @@ -251,7 +248,6 @@ function CanvasViewportPage() { const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE); const [showImageInfo, setShowImageInfo] = useState(false); const [canvasTool, setCanvasTool] = useState("box-select"); - const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode); const [hideNodeConnections, setHideNodeConnections] = useState(readCanvasHideNodeConnections); const [projectLoaded, setProjectLoaded] = useState(false); const workspaceMode: CanvasWorkspaceMode = "professional"; @@ -282,7 +278,6 @@ function CanvasViewportPage() { const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null); const [scriptScrollTopById, setScriptScrollTopById] = useState>({}); const [previsNodeId, setPrevisNodeId] = useState(null); - const [versionCompareRootId, setVersionCompareRootId] = useState(null); const [libTVImportOpen, setLibTVImportOpen] = useState(false); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); @@ -295,10 +290,6 @@ function CanvasViewportPage() { const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode(); const [focusDockRevealed, setFocusDockRevealed] = useState(false); - useEffect(() => { - persistCanvasMediaPerformanceMode(mediaPerformanceMode); - }, [mediaPerformanceMode]); - useEffect(() => { persistCanvasHideNodeConnections(hideNodeConnections); }, [hideNodeConnections]); @@ -378,7 +369,7 @@ function CanvasViewportPage() { [cleanupAssetImages, getHistoryCleanupContext], ); - const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({ + const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({ projectId, projectLoaded, nodes, @@ -409,59 +400,54 @@ function CanvasViewportPage() { cleanupCanvasFiles, }); - const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion); - const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); }; - const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]); + const openAgent = useCallback(() => { + openAssistant(); + }, [openAssistant]); - const sendSelectionToAgent = useCallback((nodeId?: string) => { - const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); - const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); - if (!references.length) return; - if (nodeId && !selectedNodeIdsRef.current.has(nodeId)) { - const selection = new Set([nodeId]); - selectedNodeIdsRef.current = selection; - setSelectedNodeIds(selection); - } - const text = `${references.map(canvasResourceMentionToken).join(" ")} `; - setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text })); - openAgent(); - setContextMenu(null); - }, [agentMentionReferences, openAgent]); - - const sendImageToPrevisAgent = useCallback((node: CanvasNodeData) => { - if (node.type !== CanvasNodeType.Image) return; - const reference = agentMentionReferences.find((item) => item.nodeId === node.id && item.kind === "image"); - if (!reference) { - message.warning("这张图片暂时没有可供 Agent 引用的画布资源"); - return; - } - if (!selectedNodeIdsRef.current.has(node.id)) { - const selection = new Set([node.id]); - selectedNodeIdsRef.current = selection; - setSelectedNodeIds(selection); - } - setAgentPrefillRequest((current) => ({ - id: (current?.id ?? 0) + 1, - text: buildImageToPrevisAgentPrompt(reference), - displayText: buildImageToPrevisDisplayText(), - canvasReferenceNodeId: reference.nodeId, - autoSubmit: true, - requiresVision: true, - })); - openAgent(); - setContextMenu(null); - }, [agentMentionReferences, message, openAgent]); - // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。 - const confirmForceSaveCanvas = useCallback(() => { - modal.confirm({ - title: "修复素材关联并保存?", - content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。", - okText: "修复并保存", - cancelText: "取消", - onOk: () => forceSaveCanvasProject(), - }); - }, [forceSaveCanvasProject, modal]); + const sendSelectionToAgent = useCallback( + (nodeId?: string) => { + const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); + const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); + if (!references.length) return; + if (nodeId && !selectedNodeIdsRef.current.has(nodeId)) { + const selection = new Set([nodeId]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + } + const text = `${references.map(canvasResourceMentionToken).join(" ")} `; + setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text })); + openAgent(); + setContextMenu(null); + }, + [agentMentionReferences, openAgent], + ); + const sendImageToPrevisAgent = useCallback( + (node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image) return; + const reference = agentMentionReferences.find((item) => item.nodeId === node.id && item.kind === "image"); + if (!reference) { + message.warning("这张图片暂时没有可供 Agent 引用的画布资源"); + return; + } + if (!selectedNodeIdsRef.current.has(node.id)) { + const selection = new Set([node.id]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + } + setAgentPrefillRequest((current) => ({ + id: (current?.id ?? 0) + 1, + text: buildImageToPrevisAgentPrompt(reference), + displayText: buildImageToPrevisDisplayText(), + canvasReferenceNodeId: reference.nodeId, + autoSubmit: true, + requiresVision: true, + })); + openAgent(); + setContextMenu(null); + }, + [agentMentionReferences, message, openAgent], + ); const applyLibTVImport = useCallback( async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { const previousNodes = nodesRef.current; @@ -956,7 +942,6 @@ function CanvasViewportPage() { setScriptEditorNodeId(clearDeletedId); setArtCritiqueNodeId(clearDeletedId); setPrevisNodeId(clearDeletedId); - setVersionCompareRootId(clearDeletedId); setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id)))); setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current)); const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : [])); @@ -1002,7 +987,6 @@ function CanvasViewportPage() { pasteCopiedNodes, restoreCopiedNodesFromText, releaseCopiedNodesPastePriority, - setPrimaryVersion, shouldPreferCopiedNodes, toggleNodeLocked, } = useCanvasNodeOperations({ @@ -1181,7 +1165,14 @@ function CanvasViewportPage() { const handleNodeDragEnd = useCallback( (nodeId: string) => { const node = nodesRef.current.find((item) => item.id === nodeId); - if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE || (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId)) { + if ( + !node || + node.type === CanvasNodeType.Script || + node.type === CanvasNodeType.Drawing || + node.type === CanvasNodeType.Panorama || + node.type === ART_CRITIQUE_NODE_TYPE || + (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) + ) { setDialogNodeId(null); return; } @@ -1347,13 +1338,7 @@ function CanvasViewportPage() { const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0]; const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text; const remoteResourceId = resourceIdFromStorageKey(asset.storageKey); - const storageKey = characterCover - ? `resource:${characterCover.resourceId}` - : local?.kind === "image" || local?.kind === "video" || local?.kind === "audio" - ? local.data.storageKey - : remoteResourceId - ? asset.storageKey - : undefined; + const storageKey = characterCover ? `resource:${characterCover.resourceId}` : local?.kind === "image" || local?.kind === "video" || local?.kind === "audio" ? local.data.storageKey : remoteResourceId ? asset.storageKey : undefined; const content = characterCover ? resourceFileUrl(characterCover.resourceId) : local?.kind === "image" @@ -1417,7 +1402,6 @@ function CanvasViewportPage() { nodeById, nodeRenderLODById, previewNode, - reduceMediaEffects, relatedHighlight, resourceReferenceByNodeId, selectedNodeBounds, @@ -1426,7 +1410,6 @@ function CanvasViewportPage() { superResolveNode, toolbarNode, upscaleNode, - versionCompareNodes, visibleNodes, } = useCanvasRenderModel({ nodes, @@ -1434,7 +1417,6 @@ function CanvasViewportPage() { assets, viewport, viewportSize: size, - mediaPerformanceMode, selectedNodeIds, hoveredNodeId, dragPreview, @@ -1454,20 +1436,20 @@ function CanvasViewportPage() { emotionNodeId, previewNodeId, contextMenu, - versionCompareRootId, previsNodeId, scriptEditorNodeId, dialogNodeId, }); const visibleDisplayConnections = useMemo( - () => filterCanvasDisplayConnections(displayConnections, { - enabled: hideNodeConnections, - hoveredNodeId, - selectedNodeIds, - activeNodeId, - selectedConnectionId, - }), + () => + filterCanvasDisplayConnections(displayConnections, { + enabled: hideNodeConnections, + hoveredNodeId, + selectedNodeIds, + activeNodeId, + selectedConnectionId, + }), [activeNodeId, displayConnections, hideNodeConnections, hoveredNodeId, selectedConnectionId, selectedNodeIds], ); useEffect(() => { @@ -1696,7 +1678,7 @@ function CanvasViewportPage() { }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]); useCanvasKeyboard({ - enabled: projectLoaded && !versions.preview, + enabled: projectLoaded, nodesRef, selectedNodeIdsRef, selectedConnectionId, @@ -1732,7 +1714,6 @@ function CanvasViewportPage() { beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)), }); - const startTitleEditing = useCallback(() => { setTitleDraft(currentProject?.title || "未命名画布"); setTitleEditing(true); @@ -1774,15 +1755,11 @@ function CanvasViewportPage() { try { const content = node.metadata?.content?.trim(); - const resourceId = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio].includes(node.type as CanvasNodeType) - ? resourceIdFromStorageKey(node.metadata?.storageKey) - : ""; + const resourceId = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio].includes(node.type as CanvasNodeType) ? resourceIdFromStorageKey(node.metadata?.storageKey) : ""; // Resource-backed media must use the central access contract. This keeps // copy operations on the configured CDN/OSS URL instead of copying the // platform file endpoint or a stale URL persisted in canvas metadata. - const copySource = resourceId - ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) - : content || ""; + const copySource = resourceId ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) : content || ""; if (!copySource) throw new Error("没有可复制的内容"); if (node.type === CanvasNodeType.Image) { try { @@ -1824,9 +1801,7 @@ function CanvasViewportPage() { const content = node?.metadata?.content?.trim(); const resourceId = resourceIdFromStorageKey(storageKey); const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : ""; - const mediaURL = resourceId - ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) - : mediaPath ? new URL(mediaPath, window.location.href).toString() : ""; + const mediaURL = resourceId ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) : mediaPath ? new URL(mediaPath, window.location.href).toString() : ""; if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址"); if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL); else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板"); @@ -1988,32 +1963,37 @@ function CanvasViewportPage() { }); return changed ? next : current; }); - nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => { - syncRowsFromConnections(node.id, true); - }); + nodesRef.current + .filter((node) => node.type === CanvasNodeType.BatchTable) + .forEach((node) => { + syncRowsFromConnections(node.id, true); + }); }, [connections, projectLoaded, setNodes, syncRowsFromConnections]); - const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => { - const tableNode = nodesRef.current.find((item) => item.id === tableNodeId); - const table = tableNode?.metadata?.batchTable; - const row = table?.rows.find((item) => item.id === rowId); - if (!tableNode || !table || !row) return; - const existingId = row.inputNodeIds[columnIndex]; - if (existingId) { - await replaceNodeMedia(existingId, file); - return; - } - const rowIndex = table.rows.findIndex((item) => item.id === rowId); - const uploadedNodeId = await createFileNode(file, { - x: tableNode.position.x - 240, - y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220, - }); - if (!uploadedNodeId) return; - const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || ""); - inputNodeIds[columnIndex] = uploadedNodeId; - updateBatchRow(tableNodeId, rowId, { inputNodeIds }); - message.success(`已上传并填入参考图 ${columnIndex + 1}`); - }, [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow]); + const handleUploadBatchReference = useCallback( + async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => { + const tableNode = nodesRef.current.find((item) => item.id === tableNodeId); + const table = tableNode?.metadata?.batchTable; + const row = table?.rows.find((item) => item.id === rowId); + if (!tableNode || !table || !row) return; + const existingId = row.inputNodeIds[columnIndex]; + if (existingId) { + await replaceNodeMedia(existingId, file); + return; + } + const rowIndex = table.rows.findIndex((item) => item.id === rowId); + const uploadedNodeId = await createFileNode(file, { + x: tableNode.position.x - 240, + y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220, + }); + if (!uploadedNodeId) return; + const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || ""); + inputNodeIds[columnIndex] = uploadedNodeId; + updateBatchRow(tableNodeId, rowId, { inputNodeIds }); + message.success(`已上传并填入参考图 ${columnIndex + 1}`); + }, + [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow], + ); const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } = useCanvasStoryboard({ @@ -2027,57 +2007,75 @@ function CanvasViewportPage() { enqueueGenerationBatch, }); - const createStoryboardFromBatchTable = useCallback(async (tableNodeId: string) => { - const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable); - const table = tableNode?.metadata?.batchTable; - if (!tableNode || !table || table.contentKind !== "storyboard") return; - const rows = storyboardRowsFromBatchTable(table); - if (!rows.length) return message.warning("当前表格没有可用的分镜行"); - const sourceIds = Array.from(new Set([ - ...(table.storyboardSourceNodeIds || []), - ...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId), - ])).filter((id) => nodesRef.current.some((node) => node.id === id)); - const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video); - const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined); - const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length); - const scriptSpec = getNodeSpec(CanvasNodeType.Script); - const scriptNode = createCanvasNode(CanvasNodeType.Script, { - x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2, - y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2, - }, { - storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds }, - storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct", - workflowKind: "storyboard", - workflowTitle: table.storyboardTitle || "视频脚本", - composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本", - status: "idle", - }); - scriptNode.title = table.storyboardTitle || "视频脚本"; - const nextConnections = [ - ...connectionsRef.current, - ...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const })), - ]; - const nextNodes = [...nodesRef.current, scriptNode]; - nodesRef.current = nextNodes; - connectionsRef.current = nextConnections; - setNodes(nextNodes); - setConnections(nextConnections); - setSelectedNodeIds(new Set([scriptNode.id])); - setDialogNodeId(null); - if (sourceVideo && keyframeTimesMs.length) { - const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs }); - if (frameNodes.length) { - const withFrames = nodesRef.current.map((node) => node.id === scriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { ...node.metadata?.storyboard, rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id), visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds } } } : node); - nodesRef.current = withFrames; - setNodes(withFrames); - message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`); + const createStoryboardFromBatchTable = useCallback( + async (tableNodeId: string) => { + const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable); + const table = tableNode?.metadata?.batchTable; + if (!tableNode || !table || table.contentKind !== "storyboard") return; + const rows = storyboardRowsFromBatchTable(table); + if (!rows.length) return message.warning("当前表格没有可用的分镜行"); + const sourceIds = Array.from( + new Set([...(table.storyboardSourceNodeIds || []), ...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)]), + ).filter((id) => nodesRef.current.some((node) => node.id === id)); + const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video); + const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined); + const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length); + const scriptSpec = getNodeSpec(CanvasNodeType.Script); + const scriptNode = createCanvasNode( + CanvasNodeType.Script, + { + x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2, + y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2, + }, + { + storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds }, + storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct", + workflowKind: "storyboard", + workflowTitle: table.storyboardTitle || "视频脚本", + composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本", + status: "idle", + }, + ); + scriptNode.title = table.storyboardTitle || "视频脚本"; + const nextConnections = [...connectionsRef.current, ...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const }))]; + const nextNodes = [...nodesRef.current, scriptNode]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set([scriptNode.id])); + setDialogNodeId(null); + if (sourceVideo && keyframeTimesMs.length) { + const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs }); + if (frameNodes.length) { + const withFrames = nodesRef.current.map((node) => + node.id === scriptNode.id + ? { + ...node, + metadata: { + ...node.metadata, + storyboard: { + ...node.metadata?.storyboard, + rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id), + visibleColumns: cinematicStoryboardColumns(), + referenceNodeIds: sourceIds, + }, + }, + } + : node, + ); + nodesRef.current = withFrames; + setNodes(withFrames); + message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`); + } else { + message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取"); + } } else { - message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取"); + message.success(`已创建视频脚本:${rows.length} 个镜头`); } - } else { - message.success(`已创建视频脚本:${rows.length} 个镜头`); - } - }, [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); + }, + [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds], + ); const handleRetryNode = useCanvasGenerationRetry({ projectId, @@ -2198,7 +2196,9 @@ function CanvasViewportPage() { isRunning={isCanvasNodeGenerating(panelNode, runningNodeId)} mentionReferences={[ ...(mentionReferencesByNodeId.get(panelNode.id) || EMPTY_RESOURCE_REFERENCES), - ...buildCanvasResourceReferences(nodesRef.current, connectionsRef.current).filter((reference) => reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)), + ...buildCanvasResourceReferences(nodesRef.current, connectionsRef.current).filter( + (reference) => reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId), + ), ]} onAddReference={(nodeId, reference) => { if (reference.active || reference.assetId || reference.kind === "skill") return reference; @@ -2301,7 +2301,9 @@ function CanvasViewportPage() { onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)} onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)} onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)} - onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onUploadReference={(rowId, columnIndex, file) => { + void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); + }} onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)} onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)} /> @@ -2483,7 +2485,6 @@ function CanvasViewportPage() { }, [openNodeTaskDetails], ); - const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []); const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []); const editCanvasPrevis = useCallback((node: CanvasNodeData) => openPrevisWorkbench(node.id), [openPrevisWorkbench]); const locateProjectStyleNode = useCallback(() => { @@ -2566,844 +2567,864 @@ function CanvasViewportPage() { > 跳转到画布主内容 -
- {!focusMode && !versions.preview ? setWorkspaceOpen(true)} onInsertAssets={handleProjectAssetsInsert} projectId={projectId} nodes={nodes} selectedNodeIds={selectedNodeIds} onClose={() => setWorkspaceOpen(false)} onAssets={() => openCanvasAssetLibrary()} onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} onCancelTask={cancelCanvasTask} onFocus={nodeId => { - const target = nodeById.get(nodeId); - const parent = target?.parentId ? nodeById.get(target.parentId) : null; - if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); - const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; - if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); - const selection = new Set([nodeId]); selectedNodeIdsRef.current = selection; setSelectedNodeIds(selection); setSelectedConnectionId(null); focusCanvasNode(nodeId); - }} /> : null} - {!workspaceOpen && !focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( +
+ {!focusMode ? ( + setWorkspaceOpen(true)} + onInsertAssets={handleProjectAssetsInsert} + projectId={projectId} + nodes={nodes} + selectedNodeIds={selectedNodeIds} + onClose={() => setWorkspaceOpen(false)} + onAssets={() => openCanvasAssetLibrary()} + onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} + onCancelTask={cancelCanvasTask} + onFocus={(nodeId) => { + const target = nodeById.get(nodeId); + const parent = target?.parentId ? nodeById.get(target.parentId) : null; + if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); + const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; + if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); + const selection = new Set([nodeId]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + setSelectedConnectionId(null); + focusCanvasNode(nodeId); + }} + /> + ) : null} + {!workspaceOpen && !focusMode && shortDramaEnabled && currentProject?.projectId ? ( openProjectAssets()} /> ) : null}
-
- {!focusMode ? ( - } - versionsOpen={versions.open} - onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }} - title={currentProject?.title || "未命名画布"} - titleDraft={titleDraft} - isTitleEditing={titleEditing} - onTitleDraftChange={setTitleDraft} - onStartTitleEditing={startTitleEditing} - onFinishTitleEditing={finishTitleEditing} - onCancelTitleEditing={() => setTitleEditing(false)} - canUndo={historyState.canUndo} - canRedo={historyState.canRedo} - onCreateProject={createAndOpenProject} - onDeleteProject={deleteCurrentProject} - onSave={() => void saveCanvasProject()} - onForceSave={confirmForceSaveCanvas} - onImportImage={() => handleUploadRequest()} - onImportLibTV={() => setLibTVImportOpen(true)} - onImportTapNow={() => setTapNowImportOpen(true)} - onUndo={undoCanvas} - onRedo={redoCanvas} - onShare={() => setShareModalOpen(true)} - shortcutRequestNonce={shortcutRequestNonce} - mediaPerformanceMode={mediaPerformanceMode} - onMediaPerformanceModeChange={setMediaPerformanceMode} - onOpenSearch={() => setNodeSearchOpen(true)} - projectContext={ - shortDramaEnabled && currentProject?.projectId - ? { - ...canvasContext, - projectId: currentProject.projectId, - projectName: linkedProjectQuery.data?.project.name || currentProject.title, - } - : undefined - } - onEnterFocusMode={enterFocusMode} - shortDramaGuide={shortDramaGuide} - /> - ) : null} - - setNodeSearchOpen(false)} - onFocus={(nodeId) => { - const target = nodeById.get(nodeId); - const parent = target?.parentId ? nodeById.get(target.parentId) : null; - if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); - const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; - if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); - const selection = new Set([nodeId]); - selectedNodeIdsRef.current = selection; - setSelectedNodeIds(selection); - setSelectedConnectionId(null); - focusCanvasNode(nodeId); - }} - /> - - {!focusMode && shortDramaGuide ? ( - - ) : null} - - setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> - setLibTVImportOpen(false)} onApply={applyLibTVImport} /> - setTapNowImportOpen(false)} onApply={applyTapNowImport} /> - - setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> - - setPrevisTemplateRequest(null)} onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)} /> - -
-
- +
+ {!focusMode ? ( + setTitleEditing(false)} + canUndo={historyState.canUndo} + canRedo={historyState.canRedo} + onCreateProject={createAndOpenProject} + onDeleteProject={deleteCurrentProject} + onSave={() => void saveCanvasProject()} + onImportImage={() => handleUploadRequest()} + onImportLibTV={() => setLibTVImportOpen(true)} + onImportTapNow={() => setTapNowImportOpen(true)} + onUndo={undoCanvas} + onRedo={redoCanvas} + onShare={() => setShareModalOpen(true)} + shortcutRequestNonce={shortcutRequestNonce} + syncIcon={} + onOpenSearch={() => setNodeSearchOpen(true)} + projectContext={ + shortDramaEnabled && currentProject?.projectId + ? { + ...canvasContext, + projectId: currentProject.projectId, + projectName: linkedProjectQuery.data?.project.name || currentProject.title, + } + : undefined } - onViewportChange={handleViewportChange} - onViewportPreviewChange={handleViewportPreviewChange} - onCanvasMouseDown={handleCanvasMouseDown} - boxSelectEnabled={canvasTool === "box-select"} - onCanvasDoubleClick={handleCanvasDoubleClick} - onCanvasDeselect={deselectCanvas} - onContextMenu={handleCanvasContextMenu} - onDrop={handleDrop} - onFileDragEnter={handleFileDragEnter} - onFileDragLeave={handleFileDragLeave} - onFileDragOver={handleFileDragOver} - > - - - + ) : null} + + setNodeSearchOpen(false)} + onFocus={(nodeId) => { + const target = nodeById.get(nodeId); + const parent = target?.parentId ? nodeById.get(target.parentId) : null; + if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); + const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; + if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); + const selection = new Set([nodeId]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + setSelectedConnectionId(null); + focusCanvasNode(nodeId); + }} + /> + + {!focusMode && shortDramaGuide ? ( + + ) : null} + + setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> + setLibTVImportOpen(false)} onApply={applyLibTVImport} /> + setTapNowImportOpen(false)} onApply={applyTapNowImport} /> + + setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> + + setPrevisTemplateRequest(null)} onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)} /> + +
+
+ { - setSelectedConnectionId(connectionId); - setSelectedNodeIds(new Set()); - setContextMenu(null); - }} - onConnectionContextMenu={(event, connectionId) => { - setSelectedConnectionId(connectionId); - setSelectedNodeIds(new Set()); - closeConnectionCreateMenu(); - setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); - }} - onNodeMouseDown={handleNodeMouseDown} - onNodeHoverStart={handleCanvasNodeHoverStart} - onNodeHoverEnd={handleCanvasNodeHoverEnd} - onConnectStart={handleConnectStart} - onNodeResize={handleNodeResize} - onToggleFrame={handleFrameToggle} - onFolderStyleChange={handleFolderStyleChange} - onFolderThemeChange={handleFolderThemeChange} - onNodeTitleChange={handleNodeTitleChange} - onNodeContextMenu={handleNodeContextMenu} - onNodeContentChange={handleNodeContentChange} - onToggleBatch={toggleBatchExpanded} - onSetBatchPrimary={setBatchPrimary} - onRetry={retryCanvasNode} - onReloadResource={reloadCanvasNodeResource} - onOpenTaskDetails={openCanvasNodeTaskDetails} - onOpenVersions={openCanvasNodeVersions} - onViewImage={viewCanvasNodeImage} - onReplaceMedia={replaceCanvasNodeMedia} - onOpenTextEditor={openTextNodeEditor} - onOpenPrevis={editCanvasPrevis} - onOpenDrawing={openDrawingNode} - onStartBatchConnection={startBatchConnection} + alignmentGuides={alignmentGuides} /> - - - + } + onViewportChange={handleViewportChange} + onViewportPreviewChange={handleViewportPreviewChange} + onCanvasMouseDown={handleCanvasMouseDown} + boxSelectEnabled={canvasTool === "box-select"} + onCanvasDoubleClick={handleCanvasDoubleClick} + onCanvasDeselect={deselectCanvas} + onContextMenu={handleCanvasContextMenu} + onDrop={handleDrop} + onFileDragEnter={handleFileDragEnter} + onFileDragLeave={handleFileDragLeave} + onFileDragOver={handleFileDragOver} + > + + + { + setSelectedConnectionId(connectionId); + setSelectedNodeIds(new Set()); + setContextMenu(null); + }} + onConnectionContextMenu={(event, connectionId) => { + setSelectedConnectionId(connectionId); + setSelectedNodeIds(new Set()); + closeConnectionCreateMenu(); + setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); + }} + onNodeMouseDown={handleNodeMouseDown} + onNodeHoverStart={handleCanvasNodeHoverStart} + onNodeHoverEnd={handleCanvasNodeHoverEnd} + onConnectStart={handleConnectStart} + onNodeResize={handleNodeResize} + onToggleFrame={handleFrameToggle} + onFolderStyleChange={handleFolderStyleChange} + onFolderThemeChange={handleFolderThemeChange} + onNodeTitleChange={handleNodeTitleChange} + onNodeContextMenu={handleNodeContextMenu} + onNodeContentChange={handleNodeContentChange} + onToggleBatch={toggleBatchExpanded} + onSetBatchPrimary={setBatchPrimary} + onRetry={retryCanvasNode} + onReloadResource={reloadCanvasNodeResource} + onOpenTaskDetails={openCanvasNodeTaskDetails} + onViewImage={viewCanvasNodeImage} + onReplaceMedia={replaceCanvasNodeMedia} + onOpenTextEditor={openTextNodeEditor} + onOpenPrevis={editCanvasPrevis} + onOpenDrawing={openDrawingNode} + onStartBatchConnection={startBatchConnection} + /> + + + - + - {focusMode ? ( - } - versionsOpen={versions.open} - onToggleVersions={() => { closeAgent(); versions.toggle(); }} - dockRevealed={focusDockRevealed} - zoomPercent={viewport.k} - onToggleDock={() => setFocusDockRevealed((value) => !value)} - onExit={exitFocusMode} - onZoomIn={zoomCanvasIn} - onZoomOut={zoomCanvasOut} - onFit={fitCanvasContent} - /> - ) : null} + {focusMode ? ( + } + dockRevealed={focusDockRevealed} + zoomPercent={viewport.k} + onToggleDock={() => setFocusDockRevealed((value) => !value)} + onExit={exitFocusMode} + onZoomIn={zoomCanvasIn} + onZoomOut={zoomCanvasOut} + onFit={fitCanvasContent} + /> + ) : null} - + - {emptyCanvasState} + {emptyCanvasState} - {!focusMode || focusDockRevealed ? ( - createNode(CanvasNodeType.Image)} - onAddVideo={() => createNode(CanvasNodeType.Video)} - onAddAudio={() => createNode(CanvasNodeType.Audio)} - onAddText={() => createNode(CanvasNodeType.Text)} - onChooseStyle={() => setStylePickerOpen(true)} - onAddScript={() => createNode(CanvasNodeType.Script)} - onAddFrame={() => createNode(CanvasNodeType.Frame)} - onAddFolder={createFolder} - onAddDrawing={() => createNode(CanvasNodeType.Drawing)} - onAddExtensionNode={(type) => createNode(type)} - onAddWorkflow={() => createNode(CanvasNodeType.Config)} - onOpenPrevis={() => setPrevisTemplateRequest({})} - onUndo={undoCanvas} - onRedo={redoCanvas} - onUpload={() => handleUploadRequest()} - onDelete={() => deleteNodes(new Set(selectedNodeIds))} - onClear={() => setClearConfirmOpen(true)} - onDeselect={deselectCanvas} - onAppearanceChange={applyCanvasAppearance} - onSaveAppearanceDefault={saveCanvasAppearanceDefault} - onBackgroundModeChange={setBackgroundMode} - onShowImageInfoChange={setShowImageInfo} - onOpenWorkspace={() => setWorkspaceOpen(value => !value)} - onOpenMyAssets={() => { - openCanvasAssetLibrary(); + {!focusMode || focusDockRevealed ? ( + createNode(CanvasNodeType.Image)} + onAddVideo={() => createNode(CanvasNodeType.Video)} + onAddAudio={() => createNode(CanvasNodeType.Audio)} + onAddText={() => createNode(CanvasNodeType.Text)} + onChooseStyle={() => setStylePickerOpen(true)} + onAddScript={() => createNode(CanvasNodeType.Script)} + onAddFrame={() => createNode(CanvasNodeType.Frame)} + onAddFolder={createFolder} + onAddDrawing={() => createNode(CanvasNodeType.Drawing)} + onAddExtensionNode={(type) => createNode(type)} + onAddWorkflow={() => createNode(CanvasNodeType.Config)} + onOpenPrevis={() => setPrevisTemplateRequest({})} + onUndo={undoCanvas} + onRedo={redoCanvas} + onUpload={() => handleUploadRequest()} + onDelete={() => deleteNodes(new Set(selectedNodeIds))} + onClear={() => setClearConfirmOpen(true)} + onDeselect={deselectCanvas} + onAppearanceChange={applyCanvasAppearance} + onSaveAppearanceDefault={saveCanvasAppearanceDefault} + onBackgroundModeChange={setBackgroundMode} + onShowImageInfoChange={setShowImageInfo} + onOpenWorkspace={() => setWorkspaceOpen((value) => !value)} + onOpenMyAssets={() => { + openCanvasAssetLibrary(); + }} + onOpenProjectCharacters={() => openCharacterLibrary()} + /> + ) : null} +
+ +
+ { + const currentNodes = nodesRef.current; + const target = currentNodes.find((node) => node.id === nodeId); + if (!target) { + message.info("该节点已删除或尚未同步到画布"); + return; + } + + const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null; + if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); + + const batchRootId = target.metadata?.batchRootId; + const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null; + if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); + + focusCanvasNode(nodeId); }} - onOpenProjectCharacters={() => openCharacterLibrary()} /> - ) : null} +
-
- { - const currentNodes = nodesRef.current; - const target = currentNodes.find((node) => node.id === nodeId); - if (!target) { message.info("该节点已删除或尚未同步到画布"); return; } - - const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null; - if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); - - const batchRootId = target.metadata?.batchRootId; - const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null; - if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); - - focusCanvasNode(nodeId); - }} /> -
-
- - {angleNode?.metadata?.content ? ( - - setAngleNodeId(null)} - onConfirm={(params) => { - void generateAngleNode(angleNode, params); - }} - /> - - ) : null} - - {lightingNode?.metadata?.content ? ( - setLightingNodeId(null)}> - setLightingNodeId(null)} - onConfirm={(options, prompt) => { - generateLightingNode(lightingNode, options, prompt); - }} - /> - - ) : null} - - {emotionNode?.metadata?.content && !isCanvasNodeMoving ? ( - setEmotionNodeId(null)} - onConfirm={(payload: CanvasImageEmotionPayload) => { - void generateEmotionNode(emotionNode, payload); - }} - /> - ) : null} - - {dialogNode && - !isCanvasImageSourceNode(dialogNode) && - !dialogNode.metadata?.fileUpload && - dialogNode.type !== CanvasNodeType.Script && - dialogNode.type !== CanvasNodeType.BatchTable && - dialogNode.type !== CanvasNodeType.Drawing && - dialogNode.type !== CanvasNodeType.Panorama && - !(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) && - !dialogNode.metadata?.previsSceneId && - !selectionBox && - !isCanvasNodeMoving ? ( - - {renderCanvasNodePanel(dialogNode)} - - ) : null} - - {pendingConnectionCreate ? ( - getConnectionCreateDisabledReason(type, pendingConnectionCreate)} - onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} - onClose={cancelPendingConnectionCreate} - /> - ) : null} - - {connectionReplaceHover ? ( -
- - 松开替换 - @{connectionReplaceHover.referenceLabel} -
- ) : null} - - {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? ( - beginBatchConnectionMode(Array.from(selectedNodeIds))} - onMergeVideos={() => void mergeSelectedVideos()} - onSendSelectionToAgent={() => sendSelectionToAgent()} - /> - ) : null} - - {uploadStatus ? : null} - {mergeVideoProgress ? : null} - {lastAgentChange ? ( - { - undoAgentOps(); - }} - onClose={dismissLastAgentChange} - /> - ) : null} - - (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))} - onEditText={openTextNodeEditor} - onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} - onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} - onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} - onGenerateImage={generateImageFromTextNode} - onUpload={(node) => handleUploadRequest(node.id)} - onDownload={downloadNodeImage} - onSaveAsset={(node) => void saveNodeAsset(node)} - onAnnotate={(node) => setAnnotationNodeId(node.id)} - onAnnotationEdit={openAnnotationEditNode} - onMaskEdit={(node) => setMaskEditNodeId(node.id)} - onRemoveBackground={openBackgroundRemoval} - onLayerDecomposition={openLayerDecomposition} - onTextEdit={openTextEditNode} - onEmotion={(node) => { - setDialogNodeId(null); - setEmotionNodeId((current) => (current === node.id ? null : node.id)); - }} - onPortraitTexture={openPortraitTextureEditor} - onCrop={(node) => setCropNodeId(node.id)} - onSplit={(node, params) => void splitImageNode(node, params)} - onUpscale={(node) => setUpscaleNodeId(node.id)} - onSuperResolve={(node) => setSuperResolveNodeId(node.id)} - onAngle={(node) => { - setDialogNodeId(null); - setAngleNodeId((current) => (current === node.id ? null : node.id)); - }} - onLighting={(node) => { - setDialogNodeId(null); - setLightingNodeId((current) => (current === node.id ? null : node.id)); - }} - onPanorama={openPanoramaConfig} - onPrevis={sendImageToPrevisAgent} - onViewImage={(node) => setPreviewNodeId(node.id)} - onExtractVideoFrames={openVideoFrameExtractor} - onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)} - onTrimVideoSegments={openVideoSegmentExtractor} - onSubtitles={(node) => setSubtitleNodeId(node.id)} - onTimeline={(node) => setTimelineNodeId(node.id)} - extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId} - extractingAudio={segmentRunningMode === "audio"} - trimmingVideo={segmentRunningMode === "video"} - onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)} - onReversePrompt={createImageReversePromptNodes} - onRetry={retryCanvasNode} - onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} - onToggleLocked={(node) => toggleNodeLocked(node.id)} - onDelete={(node) => deleteNodes(new Set([node.id]))} - /> - -{isMiniMapOpen && !focusMode ? : null} - - {!focusMode ? ( - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - setIsMiniMapOpen((value) => !value)} - onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} + panelWidth={640} + panelHeight={540} + allowOverflow + dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null} + isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))} + > + setAngleNodeId(null)} + onConfirm={(params) => { + void generateAngleNode(angleNode, params); + }} + /> + + ) : null} + + {lightingNode?.metadata?.content ? ( + setLightingNodeId(null)}> + setLightingNodeId(null)} + onConfirm={(options, prompt) => { + generateLightingNode(lightingNode, options, prompt); + }} + /> + + ) : null} + + {emotionNode?.metadata?.content && !isCanvasNodeMoving ? ( + setEmotionNodeId(null)} + onConfirm={(payload: CanvasImageEmotionPayload) => { + void generateEmotionNode(emotionNode, payload); + }} /> - - ) : null} + ) : null} - setContextMenu(null)} - onAddNode={(type, position) => createNode(type, position)} - onAddFolder={createFolder} - onChooseStyle={() => setStylePickerOpen(true)} - onOpenPrevis={(position) => setPrevisTemplateRequest({ position })} - onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} - onOpenAssets={openCanvasAssetLibrary} - onOpenProjectCharacters={(position) => openCharacterLibrary(position)} - onUndo={undoCanvas} - onRedo={redoCanvas} - onPaste={pasteAtPosition} - onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))} - onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")} - onDuplicate={duplicateNode} - onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))} - onDeleteConnection={deleteConnection} - onSaveAsset={(node) => { - void saveNodeAsset(node); - }} - onViewMedia={(node) => setPreviewNodeId(node.id)} - onEditText={openTextNodeEditor} - onOpenDrawing={openDrawingNode} - onGenerateImage={generateImageFromTextNode} - onCopyContent={(node) => { - void copyNodeContentToClipboard(node); - }} - onCopyMediaUrl={(node) => { - void copyNodeMediaUrlToClipboard(node); - }} - onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset} - onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} - onToggleFrame={(node) => handleFrameToggle(node.id)} - onSpreadSelection={spreadSelectedNodes} - onCopySelection={copySelectedNodes} - onDeleteSelection={() => deleteNodes(selectedNodeIds)} - onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)} - /> + {dialogNode && + !isCanvasImageSourceNode(dialogNode) && + !dialogNode.metadata?.fileUpload && + dialogNode.type !== CanvasNodeType.Script && + dialogNode.type !== CanvasNodeType.BatchTable && + dialogNode.type !== CanvasNodeType.Drawing && + dialogNode.type !== CanvasNodeType.Panorama && + !(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) && + !dialogNode.metadata?.previsSceneId && + !selectionBox && + !isCanvasNodeMoving ? ( + + {renderCanvasNodePanel(dialogNode)} + + ) : null} - + {pendingConnectionCreate ? ( + getConnectionCreateDisabledReason(type, pendingConnectionCreate)} + onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} + onClose={cancelPendingConnectionCreate} + /> + ) : null} - + {connectionReplaceHover ? ( +
+ + 松开替换 + @{connectionReplaceHover.referenceLabel} +
+ ) : null} - setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> + {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? ( + beginBatchConnectionMode(Array.from(selectedNodeIds))} + onMergeVideos={() => void mergeSelectedVideos()} + onSendSelectionToAgent={() => sendSelectionToAgent()} + /> + ) : null} - {subtitleNode ? ( - setSubtitleNodeId(null)} - onSave={(nodeId, patch) => { - handleConfigNodeChange(nodeId, patch); - const currentTimeline = currentProject?.timeline; - if (currentTimeline) { - const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []); - if (next !== currentTimeline) updateProject(projectId, { timeline: next }); + {uploadStatus ? : null} + {mergeVideoProgress ? : null} + {lastAgentChange ? ( + { + undoAgentOps(); + }} + onClose={dismissLastAgentChange} + /> + ) : null} + + (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))} + onEditText={openTextNodeEditor} + onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} + onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} + onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} + onGenerateImage={generateImageFromTextNode} + onUpload={(node) => handleUploadRequest(node.id)} + onDownload={downloadNodeImage} + onSaveAsset={(node) => void saveNodeAsset(node)} + onAnnotate={(node) => setAnnotationNodeId(node.id)} + onAnnotationEdit={openAnnotationEditNode} + onMaskEdit={(node) => setMaskEditNodeId(node.id)} + onRemoveBackground={openBackgroundRemoval} + onLayerDecomposition={openLayerDecomposition} + onTextEdit={openTextEditNode} + onEmotion={(node) => { + setDialogNodeId(null); + setEmotionNodeId((current) => (current === node.id ? null : node.id)); + }} + onPortraitTexture={openPortraitTextureEditor} + onCrop={(node) => setCropNodeId(node.id)} + onSplit={(node, params) => void splitImageNode(node, params)} + onUpscale={(node) => setUpscaleNodeId(node.id)} + onSuperResolve={(node) => setSuperResolveNodeId(node.id)} + onAngle={(node) => { + setDialogNodeId(null); + setAngleNodeId((current) => (current === node.id ? null : node.id)); + }} + onLighting={(node) => { + setDialogNodeId(null); + setLightingNodeId((current) => (current === node.id ? null : node.id)); + }} + onPanorama={openPanoramaConfig} + onPrevis={sendImageToPrevisAgent} + onViewImage={(node) => setPreviewNodeId(node.id)} + onExtractVideoFrames={openVideoFrameExtractor} + onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)} + onTrimVideoSegments={openVideoSegmentExtractor} + onSubtitles={(node) => setSubtitleNodeId(node.id)} + onTimeline={(node) => setTimelineNodeId(node.id)} + extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId} + extractingAudio={segmentRunningMode === "audio"} + trimmingVideo={segmentRunningMode === "video"} + onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)} + onReversePrompt={createImageReversePromptNodes} + onRetry={retryCanvasNode} + onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} + onToggleLocked={(node) => toggleNodeLocked(node.id)} + onDelete={(node) => deleteNodes(new Set([node.id]))} + /> + + {isMiniMapOpen && !focusMode ? : null} + + {!focusMode ? ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + setIsMiniMapOpen((value) => !value)} + onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} + /> + + ) : null} + + setContextMenu(null)} + onAddNode={(type, position) => createNode(type, position)} + onAddFolder={createFolder} + onChooseStyle={() => setStylePickerOpen(true)} + onOpenPrevis={(position) => setPrevisTemplateRequest({ position })} + onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} + onOpenAssets={openCanvasAssetLibrary} + onOpenProjectCharacters={(position) => openCharacterLibrary(position)} + onUndo={undoCanvas} + onRedo={redoCanvas} + onPaste={pasteAtPosition} + onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))} + onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")} + onDuplicate={duplicateNode} + onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))} + onDeleteConnection={deleteConnection} + onSaveAsset={(node) => { + void saveNodeAsset(node); + }} + onViewMedia={(node) => setPreviewNodeId(node.id)} + onEditText={openTextNodeEditor} + onOpenDrawing={openDrawingNode} + onGenerateImage={generateImageFromTextNode} + onCopyContent={(node) => { + void copyNodeContentToClipboard(node); + }} + onCopyMediaUrl={(node) => { + void copyNodeMediaUrlToClipboard(node); + }} + onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset} + onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} + onToggleFrame={(node) => handleFrameToggle(node.id)} + onSpreadSelection={spreadSelectedNodes} + onCopySelection={copySelectedNodes} + onDeleteSelection={() => deleteNodes(selectedNodeIds)} + onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)} + /> + + + + + + setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> + + {subtitleNode ? ( + setSubtitleNodeId(null)} + onSave={(nodeId, patch) => { + handleConfigNodeChange(nodeId, patch); + const currentTimeline = currentProject?.timeline; + if (currentTimeline) { + const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []); + if (next !== currentTimeline) updateProject(projectId, { timeline: next }); + } + }} + /> + ) : null} + + {frameNode ? void extractVideoFrames(frameNode, params)} /> : null} + + {segmentNode && segmentDialogMode ? ( + void handleSegmentConfirm(segmentNode, params)} + /> + ) : null} + + {timelineNode ? ( + setTimelineNodeId(null)} + onOpenSubtitleDialog={(subNodeId) => { + setTimelineNodeId(null); + setSubtitleNodeId(subNodeId); + }} + onSave={(next) => updateProject(projectId, { timeline: next })} + onSaveSubtitles={(subNodeId, entries) => + handleConfigNodeChange(subNodeId, { + subtitleEntries: entries, + ...(entries.length ? {} : { subtitleHighlights: [] }), + subtitleUpdatedAt: new Date().toISOString(), + }) + } + onOpenAssetLibrary={openTimelineAssetLibrary} + onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")} + onUploadLocalFiles={uploadTimelineMedia} + addNodeToTimelineRef={timelineAddNodeRef} + addMediaToTimelineRef={timelineMediaAddRef} + onCreateAssembledNode={createVideoNodeFromBlob} + /> + ) : null} + + setCharacterReferenceNodeId(null)} + onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))} + /> + + setTextEditorNodeId(null)} + onSave={(nodeId, title, content, richText) => { + setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node))); + }} + /> + + {drawingNode ? ( + + +
+ } + > + setDrawingNodeId(null)} + onSaved={(nodeId, summary) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + metadata: { + ...node.metadata, + drawingEngine: summary.engine, + drawingRevision: summary.revision, + drawingUpdatedAt: summary.updatedAt, + drawingShapeCount: summary.shapeCount, + drawingPageCount: summary.pageCount, + }, + } + : node, + ), + ); + message.success("绘图已保存"); + }} + /> + + ) : null} + + { + artCritiqueRunningRef.current = running; + }} + node={artCritiqueNode} + upstreamNodes={artCritiqueInputs} + open={Boolean(artCritiqueNode)} + onClose={() => setArtCritiqueNodeId(null)} + onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })} + /> + + setPanoramaConfigNodeId(null)} + onConfirm={(composedPrompt, config) => { + const node = nodes.find((n) => n.id === panoramaConfigNodeId); + if (node) { + createPanoramaViewerWithConfig(node, composedPrompt, config); } }} - /> - ) : null} - - {frameNode ? void extractVideoFrames(frameNode, params)} /> : null} - - {segmentNode && segmentDialogMode ? ( - void handleSegmentConfirm(segmentNode, params)} - /> - ) : null} - - {timelineNode ? ( - setTimelineNodeId(null)} - onOpenSubtitleDialog={(subNodeId) => { - setTimelineNodeId(null); - setSubtitleNodeId(subNodeId); + onCopyPrompt={(prompt) => { + void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词")); }} - onSave={(next) => updateProject(projectId, { timeline: next })} - onSaveSubtitles={(subNodeId, entries) => - handleConfigNodeChange(subNodeId, { - subtitleEntries: entries, - ...(entries.length ? {} : { subtitleHighlights: [] }), - subtitleUpdatedAt: new Date().toISOString(), - }) - } - onOpenAssetLibrary={openTimelineAssetLibrary} - onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")} - onUploadLocalFiles={uploadTimelineMedia} - addNodeToTimelineRef={timelineAddNodeRef} - addMediaToTimelineRef={timelineMediaAddRef} - onCreateAssembledNode={createVideoNodeFromBlob} + previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined} + nodes={nodes} /> - ) : null} - setCharacterReferenceNodeId(null)} onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))} /> + setScriptEditorNodeId(null)} + onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)} + onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => { + if (!activeScriptNode || !visibleColumns.length) return; + setNodes((prev) => + prev.map((node) => + node.id === activeScriptNode.id + ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } + : node, + ), + ); + }} + onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)} + onGenerateVideos={(rowIds) => { + if (!activeScriptNode) return; + if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds); + else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds); + }} + onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })} + /> - setTextEditorNodeId(null)} - onSave={(nodeId, title, content, richText) => { - setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node))); - }} - /> + {previsNodeId && activePrevisScene ? ( + + +
+ } + > + node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey))} + onClose={() => setPrevisNodeId(null)} + onChange={savePrevisScene} + onApply={applyPrevisOutput} + onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} + onFlush={() => flushCanvasStorePersistence()} + onboardingScope={previsOnboardingScope} + /> + + ) : null} - {drawingNode ? ( - - -
- } - > - setDrawingNodeId(null)} - onSaved={(nodeId, summary) => { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - metadata: { - ...node.metadata, - drawingEngine: summary.engine, - drawingRevision: summary.revision, - drawingUpdatedAt: summary.updatedAt, - drawingShapeCount: summary.shapeCount, - drawingPageCount: summary.pageCount, - }, - } - : node, - ), - ); - message.success("绘图已保存"); - }} - /> - - ) : null} + setCropNodeId(null)} + onCloseAnnotation={() => setAnnotationNodeId(null)} + onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)} + onCloseMaskEdit={() => setMaskEditNodeId(null)} + onCloseImageEdit={() => { + setImageEditNodeId(null); + setImageEditPreset(null); + }} + onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)} + onCloseTextEdit={() => setTextEditNodeId(null)} + onCloseUpscale={() => setUpscaleNodeId(null)} + onCrop={(node, crop) => void cropImageNode(node, crop)} + onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)} + onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)} + onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)} + onImageOperation={(node, payload) => void editImageNode(node, payload)} + onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)} + onDetectText={() => { + const node = textEditNodeId ? nodeById.get(textEditNodeId) : null; + return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在")); + }} + onTextEdit={(node, payload) => void editTextImageNode(node, payload)} + onUpscale={(node, params) => void upscaleImageNode(node, params)} + config={effectiveConfig} + /> - { - artCritiqueRunningRef.current = running; - }} - node={artCritiqueNode} - upstreamNodes={artCritiqueInputs} - open={Boolean(artCritiqueNode)} - onClose={() => setArtCritiqueNodeId(null)} - onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })} - /> + setTaskDetail(null)} + onCancelTask={cancelCanvasTask} + superResolveNode={superResolveNode} + onCloseSuperResolve={() => setSuperResolveNodeId(null)} + previewNode={previewNode} + onClosePreview={() => setPreviewNodeId(null)} + clearConfirmOpen={clearConfirmOpen} + onCancelClear={() => setClearConfirmOpen(false)} + onConfirmClear={clearCanvas} + /> - setPanoramaConfigNodeId(null)} - onConfirm={(composedPrompt, config) => { - const node = nodes.find((n) => n.id === panoramaConfigNodeId); - if (node) { - createPanoramaViewerWithConfig(node, composedPrompt, config); - } - }} - onCopyPrompt={(prompt) => { - void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词")); - }} - previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined} - nodes={nodes} - /> + - setScriptEditorNodeId(null)} - onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)} - onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => { - if (!activeScriptNode || !visibleColumns.length) return; - setNodes((prev) => - prev.map((node) => - node.id === activeScriptNode.id - ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } - : node, - ), - ); - }} - onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)} - onGenerateVideos={(rowIds) => { - if (!activeScriptNode) return; - if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds); - else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds); - }} - onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })} - /> - - {previsNodeId && activePrevisScene ? ( - - - - } - > - node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey))} - onClose={() => setPrevisNodeId(null)} - onChange={savePrevisScene} - onApply={applyPrevisOutput} - onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} - onFlush={() => flushCanvasStorePersistence()} - onboardingScope={previsOnboardingScope} - /> - - ) : null} - - setVersionCompareRootId(null)} - onSetPrimary={setPrimaryVersion} - onFocus={(nodeId) => { - setVersionCompareRootId(null); - focusCanvasNode(nodeId); - }} - /> - - setCropNodeId(null)} - onCloseAnnotation={() => setAnnotationNodeId(null)} - onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)} - onCloseMaskEdit={() => setMaskEditNodeId(null)} - onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }} - onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)} - onCloseTextEdit={() => setTextEditNodeId(null)} - onCloseUpscale={() => setUpscaleNodeId(null)} - onCrop={(node, crop) => void cropImageNode(node, crop)} - onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)} - onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)} - onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)} - onImageOperation={(node, payload) => void editImageNode(node, payload)} - onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)} - onDetectText={() => { - const node = textEditNodeId ? nodeById.get(textEditNodeId) : null; - return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在")); - }} - onTextEdit={(node, payload) => void editTextImageNode(node, payload)} - onUpscale={(node, params) => void upscaleImageNode(node, params)} - config={effectiveConfig} - /> - - setTaskDetail(null)} - onCancelTask={cancelCanvasTask} - superResolveNode={superResolveNode} - onCloseSuperResolve={() => setSuperResolveNodeId(null)} - previewNode={previewNode} - onClosePreview={() => setPreviewNodeId(null)} - clearConfirmOpen={clearConfirmOpen} - onCancelClear={() => setClearConfirmOpen(false)} - onConfirmClear={clearCanvas} - /> - - - - - setCharacterLibraryOpen(false)} - onInsert={async (payloads) => { - await handleProjectAssetsInsert(payloads, characterLibraryPosition); - }} - /> - - - {versions.preview ? : null} + + setCharacterLibraryOpen(false)} + onInsert={async (payloads) => { + await handleProjectAssetsInsert(payloads, characterLibraryPosition); + }} + /> + + - ); diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index ce9af0cda..64926924d 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -9,11 +9,10 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; -import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; +import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; -import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { CanvasHistorySnapshot } from "./use-canvas-history"; @@ -94,9 +93,6 @@ export function useCanvasProjectLifecycle({ const viewportSaveTimerRef = useRef | null>(null); const observedContentRef = useRef(null); - const loadLatestRef = useRef(false); - const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null); - const pendingReloadRef = useRef<{ promise: Promise; resolve: () => void; reject: (error: unknown) => void } | null>(null); const editorReadyRef = useRef(false); useEffect(() => { @@ -105,10 +101,7 @@ export function useCanvasProjectLifecycle({ // Keep load intent on the refs until this attempt finishes. React Strict // Mode remounts the effect; consuming the flags here would turn "load // latest" into a normal open and immediately recreate the conflict. - const latest = loadLatestRef.current; - const historyRestore = historyRestoreRef.current; - const pendingReload = pendingReloadRef.current; - const keepEditor = editorReadyRef.current && (latest || Boolean(historyRestore)); + const keepEditor = editorReadyRef.current; if (!keepEditor) { editorReadyRef.current = false; setProjectLoaded(false); @@ -156,16 +149,12 @@ export function useCanvasProjectLifecycle({ const load = async () => { const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId); - if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) { + if (cachedProject && cachedProject.nodes?.length) { // 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待 applyRestoredProject(cachedProject); } - const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject }); + const loadedProject = await loadCanvasProjectForEditing(projectId, { onLoad: applyRestoredProject }); if (cancelled) return; - if (historyRestoreRef.current === historyRestore) { - historyRestoreRef.current = null; - historyRestore?.resolve(); - } if (!loadedProject) { if (!cachedProject) navigate("/canvas", { replace: true }); return; @@ -188,24 +177,10 @@ export function useCanvasProjectLifecycle({ void load() .then(() => { if (cancelled) return; - loadLatestRef.current = false; - if (pendingReloadRef.current === pendingReload) { - pendingReloadRef.current = null; - pendingReload?.resolve(); - } }) .catch((error) => { if (cancelled) return; - loadLatestRef.current = false; - if (historyRestoreRef.current === historyRestore) { - historyRestoreRef.current = null; - historyRestore?.reject(error); - } - if (pendingReloadRef.current === pendingReload) { - pendingReloadRef.current = null; - pendingReload?.reject(error); - } - if (useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" }); + useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" }); const detail = error instanceof Error ? error.message : "读取画布失败,请重试"; if (keepEditor) message.error(detail); else setLoadError(detail); @@ -289,11 +264,8 @@ export function useCanvasProjectLifecycle({ }, [message, navigate]); const deleteCurrentProject = useCallback(async () => { - let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); + const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); try { - const drafts = await readCanvasSyncDrafts(projectId); - const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : []))); - drawingIds = drawingIds.filter((id) => !preserved.has(id)); await deleteCanvasProjectsWithRemoteSync([projectId]); } catch (error) { message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试"); @@ -335,41 +307,6 @@ export function useCanvasProjectLifecycle({ if (options.flush !== false) await flushCanvasStorePersistence(); }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); - const reloadLatestCanvasProject = useCallback(async () => { - const pending = pendingReloadRef.current; - if (pending) return pending.promise; - - // The load path writes a durable draft before replacing the editor. Waiting - // for the ordinary canvas queue here makes a viewport drag able to starve - // the cloud read, so only bring the latest editor snapshot into memory. - useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "reconciling", message: "正在加载云端最新版本" }); - try { - await persistLocalEdits({ flush: false }); - } catch (error) { - useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "准备加载云端版本失败" }); - throw error; - } - let resolve!: () => void; - let reject!: (error: unknown) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - pendingReloadRef.current = { promise, resolve, reject }; - loadLatestRef.current = true; - setLoadAttempt((value) => value + 1); - return promise; - }, [persistLocalEdits, projectId]); - - const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => { - await persistLocalEdits(); - return new Promise((resolve, reject) => { - historyRestoreRef.current?.reject(new Error("已有新的恢复请求")); - historyRestoreRef.current = { snapshotId, revision, resolve, reject }; - setLoadAttempt((value) => value + 1); - }); - }, [persistLocalEdits]); - const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise => { try { await persistLocalEdits(); @@ -390,18 +327,6 @@ export function useCanvasProjectLifecycle({ return true; }, [message, persistLocalEdits, projectId]); - const forceSaveCanvasProject = useCallback(async (): Promise => { - try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; } - try { - const result = await forceOverwriteRemoteCanvasSync(projectId); - message.success(result.clearedResources > 0 ? `已强制保存,移除了 ${result.clearedResources} 个失效素材引用;本地草稿已保留` : result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存"); - } catch (error) { - message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`); - return false; - } - return true; - }, [message, persistLocalEdits]); - const clearCanvasFiles = useCallback(() => { cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); }, [cleanupCanvasFiles, projectId]); @@ -416,10 +341,7 @@ export function useCanvasProjectLifecycle({ currentProject, deleteCurrentProject, renameCurrentProject, - reloadLatestCanvasProject, - restoreCanvasProjectVersion, saveCanvasProject, - forceSaveCanvasProject, updateProject, }; } diff --git a/web/src/services/agent-canvas-sync.ts b/web/src/services/agent-canvas-sync.ts index 40c12898c..5ae0032c1 100644 --- a/web/src/services/agent-canvas-sync.ts +++ b/web/src/services/agent-canvas-sync.ts @@ -14,7 +14,6 @@ type Options = { export function createAgentCanvasSync(options: Options) { let patches: AgentCanvasPatch[] = []; let needsRefresh = false; - let supportsPatches = false; let disposed = false; let running = false; let lastRefresh = -Infinity; @@ -51,9 +50,11 @@ export function createAgentCanvasSync(options: Options) { if (disposed || (event.payload.canvasId && event.payload.canvasId !== options.canvasId)) return; const patch = event.payload.canvasPatch as AgentCanvasPatch | undefined; if (patch && patch.canvasId === options.canvasId) { - supportsPatches = true; patches.push(patch); - } else if (event.type === "canvas_updated" || event.type === "canvas_undone" || (!supportsPatches && (event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))))) { + } else if (event.type === "canvas_updated" || event.type === "canvas_undone" || event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))) { + // A patch may be dropped even after earlier patches succeeded. + // Keep the snapshot fallback available for every meaningful task + // boundary; refreshes remain rate-limited below. needsRefresh = true; } schedule(); diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index e89abb119..dc9e812c4 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -41,6 +41,22 @@ export type CanvasLibrarySummary = Pick } + | { type: "create_node"; node: CanvasProject["nodes"][number] } + | { type: "patch_node"; id: string; patch: Record; before?: Record } + | { type: "remove_node"; id: string; expected?: CanvasProject["nodes"][number] } + | { type: "create_connection"; connection: CanvasProject["connections"][number] } + | { type: "update_connection"; id: string; connection: CanvasProject["connections"][number]; expected: CanvasProject["connections"][number] } + | { type: "remove_connection"; id: string; expected?: CanvasProject["connections"][number] } + | { type: "set_field"; field: "title" | "projectId" | "chatSessions" | "activeChatId" | "starterMode" | "appearance" | "backgroundMode" | "showImageInfo" | "previsScenes" | "timeline"; value: unknown; beforeValue: unknown }; + +export type CanvasOperationsRequest = { + mutationId: string; + baseRevision?: number; + operations: CanvasOperation[]; +}; + export function listRemoteCanvasProjectsPage(options: { page: number; pageSize: number; projectId?: string; query?: string; sort?: string; signal?: AbortSignal }) { return http.get<{ projects: CanvasLibrarySummary[]; page: number; pageSize: number; total: number; hasMore: boolean }>("/canvas-projects", { signal: options.signal, @@ -130,36 +146,10 @@ export function getRemoteCanvasProject(id: string, knownProject?: CanvasProject) .then((result) => ("notModified" in result ? { project: knownProject, notModified: true } : { ...result, notModified: false })); } -export function upsertRemoteCanvasProject(project: CanvasProject, options?: { repairMissingResources?: boolean }) { - const { viewport: _viewport, remoteContentHash: _hash, ...content } = project; - return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content, ...(options?.repairMissingResources ? { repairMissingResources: true } : {}) }); +export function applyRemoteCanvasOperations(id: string, request: CanvasOperationsRequest) { + return http.patch<{ project: RemoteUserDataSummary & { revision: number }; mutationId: string }>(`/canvas-projects/${encodeURIComponent(id)}/operations`, request); } export function deleteRemoteCanvasProject(id: string) { return http.delete<{ id: string }>(`/canvas-projects/${encodeURIComponent(id)}`); } - -export type CanvasHistoryEntry = { - id: string; - canvasId: string; - revision: number; - title: string; - nodeCount: number; - connectionCount: number; - payloadBytes: number; - reason: "automatic" | "before_restore"; - createdAt: string; - contentUpdatedAt: string; -}; - -export function listCanvasHistory(id: string, signal?: AbortSignal) { - return http.get<{ snapshots: CanvasHistoryEntry[]; currentRevision: number }>(`/canvas-projects/${encodeURIComponent(id)}/history`, { signal }); -} - -export function getCanvasHistoryEntry(id: string, snapshotId: string, signal?: AbortSignal) { - return http.get<{ snapshot: CanvasHistoryEntry; project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}`, { signal }); -} - -export function restoreRemoteCanvasHistory(id: string, snapshotId: string, revision: number) { - return http.post<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}/restore`, { revision }); -} diff --git a/web/src/services/canvas-generation-consumer.ts b/web/src/services/canvas-generation-consumer.ts index 59cc2cca3..893780286 100644 --- a/web/src/services/canvas-generation-consumer.ts +++ b/web/src/services/canvas-generation-consumer.ts @@ -207,7 +207,14 @@ function reconcileCinematicEntities(previous: continue; } const previousEntity = previousById.get(liveEntity.id); - if (!previousEntity) continue; + if (!previousEntity) { + // A generated entity may have been created after the previous + // snapshot. Keep the live branch visible so an ACK failure does not + // turn a recoverable local result into a silent deletion. + reconciled.push(liveEntity); + included.add(liveEntity.id); + continue; + } reconciled.push(previousEntity); included.add(previousEntity.id); } diff --git a/web/src/services/canvas-missing-resources.ts b/web/src/services/canvas-missing-resources.ts deleted file mode 100644 index b95a9c946..000000000 --- a/web/src/services/canvas-missing-resources.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { refreshResource, type RemoteResource } from "@/services/api/resources"; -import { ApiError } from "@/services/api/request"; -import { preserveCanvasSyncDraft } from "@/services/canvas-sync-drafts"; -import { getActiveUserScope } from "@/lib/user-scope"; -import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; - -// Keep these fields aligned with internal/assets/references.go. Prompts and -// ordinary text containing a resource URL are not media locators. -const locatorFields = new Set([ - "storageKey", - "content", - "previewContent", - "drawingPreviewStorageKey", - "drawingPreviewUrl", - "url", - "dataUrl", - "coverUrl", - "imageUrl", - "videoUrl", - "audioUrl", - "referenceUrl", - "referenceUrls", - "artifactRef", - "providerArtifactRef", -]); -const idFields = new Set(["resourceId", "resourceIds", "sampleResourceId", "referenceResourceId", "referenceResourceIds"]); -const textFields = new Set(["prompt", "composerContent", "title", "text", "description", "fileName", "name"]); - -function resourceId(field: string, value: unknown, allowUnknownField = false): string | undefined { - if (typeof value !== "string") return; - const text = value.trim(); - if (idFields.has(field)) return /^[A-Za-z0-9_-]{1,80}$/.test(text) ? text : undefined; - if ((!locatorFields.has(field) && !allowUnknownField) || textFields.has(field)) return; - return /^resource:([A-Za-z0-9_-]{1,80})$/.exec(text)?.[1] || /^(?:https?:\/\/[^/]+)?\/(?:api\/)?resources\/([A-Za-z0-9_-]{1,80})\/file(?:[?#].*)?$/.exec(text)?.[1]; -} - -export function canvasResourceIds(value: unknown, field = "", ids = new Set()): Set { - const id = resourceId(field, value); - if (id) ids.add(id); - else if (Array.isArray(value)) for (const child of value) canvasResourceIds(child, field, ids); - else if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) canvasResourceIds(child, key, ids); - return ids; -} - -/** Only called after explicit overwrite and a durable backup of the original. */ -export function clearMissingCanvasResources(value: T, missing: ReadonlySet, field = "", allowUnknownField = false): T { - const id = resourceId(field, value, allowUnknownField); - if (id && missing.has(id)) return undefined as T; - if (Array.isArray(value)) return value.map((child) => clearMissingCanvasResources(child, missing, field, allowUnknownField)).filter((child) => child !== undefined) as T; - if (!value || typeof value !== "object") return value; - const record = value as Record; - const next: Record = {}; - for (const [key, child] of Object.entries(record)) { - const cleaned = clearMissingCanvasResources(child, missing, key, allowUnknownField); - if (cleaned !== undefined) next[key] = cleaned; - } - if ( - ["storageKey", "content", "url", "dataUrl"].some((key) => { - const id = resourceId(key, record[key], allowUnknownField); - return id && missing.has(id); - }) - ) - delete next.assetId; - if (["image", "video", "audio"].includes(String(record.type)) && next.metadata && typeof next.metadata === "object") { - const metadata = next.metadata as Record; - if (!metadata.storageKey && !metadata.content && canvasResourceIds(record.metadata).size > canvasResourceIds(metadata).size) { - metadata.status = "idle"; - } - } - return next as T; -} - -export async function repairMissingResourcesForOverwrite( - projectIds?: Set, - lookup: (id: string) => Promise = refreshResource, - preserve: (project: CanvasProject, scope: string) => Promise = preserveCanvasSyncDraft, -): Promise { - const scope = getActiveUserScope(); - const selected = (project: CanvasProject) => !projectIds || projectIds.has(project.id); - const ids = [...canvasResourceIds(useCanvasStore.getState().projects.filter(selected))]; - const missing = new Set(); - for (let offset = 0; offset < ids.length; offset += 8) { - await Promise.all( - ids.slice(offset, offset + 8).map(async (id) => { - try { - const resource = await lookup(id); - if (resource.status === "deleted" || resource.status === "failed") missing.add(id); - else if (resource.status !== "ready") throw new Error("素材仍在上传,请完成后再保存"); - } catch (error) { - if (error instanceof ApiError && error.status === 404) missing.add(id); - else throw error; - } - }), - ); - } - if (getActiveUserScope() !== scope) throw new Error("账号已切换,已停止修复保存"); - if (!missing.size) return 0; - await clearMissingCanvasResourceIdsForOverwrite(projectIds, missing, preserve); - return missing.size; -} - -/** Server validation is authoritative. This fallback handles locator shapes - * added on the server before the client collector knows about them. */ -export async function clearMissingCanvasResourceIdsForOverwrite(projectIds: Set | undefined, missing: ReadonlySet, preserve: (project: CanvasProject, scope: string) => Promise = preserveCanvasSyncDraft): Promise { - const scope = getActiveUserScope(); - const selected = (project: CanvasProject) => !projectIds || projectIds.has(project.id); - const projects = useCanvasStore.getState().projects.filter(selected); - for (const project of projects) await preserve(project, scope); - if (getActiveUserScope() !== scope) throw new Error("账号已切换,已停止修复保存"); - if (projects.some((project) => useCanvasStore.getState().openProject(project.id) !== project)) throw new Error("画布仍在编辑,请重新点击覆盖保存"); - const repaired = new Map(projects.map((project) => [project.id, { ...clearMissingCanvasResources(project, missing, "", true), updatedAt: new Date().toISOString() }])); - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => repaired.get(project.id) ?? project) })); - return projects.length; -} diff --git a/web/src/services/canvas-sync-drafts.ts b/web/src/services/canvas-sync-drafts.ts deleted file mode 100644 index a51217cdb..000000000 --- a/web/src/services/canvas-sync-drafts.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { nanoid } from "nanoid"; -import { localForageStorageForScope } from "@/lib/localforage-storage"; -import { getActiveUserScope } from "@/lib/user-scope"; -import { sameCanvasContent } from "@/lib/canvas/canvas-content"; -import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository"; -import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; - -export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject }; -const draftKey = (projectId: string) => `yingce:sync-drafts:${projectId}`; -const draftIndexKey = "yingce:sync-draft-index"; - -export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) { - const raw = await localForageStorageForScope(scope).getItem(draftIndexKey); - const ids: string[] = raw ? JSON.parse(raw) : []; - return (await Promise.all(ids.map((id) => readCanvasSyncDrafts(id, scope)))).flat(); -} - -export async function readCanvasSyncDrafts(projectId: string, scope = getActiveUserScope()): Promise { - const raw = await localForageStorageForScope(scope).getItem(draftKey(projectId)); - return raw ? JSON.parse(raw) : []; -} - -/** Caller must hold the scope's canvas persistence lock. Never reacquire it here. */ -export async function preserveCanvasSyncDraftLocked(snapshot: CanvasProject, scope: string) { - try { - const drafts = await readCanvasSyncDrafts(snapshot.id, scope); - if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) { - const storage = localForageStorageForScope(scope); - const rawIndex = await storage.getItem(draftIndexKey); - const ids: string[] = rawIndex ? JSON.parse(rawIndex) : []; - if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id])); - drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot }); - await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts)); - } - if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(snapshot.id, { draftCount: drafts.length, draftError: undefined }); - return drafts.length; - } catch (error) { - if (getActiveUserScope() === scope) { - const progress = useSyncProgressStore.getState(); - const current = progress.syncingProjects[snapshot.id]; - progress.setProjectProgress(snapshot.id, { - phase: current?.phase === "conflict" ? "conflict" : "error", - errorKind: "draft", - draftError: error instanceof Error ? error.message : "本地草稿保存失败", - message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变", - }); - } - throw error; - } -} - -export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) { - const snapshot = structuredClone(project); - return withGenerationArtifactCommitLock(scope, () => withCanvasStorePersistenceLock(scope, () => preserveCanvasSyncDraftLocked(snapshot, scope))); -} diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 84886bcad..a587f53d4 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -3,19 +3,19 @@ import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resour import { deleteRemoteAssets, deleteRemoteCanvasProject, + applyRemoteCanvasOperations, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, - restoreRemoteCanvasHistory, upsertRemoteAsset, - upsertRemoteCanvasProject, } from "@/services/api/user-data"; +import type { CanvasOperation, CanvasOperationsRequest } from "@/services/api/user-data"; import { ApiError } from "@/services/api/request"; import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content"; import { getActiveUserScope } from "@/lib/user-scope"; -import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; +import { localForageStorageForScope } from "@/lib/localforage-storage"; import { appQueryClient } from "@/lib/query-client"; import { parseAssetRecordList } from "@/lib/asset-record"; import { assetForRemoteSync } from "@/lib/asset-remote-sync"; @@ -25,11 +25,10 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"; -import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair"; +import { repairMissingCanvasAssets } from "@/services/canvas-asset-repair"; import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset"; import { applyAgentCanvasPatch, mergeAgentCanvasEditor, mergeThreeWayValue, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch"; import { collectLocalMediaKeys, ensureRemoteResourceReferences, restoreRemoteTextNodeContents } from "./user-data-sync-media"; -import { clearMissingCanvasResourceIdsForOverwrite, repairMissingResourcesForOverwrite } from "./canvas-missing-resources"; export { numberValue } from "./user-data-sync-media"; @@ -45,22 +44,113 @@ let remoteOperationTail: Promise = Promise.resolve(); let subscriptionsInstalled = false; let acknowledgedAssets = new Map(); let acknowledgedProjects = new Map(); +type PendingCanvasMutation = { projectId: string; request: CanvasOperationsRequest; baseline?: CanvasProject; prepared: CanvasProject; createdAt: string }; +const pendingCanvasMutations = new Map(); +const pendingCanvasMutationKey = "yingce:canvas-mutation-journal"; +let pendingJournalScope = ""; +let pendingJournalLoaded = false; let incrementalSession = false; let sessionEpoch = 0; const verifiedProjects = new Set(); +const remoteDeletedProjects = new Set(); const verifiedAssets = new Set(); -type RemoteProjectLoad = { mode: "normal" | "latest" | "history"; promise: Promise }; +type RemoteProjectLoad = { mode: "normal"; promise: Promise }; const remoteProjectLoadPromises = new Map(); const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const; +export function remoteUserDataRetryDelay(attempt: number, retryAfterMs = 0) { + const normalizedAttempt = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0; + const delay = REMOTE_SYNC_RETRY_DELAYS_MS[Math.min(normalizedAttempt, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1)]; + return Math.max(delay, retryAfterMs); +} + +async function loadPendingCanvasMutationJournal(scope = getActiveUserScope()) { + if (pendingJournalLoaded && pendingJournalScope === scope) return; + pendingJournalScope = scope; + pendingJournalLoaded = true; + pendingCanvasMutations.clear(); + const raw = await localForageStorageForScope(scope).getItem(pendingCanvasMutationKey); + if (!raw) return; + try { + const entries = JSON.parse(raw) as PendingCanvasMutation[]; + let repaired = false; + for (const entry of entries) { + if (!(entry?.projectId && entry.request?.mutationId && Array.isArray(entry.request.operations))) continue; + const request = repairPendingCanvasMutationRequest(entry.request, entry.baseline, entry.prepared); + repaired ||= request !== entry.request; + pendingCanvasMutations.set(entry.projectId, request === entry.request ? entry : { ...entry, request }); + } + if (repaired) await persistPendingCanvasMutationJournal(); + } catch { + await localForageStorageForScope(scope).removeItem(pendingCanvasMutationKey); + } +} + +/** + * 修复旧版生成器写入 journal 的无效请求,这些请求服务端必然拒绝: + * 1. 新画布请求在 create_canvas.patch 之后又追加了同字段 set_field(beforeValue 缺失或为 null), + * 服务端先创建字段再校验基线,必然冲突;这些 set_field 与 patch 重复,直接移除。 + * 2. 已有画布的 set_field 因 undefined 被 JSON 丢弃属性,补回 JSON null 还原原始意图。 + * 服务端校验失败会整笔回滚、不写 receipt,因此沿用原 mutationId 不会触发 canvas_mutation_id_reused。 + */ +export function repairPendingCanvasMutationRequest(request: CanvasOperationsRequest, baseline?: CanvasProject, prepared?: CanvasProject): CanvasOperationsRequest { + let changed = false; + const create = request.operations.find((operation) => operation.type === "create_canvas"); + const createdFields = new Set(create && create.type === "create_canvas" ? Object.keys(create.patch || {}) : []); + const kept = request.operations.filter((operation) => { + if (operation.type === "set_field" && operation.field === "projectId" && (operation.value === null || operation.value === undefined) && operation.beforeValue === "") { + // 空字符串和缺省 projectId 都表示未关联项目。旧版本把这次语义上的 no-op + // 记录成 set_field,服务端按字面字段比较时会误报冲突;移除它不会丢弃编辑。 + changed = true; + return false; + } + if (!create || operation.type !== "set_field" || !createdFields.has(operation.field)) return true; + changed = true; + return false; + }); + const operations = kept.map((operation) => { + if (operation.type !== "set_field") return operation; + const hasBefore = Object.prototype.hasOwnProperty.call(operation, "beforeValue") && operation.beforeValue !== undefined; + const hasValue = Object.prototype.hasOwnProperty.call(operation, "value") && operation.value !== undefined; + if (hasBefore && hasValue) return operation; + changed = true; + return { + ...operation, + value: hasValue ? operation.value : canvasOperationValue(prepared?.[operation.field]), + beforeValue: hasBefore ? operation.beforeValue : canvasOperationValue(baseline?.[operation.field]), + }; + }); + return changed ? { ...request, operations } : request; +} + +async function persistPendingCanvasMutationJournal() { + if (!pendingJournalScope) return; + const storage = localForageStorageForScope(pendingJournalScope); + if (!pendingCanvasMutations.size) { + await storage.removeItem(pendingCanvasMutationKey); + return; + } + await storage.setItem(pendingCanvasMutationKey, JSON.stringify([...pendingCanvasMutations.values()])); +} + +function newCanvasMutationId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID(); + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + export async function initializeRemoteUserDataSession(userId: string) { await withRemoteUserDataSyncExclusive(async () => { resetRemoteUserDataSync(); activeRemoteUserId = userId; + await loadPendingCanvasMutationJournal(); incrementalSession = true; acknowledgedProjects = new Map(useCanvasStore.getState().projects.map((project) => [project.id, project])); + for (const pending of pendingCanvasMutations.values()) { + if (pending.baseline) acknowledgedProjects.set(pending.projectId, pending.baseline); + } acknowledgedAssets = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset])); remoteUserDataPhase = "ready"; + if (pendingCanvasMutations.size) scheduleRemoteUserDataSync(); }); } @@ -71,16 +161,11 @@ function liveCanvasIfUnchanged(id: string, snapshot: CanvasProject | null | unde return live && sameCanvasContent(live, snapshot) ? live : undefined; } -export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) { - const mode = options.historyRestore ? "history" : options.latest ? "latest" : "normal"; +export async function loadCanvasProjectForEditing(id: string, options: { onLoad?: (project: CanvasProject) => void } = {}) { + const mode = "normal"; const existing = remoteProjectLoadPromises.get(id); const existingRequest = existing?.promise; if (existingRequest) { - if (existing?.mode !== mode) { - await existingRequest.catch(() => undefined); - return loadCanvasProjectForEditing(id, options); - } - if (mode !== "normal") return existingRequest; return existingRequest.then(async (loaded) => { if (loaded) options.onLoad?.(loaded); return loaded; @@ -97,25 +182,6 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest? if (live === undefined) throw new Error(message); return live; }; - if (options.historyRestore) { - const local = useCanvasStore.getState().openProject(id); - if (local) { - const draftCount = await preserveCanvasSyncDraft(local, scope); - useSyncProgressStore.getState().setProjectProgress(id, { draftCount }); - } - requireUnchangedLocal(local, "本地内容仍在更新,请稍后再恢复历史版本"); - const { snapshotId, revision } = options.historyRestore; - try { - const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision); - if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果"); - if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容"); - } catch (error) { - if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) { - useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message }); - } - throw error; - } - } let remote: CanvasProject; try { const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined; @@ -137,27 +203,17 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest? const cachedClean = local?.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local)); const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local)); if (dirty && !sameCanvasContent(local, remote)) { - const draftCount = await preserveCanvasSyncDraft(local, scope); - const liveAfterDraft = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本"); - useSyncProgressStore.getState().setProjectProgress(id, { draftCount }); - if (!options.latest && !options.historyRestore && local.revision !== undefined) { - if (local.revision !== remote.revision) { - useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" }); - } else { - // A cached draft is not an acknowledged cloud save. Once its - // ancestor is verified, retain it as pending against that content. - acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash }); - verifiedProjects.add(id); - useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" }); - scheduleRemoteUserDataSync(); - } - options.onLoad?.(liveAfterDraft || local); - return liveAfterDraft || local; - } + const live = requireUnchangedLocal(local, "画布仍在更新,未确认操作已保留"); + acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash }); + verifiedProjects.add(id); + useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", cloudRevision: remote.revision, pendingChanges: 1, message: "保留本地未确认操作,等待自动重放" }); + options.onLoad?.(live || local); + scheduleRemoteUserDataSync(); + return live || local; } // Editing/generation may continue during the network request or draft write. // Viewport-only updates are not document edits and must not block adopting cloud content. - const live = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本"); + const live = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容"); const project = { ...remote, viewport: live?.viewport || local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash }; // Align live editor refs synchronously before publishing the new revision. // A generation callback must never see old rendered nodes paired with a new revision. @@ -166,7 +222,7 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest? verifiedProjects.add(id); useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] })); await flushCanvasStorePersistence(); - useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已加载云端最新版本" }); + useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已确认云端画布基线" }); return project; }); const pending = { mode, promise: request } as RemoteProjectLoad; @@ -254,7 +310,7 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa let projected = current; // A rejected delta requests a snapshot through createAgentCanvasSync. // Only that reconciliation can distinguish stale replay from a conflict. - if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== (await canvasContentHash(baseline))) throw new Error("本地缓存尚未核对,请加载云端最新版本"); + if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== (await canvasContentHash(baseline))) throw new Error("本地画布基线尚未核对,等待自动校准"); if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化"); const orderedPatches: AgentCanvasPatch[] = []; const seenRevisions = new Set(); @@ -291,9 +347,24 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa } async function preserveAgentConflict(project: CanvasProject) { - useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" }); - const draftCount = await preserveCanvasSyncDraft(project); - useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount }); + useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", pendingChanges: 1, message: "Agent 修改等待按原操作自动重放" }); + scheduleRemoteUserDataSync(); +} + +function pendingMutationCreatesCanvas(mutation: PendingCanvasMutation | undefined) { + return mutation?.request.operations[0]?.type === "create_canvas"; +} + +export function mergeRemoteAssetsWithLocalPending(remoteAssets: Asset[], localAssets: Asset[]) { + const merged = new Map(remoteAssets.map((asset) => [asset.id, asset])); + for (const local of localAssets) { + const remote = merged.get(local.id); + // A local asset absent from the snapshot is an unacknowledged create. + // A same-ID difference is an unconfirmed edit. Keep both kinds visible; + // the normal save path will validate/rebase them against the server. + if (!remote || !sameEntitySnapshot(remote, local)) merged.set(local.id, local); + } + return [...merged.values()]; } async function waitForRemoteProjectLoads() { @@ -357,8 +428,9 @@ export async function syncRemoteUserData(userId?: string | null) { await withRemoteUserDataSyncExclusive(async () => { incrementalSession = false; activeRemoteUserId = userId || ""; + if (activeRemoteUserId) await loadPendingCanvasMutationJournal(); acknowledgedProjects.clear(); - acknowledgedAssets.clear(); + acknowledgedAssets = new Map(); if (!activeRemoteUserId) { remoteUserDataPhase = "inactive"; return; @@ -369,29 +441,84 @@ export async function syncRemoteUserData(userId?: string | null) { // 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。 const snapshot = await getRemoteUserDataSnapshot(); const localProjects = useCanvasStore.getState().projects; + const localAssets = useAssetStore.getState().assets; const remoteById = new Map(snapshot.projects.map((project) => [project.id, project])); - // Archive unsynced work before replacing the active cache, including deleted remote canvases. + // Convert cached local edits into the same durable operation journal before replacing the cache. for (const local of localProjects) { + const pending = pendingCanvasMutations.get(local.id); + const remote = remoteById.get(local.id); + if (!remote) { + if (pending && !pendingMutationCreatesCanvas(pending)) { + remoteDeletedProjects.add(local.id); + useSyncProgressStore.getState().setProjectProgress(local.id, { phase: "error", errorKind: "conflict", pendingChanges: 1, message: "云端画布已删除,未确认操作不会重新创建它" }); + continue; + } + if (!pending && (local.revision ?? 0) > 0) { + remoteDeletedProjects.add(local.id); + useSyncProgressStore.getState().setProjectProgress(local.id, { phase: "error", errorKind: "conflict", pendingChanges: 1, message: "云端画布已删除,未确认操作不会重新创建它" }); + continue; + } + } + if (remote) remoteDeletedProjects.delete(local.id); + if (pending) continue; const clean = local.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local)); - if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local); + if (clean || sameCanvasContent(local, remote)) continue; + const operations = buildCanvasOperations(remote, local); + if (operations.length) { + pendingCanvasMutations.set(local.id, { + projectId: local.id, + request: { mutationId: newCanvasMutationId(), ...(remote ? { baseRevision: remote.revision } : {}), operations }, + baseline: remote, + prepared: local, + createdAt: new Date().toISOString(), + }); + } } - const projects = await Promise.all( + await persistPendingCanvasMutationJournal(); + const remoteProjects = await Promise.all( snapshot.projects.map(async (project) => { const local = localProjects.find((item) => item.id === project.id); - const draftCount = (await readCanvasSyncDrafts(project.id)).length; - useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, draftCount, message: "已保存到云端" }); - return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) }; + const remoteContentHash = await canvasContentHash(project); + const pending = pendingCanvasMutations.has(project.id); + useSyncProgressStore.getState().setProjectProgress(project.id, { phase: pending ? "pending" : "done", cloudRevision: project.revision, pendingChanges: pending ? 1 : 0, message: pending ? "保留未确认画布操作,等待重放" : "已保存到云端" }); + return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash }; }), ); + const orphanProjects = localProjects + .filter((project) => remoteDeletedProjects.has(project.id)) + .map((project) => ({ ...project, remoteContentHash: project.remoteContentHash })); + // A revision-0 project can be absent from the cloud snapshot because its + // create mutation has not been acknowledged yet. Keep it visible so the + // pending journal can replay against the next session. + const pendingProjects = localProjects + .filter((project) => { + const pending = pendingCanvasMutations.get(project.id); + return Boolean(pending && pendingMutationCreatesCanvas(pending) && !remoteById.has(project.id)); + }) + .map((project) => ({ ...project, remoteContentHash: project.remoteContentHash })); + const projects = [ + ...remoteProjects.map((project) => { + const pending = pendingCanvasMutations.get(project.id); + const local = localProjects.find((item) => item.id === project.id); + return pending && local ? { ...local, revision: project.revision, remoteContentHash: project.remoteContentHash } : project; + }), + ...orphanProjects, + ...pendingProjects.filter((project) => !remoteDeletedProjects.has(project.id)), + ]; + const remoteAssets = parseAssetRecordList(snapshot.assets); + const assets = mergeRemoteAssetsWithLocalPending(remoteAssets, localAssets); if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步"); useCanvasStore.getState().replaceProjects(projects); - useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets)); + useAssetStore.getState().replaceAssets(assets); await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); - acknowledgedProjects = new Map(projects.map((project) => [project.id, project])); - acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset])); + acknowledgedProjects = new Map(remoteProjects.map((project) => [project.id, project])); + acknowledgedAssets = new Map(remoteAssets.map((asset) => [asset.id, asset])); remoteUserDataPhase = "ready"; + syncRetryAttempt = 0; + if (pendingCanvasMutations.size) scheduleRemoteUserDataSync(); } catch (error) { remoteUserDataPhase = "failed"; + scheduleRemoteUserDataHydrationRetry(error); throw error; } }); @@ -410,7 +537,6 @@ export function installRemoteUserDataAutoSync() { if (remoteUserDataPhase === "ready") { for (const project of changed) { if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue; - if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue; useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" }); } } @@ -425,12 +551,16 @@ export function resetRemoteUserDataSync() { sessionEpoch += 1; incrementalSession = false; verifiedProjects.clear(); + remoteDeletedProjects.clear(); verifiedAssets.clear(); remoteProjectLoadPromises.clear(); activeRemoteUserId = ""; remoteUserDataPhase = "inactive"; acknowledgedAssets.clear(); acknowledgedProjects.clear(); + pendingCanvasMutations.clear(); + pendingJournalScope = ""; + pendingJournalLoaded = false; if (syncTimer) { window.clearTimeout(syncTimer); syncTimer = null; @@ -499,22 +629,10 @@ function updateRetryProgress(delayMs: number) { } } -function stopRetryProgress() { - const progress = useSyncProgressStore.getState(); - for (const item of Object.values(progress.syncingProjects)) { - if (item.phase !== "error" || !item.message?.includes("自动重试")) continue; - progress.setProjectProgress(item.projectId, { message: "云端同步失败,请点击立即重试" }); - } -} - function scheduleRemoteUserDataRetry(error: unknown) { if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "ready") return false; - if (syncRetryAttempt >= REMOTE_SYNC_RETRY_DELAYS_MS.length) { - stopRetryProgress(); - return false; - } - const delayMs = Math.max(REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt], error instanceof ApiError ? error.retryAfterMs || 0 : 0); - syncRetryAttempt += 1; + const delayMs = remoteUserDataRetryDelay(syncRetryAttempt, error instanceof ApiError ? error.retryAfterMs || 0 : 0); + syncRetryAttempt = Math.min(syncRetryAttempt + 1, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1); if (syncTimer) window.clearTimeout(syncTimer); updateRetryProgress(delayMs); syncTimer = window.setTimeout(() => { @@ -524,23 +642,25 @@ function scheduleRemoteUserDataRetry(error: unknown) { return true; } -/** 用户主动点击重试时重新开始退避窗口,不影响本地已保存的内容。 */ -export function retryRemoteUserDataSync(projectId?: string) { - syncRetryAttempt = 0; - if (syncTimer) { - window.clearTimeout(syncTimer); +function scheduleRemoteUserDataHydrationRetry(error: unknown) { + if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "failed") return false; + const delayMs = remoteUserDataRetryDelay(syncRetryAttempt, error instanceof ApiError ? error.retryAfterMs || 0 : 0); + syncRetryAttempt = Math.min(syncRetryAttempt + 1, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1); + if (syncTimer) window.clearTimeout(syncTimer); + syncTimer = window.setTimeout(() => { syncTimer = null; - } - return saveRemoteUserDataNow(projectId); + void syncRemoteUserData(activeRemoteUserId).catch((retryError) => console.warn("云端数据基线自动恢复失败", retryError)); + }, delayMs); + return true; } export function formatLocalSavedRemotePending(localAction: string, error: unknown): string { const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误"; if (error instanceof ApiError && error.reason === "canvas_history_resources_missing") { - return `${localAction},云端同步已暂停:${detail}。请修复缺失素材或从有效历史版本恢复。`; + return `${localAction},云端同步已暂停:${detail}。资源补齐后会自动重放未确认操作。`; } if (error instanceof ApiError && (error.status === 409 || error.status === 428)) { - return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`; + return `${localAction},云端同步已暂停:${detail}。未确认操作会保留并按原请求自动重放。`; } return `${localAction},云端同步失败:${detail}。将自动重试。`; } @@ -554,6 +674,7 @@ export function localSavedRemotePendingMessage(localAction: string, error: unkno export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { const id = useCanvasStore.getState().createProject(title, projectId); if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); + remoteDeletedProjects.delete(id); if (!activeRemoteUserId) return { id }; try { await saveRemoteUserDataNow(id); @@ -610,6 +731,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { if (activeRemoteUserId) { await deleteRemoteCanvasProject(id); acknowledgedProjects.delete(id); + remoteDeletedProjects.delete(id); } useCanvasStore.getState().deleteProjects([id]); // 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。 @@ -707,54 +829,27 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { }); } -export async function saveRemoteUserDataNow(input?: string | readonly string[] | { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean }) { - const projectId = typeof input === "string" || Array.isArray(input) - ? (input as string | readonly string[]) - : input && typeof input === "object" && !Array.isArray(input) - ? (input as { projectId?: string | readonly string[] }).projectId - : undefined; - const options = input && typeof input === "object" && !Array.isArray(input) - ? (input as { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean }) - : {}; +export async function saveRemoteUserDataNow(input?: string | readonly string[]) { + const projectId = input; const epoch = sessionEpoch; if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地"); requireRemoteUserDataBaseline(); - const assertNoConflict = () => { - if (options.force) return; - const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId; - if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) { - throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 }); - } - }; - if (projectId !== undefined) assertNoConflict(); await waitForRemoteProjectLoads(); if (syncPromise) { syncQueued = true; - try { - await syncPromise; - } catch (error) { - // A failed automatic save must not cancel a later explicit repair. - if (!options.force && !options.repairMissingResources) throw error; - } + await syncPromise; if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存"); - assertNoConflict(); - // The in-flight drain may have started before this explicit repair/force - // request. Re-enter after it settles so the user's intent is not lost. - if (options.force || options.repairMissingResources) { - return saveRemoteUserDataNow(input); - } return; } syncPromise = withRemoteUserDataSyncExclusive(async () => { requireRemoteUserDataBaseline(); if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存"); - await drainRemoteUserDataChanges(options); + await drainRemoteUserDataChanges({ projectId }); }); let syncFailed = false; let syncError: unknown; try { await syncPromise; - assertNoConflict(); syncRetryAttempt = 0; } catch (error) { syncFailed = true; @@ -769,56 +864,98 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] | } } -export async function overwriteRemoteCanvasProject(projectId: string) { - if (!projectId.trim()) throw new Error("缺少画布 ID,无法覆盖云端"); - return forceOverwriteRemoteCanvasSync(projectId); +function canvasOperationValueEqual(left: unknown, right: unknown) { + return canonicalize(left) === canonicalize(right); } -export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise { - const epoch = sessionEpoch; - if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试"); - requireRemoteUserDataBaseline(); - await waitForRemoteProjectLoads(); - const rebind = await withRemoteUserDataSyncExclusive(async () => { - if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存"); - requireRemoteUserDataBaseline(); - const selectedProjectIds = projectId === undefined ? undefined : new Set([projectId]); - if (projectId !== undefined && !useCanvasStore.getState().openProject(projectId)) throw new Error("画布不存在,无法覆盖云端"); - const clearedResources = await repairMissingResourcesForOverwrite(selectedProjectIds); - if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存"); - const projects = useCanvasStore.getState().projects; - const claimedIds = [...collectCanvasMediaAssetIds(selectedProjectIds ? projects.filter((project) => selectedProjectIds.has(project.id)) : projects)]; - const remoteAssets: Asset[] = []; - for (let offset = 0; offset < claimedIds.length; offset += 100) { - const { assets } = await getRemoteAssetsByIds(claimedIds.slice(offset, offset + 100)); - remoteAssets.push(...assets); +function diffCanvasObject(before: Record, after: Record, prefix = "") { + const patch: Record = {}; + const previous: Record = {}; + const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(); + for (const key of keys) { + const path = prefix ? `${prefix}.${key}` : key; + const hasBefore = Object.prototype.hasOwnProperty.call(before, key); + const hasAfter = Object.prototype.hasOwnProperty.call(after, key); + const oldValue = before[key]; + const newValue = after[key]; + if (hasBefore && hasAfter && oldValue && newValue && typeof oldValue === "object" && typeof newValue === "object" && !Array.isArray(oldValue) && !Array.isArray(newValue)) { + const nested = diffCanvasObject(oldValue as Record, newValue as Record, path); + Object.assign(patch, nested.patch); + Object.assign(previous, nested.previous); + continue; } - if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存"); - const remoteById = new Map(remoteAssets.map((asset) => [asset.id, asset])); - const merged = [...remoteAssets, ...useAssetStore.getState().assets.filter((asset) => !remoteById.has(asset.id))]; - const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged), selectedProjectIds); - await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); - return { ...result, clearedResources }; - }); - try { - await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true }); - } catch (error) { - // The server is authoritative about resource locators. Retry once with - // the IDs returned in its diagnostic in case the client did not yet - // recognize a newly introduced locator field. - if (!(error instanceof ApiError) || error.reason !== "canvas_history_resources_missing") throw error; - const ids = Array.isArray(error.details?.resourceIds) - ? error.details.resourceIds.filter((id): id is string => typeof id === "string" && id.length > 0) - : []; - if (!ids.length) throw error; - await clearMissingCanvasResourceIdsForOverwrite(projectId === undefined ? undefined : new Set([projectId]), new Set(ids)); - await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); - await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true }); + if (hasBefore && hasAfter && canvasOperationValueEqual(oldValue, newValue)) continue; + previous[path] = hasBefore ? oldValue : null; + patch[path] = hasAfter ? newValue : null; } - return rebind; + return { patch, previous }; } -async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) { +function canvasOperationValue(value: unknown) { + return value === undefined ? null : value; +} + +const CANVAS_OPERATION_FIELDS = ["title", "projectId", "chatSessions", "activeChatId", "starterMode", "appearance", "backgroundMode", "showImageInfo", "previsScenes", "timeline"] as const; + +export function buildCanvasOperations(base: CanvasProject | undefined, next: CanvasProject): CanvasOperation[] { + const operations: CanvasOperation[] = []; + if (!base) { + operations.push({ + type: "create_canvas", + id: next.id, + patch: { + title: next.title, + projectId: next.projectId, + chatSessions: next.chatSessions, + activeChatId: next.activeChatId, + starterMode: next.starterMode, + appearance: next.appearance, + backgroundMode: next.backgroundMode, + showImageInfo: next.showImageInfo, + previsScenes: next.previsScenes, + timeline: next.timeline, + }, + }); + } + const baseNodes = new Map((base?.nodes || []).map((node) => [node.id, node])); + const nextNodes = new Map(next.nodes.map((node) => [node.id, node])); + for (const node of next.nodes) { + const previous = baseNodes.get(node.id); + if (!previous) { + operations.push({ type: "create_node", node }); + continue; + } + const diff = diffCanvasObject(previous as unknown as Record, node as unknown as Record); + if (Object.keys(diff.patch).length) operations.push({ type: "patch_node", id: node.id, patch: diff.patch, before: diff.previous }); + } + for (const node of base?.nodes || []) { + if (!nextNodes.has(node.id)) operations.push({ type: "remove_node", id: node.id, expected: node }); + } + const baseConnections = new Map((base?.connections || []).map((connection) => [connection.id, connection])); + const nextConnections = new Map(next.connections.map((connection) => [connection.id, connection])); + for (const connection of next.connections) { + const previous = baseConnections.get(connection.id); + if (previous && !canvasOperationValueEqual(previous, connection)) { + operations.push({ type: "update_connection", id: connection.id, connection, expected: previous }); + } else if (!previous) { + operations.push({ type: "create_connection", connection }); + } + } + for (const connection of base?.connections || []) { + if (!nextConnections.has(connection.id)) operations.push({ type: "remove_connection", id: connection.id, expected: connection }); + } + // 新画布的字段已由 create_canvas.patch 写入;再追加 set_field(beforeValue=null) + // 会在服务端看到刚创建的字段而判定冲突,所以只有已有基线时才生成字段操作。 + if (!base) return operations; + for (const field of CANVAS_OPERATION_FIELDS) { + // 服务端用 "" 表示未关联项目,本地可能是 undefined;两者语义相同,不能生成写操作。 + if (field === "projectId" && !base.projectId && !next.projectId) continue; + if (!canvasOperationValueEqual(base[field], next[field])) operations.push({ type: "set_field", field, value: canvasOperationValue(next[field]), beforeValue: canvasOperationValue(base[field]) }); + } + return operations; +} + +async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[] } = {}) { const uploaded = new Map(); do { syncQueued = false; @@ -826,7 +963,7 @@ async function drainRemoteUserDataChanges(options: { projectId?: string | readon } while (syncQueued); } -async function saveRemoteUserDataBatch(uploaded: Map, options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) { +async function saveRemoteUserDataBatch(uploaded: Map, options: { projectId?: string | readonly string[] } = {}) { // 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。 // 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。 const projectsBeforeRepair = useCanvasStore.getState().projects; @@ -838,24 +975,20 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { : new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id))); const changedProjectIds = new Set( projectsBeforeRepair - .filter((project) => (syncProjectIds === undefined || syncProjectIds.has(project.id)) && !sameCanvasContent(acknowledgedProjects.get(project.id), project)) + .filter((project) => !remoteDeletedProjects.has(project.id) && (syncProjectIds === undefined || syncProjectIds.has(project.id)) && (pendingCanvasMutations.has(project.id) || !sameCanvasContent(acknowledgedProjects.get(project.id), project))) .map((project) => project.id), ); repairMissingCanvasAssets(changedProjectIds, incrementalSession); const currentProjects = useCanvasStore.getState().projects; const currentAssets = useAssetStore.getState().assets; const dirtyProjects = currentProjects.filter((project) => { - const dirty = !sameCanvasContent(acknowledgedProjects.get(project.id), project); - const conflicted = useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict"; + const dirty = !remoteDeletedProjects.has(project.id) && (pendingCanvasMutations.has(project.id) || !sameCanvasContent(acknowledgedProjects.get(project.id), project)); const selected = selectedProjectIds === undefined || selectedProjectIds.has(project.id); - return selected && dirty && (options.force || !conflicted); + return selected && dirty; }); - const scopedAssetIds = options.force && syncProjectIds - ? new Set(collectCanvasMediaAssetIds(currentProjects.filter((project) => syncProjectIds.has(project.id)))) - : undefined; const dirtyAssets = currentAssets.filter((asset) => { const dirty = !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset); - return dirty && (scopedAssetIds === undefined || scopedAssetIds.has(asset.id)); + return dirty; }); if (!dirtyProjects.length && !dirtyAssets.length) return; @@ -952,11 +1085,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { const baseline = acknowledgedAssets.get(source.id); if (!baseline || verifiedAssets.has(source.id)) continue; const { asset } = await getRemoteAsset(source.id); - if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) { - if (!options.force) throw assetRemoteVersionConflict(); - // 强制覆盖是用户显式指令:采纳远端版本为新基线后继续用本地内容覆盖。 - acknowledgedAssets.set(source.id, asset); - } + if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) throw assetRemoteVersionConflict(); verifiedAssets.add(source.id); } } @@ -983,7 +1112,6 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { completed: 0, phase: total > 0 ? "uploading" : "saving", errorKind: undefined, - draftError: undefined, pendingChanges: 1, message: total > 0 ? "正在同步媒体至云端" : "正在保存画布", }); @@ -993,129 +1121,73 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { } }; try { - let candidate = source; - let saved = false; - let lastError: unknown; - for (let attempt = 0; attempt < 3 && !saved; attempt += 1) { - if (!Number.isSafeInteger(candidate.revision) || candidate.revision! < 0) { - throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 }); - } - const hash = await canvasContentHash(candidate); - const remotePayload = await ensureRemoteResourceReferences(candidate, uploaded, onMediaUploaded); - if (total > 0) { - useSyncProgressStore.getState().setProjectProgress(source.id, { - phase: attempt > 0 ? "reconciling" : "saving", - message: attempt > 0 ? "正在自动合并云端与本地修改" : "正在保存画布结构", - }); - } - try { - const { project: savedProject } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload), { repairMissingResources: options.repairMissingResources === true }); - if (!Number.isSafeInteger(savedProject.revision) || savedProject.revision !== candidate.revision! + 1) { - throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 }); - } - const current = useCanvasStore.getState().openProject(source.id); - if (current && current.revision !== candidate.revision) { - throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 }); - } - const acknowledged = { ...candidate, revision: savedProject.revision, remoteContentHash: hash }; - acknowledgedProjects.set(source.id, acknowledged); + const current = useCanvasStore.getState().openProject(source.id); + if (!current) throw new Error("画布已关闭,停止提交操作"); + const base = acknowledgedProjects.get(source.id); + const prepared = await ensureRemoteResourceReferences(current, uploaded, onMediaUploaded); + const pendingJournal = pendingCanvasMutations.get(source.id); + let preparedForMutation = prepared; + let savedRevision: number; + if (pendingJournal) { + preparedForMutation = pendingJournal.prepared; + useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "saving", message: "正在重放未确认的画布操作" }); + const response = await applyRemoteCanvasOperations(source.id, pendingJournal.request); + savedRevision = Number(response.project.revision); + pendingCanvasMutations.delete(source.id); + await persistPendingCanvasMutationJournal(); + } else { + const operations = buildCanvasOperations(base, prepared); + if (!operations.length) { + acknowledgedProjects.set(source.id, { ...prepared, revision: current.revision, remoteContentHash: await canvasContentHash(prepared) }); verifiedProjects.add(source.id); - if (current) { - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === current ? { ...project, revision: savedProject.revision, remoteContentHash: hash } : project)) })); - } - await flushCanvasStorePersistence(); - const pending = !sameCanvasContent(candidate, useCanvasStore.getState().openProject(source.id) || undefined); - useSyncProgressStore.getState().setProjectProgress(source.id, { - phase: pending ? "pending" : "done", - cloudRevision: savedProject.revision, - pendingChanges: pending ? 1 : 0, - errorKind: undefined, - draftError: undefined, - message: pending ? "有新修改等待保存" : "已保存到云端", - }); - if (pending) syncQueued = true; - saved = true; continue; - } catch (error) { - lastError = error; - const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428); - if (!conflict || attempt >= 2) throw error; - try { - const { project: remote } = await getRemoteCanvasProject(source.id); - const current = useCanvasStore.getState().openProject(source.id); - if (!current || current !== useCanvasStore.getState().openProject(source.id)) throw error; - if (sameCanvasContent(current, remote)) { - const remoteHash = await canvasContentHash(remote); - const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: remoteHash }; - acknowledgedProjects.set(source.id, reconciled); - verifiedProjects.add(source.id); - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) })); - await flushCanvasStorePersistence(); - useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", cloudRevision: reconciled.revision, pendingChanges: 0, errorKind: undefined, draftError: undefined, message: "云端内容一致,已自动校准版本" }); - saved = true; - continue; - } - if (options.force) { - const remoteHash = await canvasContentHash(remote); - const forced = { ...current, revision: remote.revision, remoteContentHash: remoteHash }; - acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash }); - verifiedProjects.add(source.id); - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? forced : project)) })); - await flushCanvasStorePersistence(); - candidate = forced; - useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "正在用本地版本覆盖云端" }); - continue; - } - const baseline = acknowledgedProjects.get(source.id); - if (!baseline) throw error; - let rebased: CanvasProject; - try { - rebased = mergeAgentCanvasEditor(baseline, remote, current.nodes, current.connections, current); - } catch (mergeError) { - throw new ApiError(mergeError instanceof Error ? mergeError.message : "本地与云端存在同一字段修改", { status: 409 }); - } - const remoteHash = await canvasContentHash(remote); - if (useCanvasStore.getState().openProject(source.id) !== current) { - throw new ApiError("画布在自动合并期间发生新修改,请重试", { status: 409 }); - } - candidate = { ...rebased, viewport: current.viewport, remoteContentHash: remoteHash }; - acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash }); - verifiedProjects.add(source.id); - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? candidate : project)) })); - await flushCanvasStorePersistence(); - useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "检测到云端更新,正在自动合并本地修改" }); - } catch (rebaseError) { - lastError = rebaseError; - throw rebaseError; - } } + const request: CanvasOperationsRequest = { + mutationId: newCanvasMutationId(), + ...(base ? { baseRevision: base.revision } : {}), + operations, + }; + pendingCanvasMutations.set(source.id, { projectId: source.id, request, baseline: base, prepared, createdAt: new Date().toISOString() }); + await persistPendingCanvasMutationJournal(); + useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "saving", message: base ? "正在提交画布操作" : "正在创建画布" }); + const response = await applyRemoteCanvasOperations(source.id, request); + savedRevision = Number(response.project.revision); + pendingCanvasMutations.delete(source.id); + await persistPendingCanvasMutationJournal(); } - if (!saved && lastError) throw lastError; - } catch (error) { - const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428); - const resourcesMissing = error instanceof ApiError && error.reason === "canvas_history_resources_missing"; - const errorKind = conflict ? "conflict" : resourcesMissing ? "resources" : error instanceof ApiError && error.retryable ? "network" : "local"; + if (!Number.isSafeInteger(savedRevision)) throw new Error("服务端未返回有效画布版本"); + const remote = await restoreRemoteTextNodeContents((await getRemoteCanvasProject(source.id)).project); + const latest = useCanvasStore.getState().openProject(source.id); + if (!latest) throw new Error("画布已关闭,停止提交操作"); + const remoteHash = await canvasContentHash(remote); + const acknowledged = { ...remote, viewport: latest.viewport || remote.viewport, remoteContentHash: remoteHash }; + acknowledgedProjects.set(source.id, acknowledged); + verifiedProjects.add(source.id); + if (sameCanvasContent(latest, preparedForMutation)) { + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === latest ? acknowledged : project) })); + } else { + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === latest ? { ...project, revision: remote.revision, remoteContentHash: remoteHash } : project) })); + } + await flushCanvasStorePersistence(); + const currentAfterSave = useCanvasStore.getState().openProject(source.id); + const pending = Boolean(currentAfterSave && !sameCanvasContent(acknowledged, currentAfterSave)); useSyncProgressStore.getState().setProjectProgress(source.id, { - phase: conflict ? "conflict" : "error", - errorKind, - draftError: undefined, - pendingChanges: 1, - message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试", + phase: pending ? "pending" : "done", + cloudRevision: Number(remote.revision), + pendingChanges: pending ? 1 : 0, + errorKind: undefined, + message: pending ? "有新修改等待保存" : "已保存到云端", + }); + if (pending) syncQueued = true; + } catch (error) { + const resourcesMissing = error instanceof ApiError && error.reason === "canvas_history_resources_missing"; + const errorKind = resourcesMissing ? "resources" : error instanceof ApiError && error.retryable ? "network" : "local"; + useSyncProgressStore.getState().setProjectProgress(source.id, { + phase: "error", + errorKind, + pendingChanges: 1, + message: error instanceof Error ? error.message : "云端同步失败,等待自动恢复", }); - if (conflict) { - try { - const current = useCanvasStore.getState().openProject(source.id) || source; - const draftCount = await preserveCanvasSyncDraft(current); - useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount, draftError: undefined }); - } catch (draftError) { - useSyncProgressStore.getState().setProjectProgress(source.id, { - draftError: draftError instanceof Error ? draftError.message : "本地草稿保存失败", - errorKind: "draft", - message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变", - }); - errors.push(draftError); - } - } errors.push(error); } } @@ -1124,7 +1196,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { } function assetRemoteVersionConflict() { - return new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库"); + return new Error("素材远端基线已变化,未确认修改会在下一轮同步中重新校验"); } function requireRemoteUserDataBaseline() { @@ -1134,42 +1206,3 @@ function requireRemoteUserDataBaseline() { function sameEntitySnapshot(acknowledged: T | undefined, current: T) { return acknowledged !== undefined && (acknowledged === current || JSON.stringify(acknowledged) === JSON.stringify(current)); } - -function sanitizeCanvasProjectForRemoteSync(project: T): T { - if (!project || typeof project !== "object") return project; - const { viewport: _viewport, ...clone } = project as Record; - if (Array.isArray(clone.chatSessions)) { - clone.chatSessions = clone.chatSessions.map((session) => { - if (!session || typeof session !== "object") return session; - const s = { ...(session as Record) }; - if (Array.isArray(s.messages)) { - s.messages = s.messages.map((message) => { - if (!message || typeof message !== "object" || !message.detail) return message; - const m = { ...(message as Record) }; - if (m.detail && typeof m.detail === "object") { - const d = { ...(m.detail as Record) }; - if (Array.isArray(d.results)) { - d.results = d.results.map((r) => { - if (!r || typeof r !== "object") return r; - const res = { ...(r as Record) }; - if (res.result && typeof res.result === "object") { - const inner = { ...(res.result as Record) }; - if (inner.data && typeof inner.data === "object") { - const { snapshot: _s, before: _b, after: _a, ...restData } = inner.data as Record; - inner.data = restData; - } - res.result = inner; - } - return res; - }); - } - m.detail = d; - } - return m; - }); - } - return s; - }); - } - return clone as T; -} diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts index 563b01d20..e4d873ed4 100644 --- a/web/src/stores/canvas/use-canvas-store.ts +++ b/web/src/stores/canvas/use-canvas-store.ts @@ -151,20 +151,19 @@ function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal: } async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType["conflicts"]) { - if (!conflicts.length) return; + if (!conflicts.length || getActiveUserScope() !== scope) return; const ids = new Set(conflicts.map((conflict) => conflict.projectId || conflict.id)); - const { preserveCanvasSyncDraftLocked } = await import("@/services/canvas-sync-drafts"); for (const project of projects.filter((item) => ids.has(item.id))) { - const draftCount = await preserveCanvasSyncDraftLocked(project, scope); - if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(project.id, { - phase: "conflict", errorKind: "conflict", draftCount, - message: "其他标签页也修改了此画布,本地版本已保留为草稿", + useSyncProgressStore.getState().setProjectProgress(project.id, { + phase: "pending", + pendingChanges: 1, + errorKind: "conflict", + message: "其他标签页也修改了此画布,未确认操作会按字段重放", }); } } - function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial) { const progress = useSyncProgressStore.getState(); for (const project of projects) { @@ -462,6 +461,11 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys]; else delete metadata.generationEffectKeys; nodes.push({ ...rolledBack, metadata }); + } else { + // There is no durable ancestor for a locally-created result. + // Preserve it in the recovery snapshot; the effect key makes a + // later replay idempotent and the user must not lose the node. + nodes.push(node); } continue; } @@ -506,11 +510,14 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du chatSessions.push(nextSession); } if (durableProject && hasUnconfirmedGeneration) { + const localConnectionIds = new Set(project.connections.map((connection) => connection.id)); return { ...project, nodes, - // Connection/active-chat records carry no effect stamp provenance, so keep them fail-closed while any generation entity is unconfirmed. - connections: durableProject.connections, + // Connection/active-chat records carry no effect stamp provenance. Keep + // local connections and append durable-only ones instead of replacing + // the whole list and dropping edits made during generation. + connections: [...project.connections, ...durableProject.connections.filter((connection) => !localConnectionIds.has(connection.id))], chatSessions, activeChatId: durableProject.activeChatId, }; diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 61126e84a..ba8a63b14 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -13,7 +13,6 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage"; import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository"; import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store"; -import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity"; export type { AssetCategory } from "@/lib/asset-category"; @@ -443,7 +442,6 @@ export const useAssetStore = create()( await new Promise((resolve, reject) => { window.setTimeout(() => withGenerationArtifactCommitLock(scope, async () => { - const syncDrafts = await readAllCanvasSyncDrafts(scope); // 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。 const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => { await commitPendingCanvasStorePersistenceLocked(scope); @@ -453,7 +451,7 @@ export const useAssetStore = create()( await withGenerationAssetStorageLock(scope, async () => { await commitPendingAssetStorePersistenceLocked(scope); const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets; - const references = { projects: canvasProjects, assets: durableAssets, syncDrafts }; + const references = { projects: canvasProjects, assets: durableAssets }; const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]); const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]); await cleanupUnusedImages( diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts index efe4007ea..1f15fa520 100644 --- a/web/src/stores/use-sync-progress-store.ts +++ b/web/src/stores/use-sync-progress-store.ts @@ -6,11 +6,9 @@ export type SyncProjectProgress = { projectId: string; total: number; completed: number; - phase: "local" | "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict"; + phase: "local" | "pending" | "uploading" | "saving" | "done" | "error"; message?: string; - draftCount?: number; - draftError?: string; - errorKind?: "local" | "network" | "conflict" | "draft" | "resources"; + errorKind?: "local" | "network" | "conflict" | "resources"; localPhase?: LocalCanvasPersistencePhase; localSavedAt?: string; localError?: string; @@ -66,7 +64,7 @@ export const useSyncProgressStore = create((set, get) => ({ clearAll: () => set({ syncingProjects: {} }), isAnySyncing: () => { const list = Object.values(get().syncingProjects); - return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error" || Boolean(item.draftError)); + return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error"); }, })); diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index cc09e9d7f..018a554c9 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -50,7 +50,6 @@ export function isBuiltinCanvasNodeType(type: CanvasNodeTypeId): type is CanvasN } export type CanvasNodeStatus = "idle" | "success" | "loading" | "error"; -export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance"; export type CanvasWorkspaceMode = "simple" | "professional"; export type CanvasToolMode = "move" | "box-select"; export type CanvasFolderStyle = "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact"; diff --git a/web/test/agent-canvas-refresh.test.ts b/web/test/agent-canvas-refresh.test.ts deleted file mode 100644 index 0ea15e55c..000000000 --- a/web/test/agent-canvas-refresh.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import localforage from "localforage"; -import { canvasContentHash } from "@/lib/canvas/canvas-content"; -import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; -import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; -import { http } from "@/services/api/request"; -import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh, saveRemoteUserDataNow } from "@/services/user-data-sync"; -import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { CanvasNodeType } from "@/types/canvas"; - -const initial: CanvasProject = { id: "agent-test", revision: 1, title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } }; -const remote: CanvasProject = { ...initial, revision: 2, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] }; -let unsubscribe = () => {}; -let restore = () => {}; -const originalGet = localforage.getItem; -const originalSet = localforage.setItem; -const originalWindow = globalThis.window; -const indexed = new Map(); -beforeEach(() => { - resetRemoteUserDataSync(); - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - setTimeout: () => 1, - clearTimeout: () => {}, - localStorage: { getItem: () => "agent-user", setItem: () => {} }, - }, - }); - indexed.clear(); - localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem; - localforage.setItem = (async (key: string, value: unknown) => { - indexed.set(key, value); - return value; - }) as typeof localforage.setItem; -}); -afterEach(async () => { - unsubscribe(); - restore(); - resetRemoteUserDataSync(); - await flushCanvasStorePersistence(); - localforage.getItem = originalGet; - localforage.setItem = originalSet; - if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; - else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); -}); - -async function setup(project: CanvasProject = remote) { - initial.remoteContentHash = await canvasContentHash(initial); - useCanvasStore.setState({ projects: [initial] }); - await initializeRemoteUserDataSession("agent-user"); - const mock = spyOn(http, "get").mockResolvedValue({ project }); - restore = () => mock.mockRestore(); - return mock; -} - -test("successful Agent refresh projects nodes into the open editor and store", async () => { - await setup(); - let editor = initial; - unsubscribe = subscribeAgentCanvasRefresh((project, previous) => { - expect(previous).toEqual(initial); - editor = project; - }); - await refreshCanvasAfterAgent(initial.id); - expect(editor.nodes).toEqual(remote.nodes); - expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes); -}); - -test("unsaved editor conflicts preserve store and acknowledged baseline", async () => { - await setup(); - unsubscribe = subscribeAgentCanvasRefresh(() => { - throw new Error("本地编辑冲突"); - }); - await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地编辑冲突"); - expect(useCanvasStore.getState().projects[0]).toEqual(initial); - unsubscribe(); - await refreshCanvasAfterAgent(initial.id); - expect(useCanvasStore.getState().projects[0]).toMatchObject(remote); -}); - -test("dirty persisted edits are rebased when Agent changes different fields", async () => { - await setup(); - useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] }); - let notified = false; - unsubscribe = subscribeAgentCanvasRefresh(() => { - notified = true; - }); - await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes }); - expect(notified).toBe(true); - expect(useCanvasStore.getState().projects[0]).toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes }); - expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending"); -}); - -test("dirty persisted edits still conflict when Agent changes the same field", async () => { - const mock = await setup({ ...remote, title: "云端修改" }); - useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] }); - mock.mockResolvedValue({ project: { ...remote, title: "云端修改" } }); - await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow(); - expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict"); - expect(useCanvasStore.getState().projects[0].title).toBe("本地修改"); -}); - -test("automatic focus and panning do not block Agent updates or reset the local viewport", async () => { - await setup(); - const viewport = { x: -800, y: -400, k: 0.7 }; - useCanvasStore.setState({ projects: [{ ...initial, viewport, updatedAt: "2026-09-13" }] }); - let editor = initial; - unsubscribe = subscribeAgentCanvasRefresh((project) => { - editor = project; - }); - await refreshCanvasAfterAgent(initial.id); - expect(editor.nodes).toEqual(remote.nodes); - expect(editor.viewport).toEqual(viewport); - expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport); -}); - -test("refresh-only Previs mutations do not conflict with an editor-only viewport change", async () => { - const scene = { - id: "scene-1", - version: 1 as const, - title: "镜头", - background: "#111111", - environmentIntensity: 0.7, - environment: { mode: "color" as const }, - gridVisible: true, - objects: [], - cameras: [], - lights: [], - shots: [], - activeShotId: "shot-1", - createdAt: "2026-09-13T00:00:00Z", - updatedAt: "2026-09-13T00:00:00Z", - }; - const local = { ...initial, previsScenes: [scene] }; - local.remoteContentHash = await canvasContentHash(local); - const remoteWithPrevis = { - ...local, - revision: 2, - previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }], - }; - useCanvasStore.setState({ projects: [local] }); - await initializeRemoteUserDataSession("agent-user"); - const mock = spyOn(http, "get").mockResolvedValue({ project: remoteWithPrevis }); - restore = () => mock.mockRestore(); - const viewport = { x: -640, y: -280, k: 0.68 }; - useCanvasStore.setState({ projects: [{ ...local, viewport, updatedAt: "2026-09-13T12:01:00Z" }] }); - await refreshCanvasAfterAgent(local.id); - expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport); - expect(useCanvasStore.getState().projects[0].previsScenes).toEqual(remoteWithPrevis.previsScenes); - expect(useSyncProgressStore.getState().syncingProjects[local.id].phase).toBe("done"); -}); - -test("replayed completion events do not reapply unchanged canvas nodes", async () => { - await setup(); - let deliveries = 0; - unsubscribe = subscribeAgentCanvasRefresh(() => { - deliveries += 1; - }); - await refreshCanvasAfterAgent(initial.id); - await refreshCanvasAfterAgent(initial.id); - expect(deliveries).toBe(1); -}); - -test("media admission and completion refresh the editor with references and result", async () => { - const video = { id: "video-1", type: CanvasNodeType.Video, title: "镜头1", position: { x: 500, y: 20 }, width: 360, height: 640, metadata: { taskId: "task-1", status: "loading" as const, referenceNodeIds: ["image-1"] } }; - const project: CanvasProject = { ...remote, nodes: [...remote.nodes, { ...video, id: "image-1", type: CanvasNodeType.Image }, video], connections: [{ id: "edge-1", fromNodeId: "image-1", toNodeId: "video-1" }] }; - const mock = await setup(project); - let editor = initial; - unsubscribe = subscribeAgentCanvasRefresh((updated) => { - editor = updated; - }); - await refreshCanvasAfterAgent(initial.id); - expect(editor.connections).toEqual(project.connections); - expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]); - const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => (node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node)) }; - mock.mockResolvedValue({ project: complete }); - await refreshCanvasAfterAgent(initial.id); - expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result"); - expect(useCanvasStore.getState().projects[0].nodes).toEqual(complete.nodes); -}); - -test("incremental batches update the editor once without full canvas requests", async () => { - const get = await setup(); - let deliveries = 0; - unsubscribe = subscribeAgentCanvasRefresh(() => { - deliveries++; - }); - const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; - await applyAgentCanvasPatches(initial.id, [patch, patch]); - expect(get).not.toHaveBeenCalled(); - expect(deliveries).toBe(1); - expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes); -}); - -test("incremental batches sort and deduplicate revisions before applying", async () => { - await setup(); - const first = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-1" } }], connections: [] }; - const second = { canvasId: initial.id, baseRevision: 2, revision: 3, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-2" } }], connections: [] }; - await applyAgentCanvasPatches(initial.id, [second, first, first]); - expect(useCanvasStore.getState().projects[0].revision).toBe(3); - expect(useCanvasStore.getState().projects[0].nodes.map((node) => node.id)).toEqual(["agent-1", "agent-2"]); -}); -test("already projected deltas still advance the remote baseline", async () => { - await setup(); - useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] }); - let deliveries = 0; - unsubscribe = subscribeAgentCanvasRefresh(() => { - deliveries++; - }); - await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]); - expect(deliveries).toBe(0); - await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote); - expect(deliveries).toBe(0); -}); - -test("editor rejection keeps delta projection atomic and retryable", async () => { - await setup(); - const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; - unsubscribe = subscribeAgentCanvasRefresh(() => { - throw new Error("编辑冲突"); - }); - await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突"); - expect(useCanvasStore.getState().projects[0]).toEqual(initial); - unsubscribe(); - await applyAgentCanvasPatches(initial.id, [patch]); - expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes); -}); - -test("50 distinct media completions project once and keep the local viewport", async () => { - const get = await setup(); - const nodes = Array.from({ length: 50 }, (_, index) => ({ - id: `video-${index}`, - title: `动画${index}`, - type: CanvasNodeType.Video, - position: { x: index * 400, y: 100 }, - width: 320, - height: 180, - metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" }, - })); - const pending = { ...initial, nodes }; - useCanvasStore.setState({ projects: [pending] }); - pending.remoteContentHash = await canvasContentHash(pending); - await initializeRemoteUserDataSession("agent-user"); - let deliveries = 0; - unsubscribe = subscribeAgentCanvasRefresh(() => { - deliveries++; - }); - await applyAgentCanvasPatches( - initial.id, - nodes.map((before, index) => ({ - canvasId: initial.id, - baseRevision: index + 1, - revision: index + 2, - updatedAt: initial.updatedAt, - nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? ("error" as const) : ("success" as const), taskStatus: index % 2 ? "failed" : "succeeded" } } }], - connections: [], - })), - ); - const result = useCanvasStore.getState().projects[0]; - expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25); - expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25); - expect(result.viewport).toEqual(initial.viewport); - expect(deliveries).toBe(1); - expect(get).not.toHaveBeenCalled(); -}); - -test("a delta gap reconciles against the cloud before declaring a conflict", async () => { - await setup(); - await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续"); - expect(useCanvasStore.getState().projects[0].revision).toBe(1); - expect(useSyncProgressStore.getState().syncingProjects[initial.id]?.phase).not.toBe("conflict"); - expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0); - await refreshCanvasAfterAgent(initial.id); - expect(useCanvasStore.getState().projects[0]).toMatchObject(remote); - expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("done"); -}); - -test("reconciliation after a delta gap still protects genuinely conflicting edits", async () => { - await setup({ ...remote, title: "云端标题" }); - useCanvasStore.setState({ projects: [{ ...initial, title: "未保存标题" }] }); - await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续"); - await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("冲突"); - expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict"); - expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe("未保存标题"); - await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新"); -}); - -test("an identical cloud snapshot clears a stale conflict without replaying editor nodes", async () => { - await setup(initial); - useSyncProgressStore.getState().setProjectProgress(initial.id, { phase: "conflict", draftCount: 9 }); - let deliveries = 0; - unsubscribe = subscribeAgentCanvasRefresh(() => { - deliveries++; - }); - await refreshCanvasAfterAgent(initial.id); - expect(deliveries).toBe(0); - expect(useSyncProgressStore.getState().syncingProjects[initial.id]).toMatchObject({ phase: "done", draftCount: 9 }); - await expect(saveRemoteUserDataNow(initial.id)).resolves.toBeUndefined(); -}); - -test("replayed events against an unchanged cloud ancestor preserve pending local edits", async () => { - await setup(initial); - await refreshCanvasAfterAgent(initial.id); - const edited = { ...initial, title: "等待保存的标题" }; - useCanvasStore.setState({ projects: [edited] }); - await refreshCanvasAfterAgent(initial.id); - expect(useCanvasStore.getState().projects[0]).toBe(edited); - expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending"); - expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0); -}); - -test("an unverified dirty cache cannot become a save baseline merely because revisions match", async () => { - await setup(initial); - const edited = { ...initial, title: "离线标题" }; - useCanvasStore.setState({ projects: [edited] }); - await initializeRemoteUserDataSession("agent-user"); - await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("缺少画布合并基线"); - expect(useCanvasStore.getState().projects[0]).toBe(edited); - expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe(edited.title); -}); - -test("Agent updates merge a local title edit and the next save uses the new revision", async () => { - await setup(); - useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] }); - const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; - await applyAgentCanvasPatches(initial.id, [patch]); - const merged = useCanvasStore.getState().projects[0]; - expect(merged.title).toBe("本地标题"); - expect(merged.revision).toBe(2); - expect(merged.nodes).toEqual(remote.nodes); - const put = spyOn(http, "put").mockImplementation(async (_url, body) => { - const project = (body as { project: CanvasProject }).project; - expect(project.revision).toBe(2); - return { project: { ...project, revision: 3 } } as never; - }); - try { - await saveRemoteUserDataNow(initial.id); - expect(put).toHaveBeenCalledTimes(1); - await applyAgentCanvasPatches(initial.id, [patch]); - expect(useCanvasStore.getState().projects[0].revision).toBe(3); - expect(useCanvasStore.getState().projects[0].title).toBe("本地标题"); - } finally { - put.mockRestore(); - } -}); diff --git a/web/test/canvas-operations-build.test.ts b/web/test/canvas-operations-build.test.ts new file mode 100644 index 000000000..4838e4249 --- /dev/null +++ b/web/test/canvas-operations-build.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { buildCanvasOperations, repairPendingCanvasMutationRequest } from "@/services/user-data-sync"; +import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; + +function canvas(patch: Partial = {}): CanvasProject { + return { + id: "canvas", title: "自由画布 1", createdAt: "", updatedAt: "", nodes: [], connections: [], chatSessions: [], activeChatId: null, + backgroundMode: "dots" as CanvasProject["backgroundMode"], showImageInfo: false, viewport: { x: 0, y: 0, k: 1 }, previsScenes: [], ...patch, + }; +} + +describe("画布 operations 生成", () => { + test("新画布只发 create_canvas,不追加会与创建字段冲突的 set_field", () => { + const operations = buildCanvasOperations(undefined, canvas()); + expect(operations[0]?.type).toBe("create_canvas"); + expect(operations.some((operation) => operation.type === "set_field")).toBe(false); + }); + + test("已有画布改标题时带上 beforeValue", () => { + const operations = buildCanvasOperations(canvas({ title: "旧" }), canvas({ title: "新" })); + expect(operations).toEqual([{ type: "set_field", field: "title", value: "新", beforeValue: "旧" }]); + }); + + test("修复旧 journal:移除新画布中重复的 set_field,保留 mutationId", () => { + const legacy = JSON.parse(JSON.stringify({ + mutationId: "m-1", + operations: [ + { type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } }, + { type: "set_field", field: "title", value: "自由画布 1", beforeValue: undefined }, + ], + })); + const repaired = repairPendingCanvasMutationRequest(legacy, undefined, canvas()); + expect(repaired.mutationId).toBe("m-1"); + expect(repaired.operations).toEqual([{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } }]); + }); + + test("修复旧 journal:移除空 projectId 到缺省值的语义 no-op", () => { + const legacy = { mutationId: "m-3", operations: [ + { type: "create_node", node: { id: "node-1" } }, + { type: "set_field", field: "projectId", value: null, beforeValue: "" }, + ] } as never; + const repaired = repairPendingCanvasMutationRequest(legacy, canvas({ projectId: "" }), canvas({ projectId: undefined })); + expect(repaired.mutationId).toBe("m-3"); + expect(repaired.operations).toEqual([{ type: "create_node", node: { id: "node-1" } }]); + }); + test("修复旧 journal:缺失的可选字段基线编码为 null", () => { + const legacy = JSON.parse(JSON.stringify({ mutationId: "m-2", operations: [{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: undefined }] })); + const repaired = repairPendingCanvasMutationRequest(legacy, canvas(), canvas()); + expect(repaired.operations).toEqual([{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: null }]); + }); +}); diff --git a/web/test/canvas-persistence-loss.test.ts b/web/test/canvas-persistence-loss.test.ts new file mode 100644 index 000000000..839941f82 --- /dev/null +++ b/web/test/canvas-persistence-loss.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; + +import { rebaseCanvasProjects, parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; +import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result"; +import { buildCanvasOperations, mergeRemoteAssetsWithLocalPending, remoteUserDataRetryDelay } from "@/services/user-data-sync"; +import type { Asset } from "@/stores/use-asset-store"; +import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; + +const node = (id: string, title = id): CanvasNodeData => ({ + id, + type: CanvasNodeType.Image, + title, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { content: title }, +}); + +const project = (nodes: CanvasNodeData[], revision = 1): CanvasProject => ({ + id: "canvas", + revision, + title: "Canvas", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + nodes, + connections: [], + chatSessions: [], + activeChatId: null, + backgroundMode: "dots", + showImageInfo: false, + viewport: { x: 0, y: 0, k: 1 }, + previsScenes: [], +}); + +test("storage rebase keeps durable-only and local-only nodes when project revisions differ", () => { + const base = project([node("base")], 1); + const local = project([node("base"), node("local")], 1); + const durable = project([node("base"), node("remote")], 2); + const document = parseCanvasStorageDocument(null, [durable]); + + const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 }); + const result = rebased.document.state.projects[0]!; + + expect(result.nodes.map((item) => item.id).sort()).toEqual(["base", "local", "remote"]); +}); + +test("storage rebase preserves a local node recreated after a concurrent deletion", () => { + const base = project([node("same", "before")]); + const local = project([node("same", "recreated")]); + const document = parseCanvasStorageDocument(null, [project([], 2)]); + document.tombstones.nodes.canvas = { same: 2 }; + + const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 }); + const result = rebased.document.state.projects[0]!; + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]).toMatchObject({ id: "same", title: "recreated" }); + expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true); +}); + +test("storage rebase keeps a durable edit when a stale local delete races with it", () => { + const base = project([node("same", "before")]); + const local = project([]); + const durable = project([node("same", "remote edit")], 2); + const document = parseCanvasStorageDocument(null, [durable]); + + const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 }); + const result = rebased.document.state.projects[0]!; + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]).toMatchObject({ id: "same", title: "remote edit" }); + expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true); +}); + +test("generation result refreshes an existing additional node without losing its local presentation edits", () => { + const before = node("root", "Root"); + const child = { ...node("child", "Renamed"), position: { x: 900, y: 700 }, metadata: { content: "old" } }; + const result = { ...before, metadata: { content: "root-result" } }; + const refreshedChild = { ...child, metadata: { content: "new" } }; + + const committed = commitCanvasGenerationResult([before, child], before, result, "task", [refreshedChild]); + + expect(committed).toHaveLength(2); + expect(committed[1]).toMatchObject({ id: "child", title: "Renamed", position: { x: 900, y: 700 }, metadata: { content: "new" } }); +}); + +test("login keeps local asset edits and new assets while retaining remote assets", () => { + const remoteAsset = { id: "shared", kind: "text", title: "remote", coverUrl: "", tags: [], createdAt: "2026-01-01", updatedAt: "2026-01-02", data: { content: "remote" } } as Asset; + const localEdit = { ...remoteAsset, title: "local edit", updatedAt: "2026-01-03" }; + const localNew = { ...remoteAsset, id: "local-only", title: "local only" }; + const merged = mergeRemoteAssetsWithLocalPending([remoteAsset], [localEdit, localNew]); + + expect(merged).toHaveLength(2); + expect(merged.find((asset) => asset.id === "shared")?.title).toBe("local edit"); + expect(merged.find((asset) => asset.id === "local-only")?.title).toBe("local only"); +}); + +test("remote sync retry keeps polling at the capped backoff instead of stopping", () => { + expect(remoteUserDataRetryDelay(0)).toBe(2_000); + expect(remoteUserDataRetryDelay(4)).toBe(30_000); + expect(remoteUserDataRetryDelay(20)).toBe(30_000); + expect(remoteUserDataRetryDelay(20, 45_000)).toBe(45_000); +}); + +test("new revision-zero canvas produces an explicit create mutation instead of an implicit update", () => { + const draft = project([node("local")], 0); + const operations = buildCanvasOperations(undefined, draft); + + expect(operations[0]).toMatchObject({ type: "create_canvas", id: "canvas" }); + expect(operations.some((operation) => operation.type === "create_node")).toBe(true); +}); diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts deleted file mode 100644 index 1386ba70f..000000000 --- a/web/test/canvas-remote-revision.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import localforage from "localforage"; -import { apiClient } from "../src/services/api/request"; -import { canvasContentHash } from "../src/lib/canvas/canvas-content"; -import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision"; -import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts"; -import { - applyAgentCanvasPatches, - deleteCanvasProjectsWithRemoteSync, - refreshCanvasAfterAgent, - initializeRemoteUserDataSession, - installRemoteUserDataAutoSync, - loadCanvasProjectForEditing, - resetRemoteUserDataSync, - saveRemoteUserDataNow, - syncRemoteUserData, - overwriteRemoteCanvasProject, -} from "../src/services/user-data-sync"; -import { createAgentCanvasSync } from "../src/services/agent-canvas-sync"; -import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store"; -import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store"; -import { useSyncProgressStore } from "../src/stores/use-sync-progress-store"; -import { CanvasNodeType } from "../src/types/canvas"; - -const originalWindow = globalThis.window; -const originalAdapter = apiClient.defaults.adapter; -const originalGet = localforage.getItem; -const originalSet = localforage.setItem; -const indexed = new Map(); -let scope = ""; -let sequence = 0; -let autoSave: (() => void) | undefined; -let remote = new Map(); -let requests: Array<{ method: string; id: string; project?: CanvasProject }> = []; -let beforePut: (() => Promise) | undefined; -let deleteFailureId: string | undefined; -let temporaryPutFailures = 0; - -function canvas(id = "canvas"): CanvasProject { - return { - id, - revision: 1, - title: id, - createdAt: "2026-09-01T00:00:00Z", - updatedAt: "2026-09-01T00:00:00Z", - nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }], - connections: [], - chatSessions: [], - activeChatId: null, - backgroundMode: "dots", - showImageInfo: false, - viewport: { x: 0, y: 0, k: 1 }, - directorScenes: [], - }; -} - -function addNode(project: CanvasProject, id: string) { - return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] }; -} - -beforeEach(async () => { - resetRemoteUserDataSync(); - scope = `canvas-revision-test-${++sequence}`; - indexed.clear(); - requests = []; - autoSave = undefined; - beforePut = undefined; - deleteFailureId = undefined; - temporaryPutFailures = 0; - remote = new Map([ - ["canvas", canvas()], - ["other", canvas("other")], - ]); - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - setTimeout: (callback: () => void) => { - autoSave = callback; - return 1; - }, - clearTimeout: () => { - autoSave = undefined; - }, - localStorage: { getItem: () => scope, setItem: () => undefined }, - }, - }); - localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem; - localforage.setItem = (async (key: string, value: string) => { - indexed.set(key, value); - return value; - }) as typeof localforage.setItem; - apiClient.defaults.adapter = async (config) => { - const method = config.method || "get"; - const id = String(config.url).split("/").at(-1)!; - const body = config.data ? JSON.parse(config.data) : undefined; - requests.push({ method, id, project: body?.project }); - let data: unknown; - let status = 200; - if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] }; - else if (method === "delete") { - if (id === deleteFailureId) status = 403; - else { - remote.delete(id); - data = { id }; - } - } else if (id === "batch" && method === "post") { - data = { - assets: [ - { - id: "invalid-mime-asset", - kind: "image", - title: "历史素材", - coverUrl: "", - tags: [], - createdAt: "2026-09-01", - updatedAt: "2026-09-01", - data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" }, - }, - ], - }; - } else if (id === "restore" && method === "post") { - const current = remote.get("canvas")!; - if (body.revision !== current.revision) status = 409; - else { - const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 }; - remote.set("canvas", saved); - data = { project: saved }; - } - } else if (method === "put") { - await beforePut?.(); - const project = body.project as CanvasProject; - const current = remote.get(id); - if (temporaryPutFailures > 0) { - temporaryPutFailures -= 1; - status = 503; - } else if (project.revision !== (current?.revision ?? 0)) status = 409; - else { - const saved = { ...structuredClone(project), revision: project.revision! + 1 }; - remote.set(id, saved); - data = { project: saved }; - } - } else data = { project: structuredClone(remote.get(id)) }; - return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } }; - }; - useCanvasStore.setState({ projects: [] }); - useAssetStore.setState({ assets: [] }); - installRemoteUserDataAutoSync(); - await syncRemoteUserData(scope); -}); - -async function waitForSyncState(predicate: () => boolean) { - const deadline = Date.now() + 1_000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error("同步测试状态等待超时"); - await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); - } -} - -afterEach(async () => { - resetRemoteUserDataSync(); - await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); - apiClient.defaults.adapter = originalAdapter; - localforage.getItem = originalGet; - localforage.setItem = originalSet; - if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; - else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); -}); - -test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => { - const project = canvas(); - project.nodes[0].metadata = { assetId: "invalid-mime-asset" }; - remote.set(project.id, project); - await initializeRemoteUserDataSession(scope); - await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME"); - useCanvasStore.getState().deleteProjects(["other"]); - await flushCanvasStorePersistence(); - requests = []; - - await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]); - - expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([ - { method: "delete", id: "canvas" }, - { method: "delete", id: "other" }, - ]); - expect(remote.size).toBe(0); - expect(useCanvasStore.getState().projects).toEqual([]); - await useCanvasStore.persist.rehydrate(); - expect(useCanvasStore.getState().projects).toEqual([]); -}); - -test("batch deletion retains failed canvases and persists successful deletions", async () => { - await initializeRemoteUserDataSession(scope); - deleteFailureId = "other"; - - await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow(); - - expect([...remote.keys()]).toEqual(["other"]); - expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]); - await useCanvasStore.persist.rehydrate(); - expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]); -}); - -test("stale viewport, no-op restore and open do not submit old content", async () => { - const old = useCanvasStore.getState().openProject("canvas")!; - remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 }); - useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } }); - useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] }); - expect(autoSave).toBeUndefined(); - expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt); - await flushCanvasStorePersistence(); - await saveRemoteUserDataNow(); - const latest = await loadCanvasProjectForEditing("canvas"); - expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]); - expect(latest.revision).toBe(2); - expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 }); - await saveRemoteUserDataNow(); - expect(requests.filter((request) => request.method === "put")).toHaveLength(0); -}); - -test("explicit local overwrite replaces only the selected cloud canvas after a revision conflict", async () => { - remote.set("canvas", { ...canvas(), title: "cloud edit", revision: 2 }); - useCanvasStore.getState().renameProject("canvas", "local edit"); - - await overwriteRemoteCanvasProject("canvas"); - - expect(remote.get("canvas")).toMatchObject({ title: "local edit", revision: 3 }); - expect(remote.get("other")).toMatchObject({ title: "other", revision: 1 }); - expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", cloudRevision: 3, pendingChanges: 0 }); -}); - -test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => { - remote.set("canvas", { ...canvas(), revision: 9 }); - await loadCanvasProjectForEditing("canvas", { latest: true }); - useCanvasStore.getState().renameProject("canvas", "已保存的标题"); - await saveRemoteUserDataNow("canvas"); - expect(remote.get("canvas")!.revision).toBe(10); - - for (let reload = 0; reload < 3; reload++) { - resetRemoteUserDataSync(); - await useCanvasStore.persist.rehydrate(); - await initializeRemoteUserDataSession(scope); - await loadCanvasProjectForEditing("canvas"); - let resolve!: () => void; - let reject!: (error: unknown) => void; - const reconciled = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - const sync = createAgentCanvasSync({ - canvasId: "canvas", - batchMs: 0, - applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches), - refresh: async () => { - await refreshCanvasAfterAgent("canvas"); - resolve(); - }, - onError: reject, - }); - try { - sync.receive({ - eventId: "historical-event", - runId: "completed-run", - seq: 1, - type: "canvas_updated", - createdAt: "2026-09-17", - payload: { - canvasId: "canvas", - canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] }, - }, - }); - await reconciled; - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); - await saveRemoteUserDataNow("canvas"); - expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes }); - expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0); - } finally { - sync.dispose(); - } - } - expect(requests.filter((request) => request.method === "put")).toHaveLength(1); -}); - -test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => { - useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere"); - beforePut = async () => { - const current = useCanvasStore.getState().openProject("canvas")!; - remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 }); - }; - - await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined(); - - expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 }); - expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" }); - expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0); -}); - -test("a stale save rebases disjoint Agent edits and keeps saving", async () => { - remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 }); - useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes }); - useCanvasStore.getState().renameProject("other", "other edit"); - - await expect(saveRemoteUserDataNow()).resolves.toBeUndefined(); - - expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "local-video", "remote-video"]); - expect(remote.get("canvas")!.revision).toBe(3); - expect(remote.get("other")!.title).toBe("other edit"); - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); - - useCanvasStore.getState().renameProject("canvas", "more local edits"); - await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined(); - expect(remote.get("canvas")!.title).toBe("more local edits"); - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); -}); - -test("transient cloud failures schedule a bounded automatic retry", async () => { - temporaryPutFailures = 1; - useCanvasStore.getState().renameProject("canvas", "retry me"); - expect(autoSave).toBeDefined(); - - autoSave?.(); - await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1); - expect(autoSave).toBeDefined(); - expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试"); - - autoSave?.(); - await waitForSyncState(() => remote.get("canvas")?.title === "retry me"); - expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2); - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); -}); - -test("edits during a save retain the right ancestor revision and are saved next", async () => { - let release!: () => void; - let started!: () => void; - const waiting = new Promise((resolve) => { - release = resolve; - }); - const entered = new Promise((resolve) => { - started = resolve; - }); - beforePut = async () => { - started(); - await waiting; - }; - useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes }); - const saving = saveRemoteUserDataNow(); - await entered; - useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes }); - release(); - await saving; - expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]); - expect(remote.get("canvas")!.revision).toBe(3); - expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]); - expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!)); - expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true); -}); - -test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => { - useCanvasStore.getState().renameProject("canvas", "unsaved draft"); - await flushCanvasStorePersistence(); - resetRemoteUserDataSync(); - await useCanvasStore.persist.rehydrate(); - await syncRemoteUserData(scope); - expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas"); - expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft"); - expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]); - expect(requests.some((request) => request.method === "put")).toBe(false); -}); - -test("failed draft storage prevents replacement by the latest server content", async () => { - const working = localforage.setItem; - useCanvasStore.getState().renameProject("canvas", "must survive"); - localforage.setItem = (async (key: string, value: unknown) => { - if (key.includes("sync-drafts")) throw new Error("disk full"); - return working(key, value); - }) as typeof localforage.setItem; - await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full"); - expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive"); -}); - -test("local storage rebase cannot pair old edits with another tab's newer revision", () => { - const base = canvas(); - const local = addNode(base, "unsaved-local"); - const durable = { ...addNode(base, "remote-video"), revision: 2 }; - const document = parseCanvasStorageDocument(null, [durable]); - const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0]; - expect(rebased.revision).toBe(1); - expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local"); -}); - -test("loading aligns live editor content before publishing its server revision", async () => { - remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 }); - let applied = false; - await loadCanvasProjectForEditing("canvas", { - onLoad: (project) => { - expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); - expect(project.revision).toBe(2); - expect(project.nodes.at(-1)!.id).toBe("new-cloud-node"); - applied = true; - }, - }); - expect(applied).toBe(true); - expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2); -}); - -test("viewport-only updates during latest load still adopt cloud content", async () => { - remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 }); - useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes }); - const working = localforage.setItem; - let release!: () => void; - let started!: () => void; - const waiting = new Promise((resolve) => { - release = resolve; - }); - const entered = new Promise((resolve) => { - started = resolve; - }); - localforage.setItem = (async (key: string, value: unknown) => { - if (key.includes("sync-drafts:")) { - started(); - await waiting; - } - return working(key, value); - }) as typeof localforage.setItem; - const loading = loadCanvasProjectForEditing("canvas", { latest: true }); - await entered; - useCanvasStore.getState().updateProject("canvas", { viewport: { x: 40, y: 12, k: 1.5 } }); - release(); - const loaded = await loading; - expect(loaded.nodes.at(-1)!.id).toBe("remote-video"); - expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2); - expect(useCanvasStore.getState().openProject("canvas")!.viewport).toEqual({ x: 40, y: 12, k: 1.5 }); -}); - -test("edits arriving during draft backup prevent replacement of the live canvas", async () => { - useCanvasStore.getState().renameProject("canvas", "first local edit"); - const working = localforage.setItem; - let release!: () => void; - let started!: () => void; - const waiting = new Promise((resolve) => { - release = resolve; - }); - const entered = new Promise((resolve) => { - started = resolve; - }); - localforage.setItem = (async (key: string, value: unknown) => { - if (key.includes("sync-drafts:")) { - started(); - await waiting; - } - return working(key, value); - }) as typeof localforage.setItem; - const loading = loadCanvasProjectForEditing("canvas", { latest: true }); - await entered; - useCanvasStore.getState().renameProject("canvas", "second local edit"); - release(); - await expect(loading).rejects.toThrow("画布仍在更新"); - expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit"); -}); - -test("unknown revision cannot borrow the latest revision to submit stale content", async () => { - const local = useCanvasStore.getState().openProject("canvas")!; - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) })); - await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本"); - expect(requests.filter((request) => request.method === "put")).toHaveLength(0); - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict"); - expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft"); -}); - -test("HTTP environments without Web Crypto still distinguish unsaved content", async () => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!; - Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined }); - try { - const baseline = await canvasContentHash(canvas()); - expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } })); - expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video"))); - } finally { - Object.defineProperty(globalThis, "crypto", descriptor); - } -}); - -test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => { - useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes }); - let applied = false; - const restored = await loadCanvasProjectForEditing("canvas", { - historyRestore: { snapshotId: "historical", revision: 1 }, - onLoad: (project) => { - expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); - expect(project.revision).toBe(2); - applied = true; - }, - }); - expect(applied).toBe(true); - expect(restored.title).toBe("historical content"); - expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true); - await saveRemoteUserDataNow("canvas"); - expect(requests.filter((request) => request.method === "post")).toHaveLength(1); - expect(requests.filter((request) => request.method === "put")).toHaveLength(0); -}); - -test("a stale history restore preserves both current cloud content and local edits", async () => { - remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 }); - useCanvasStore.getState().renameProject("canvas", "local draft"); - await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突"); - expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video"); - expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft"); - expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft"); -}); - -test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => { - useCanvasStore.getState().renameProject("canvas", "offline draft"); - await flushCanvasStorePersistence(); - await initializeRemoteUserDataSession(scope); - const loaded = await loadCanvasProjectForEditing("canvas"); - expect(loaded.title).toBe("offline draft"); - expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending"); - expect(remote.get("canvas")!.title).toBe("canvas"); - await saveRemoteUserDataNow("canvas"); - expect(remote.get("canvas")!.title).toBe("offline draft"); - expect(remote.get("canvas")!.revision).toBe(2); -}); - -test("failed draft backup prevents a history restore request", async () => { - const working = localforage.setItem; - localforage.setItem = (async (key: string, value: unknown) => { - if (key.includes("sync-drafts:")) throw new Error("disk full"); - return working(key, value); - }) as typeof localforage.setItem; - await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full"); - expect(requests.some((request) => request.method === "post")).toBe(false); - expect(remote.get("canvas")!.revision).toBe(1); -}); diff --git a/web/test/director-diagnostics-recorder.test.ts b/web/test/director-diagnostics-recorder.test.ts deleted file mode 100644 index f01f0161d..000000000 --- a/web/test/director-diagnostics-recorder.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; - -import { recordPrevisDiagnostic, resetPrevisDiagnosticDedupe } from "../src/lib/canvas/previs/previs-diagnostics-recorder"; -import { getClientDiagnosticEvents } from "../src/services/diagnostics/client-diagnostics"; - -/** 只看预演台自己的稳定码事件,避免与其它测试写入的诊断流互相干扰。 */ -function previsEvents() { - return getClientDiagnosticEvents().filter((event) => (event.code || "").startsWith("PREVIS_")); -} - -function newestPrevisEvent() { - return previsEvents().at(-1); -} - -const LEAK_PROBES = [ - "https://cdn.example.com/actor.glb?token=abc123#frag", - "authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig", - "cookie=session=deadbeef; Path=/", - "api_key=sk-live-0000", - "Error: boom\n at PrevisModel (previs-viewport.tsx:571:29)", - "演员 1 的镜头意图正文", -]; - -beforeEach(() => { - resetPrevisDiagnosticDedupe(); -}); - -describe("记录进统一诊断缓冲区", () => { - test("写入的事件带稳定码、固定文案与 runtime 分类", () => { - const before = previsEvents().length; - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED")).toBe(true); - - expect(previsEvents().length).toBe(before + 1); - const event = newestPrevisEvent(); - expect(event?.code).toBe("PREVIS_VIEWPORT_RENDER_FAILED"); - expect(event?.level).toBe("error"); - expect(event?.category).toBe("runtime"); - expect(event?.message).toBe("预演台 3D 视口渲染失败"); - // 绝不落 stack。 - expect(event?.stack || "").toBe(""); - }); - - test("安全字段以枚举后缀进入 code,便于检索", () => { - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model", attempt: 2 })).toBe(true); - const event = newestPrevisEvent(); - expect(event?.code).toBe("PREVIS_MODEL_LOAD_FAILED object=obj-1 kind=model attempt=2"); - }); - - test("sceneId 只进 code 后缀,不冒充 canvasId", () => { - expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "repro-scene-1", revision: 3 })).toBe(true); - const event = newestPrevisEvent(); - // PrevisScene.id 不是 canvas id,绝不写入既有后端契约字段。 - expect(event?.canvasId).toBeUndefined(); - expect(event?.code).toBe("PREVIS_SAVE_FLUSH_FAILED scene=repro-scene-1 revision=3"); - }); - - test("三个保存相关码都能写入且级别正确", () => { - recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { revision: 1 }); - expect(newestPrevisEvent()?.level).toBe("info"); - recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { revision: 1 }); - expect(newestPrevisEvent()?.level).toBe("error"); - recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" }); - expect(newestPrevisEvent()?.level).toBe("warning"); - }); -}); - -describe("去重与噪声控制", () => { - test("同一 code+字段在窗口内只记一次", () => { - const before = previsEvents().length; - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false); - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false); - expect(previsEvents().length).toBe(before + 1); - }); - - test("字段不同视为不同事件,不会被误去重", () => { - const before = previsEvents().length; - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 1 })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 2 })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor", attempt: 1 })).toBe(true); - expect(previsEvents().length).toBe(before + 3); - }); - - test("不同 objectId 在窗口内不互相吞掉", () => { - const before = previsEvents().length; - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2", objectKind: "model" })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-3", objectKind: "model" })).toBe(true); - // 同一 objectId 重复仍要被去重。 - expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(false); - expect(previsEvents().length).toBe(before + 3); - }); - - test("不同 sceneId 在窗口内不互相吞掉", () => { - const before = previsEvents().length; - expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-2", revision: 1 })).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(false); - expect(previsEvents().length).toBe(before + 2); - }); - - test("所有写入的 code 都以稳定 PREVIS_ 前缀开头", () => { - resetPrevisDiagnosticDedupe(); - recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: "scene-1", saveOutcome: "stay" }); - for (const event of previsEvents()) { - expect((event.code || "").startsWith("PREVIS_")).toBe(true); - } - }); - - test("重置去重状态后可再次记录同一事件", () => { - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true); - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(false); - resetPrevisDiagnosticDedupe(); - expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true); - }); -}); - -describe("不泄漏敏感内容", () => { - test("伪造的敏感字段不会进入缓冲区任何字段", () => { - for (const probe of LEAK_PROBES) { - resetPrevisDiagnosticDedupe(); - recordPrevisDiagnostic("PREVIS_MODEL_ADOPT_FAILED", { - objectId: probe, - sceneId: probe, - // @ts-expect-error 故意传入非法枚举,验证运行时白名单而非仅靠类型。 - objectKind: probe, - // @ts-expect-error 故意传入非法数值类型。 - attempt: probe, - }); - - const event = newestPrevisEvent(); - const serialized = JSON.stringify(event); - expect(serialized).not.toContain("cdn.example.com"); - expect(serialized).not.toContain("token=abc123"); - expect(serialized).not.toContain("sk-live-0000"); - expect(serialized).not.toContain("deadbeef"); - expect(serialized).not.toContain("Bearer"); - expect(serialized).not.toContain("演员"); - expect(serialized).not.toContain("previs-viewport.tsx"); - expect(event?.canvasId).toBeUndefined(); - expect(event?.code).toBe("PREVIS_MODEL_ADOPT_FAILED"); - } - }); - - test("未知 code 既不写入也不抛错", () => { - const before = previsEvents().length; - // @ts-expect-error 故意传入白名单外的 code。 - expect(recordPrevisDiagnostic("PREVIS_NOT_A_REAL_CODE", { objectId: "obj-1" })).toBe(false); - expect(previsEvents().length).toBe(before); - }); -}); diff --git a/web/test/director-diagnostics.test.ts b/web/test/director-diagnostics.test.ts deleted file mode 100644 index 258748276..000000000 --- a/web/test/director-diagnostics.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { PREVIS_DIAGNOSTIC_CODES, previsDiagnosticObjectKind, formatPrevisDiagnosticCode, isPrevisDiagnosticCode, projectPrevisDiagnostic } from "../src/lib/canvas/previs/previs-diagnostics"; - -/** 任何一个都不允许出现在投影输出里。 */ -const LEAK_PROBES = [ - "https://cdn.example.com/actor.glb?token=abc123", - "http://localhost:3000/api/tasks?authorization=Bearer%20xyz", - "Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature", - "authorization: Bearer secret-token", - "cookie=session=deadbeef; Path=/", - "api_key=sk-live-0000", - "Error: adopt failed\n at PrevisModel (previs-viewport.tsx:571:29)", - "演员 1 的镜头意图正文", - "/Users/someone/secret/path/scene.glb", -]; - -function serialize(value: unknown) { - return JSON.stringify(value ?? {}); -} - -describe("code 白名单", () => { - test("白名单内的 code 全部被接受且有稳定文案与级别", () => { - for (const code of PREVIS_DIAGNOSTIC_CODES) { - expect(isPrevisDiagnosticCode(code)).toBe(true); - const event = projectPrevisDiagnostic(code, {}); - expect(event).not.toBeNull(); - expect(event?.code).toBe(code); - expect(event?.message.length).toBeGreaterThan(0); - expect(["info", "warning", "error"]).toContain(event?.level ?? ""); - } - }); - - test("未知 code 一律拒绝,不透传", () => { - const rejected = ["", "previs_viewport_render_failed", "PREVIS_UNKNOWN", "SOME_OTHER_CODE", "PREVIS_VIEWPORT_RENDER_FAILED ", null, undefined, 42, {}, []]; - for (const code of rejected) { - expect(isPrevisDiagnosticCode(code)).toBe(false); - expect(projectPrevisDiagnostic(code, {})).toBeNull(); - } - }); -}); - -describe("字段白名单", () => { - test("未知字段不会出现在输出中", () => { - const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { - objectId: "obj-1", - sceneName: "演员 1 场景", - url: "https://cdn.example.com/a.glb", - stack: "Error: boom\n at x", - prompt: "镜头意图正文", - storageKey: "assets/secret.glb", - token: "sk-live-0000", - }); - - expect(event?.fields).toEqual({ objectId: "obj-1" }); - expect(serialize(event)).not.toContain("cdn.example.com"); - expect(serialize(event)).not.toContain("镜头意图正文"); - expect(serialize(event)).not.toContain("sk-live-0000"); - }); - - test("伪造的敏感输入无法通过任一字段进入输出", () => { - for (const probe of LEAK_PROBES) { - const event = projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { - objectId: probe, - sceneId: probe, - objectKind: probe, - saveOutcome: probe, - attempt: probe, - revision: probe, - draftStored: probe, - userInitiated: probe, - }); - - expect(event).not.toBeNull(); - // 所有字段都应被拒绝:probe 既不是 safe-id,也不在枚举内,也不是数值/布尔。 - expect(event?.fields).toEqual({}); - const text = serialize(event); - expect(text).not.toContain("http"); - expect(text).not.toContain("token"); - expect(text).not.toContain("cookie"); - expect(text).not.toContain("Bearer"); - expect(text).not.toContain("演员"); - } - }); - - test("safe-id 只接受受限字符集", () => { - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1_a.b:c" })?.fields.objectId).toBe("obj-1_a.b:c"); - for (const bad of ["obj 1", "obj/1", "obj?1", "obj#1", "a".repeat(97), "", " ", "obj\n1", "obj%201"]) { - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad })?.fields.objectId).toBeUndefined(); - } - }); - - test("枚举字段只接受白名单值", () => { - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor" })?.fields.objectKind).toBe("actor"); - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "spaceship" })?.fields.objectKind).toBeUndefined(); - expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" })?.fields.saveOutcome).toBe("stay"); - expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "explode" })?.fields.saveOutcome).toBeUndefined(); - }); - - test("数值字段有界且拒绝非有限值", () => { - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 3 })?.fields.attempt).toBe(3); - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 2.6 })?.fields.attempt).toBe(3); - for (const bad of [-1, 1000, Number.NaN, Number.POSITIVE_INFINITY, "3", null]) { - expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: bad })?.fields.attempt).toBeUndefined(); - } - expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: 0 })?.fields.revision).toBe(0); - expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: -1 })?.fields.revision).toBeUndefined(); - }); - - test("布尔字段只接受真布尔", () => { - expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: false })?.fields.draftStored).toBe(false); - expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: true })?.fields.draftStored).toBe(true); - for (const bad of ["true", 1, 0, null, {}]) { - expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: bad })?.fields.draftStored).toBeUndefined(); - } - }); - - test("非对象 fields 不抛错且产出空字段", () => { - for (const fields of [null, undefined, 42, "objectId=1", [1, 2, 3]]) { - const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST", fields); - expect(event?.fields).toEqual({}); - } - }); -}); - -describe("稳定码格式化", () => { - test("只拼接安全枚举与数值", () => { - const event = projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: "scene-1", revision: 4, draftStored: true, userInitiated: true }); - expect(event).not.toBeNull(); - if (!event) return; - const formatted = formatPrevisDiagnosticCode(event); - expect(formatted).toBe("PREVIS_SAVE_RETRY_FAILED scene=scene-1 revision=4 draft=1 user=1"); - // scene/object 后缀只可能是 safe-id 投影后的值。 - expect(formatted.startsWith("PREVIS_")).toBe(true); - }); - - test("scene 与 object 后缀都进入 code,顺序确定", () => { - const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1", objectId: "obj-9", objectKind: "model", attempt: 2 }); - expect(event).not.toBeNull(); - if (!event) return; - expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_MODEL_LOAD_FAILED scene=scene-1 object=obj-9 kind=model attempt=2"); - }); - - test("不同 objectId / sceneId 产出不同签名", () => { - const a = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1" }); - const b = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2" }); - const c = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1" }); - const d = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-2" }); - expect(a && b && c && d).toBeTruthy(); - if (!a || !b || !c || !d) return; - const signatures = [a, b, c, d].map(formatPrevisDiagnosticCode); - expect(new Set(signatures).size).toBe(4); - }); - - test("非法 id 不进入 code 后缀", () => { - for (const bad of ["https://cdn.example.com/a.glb?token=abc", "obj 1", "obj/1", "a".repeat(97)]) { - const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad, sceneId: bad }); - expect(event).not.toBeNull(); - if (!event) continue; - const formatted = formatPrevisDiagnosticCode(event); - expect(formatted).toBe("PREVIS_MODEL_LOAD_FAILED"); - expect(formatted).not.toContain("scene="); - expect(formatted).not.toContain("object="); - expect(formatted).not.toContain("cdn.example.com"); - } - }); - - test("无字段时只有 code 本身", () => { - const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED", {}); - expect(event).not.toBeNull(); - if (!event) return; - expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_VIEWPORT_RENDER_FAILED"); - }); -}); - -describe("对象种类推导", () => { - test("按形态映射,不读取名称或地址", () => { - expect(previsDiagnosticObjectKind({ kind: "actor" })).toBe("actor"); - expect(previsDiagnosticObjectKind({ kind: "model" })).toBe("model"); - expect(previsDiagnosticObjectKind({ kind: "billboard" })).toBe("billboard"); - expect(previsDiagnosticObjectKind({ primitive: "box" })).toBe("primitive"); - expect(previsDiagnosticObjectKind({})).toBe("unknown"); - expect(previsDiagnosticObjectKind(null)).toBe("unknown"); - expect(previsDiagnosticObjectKind(undefined)).toBe("unknown"); - }); -}); diff --git a/web/test/director-save-coordinator.test.ts b/web/test/director-save-coordinator.test.ts deleted file mode 100644 index 6ec8259a7..000000000 --- a/web/test/director-save-coordinator.test.ts +++ /dev/null @@ -1,995 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { scopedStorageKey } from "../src/lib/user-scope"; -import type { PrevisScene } from "../src/types/previs"; -import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisFlushRequest, type FlushFunction, type StorageLike } from "../src/lib/canvas/previs/previs-save"; - -class MemoryStorage implements StorageLike { - private readonly values = new Map(); - throwOnGet = false; - throwOnSet = false; - throwOnRemove = false; - - getItem(key: string): string | null { - if (this.throwOnGet) throw new Error("get failed"); - return this.values.get(key) ?? null; - } - - setItem(key: string, value: string): void { - if (this.throwOnSet) throw new Error("set failed"); - this.values.set(key, value); - } - - removeItem(key: string): void { - if (this.throwOnRemove) throw new Error("remove failed"); - this.values.delete(key); - } - - keys(): string[] { - return [...this.values.keys()]; - } -} - -class ManualScheduler { - private callbacks = new Map void>(); - private id = 0; - - schedule(callback: () => void, delay: number): unknown { - const handle = this.id++; - this.callbacks.set(handle, callback); - return handle; - } - - cancel(handle: unknown): void { - this.callbacks.delete(handle as number); - } - - runAll(): void { - for (const cb of this.callbacks.values()) { - cb(); - } - this.callbacks.clear(); - } - - size(): number { - return this.callbacks.size; - } -} - -function deferred() { - let res: (() => void) | null = null; - let rej: ((err: unknown) => void) | null = null; - const p = new Promise((resolve, reject) => { - res = resolve; - rej = reject; - }); - return { - promise: p, - resolve: () => res?.(), - reject: (err: unknown) => rej?.(err), - }; -} - -const makeScene = (overrides: Partial = {}): PrevisScene => ({ - id: "scene-1", - version: 1, - title: "Test Scene", - background: "#000", - environmentIntensity: 0.5, - gridVisible: true, - objects: [], - cameras: [], - lights: [], - shots: [], - activeShotId: "shot-1", - createdAt: "2025-01-01T00:00:00Z", - updatedAt: "2025-01-01T00:00:00Z", - ...overrides, -}); - -const makeEnvelope = (overrides: Partial = {}): PrevisDraftEnvelope => { - const scene = overrides.scene ?? makeScene(); - return { - schemaVersion: 1, - sceneId: scene.id, - baseUpdatedAt: scene.updatedAt, - revision: 1, - savedAt: 1000, - ...overrides, - scene, - }; -}; - -/** Serializes a complete valid envelope after breaking only the target fields. */ -const corruptEnvelope = (corrupt: (envelope: Record) => void, overrides: Partial = {}): string => { - const envelope: Record = { - ...makeEnvelope(overrides), - }; - corrupt(envelope); - return JSON.stringify(envelope); -}; - -/** - * Serializes a complete valid envelope, splicing raw JSON number tokens that - * JSON.stringify cannot express (for example 1e999 parsing back as Infinity). - */ -const rawEnvelopeWith = (rawFields: Record): string => { - const envelope: Record = { ...makeEnvelope() }; - const entries = Object.entries(envelope) - .filter(([field]) => !(field in rawFields)) - .map(([field, value]) => JSON.stringify(field) + ":" + JSON.stringify(value)); - const rawEntries = Object.entries(rawFields).map(([field, token]) => JSON.stringify(field) + ":" + token); - return "{" + [...entries, ...rawEntries].join(",") + "}"; -}; - -type HarnessOptions = { - scope?: string; - storage?: MemoryStorage; - initialScene?: PrevisScene; -}; - -const createHarness = (options: HarnessOptions = {}) => { - const scope = options.scope ?? "user-a"; - const storage = options.storage ?? new MemoryStorage(); - const initialScene = options.initialScene ?? makeScene(); - const scheduler = new ManualScheduler(); - let now = 1000; - const requests: PrevisFlushRequest[] = []; - let flushBehavior: FlushFunction = async () => undefined; - - const flushFn: FlushFunction = async (request) => { - requests.push(structuredClone(request)); - await flushBehavior(request); - }; - - const coord = createPrevisSaveCoordinator({ - initialScene, - scope, - storage, - flush: flushFn, - now: () => now++, - debounceMs: 25, - schedule: (callback) => scheduler.schedule(callback, 0), - cancelSchedule: (handle) => scheduler.cancel(handle), - }); - - const key = scopedStorageKey("previs-scene-draft:" + initialScene.id, scope); - - const editedScene = (overrides: Partial = {}) => { - const scene = makeScene(overrides); - coord.edit(scene); - return scene; - }; - - const readEnvelope = (id: string = initialScene.id): PrevisDraftEnvelope | null => { - const readKey = id === initialScene.id ? key : scopedStorageKey("previs-scene-draft:" + id, scope); - const raw = storage.getItem(readKey); - return raw === null ? null : (JSON.parse(raw) as PrevisDraftEnvelope); - }; - - const writeRaw = (raw: string): void => { - storage.setItem(key, raw); - }; - - const setFlushBehavior = (next: FlushFunction): void => { - flushBehavior = next; - }; - - const flushMicrotasks = async (): Promise => { - scheduler.runAll(); - await Promise.resolve(); - await Promise.resolve(); - }; - - const dispose = (): void => { - coord.dispose(); - scheduler.runAll(); - }; - - return { - storage, - scheduler, - now: () => now, - requests, - coord, - scope, - key, - editedScene, - readEnvelope, - writeRaw, - setFlushBehavior, - flushMicrotasks, - dispose, - }; -}; - -describe("PrevisSaveCoordinator", () => { - describe("01 initial", () => { - it("should be saved with revision 0, no draft, no recovery", () => { - const h = createHarness(); - const snap = h.coord.getSnapshot(); - expect(snap.status).toBe("saved"); - expect(snap.revision).toBe(0); - expect(snap.confirmedRevision).toBe(0); - expect(snap.draftStored).toBe(false); - expect(h.readEnvelope("scene-1")).toBeNull(); - }); - }); - - describe("02 edit", () => { - it("should write to fixed scoped key with complete envelope", () => { - const h = createHarness(); - const s = h.editedScene({ title: "Edited" }); - expect(s).toBeDefined(); - const env = h.readEnvelope("scene-1"); - expect(env).toMatchObject({ - schemaVersion: 1, - sceneId: "scene-1", - scene: expect.objectContaining({ title: "Edited" }), - }); - expect(env.revision).toBe(1); - expect(env.savedAt).toBeDefined(); - }); - }); - - describe("03 continuous edit", () => { - it("should monotonically increase revision and reflect latest scene", () => { - const h = createHarness(); - h.editedScene({ title: "v1" }); - h.editedScene({ title: "v2" }); - const snap = h.coord.getSnapshot(); - expect(snap.revision).toBe(2); - expect(snap.status).toBe("dirty"); - }); - }); - - describe("04 invalid scene id", () => { - it("should reject edit with wrong scene id and leave status unchanged", () => { - const h = createHarness(); - const originalStatus = h.coord.getSnapshot().status; - const result = h.coord.edit(makeScene({ id: "wrong" })); - expect(result).toBe(false); - expect(h.coord.getSnapshot().status).toBe(originalStatus); - }); - }); - - describe("05 storage set fail", () => { - it("should keep memory edit, set error status, draftStored false", () => { - const h = createHarness(); - h.storage.throwOnSet = true; - const scene = makeScene({ title: "memory-only" }); - expect(h.coord.edit(scene)).toBe(true); - const snap = h.coord.getSnapshot(); - expect(snap.scene.title).toBe("memory-only"); - expect(snap.status).toBe("error"); - expect(snap.draftStored).toBe(false); - }); - }); - - describe("06 debounce", () => { - it("should debounce multiple edits and flush only the latest scene", async () => { - const h = createHarness(); - h.editedScene({ title: "a" }); - h.editedScene({ title: "b" }); - expect(h.scheduler.size()).toBe(1); - await h.flushMicrotasks(); - expect(h.requests).toHaveLength(1); - expect(h.requests[0]?.scene.title).toBe("b"); - }); - }); - - describe("07 concurrent flush", () => { - it("should return the same Promise for concurrent flushLatest calls", async () => { - const h = createHarness(); - h.editedScene({ title: "dirty" }); - const first = h.coord.flushLatest(); - const second = h.coord.flushLatest(); - expect(first).toBe(second); - expect(await first).toBe(true); - }); - }); - - describe("08 concurrent edit during flush", () => { - it("should send second request in drain and update remaining draft base", async () => { - const h = createHarness(); - const first = deferred(); - const second = deferred(); - let flushCall = 0; - h.setFlushBehavior(async () => { - const gate = flushCall++ === 0 ? first : second; - await gate.promise; - }); - - const firstScene = h.editedScene({ - title: "first", - updatedAt: "2026-01-01T00:00:01.000Z", - }); - const pending = h.coord.flushLatest(); - expect(h.requests).toHaveLength(1); - expect(h.requests[0]?.scene.title).toBe("first"); - - h.editedScene({ - title: "second", - updatedAt: "2026-01-01T00:00:02.000Z", - }); - first.resolve(); - await h.flushMicrotasks(); - - expect(h.requests).toHaveLength(2); - expect(h.requests[1]?.scene.title).toBe("second"); - expect(h.coord.getSnapshot().status).not.toBe("saved"); - expect(h.readEnvelope()?.baseUpdatedAt).toBe(firstScene.updatedAt); - - second.resolve(); - expect(await pending).toBe(true); - expect(h.coord.getSnapshot()).toMatchObject({ - revision: 2, - confirmedRevision: 2, - status: "saved", - }); - expect(h.readEnvelope()).toBeNull(); - }); - }); - - describe("09 remote fail", () => { - it("should keep safe draft and set error status", async () => { - const h = createHarness(); - h.editedScene({ title: "remote-fail" }); - h.setFlushBehavior(async () => { - throw new Error("remote failed"); - }); - expect(await h.coord.flushLatest()).toBe(false); - expect(h.requests).toHaveLength(1); - expect(h.coord.getSnapshot()).toMatchObject({ - status: "error", - draftStored: true, - confirmedRevision: 0, - }); - expect(h.readEnvelope()?.scene.title).toBe("remote-fail"); - }); - }); - - describe("10 retry", () => { - it("should re-call remote and clear draft on success", async () => { - const h = createHarness(); - await h.coord.flushLatest(); - const result = await h.coord.retry(); - expect(result).toBe(true); - expect(h.readEnvelope("scene-1")).toBeNull(); - }); - }); - - describe("11 successful flush clears only matching draft", () => { - it("should clear only draft with confirmed revision", async () => { - const h = createHarness(); - h.editedScene(); - await h.coord.flushLatest(); - expect(h.readEnvelope("scene-1")).toBeNull(); - }); - }); - - describe("12 remove fail keeps saved status", () => { - it("should stay saved but keep the draft when removeItem fails", async () => { - const h = createHarness(); - h.editedScene({ title: "remove-fail" }); - expect(h.coord.getSnapshot().draftStored).toBe(true); - expect(h.readEnvelope()?.revision).toBe(1); - - h.storage.throwOnRemove = true; - expect(await h.coord.flushLatest()).toBe(true); - - expect(h.coord.getSnapshot()).toMatchObject({ - status: "saved", - confirmedRevision: 1, - draftStored: true, - }); - - h.storage.throwOnRemove = false; - expect(h.readEnvelope()?.revision).toBe(1); - }); - }); - - describe("13 malformed JSON", () => { - it("should reject unparsable JSON stored under the scoped key", () => { - const h = createHarness(); - h.writeRaw("invalid json"); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("14 wrong schema", () => { - it("should reject a candidate whose schemaVersion is not 1", () => { - const h = createHarness(); - - h.writeRaw(JSON.stringify(makeEnvelope())); - expect(h.coord.restoreCandidate()).not.toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.schemaVersion = 2; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("15 wrong sceneId", () => { - it("should reject a candidate whose sceneId does not match the scene", () => { - const h = createHarness(); - - h.writeRaw(JSON.stringify(makeEnvelope())); - expect(h.coord.restoreCandidate()).not.toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.sceneId = "scene-other"; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("16 missing baseUpdatedAt", () => { - it("should reject a candidate whose baseUpdatedAt is missing or not a string", () => { - const h = createHarness(); - - h.writeRaw(JSON.stringify(makeEnvelope())); - expect(h.coord.restoreCandidate()).not.toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - delete envelope.baseUpdatedAt; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.baseUpdatedAt = 20250101; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("17 invalid scene shape", () => { - it("should reject a candidate whose scene fails PrevisScene validation", () => { - const h = createHarness(); - - h.writeRaw(JSON.stringify(makeEnvelope())); - expect(h.coord.restoreCandidate()).not.toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.scene = { ...makeScene(), gridVisible: "yes" }; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.scene = { ...makeScene(), shots: "none" }; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.scene = makeScene({ id: "scene-2" }); - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("18 invalid revision/savedAt", () => { - it("should reject negative, fractional, unsafe revision and non-finite savedAt", () => { - const h = createHarness(); - - h.writeRaw(JSON.stringify(makeEnvelope())); - expect(h.coord.restoreCandidate()).not.toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.revision = -1; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.revision = 1.5; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw( - corruptEnvelope((envelope) => { - envelope.revision = Number.MAX_SAFE_INTEGER + 1; - }), - ); - expect(h.coord.restoreCandidate()).toBeNull(); - - h.writeRaw(rawEnvelopeWith({ savedAt: "1e999" })); - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("19 scope isolation", () => { - it("should only read the draft written under its own user scope", () => { - const storage = new MemoryStorage(); - const userA = createHarness({ scope: "user-a", storage }); - const userB = createHarness({ scope: "user-b", storage }); - expect(userA.key).not.toBe(userB.key); - - userB.writeRaw( - JSON.stringify( - makeEnvelope({ - revision: 3, - scene: makeScene({ title: "user-b draft" }), - }), - ), - ); - - expect(storage.keys()).toEqual([userB.key]); - expect(userA.coord.restoreCandidate()).toBeNull(); - - const candidate = userB.coord.restoreCandidate(); - expect(candidate).not.toBeNull(); - expect(candidate?.revision).toBe(3); - expect(candidate?.scene.title).toBe("user-b draft"); - }); - }); - - describe("20 explicitRestore success", () => { - it("should succeed and set dirty status", () => { - const h = createHarness(); - const env = { - schemaVersion: 1, - sceneId: "scene-1", - baseUpdatedAt: "2025", - scene: makeScene(), - revision: 5, - savedAt: 100, - }; - const result = h.coord.explicitRestore(env); - expect(result).toBe(true); - expect(h.coord.getSnapshot().status).toBe("dirty"); - expect(h.coord.getSnapshot().revision).toBe(5); - }); - }); - - describe("21 invalid explicitRestore", () => { - it("should reject older or invalid envelope", () => { - const h = createHarness(); - h.editedScene(); - const env = { ...h.readEnvelope("scene-1")!, revision: 0 }; - const result = h.coord.explicitRestore(env); - expect(result).toBe(false); - }); - }); - - describe("22 clean prepareClose", () => { - it("should immediately return close when confirmedRevision equals current", async () => { - const h = createHarness(); - const result = await h.coord.prepareClose(); - expect(result).toBe("close"); - }); - }); - - describe("23 dirty prepareClose", () => { - it("should reuse promise and return close on success", async () => { - const h = createHarness(); - h.editedScene(); - const p1 = h.coord.prepareClose(); - const p2 = h.coord.prepareClose(); - expect(p1).toBe(p2); - const result = await p1; - expect(result).toBe("close"); - }); - }); - - describe("24 prepareClose fail with draft", () => { - it("should return offer-draft-exit when remote fails but the draft is safe", async () => { - const h = createHarness(); - h.editedScene({ title: "close-fail" }); - expect(h.coord.getSnapshot().draftStored).toBe(true); - expect(h.readEnvelope()?.scene.title).toBe("close-fail"); - - h.setFlushBehavior(async () => { - throw new Error("remote rejected"); - }); - - expect(await h.coord.prepareClose()).toBe("offer-draft-exit"); - expect(h.coord.getSnapshot().status).toBe("error"); - expect(h.readEnvelope()?.scene.title).toBe("close-fail"); - }); - }); - - describe("25 prepareClose full fail", () => { - it("should return stay when both the local draft and the remote flush fail", async () => { - const h = createHarness(); - h.storage.throwOnSet = true; - h.setFlushBehavior(async () => { - throw new Error("remote rejected"); - }); - - h.editedScene({ title: "no-draft" }); - expect(h.coord.getSnapshot().draftStored).toBe(false); - - expect(await h.coord.prepareClose()).toBe("stay"); - expect(h.coord.getSnapshot().status).toBe("error"); - expect(h.readEnvelope()).toBeNull(); - }); - }); - - describe("26 handlePageHide", () => { - it("should reuse one in-flight promise and flush the latest revision", async () => { - const h = createHarness(); - const gate = deferred(); - h.setFlushBehavior(async () => { - await gate.promise; - }); - - h.editedScene({ title: "hide-1" }); - h.editedScene({ title: "hide-2" }); - expect(h.coord.getSnapshot().revision).toBe(2); - - const p1 = h.coord.handlePageHide(); - const p2 = h.coord.handlePageHide(); - expect(p1).toBe(p2); - - expect(h.requests).toHaveLength(1); - expect(h.requests[0]?.revision).toBe(2); - expect(h.requests[0]?.scene.title).toBe("hide-2"); - - gate.resolve(); - expect(await p1).toBe(true); - expect(h.coord.getSnapshot()).toMatchObject({ - status: "saved", - confirmedRevision: 2, - }); - }); - }); - - describe("27 dispose", () => { - it("should cancel debounce, stop notifying, and reject later edits", () => { - const h = createHarness(); - const seen: string[] = []; - h.coord.onStatusChange((status) => { - seen.push(status); - }); - - expect(h.coord.edit(makeScene({ title: "before-dispose" }))).toBe(true); - expect(seen).toEqual(["dirty"]); - expect(h.scheduler.size()).toBe(1); - - h.coord.dispose(); - expect(h.scheduler.size()).toBe(0); - - expect(h.coord.edit(makeScene({ title: "after-dispose" }))).toBe(false); - expect(seen).toEqual(["dirty"]); - }); - }); - - describe("28 dispose in-flight", () => { - it("should not mark saved or confirmed when disposed mid-flight", async () => { - const h = createHarness(); - const gate = deferred(); - h.setFlushBehavior(async () => { - await gate.promise; - }); - - h.editedScene({ title: "in-flight" }); - const pending = h.coord.flushLatest(); - expect(h.requests).toHaveLength(1); - expect(h.requests[0]?.revision).toBe(1); - - h.coord.dispose(); - gate.resolve(); - - expect(await pending).toBe(false); - const snap = h.coord.getSnapshot(); - expect(snap.confirmedRevision).toBe(0); - expect(snap.status).not.toBe("saved"); - }); - }); - - describe("29 unsubscribe", () => { - it("should only remove the unsubscribed listener", () => { - const h = createHarness(); - const first: string[] = []; - const second: string[] = []; - - const unsubscribeFirst = h.coord.onStatusChange((status) => { - first.push(status); - }); - h.coord.onStatusChange((status) => { - second.push(status); - }); - - unsubscribeFirst(); - h.editedScene({ title: "notify-second" }); - - expect(first).toEqual([]); - expect(second).toEqual(["dirty"]); - }); - }); - - describe("30 restoreCandidate on get fail", () => { - it("should return null when storage.getItem throws", () => { - const h = createHarness(); - h.storage.throwOnGet = true; - expect(h.coord.restoreCandidate()).toBeNull(); - }); - }); - - describe("31 discardDraft", () => { - it("should really delete the scoped draft so the same candidate never returns", () => { - const h = createHarness(); - h.editedScene({ title: "discard-me" }); - expect(h.coord.restoreCandidate()?.scene.title).toBe("discard-me"); - - expect(h.coord.discardDraft()).toBe(true); - - expect(h.readEnvelope()).toBeNull(); - expect(h.storage.keys()).toEqual([]); - expect(h.coord.restoreCandidate()).toBeNull(); - expect(h.coord.getSnapshot().draftStored).toBe(false); - }); - - it("should report false and keep the draft when removal fails", () => { - const h = createHarness(); - h.editedScene({ title: "keep-me" }); - - h.storage.throwOnRemove = true; - expect(h.coord.discardDraft()).toBe(false); - - h.storage.throwOnRemove = false; - expect(h.readEnvelope()?.scene.title).toBe("keep-me"); - expect(h.coord.getSnapshot().draftStored).toBe(true); - }); - - it("should leave a later flush unaffected after discarding", async () => { - const h = createHarness(); - h.editedScene({ title: "discard-then-save" }); - expect(h.coord.discardDraft()).toBe(true); - - expect(await h.coord.flushLatest()).toBe(true); - expect(h.requests).toHaveLength(1); - expect(h.requests[0]?.scene.title).toBe("discard-then-save"); - expect(h.coord.getSnapshot()).toMatchObject({ - status: "saved", - confirmedRevision: 1, - }); - }); - }); - - describe("32 restore then save", () => { - it("should adopt the restored revision and confirm it on the next flush", async () => { - const storage = new MemoryStorage(); - const previousSession = createHarness({ storage }); - previousSession.editedScene({ - title: "recovered", - updatedAt: "2026-02-02T00:00:00.000Z", - }); - previousSession.dispose(); - - const reopened = createHarness({ storage }); - const candidate = reopened.coord.restoreCandidate(); - expect(candidate?.scene.title).toBe("recovered"); - expect(candidate).not.toBeNull(); - if (!candidate) return; - - expect(reopened.coord.explicitRestore(candidate)).toBe(true); - expect(reopened.coord.getSnapshot()).toMatchObject({ - status: "dirty", - revision: candidate.revision, - confirmedRevision: 0, - draftStored: true, - }); - - expect(await reopened.coord.flushLatest()).toBe(true); - expect(reopened.requests).toHaveLength(1); - expect(reopened.requests[0]?.scene.title).toBe("recovered"); - expect(reopened.requests[0]?.revision).toBe(candidate.revision); - expect(reopened.coord.getSnapshot()).toMatchObject({ - status: "saved", - confirmedRevision: candidate.revision, - }); - expect(reopened.readEnvelope()).toBeNull(); - }); - }); - - describe("33 status subscription and retry", () => { - it("should publish the full dirty/saving/error/saved sequence across a failed then retried save", async () => { - const h = createHarness(); - const seen: string[] = []; - h.coord.onStatusChange((status) => { - seen.push(status); - }); - - let failNext = true; - h.setFlushBehavior(async () => { - if (failNext) throw new Error("remote rejected"); - }); - - h.editedScene({ title: "retry-me" }); - expect(seen).toEqual(["dirty"]); - - expect(await h.coord.flushLatest()).toBe(false); - expect(seen).toEqual(["dirty", "saving", "error"]); - expect(h.coord.getSnapshot().draftStored).toBe(true); - - failNext = false; - expect(await h.coord.retry()).toBe(true); - expect(seen).toEqual(["dirty", "saving", "error", "saving", "saved"]); - expect(h.requests).toHaveLength(2); - expect(h.requests[1]?.scene.title).toBe("retry-me"); - expect(h.coord.getSnapshot()).toMatchObject({ - status: "saved", - confirmedRevision: 1, - draftStored: false, - }); - expect(h.readEnvelope()).toBeNull(); - }); - - it("should stop publishing to a listener that unsubscribed mid-sequence", async () => { - const h = createHarness(); - const seen: string[] = []; - const unsubscribe = h.coord.onStatusChange((status) => { - seen.push(status); - }); - - h.editedScene({ title: "unsub" }); - expect(seen).toEqual(["dirty"]); - - unsubscribe(); - expect(await h.coord.flushLatest()).toBe(true); - expect(seen).toEqual(["dirty"]); - expect(h.coord.getSnapshot().status).toBe("saved"); - }); - }); - - describe("34 close decisions", () => { - it("should return close without any request when nothing is dirty", async () => { - const h = createHarness(); - expect(await h.coord.prepareClose()).toBe("close"); - expect(h.requests).toEqual([]); - }); - - it("should reuse one close promise while a slow save is in flight", async () => { - const h = createHarness(); - const gate = deferred(); - h.setFlushBehavior(async () => { - await gate.promise; - }); - - h.editedScene({ title: "slow-close" }); - const first = h.coord.prepareClose(); - const second = h.coord.prepareClose(); - expect(first).toBe(second); - expect(h.requests).toHaveLength(1); - - gate.resolve(); - expect(await first).toBe("close"); - expect(h.coord.getSnapshot().confirmedRevision).toBe(1); - }); - - it("should offer draft exit when the remote fails but the draft survives", async () => { - const h = createHarness(); - h.editedScene({ title: "draft-exit" }); - h.setFlushBehavior(async () => { - throw new Error("remote rejected"); - }); - - expect(await h.coord.prepareClose()).toBe("offer-draft-exit"); - expect(h.readEnvelope()?.scene.title).toBe("draft-exit"); - - // 放弃草稿后再次关闭仍要如实回报失败,不能因为草稿消失就假装可以安全退出。 - expect(h.coord.discardDraft()).toBe(true); - expect(await h.coord.prepareClose()).toBe("stay"); - }); - - it("should stay when neither the remote nor the local draft succeeded", async () => { - const h = createHarness(); - h.storage.throwOnSet = true; - h.setFlushBehavior(async () => { - throw new Error("remote rejected"); - }); - - h.editedScene({ title: "no-escape" }); - expect(await h.coord.prepareClose()).toBe("stay"); - expect(h.coord.getSnapshot().draftStored).toBe(false); - }); - - it("should stay after dispose instead of reporting a clean close", async () => { - const h = createHarness(); - h.editedScene({ title: "disposed-close" }); - h.coord.dispose(); - expect(await h.coord.prepareClose()).toBe("stay"); - }); - }); - - describe("35 snapshot visible inside the status callback", () => { - it("should expose a fully confirmed, draft-free snapshot when saved fires", async () => { - const h = createHarness(); - const observed: Array<{ status: string; revision: number; confirmedRevision: number; draftStored: boolean }> = []; - - h.coord.onStatusChange((status) => { - // 订阅方只拿到 status 是不够的:必须能在回调内读到完整的新快照。 - const snap = h.coord.getSnapshot(); - observed.push({ - status, - revision: snap.revision, - confirmedRevision: snap.confirmedRevision, - draftStored: snap.draftStored, - }); - }); - - h.editedScene({ title: "observe-me" }); - expect(await h.coord.flushLatest()).toBe(true); - - expect(observed.map((entry) => entry.status)).toEqual(["dirty", "saving", "saved"]); - - const saved = observed.at(-1); - expect(saved?.confirmedRevision).toBe(1); - expect(saved?.revision).toBe(1); - expect(saved?.confirmedRevision).toBe(saved?.revision); - expect(saved?.draftStored).toBe(false); - }); - - it("should expose the retained draft and unconfirmed revision when error fires", async () => { - const h = createHarness(); - let seen: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null; - - h.setFlushBehavior(async () => { - throw new Error("remote rejected"); - }); - h.coord.onStatusChange((status) => { - if (status !== "error") return; - const snap = h.coord.getSnapshot(); - seen = { - confirmedRevision: snap.confirmedRevision, - revision: snap.revision, - draftStored: snap.draftStored, - }; - }); - - h.editedScene({ title: "error-observe" }); - expect(await h.coord.flushLatest()).toBe(false); - - expect(seen).not.toBeNull(); - expect(seen?.revision).toBe(1); - expect(seen?.confirmedRevision).toBe(0); - expect(seen?.draftStored).toBe(true); - }); - - it("should reach a saved snapshot through the debounced schedule, not just explicit flush", async () => { - const h = createHarness(); - let savedSnapshot: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null; - - h.coord.onStatusChange((status) => { - if (status !== "saved") return; - const snap = h.coord.getSnapshot(); - savedSnapshot = { - confirmedRevision: snap.confirmedRevision, - revision: snap.revision, - draftStored: snap.draftStored, - }; - }); - - h.editedScene({ title: "debounced" }); - expect(h.scheduler.size()).toBe(1); - - // 自动 debounce 保存完成后,订阅方看到的必须已经是确认态。 - await h.flushMicrotasks(); - - expect(h.requests).toHaveLength(1); - expect(savedSnapshot).not.toBeNull(); - expect(savedSnapshot?.confirmedRevision).toBe(1); - expect(savedSnapshot?.revision).toBe(1); - expect(savedSnapshot?.draftStored).toBe(false); - expect(h.readEnvelope()).toBeNull(); - }); - }); -}); diff --git a/web/test/director-save-wiring.test.ts b/web/test/director-save-wiring.test.ts deleted file mode 100644 index d7d67418f..000000000 --- a/web/test/director-save-wiring.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { PrevisScene } from "../src/types/previs"; -import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisSaveSnapshot } from "../src/lib/canvas/previs/previs-save"; -import { - describePrevisSaveStatus, - previsSaveProgress, - idlePrevisSaveProgress, - resolvePrevisCloseOutcome, - resolvePrevisDraftStorage, - shouldBlockPrevisUnload, - shouldOfferPrevisDraftRecovery, - unavailablePrevisDraftStorage, -} from "../src/lib/canvas/previs/previs-save-wiring"; - -const makeScene = (overrides: Partial = {}): PrevisScene => ({ - id: "scene-1", - version: 1, - title: "Test Scene", - background: "#000", - environmentIntensity: 0.5, - gridVisible: true, - objects: [], - cameras: [], - lights: [], - shots: [], - activeShotId: "shot-1", - createdAt: "2025-01-01T00:00:00.000Z", - updatedAt: "2025-01-01T00:00:00.000Z", - ...overrides, -}); - -const makeSnapshot = (overrides: Partial = {}): PrevisSaveSnapshot => ({ - scene: makeScene(), - revision: 0, - confirmedRevision: 0, - status: "saved", - draftStored: false, - ...overrides, -}); - -const makeEnvelope = (overrides: Partial = {}): PrevisDraftEnvelope => { - const scene = overrides.scene ?? makeScene(); - return { - schemaVersion: 1, - sceneId: scene.id, - baseUpdatedAt: "2025-01-01T00:00:00.000Z", - revision: 1, - savedAt: 1000, - ...overrides, - scene, - }; -}; - -describe("describePrevisSaveStatus", () => { - it("should report a quiet idle state only when everything is confirmed", () => { - const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 3, confirmedRevision: 3 })); - expect(indicator).toEqual({ label: "已保存", tone: "idle", retryable: false, busy: false }); - }); - - it("should stay retryable when the status is saved but a revision is unconfirmed", () => { - const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 4, confirmedRevision: 3 })); - expect(indicator.label).toBe("有未保存修改"); - expect(indicator.tone).toBe("pending"); - expect(indicator.retryable).toBe(true); - }); - - it("should mark saving as busy and not retryable", () => { - const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saving", revision: 2, confirmedRevision: 1 })); - expect(indicator).toEqual({ label: "正在保存…", tone: "pending", retryable: false, busy: true }); - }); - - it("should distinguish an error with a safe draft from one without", () => { - const withDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: true })); - const withoutDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: false })); - - expect(withDraft.label).toBe("保存失败 · 本地草稿已保留"); - expect(withoutDraft.label).toBe("保存失败 · 本地草稿也未写入"); - expect(withDraft.tone).toBe("danger"); - expect(withoutDraft.tone).toBe("danger"); - expect(withDraft.retryable).toBe(true); - expect(withoutDraft.retryable).toBe(true); - }); - - it("should surface dirty as pending and retryable", () => { - const indicator = describePrevisSaveStatus(makeSnapshot({ status: "dirty", revision: 1, confirmedRevision: 0, draftStored: true })); - expect(indicator.label).toBe("有未保存修改"); - expect(indicator.retryable).toBe(true); - expect(indicator.busy).toBe(false); - }); -}); - -describe("shouldOfferPrevisDraftRecovery", () => { - it("should offer recovery when the draft is newer than the authoritative scene", () => { - const authoritativeScene = makeScene({ updatedAt: "2025-01-01T00:00:00.000Z" }); - const candidate = makeEnvelope({ - baseUpdatedAt: "2025-01-01T00:00:00.000Z", - scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }), - }); - - expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(true); - }); - - it("should not offer recovery when the project already holds that content", () => { - const authoritativeScene = makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }); - const candidate = makeEnvelope({ - baseUpdatedAt: "2025-06-01T00:00:00.000Z", - scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }), - }); - - expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false); - }); - - it("should not offer a stale draft whose base fell behind the authoritative scene", () => { - const authoritativeScene = makeScene({ updatedAt: "2025-09-01T00:00:00.000Z" }); - const candidate = makeEnvelope({ - baseUpdatedAt: "2025-01-01T00:00:00.000Z", - scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }), - }); - - expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false); - }); - - it("should reject a candidate belonging to another scene", () => { - const authoritativeScene = makeScene({ id: "scene-1" }); - const candidate = makeEnvelope({ - sceneId: "scene-2", - scene: makeScene({ id: "scene-2", updatedAt: "2025-06-01T00:00:00.000Z" }), - }); - - expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false); - }); - - it("should never prompt without both a candidate and an authoritative scene", () => { - expect(shouldOfferPrevisDraftRecovery({ candidate: null, authoritativeScene: makeScene() })).toBe(false); - expect(shouldOfferPrevisDraftRecovery({ candidate: makeEnvelope(), authoritativeScene: null })).toBe(false); - }); -}); - -describe("resolvePrevisCloseOutcome", () => { - it("should close outright on a clean decision", () => { - expect(resolvePrevisCloseOutcome("close")).toEqual({ kind: "close" }); - }); - - it("should require confirmation and name the safe draft on offer-draft-exit", () => { - const outcome = resolvePrevisCloseOutcome("offer-draft-exit"); - expect(outcome.kind).toBe("confirm-draft-exit"); - if (outcome.kind !== "confirm-draft-exit") return; - expect(outcome.message).toContain("远端保存失败"); - expect(outcome.message).toContain("本地草稿"); - }); - - it("should block the exit and explain why on stay", () => { - const outcome = resolvePrevisCloseOutcome("stay"); - expect(outcome.kind).toBe("blocked"); - if (outcome.kind !== "blocked") return; - expect(outcome.message).toContain("重试"); - }); -}); - -describe("shouldBlockPrevisUnload", () => { - it("should block while any revision is unconfirmed", () => { - expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 2, confirmedRevision: 1 }))).toBe(true); - }); - - it("should block an error state that has no safe draft", () => { - expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: false }))).toBe(true); - }); - - it("should let an error state with a safe draft through", () => { - expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: true }))).toBe(false); - }); - - it("should not block a fully confirmed session", () => { - expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 5, confirmedRevision: 5 }))).toBe(false); - }); -}); - -describe("previsSaveProgress", () => { - it("should project every progress field and drop the scene", () => { - const progress = previsSaveProgress(makeSnapshot({ status: "saving", revision: 4, confirmedRevision: 2, draftStored: true })); - - expect(progress).toEqual({ - status: "saving", - revision: 4, - confirmedRevision: 2, - draftStored: true, - }); - expect("scene" in progress).toBe(false); - }); - - it("should start idle with nothing unconfirmed and no draft", () => { - expect(idlePrevisSaveProgress).toEqual({ - status: "saved", - revision: 0, - confirmedRevision: 0, - draftStored: false, - }); - expect(describePrevisSaveStatus(idlePrevisSaveProgress).retryable).toBe(false); - expect(shouldBlockPrevisUnload(idlePrevisSaveProgress)).toBe(false); - }); -}); - -describe("resolvePrevisDraftStorage", () => { - it("should delegate to the provided storage when it is usable", () => { - const values = new Map(); - const storage = resolvePrevisDraftStorage( - () => - ({ - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => { - values.set(key, value); - }, - removeItem: (key: string) => { - values.delete(key); - }, - }) as unknown as Storage, - ); - - storage.setItem("draft", "payload"); - expect(storage.getItem("draft")).toBe("payload"); - storage.removeItem("draft"); - expect(storage.getItem("draft")).toBeNull(); - }); - - it("should fall back to the unavailable implementation when the getter throws", () => { - const storage = resolvePrevisDraftStorage(() => { - throw new Error("localStorage blocked"); - }); - expect(storage).toBe(unavailablePrevisDraftStorage); - }); - - it("should fall back to the unavailable implementation when there is no storage", () => { - expect(resolvePrevisDraftStorage(() => null)).toBe(unavailablePrevisDraftStorage); - expect(resolvePrevisDraftStorage(() => undefined)).toBe(unavailablePrevisDraftStorage); - }); -}); - -describe("unavailablePrevisDraftStorage", () => { - it("should read empty and refuse to write, so no draft is ever claimed as safe", () => { - expect(unavailablePrevisDraftStorage.getItem("draft")).toBeNull(); - expect(() => unavailablePrevisDraftStorage.setItem("draft", "payload")).toThrow(); - expect(() => unavailablePrevisDraftStorage.removeItem("draft")).toThrow(); - }); - - it("should drive the coordinator to draftStored false and a stay decision", async () => { - const coordinator = createPrevisSaveCoordinator({ - initialScene: makeScene(), - scope: "user-a", - storage: unavailablePrevisDraftStorage, - flush: async () => { - throw new Error("remote rejected"); - }, - schedule: () => 0, - cancelSchedule: () => undefined, - }); - - expect(coordinator.edit(makeScene({ title: "no-draft" }))).toBe(true); - expect(coordinator.getSnapshot().draftStored).toBe(false); - expect(coordinator.restoreCandidate()).toBeNull(); - - // 没有安全草稿时必须留在工作台,不能谎称「下次可恢复」。 - expect(await coordinator.prepareClose()).toBe("stay"); - }); -});