diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 137afc489..7b5c936f4 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -981,6 +981,14 @@ func completeCloudAgentMediaNode(repo *repository.Repository, userID, canvasID, return stringValue(node["id"]), &cloudAgentMediaWritebackError{error: BadAuthRequest("生成结果没有可用的账号资源,未写入媒体地址"), reason: "result_resource_unavailable"} } meta["content"], meta["storageKey"], meta["status"] = resourceFileURL(id), "resource:"+id, "success" + // 完成交易已登记的首个产物,与 Agent 回写的首个资源共用身份。 + assetID := generationMediaAssetID(task.ID, 0) + delete(meta, "assetId") + if _, err := repo.AssetForUser(userID, assetID); err == nil { + meta["assetId"] = assetID + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } meta["naturalWidth"], meta["naturalHeight"] = resource.Width, resource.Height if resource.Width > 0 && resource.Height > 0 { if width, ok := node["width"].(float64); ok && width > 0 { diff --git a/backend/internal/app/task_media_assets.go b/backend/internal/app/task_media_assets.go index 81c6173b1..2bbb9afc7 100644 --- a/backend/internal/app/task_media_assets.go +++ b/backend/internal/app/task_media_assets.go @@ -61,6 +61,11 @@ func (s *Service) AudioOutputDurationMs(task model.Task) (int64, error) { return resource.DurationMs, nil } +func generationMediaAssetID(taskID string, index int) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("materialize:%s:%d", taskID, index))) + return "generation_" + hex.EncodeToString(sum[:]) +} + // Uses the same effect key and ID as browser materialization, so reconnecting // the canvas cannot create a second asset for a server-delivered output. func (s *Service) registerRecoveredMediaAssets(task model.Task) error { @@ -99,8 +104,7 @@ func (s *Service) registerRecoveredMediaAssets(task model.Task) error { return errors.New("作品文件尚未保存完成") } effectKey := fmt.Sprintf("materialize:%s:%d", task.ID, index) - sum := sha256.Sum256([]byte(effectKey)) - assetID := "generation_" + hex.EncodeToString(sum[:]) + assetID := generationMediaAssetID(task.ID, index) // Preserve existing user edits on replay; the deterministic key is unique. if _, err := s.repo.AssetForUser(task.UserID, assetID); err == nil { continue @@ -115,12 +119,12 @@ func (s *Service) registerRecoveredMediaAssets(task model.Task) error { } else { data["url"] = url } - title := "生成作品" + title := map[string]string{"image": "生成图片", "video": "生成视频", "audio": "生成音频"}[checkpoint.Mode] metadata := map[string]any{"source": "generation-task", "generationEffectKey": effectKey, "taskId": task.ID, "outputIndex": index} if projectID != "" { metadata["projectIds"] = []string{projectID} } - payload, err := json.Marshal(map[string]any{"id": assetID, "kind": checkpoint.Mode, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed, "title": title, "coverUrl": url, "tags": []string{"生成"}, "createdAt": now.UTC().Format(time.RFC3339Nano), "updatedAt": now.UTC().Format(time.RFC3339Nano), "data": data, "metadata": metadata}) + payload, err := json.Marshal(map[string]any{"id": assetID, "kind": checkpoint.Mode, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed, "source": "生成任务", "title": title, "coverUrl": url, "tags": []string{"生成"}, "createdAt": now.UTC().Format(time.RFC3339Nano), "updatedAt": now.UTC().Format(time.RFC3339Nano), "data": data, "metadata": metadata}) if err != nil { return err } diff --git a/backend/internal/app/task_media_browser_identity_test.go b/backend/internal/app/task_media_browser_identity_test.go new file mode 100644 index 000000000..4df641318 --- /dev/null +++ b/backend/internal/app/task_media_browser_identity_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +// 可选的 Node runner 只替换浏览器 API/存储边界;任务完成和素材落库使用真实实现。 +func TestGeneratedAssetBrowserIdentity(t *testing.T) { + runner := os.Getenv("CANVAS_TEST_GENERATION_BROWSER_RUNNER") + if runner == "" { + t.Skip("设置 CANVAS_TEST_GENERATION_BROWSER_RUNNER 运行跨端行为测试") + } + for _, scenario := range []string{"open", "reopen", "reopen-synced", "agent-live", "agent-reopen", "agent-reopen-edited", "agent-without-id", "reopen-edited", "remote-absent", "remote-unavailable", "edit-before-get", "edit-disjoint", "edit-conflict", "edit-during-get", "edit-category", "edit-source", "edit-display-url", "edit-metadata", "edit-video", "edit-audio", "edit-resource-conflict"} { + t.Run(scenario, func(t *testing.T) { + s, db := newMediaRecoveryTestService(t) + task := seedMediaTask(t, db, providerConfig{}) + mode, mimeType := "image", "image/png" + if scenario == "edit-video" || scenario == "edit-audio" { + mode = strings.TrimPrefix(scenario, "edit-") + mimeType = map[string]string{"video": "video/mp4", "audio": "audio/mpeg"}[mode] + input, _ := json.Marshal(canvasGenerationInput{Mode: mode, Prompt: "test"}) + task.Type, task.InputJSON = "canvas_"+mode, string(input) + if err := db.Save(task).Error; err != nil { + t.Fatal(err) + } + } + // 模拟存储已完成,保留真实 worker 的 checkpoint 恢复与完成登记。 + resource := model.Resource{ID: newID(), UserID: task.UserID, Kind: mode, Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "generated", MimeType: mimeType, Size: 80, Width: 2, Height: 3, UploadKey: mediaUploadKey(task.ID, 0)} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + checkpoint := &mediaCheckpoint{Mode: mode, StartedAt: time.Now(), Items: []mediaCheckpointItem{{ResourceID: resource.ID, MIMEType: mimeType}}} + if err := s.saveMediaCheckpoint(task, checkpoint, "register"); err != nil { + t.Fatal(err) + } + // 从持久 checkpoint 走 worker 的真实恢复与完成交易,不调用模型服务。 + if err := s.taskWorker().processClaimedTask(task, nil); err != nil { + t.Fatal(err) + } + completed, err := s.repo.Task(task.ID) + if err != nil || completed.Status != model.TaskStatusSucceeded { + t.Fatalf("completion: %+v, %v", completed, err) + } + assets, err := s.repo.Assets(task.UserID) + if err != nil || len(assets) != 1 { + t.Fatalf("backend assets: %d, %v", len(assets), err) + } + var project json.RawMessage + if strings.HasPrefix(scenario, "agent-") { + // 使用真实 Agent 完成写回;成功节点不经过前端 recovery materializer。 + doc := map[string]any{"id": "image-canvas", "title": "画布", "revision": 1, "createdAt": time.Now(), "updatedAt": time.Now(), "nodes": []any{map[string]any{"id": "image-node", "type": "image", "title": "图片", "position": map[string]int{"x": 0, "y": 0}, "width": 320, "height": 240, "metadata": map[string]any{"taskId": task.ID, "status": "loading"}}}, "connections": []any{}, "chatSessions": []any{}, "activeChatId": nil, "viewport": map[string]int{"x": 0, "y": 0, "k": 1}, "directorScenes": []any{}} + raw, _ := json.Marshal(doc) + canvas := model.CanvasProject{ID: "image-canvas", UserID: task.UserID, Title: "画布", PayloadJSON: string(raw)} + if err := s.repo.UpsertCanvasProject(&canvas); err != nil { + t.Fatal(err) + } + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + if _, err := completeCloudAgentMediaNode(s.repo, task.UserID, canvas.ID, "image-node", completed, policy); err != nil { + t.Fatal(err) + } + written, err := s.repo.CanvasProjectForUser(task.UserID, canvas.ID) + if err != nil { + t.Fatal(err) + } + project = json.RawMessage(written.PayloadJSON) + writtenDoc, err := creationDocument(written.PayloadJSON) + if err != nil { + t.Fatal(err) + } + meta := creationMaps(writtenDoc["nodes"])[0]["metadata"].(map[string]any) + if meta["status"] != "success" || meta["assetId"] != assets[0].ID { + t.Errorf("Agent writeback identity: %v; registered=%s", meta, assets[0].ID) + } + } + if scenario == "reopen-edited" || scenario == "agent-reopen-edited" { + var payload map[string]any + if err := json.Unmarshal([]byte(assets[0].PayloadJSON), &payload); err != nil { + t.Fatal(err) + } + payload["title"], payload["tags"] = "用户自定义标题", []string{"精选", "已审阅"} + raw, _ := json.Marshal(payload) + assets[0].Title, assets[0].PayloadJSON = "用户自定义标题", string(raw) + if err := s.repo.UpsertAsset(&assets[0]); err != nil { + t.Fatal(err) + } + } + if scenario == "edit-before-get" || scenario == "edit-category" || scenario == "edit-metadata" { + // 模拟已存储的远端上下文;保留真实完成交易的标题、分类和来源。 + var payload map[string]any + if err := json.Unmarshal([]byte(assets[0].PayloadJSON), &payload); err != nil { + t.Fatal(err) + } + metadata := payload["metadata"].(map[string]any) + metadata["messageId"], metadata["batchIndex"] = "remote-message", 7 + metadata["clientContext"] = map[string]any{"messageId": "remote-context-message", "remoteOnly": true} + raw, _ := json.Marshal(payload) + assets[0].PayloadJSON = string(raw) + if err := s.repo.UpsertAsset(&assets[0]); err != nil { + t.Fatal(err) + } + } + devices := 1 + if scenario == "agent-reopen" || scenario == "agent-reopen-edited" { + devices = 2 + } + for device := 1; device <= devices; device++ { + remote, err := s.repo.Assets(task.UserID) + if err != nil { + t.Fatal(err) + } + remotePayloads := make([]json.RawMessage, 0, len(remote)) + for _, asset := range remote { + remotePayloads = append(remotePayloads, json.RawMessage(asset.PayloadJSON)) + } + input, err := json.Marshal(map[string]any{"scenario": scenario, "task": taskForOutput(*completed), "asset": json.RawMessage(assets[0].PayloadJSON), "project": project, "remoteAssets": remotePayloads}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "node", runner) + cmd.Stdin = bytes.NewReader(input) + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + t.Fatalf("browser: %v\n%s\n%s", err, stderr.String(), output) + } + var browser struct { + Assets []json.RawMessage `json:"assets"` + NodeAssetID string `json:"nodeAssetId"` + RepairCreated int `json:"repairCreated"` + LocalAsset struct { + Title string `json:"title"` + Tags []string `json:"tags"` + } `json:"localAsset"` + } + if err := json.Unmarshal(output, &browser); err != nil { + t.Fatalf("browser output: %v: %s", err, output) + } + wantWrites := 0 + if scenario == "remote-absent" || (strings.HasPrefix(scenario, "edit-") && !strings.HasSuffix(scenario, "conflict")) { + wantWrites = 1 + } + if len(browser.Assets) != wantWrites { + t.Fatalf("browser PUT count: got %d, want %d", len(browser.Assets), wantWrites) + } + // 将浏览器实际生成的记录送到 PUT 所用的 repository,不在测试中重新推导 ID。 + for _, payload := range browser.Assets { + var asset model.Asset + if err := json.Unmarshal(payload, &asset); err != nil { + t.Fatal(err) + } + asset.UserID, asset.PayloadJSON = task.UserID, string(payload) + if err := s.repo.UpsertAsset(&asset); err != nil { + t.Fatal(err) + } + } + final, err := s.repo.Assets(task.UserID) + if err != nil || len(final) != 1 || browser.NodeAssetID != assets[0].ID || browser.RepairCreated != 0 { + t.Errorf("device=%d backend=%s browser=%s repair=%d final=%d err=%v", device, assets[0].ID, browser.NodeAssetID, browser.RepairCreated, len(final), err) + } + if len(browser.Assets) == 1 && len(final) == 1 && final[0].PayloadJSON != string(browser.Assets[0]) { + t.Error("stored DB payload must preserve the complete browser PUT including metadata") + } + if (scenario == "reopen-edited" || scenario == "agent-reopen-edited") && (final[0].Title != "用户自定义标题" || browser.LocalAsset.Title != "用户自定义标题" || len(browser.LocalAsset.Tags) != 2 || browser.LocalAsset.Tags[0] != "精选") { + t.Errorf("remote edit overwritten: DB title=%s local=%+v", final[0].Title, browser.LocalAsset) + } + t.Logf("device=%d backend=1 browser=%d final=%d repairCreated=%d sameID=%s", device, len(browser.Assets), len(final), browser.RepairCreated, browser.NodeAssetID) + } + }) + } +} diff --git a/tools/test-gen-asset-browser.cjs b/tools/test-gen-asset-browser.cjs new file mode 100644 index 000000000..6234ac4e6 --- /dev/null +++ b/tools/test-gen-asset-browser.cjs @@ -0,0 +1,264 @@ +// Go 完成交易的 JSON 从 stdin 进入;只替换 API、媒体读取与浏览器存储边界。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const root = path.resolve(__dirname, '..'); +const web = createRequire(path.join(root, 'web/package.json')); +const { transformSync } = web('rolldown/experimental'); +const babel = web('@babel/core'); +const input = JSON.parse(fs.readFileSync(0, 'utf8')); +const durable = new Map(), local = new Map(), tails = new Map(); +const storage = { + config() {}, + async getItem(key) { return durable.get(key) ?? null; }, + async setItem(key, value) { durable.set(key, value); return value; }, + async removeItem(key) { durable.delete(key); }, + createInstance() { return storage; }, + async keys() { return [...durable.keys()]; }, +}; +global.window = { + localStorage: { getItem: key => local.get(key) ?? null, setItem: (key, value) => local.set(key, value), removeItem: key => local.delete(key) }, + setTimeout, clearTimeout, addEventListener() {}, removeEventListener() {}, +}; +Object.defineProperty(global, 'navigator', { configurable: true, value: { locks: { + async request(key, callback) { + const previous = tails.get(key) ?? Promise.resolve(); + let release; + const next = new Promise(resolve => { release = resolve; }); + tails.set(key, previous.then(() => next)); + await previous; + try { return await callback(); } finally { release(); } + }, +} } }); +global.fetch = async () => { throw Error('Unexpected network request'); }; +const remoteAssets = new Map((input.remoteAssets || [input.asset]).map(asset => [asset.id, asset])); +if (input.scenario === 'remote-absent') remoteAssets.clear(); +const uploadedAssets = []; +let duringAssetGet; +let remoteProject; +class ApiError extends Error { + constructor(message, options = {}) { super(message); Object.assign(this, options); } +} +const mocks = { + localforage: { __esModule: true, default: storage }, + '@/services/api/request': { ApiError, apiBaseURL: '/api', http: new Proxy({}, { get: (_, name) => () => { throw Error(`Unexpected HTTP ${name}`); } }) }, + '@/services/api/user-data': { + getRemoteAsset: async id => { + if (duringAssetGet) { + const edit = duringAssetGet; + duringAssetGet = undefined; + await edit(); + } + if (input.scenario === 'remote-unavailable') throw new ApiError('Unavailable', { status: 503 }); + if (!remoteAssets.has(id)) throw new ApiError('Not found', { status: 404 }); + return { asset: structuredClone(remoteAssets.get(id)) }; + }, + getRemoteAssetsByIds: async ids => ({ assets: ids.flatMap(id => remoteAssets.has(id) ? [remoteAssets.get(id)] : []) }), + getRemoteCanvasProject: async () => ({ project: structuredClone(remoteProject) }), + upsertRemoteAsset: async asset => { + uploadedAssets.push(structuredClone(asset)); + remoteAssets.set(asset.id, structuredClone(asset)); + return { asset }; + }, + upsertRemoteCanvasProject: async project => { + remoteProject = { ...project, revision: (project.revision || 0) + 1 }; + return { project: remoteProject }; + }, + }, + '@/services/image-storage': { resolveImageUrl: async (key) => `https://display.example/${key}?signature=first` }, + '@/services/file-storage': { resolveMediaUrl: async (_key, url) => url }, + '@/services/resource-blob-cache': { getCachedResourceBlob: async () => new Blob(['fixture']) }, +}; +const modules = new Map(); +function load(file) { + if (modules.has(file)) return modules.get(file).exports; + const module = { exports: {} }; + modules.set(file, module); + let source = fs.readFileSync(file, 'utf8'); + const mutations = { + adoption: ['/services/project-asset-sync.ts', 'if (!asset && options.node.metadata?.assetId)', 'if (false)'], + managed: ['/services/project-asset-sync.ts', 'if ((options.taskId || options.node.metadata?.taskId) && options.node.metadata?.storageKey?.startsWith("resource:"))', 'if (false)'], + lookup: ['/services/user-data-sync.ts', 'if (!baseline) {\n // 空缓存', 'if (false) {\n // 空缓存'], + skipPut: ['/services/user-data-sync.ts', 'if (adoptedAssetIds.has(dirty.id)) continue;', ''], + lookupFailure: ['/services/user-data-sync.ts', 'if (error instanceof ApiError && error.status === 404) continue;', 'if (error instanceof ApiError) continue;'], + repair: ['/services/canvas-asset-repair.ts', 'if (node.metadata?.taskId && node.metadata.storageKey?.startsWith("resource:")) return undefined;', ''], + dropLocal: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = remoteContent;'], + dropRemote: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = content(source);'], + ignoreConflict: ['/services/user-data-sync.ts', 'throw assetRemoteVersionConflict();', 'merged = asset;'], + retryDropsLocal: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = remoteContent;'], + categoryDefault: ['/services/project-asset-sync.ts', 'category: "material",', ''], + stableMedia: ['/services/user-data-sync.ts', 'if (resourceId) {', 'if (false) {'], + clientMetadata: ['/services/user-data-sync.ts', 'delete metadata[key];', 'void metadata[key];'], + storedMetadata: ['/services/user-data-sync.ts', 'merged = applyEdits(asset, source, remoteContent, mergedContent) as Asset;', 'merged = mergedContent;'], + localContext: ['/services/user-data-sync.ts', 'metadata[key] = source.metadata[key];', 'void source.metadata[key];'], + staleGet: ['/services/user-data-sync.ts', 'if (!sameEntitySnapshot(current, source)) throw new Error("素材仍在编辑,请重新同步");', ''], + }; + const mutation = mutations[process.env.CANVAS_TEST_GENERATION_GUARD_MUTATION]; + if (mutation && file.endsWith(mutation[0])) { + assert(source.includes(mutation[1]), `Mutation target missing: ${mutation[0]}`); + source = source.replace(mutation[1], mutation[2]); + } + // 只用于负对照:改变前端身份,证明真实双写会被数据库笔数断言发现。 + if (process.env.CANVAS_TEST_GENERATION_ID_MUTATION === '1' && file.endsWith('/stores/use-asset-store.ts')) { + assert(source.includes('new TextEncoder().encode(effectKey)')); + source = source.replace('new TextEncoder().encode(effectKey)', 'new TextEncoder().encode("different:" + effectKey)'); + } + const js = transformSync(file, source, { jsx: { runtime: 'automatic' } }).code; + const code = babel.transformSync(js, { configFile: false, babelrc: false, plugins: [web('@babel/plugin-transform-modules-commonjs')] }).code; + function req(name) { + if (name in mocks) return mocks[name]; + if (name.startsWith('@/') || name.startsWith('.')) { + const base = name.startsWith('@/') ? path.join(root, 'web/src', name.slice(2)) : path.resolve(path.dirname(file), name); + const resolved = ['', '.ts', '.tsx', '/index.ts'].map(ext => base + ext).find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); + if (!resolved) throw Error(`Cannot resolve ${name} from ${file}`); + // 延迟读取无关模块,实际调用的业务函数仍执行原始源码。 + return new Proxy({}, { get: (_, key) => key === '__esModule' ? true : load(resolved)[key] }); + } + return web(name); + } + new Function('require', 'module', 'exports', code)(req, module, module.exports); + return module.exports; +} +const src = file => load(path.join(root, 'web/src', file)); + +async function run() { + src('lib/user-scope.ts').setActiveUserScope('media-user'); + const assets = src('stores/use-asset-store.ts'); + const canvases = src('stores/canvas/use-canvas-store.ts'); + await Promise.all([assets.useAssetStore.persist.rehydrate(), canvases.useCanvasStore.persist.rehydrate()]); + const pipeline = src('services/project-asset-sync.ts'); + const sync = src('services/user-data-sync.ts'); + const repair = src('services/canvas-asset-repair.ts'); + input.task.clientContext = { ...input.task.clientContext, conversationId: 'conversation', messageId: 'message', batchIndex: 0 }; + const node = input.project?.nodes[0] || { id: 'image-node', type: input.asset.kind, title: '图片', position: { x: 0, y: 0 }, width: 320, height: 240, metadata: { taskId: input.task.id, status: 'loading' } }; + if (input.scenario === 'agent-without-id') delete node.metadata.assetId; + remoteProject = input.project || { id: 'image-canvas', title: '画布', revision: 1, createdAt: input.task.createdAt, updatedAt: input.task.createdAt, nodes: [node], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 }, directorScenes: [] }; + if (input.scenario === 'open') { + canvases.useCanvasStore.setState({ projects: [structuredClone(remoteProject)] }); + await canvases.flushCanvasStorePersistence(); + } + await sync.initializeRemoteUserDataSession('media-user'); + if (input.scenario === 'agent-live') await sync.refreshCanvasAfterAgent(remoteProject.id); + else if (input.scenario !== 'open') await sync.loadCanvasProjectForEditing(remoteProject.id); + if (input.scenario === 'reopen-synced') await sync.loadAssetsForUse([input.asset.id]); + // Agent 已完成节点直接自动保存,不先运行 loading 节点的 materializer。 + let repairCreated = input.project ? 0 : repair.repairMissingCanvasAssets(undefined, true).createdAssets; + const nodesRef = { current: canvases.useCanvasStore.getState().projects[0].nodes }; + const setNodes = update => { nodesRef.current = typeof update === 'function' ? update(nodesRef.current) : update; }; + const consume = async task => pipeline.consumeGenerationTaskNode(task, node.id, 0, args => src('services/canvas-generation-consumer.ts').applyCanvasGenerationTaskNodeEffect({ + projectId: remoteProject.id, nodeId: node.id, ...args, nodesRef, setNodes, + })); + if (!input.project) await consume(input.task); + if (input.scenario.startsWith('edit-')) { + // consume 已走真实 addGenerationAsset;仅在 API / storage 边界制造交错。 + const id = assets.useAssetStore.getState().assets[0].id; + const generated = assets.useAssetStore.getState().assets[0]; + assert.equal(generated.title, input.asset.title, 'backend/frontend title defaults must agree'); + assert.equal(generated.category, 'material', 'generated category must be material'); + assert.equal(generated.source, input.asset.source, 'backend/frontend source defaults must agree'); + const localPatch = input.scenario === 'edit-category' ? { category: 'other' } + : input.scenario === 'edit-source' ? { source: '我的來源' } + : { title: '使用者剛改的標題', tags: ['保留我的編輯'] }; + localPatch.metadata = { ...generated.metadata, clientContext: { messageId: 'local-context-message', conversationId: 'local-context-conversation' } }; + if (input.scenario === 'edit-display-url') { + localPatch.data = { ...generated.data, dataUrl: 'https://display.example/renewed?signature=second' }; + localPatch.coverUrl = localPatch.data.dataUrl; + } + if (input.scenario === 'edit-metadata') localPatch.metadata = { ...generated.metadata, conversationId: 'renewed', clientContext: { messageId: 'new-message' } }; + const remoteBefore = structuredClone(remoteAssets.get(id)); + if (input.scenario === 'edit-display-url') { + remoteBefore.data.dataUrl = 'https://display.example/remote?signature=stored'; + remoteBefore.coverUrl = remoteBefore.data.dataUrl; + remoteAssets.set(id, structuredClone(remoteBefore)); + } + const edit = () => assets.useAssetStore.getState().updateAsset(id, localPatch); + if (input.scenario === 'edit-during-get') { + duringAssetGet = async () => { + edit(); + // GET 不能持有 persist lock,否则此处会死锁。 + await assets.flushAssetStorePersistence(); + }; + await assert.rejects(sync.saveRemoteUserDataNow(), /素材仍在编辑/, 'GET edit must stop stale adoption'); + assert.equal(uploadedAssets.length, 0); + } else { + edit(); + } + if (input.scenario === 'edit-disjoint') { + assets.useAssetStore.getState().updateAsset(id, { tags: ['生成'], coverUrl: 'https://example.test/custom-cover.png' }); + remoteBefore.tags = ['遠端標籤']; + remoteBefore.metadata.projectIds = ['remote-project']; + remoteAssets.set(id, structuredClone(remoteBefore)); + } + if (input.scenario.endsWith('conflict')) { + if (input.scenario === 'edit-resource-conflict') { + assets.useAssetStore.getState().updateAsset(id, { data: { ...generated.data, storageKey: 'resource:local-replacement' } }); + remoteBefore.data.storageKey = 'resource:remote-replacement'; + } else remoteBefore.title = '遠端改過的標題'; + remoteAssets.set(id, structuredClone(remoteBefore)); + for (let retry = 0; retry < 2; retry++) { + await assert.rejects(sync.saveRemoteUserDataNow(), /素材远端版本已变化/, 'both edited must surface the normal asset conflict'); + assert.equal(assets.useAssetStore.getState().assets[0].title, localPatch.title, 'conflict must preserve local edits'); + assert.deepEqual(remoteAssets.get(id), remoteBefore, 'conflict must preserve remote edits'); + assert.equal(uploadedAssets.length, 0, 'conflict must not PUT'); + } + } else { + const editedAt = assets.useAssetStore.getState().assets[0].updatedAt; + await sync.saveRemoteUserDataNow(); + const localAsset = assets.useAssetStore.getState().assets[0]; + assert.equal(localAsset.title, localPatch.title ?? generated.title, 'local edit must survive sync and retry'); + assert.equal(localAsset.category, localPatch.category ?? 'material', 'category edit must survive sync'); + assert.equal(localAsset.source, localPatch.source ?? '生成任务', 'source edit must survive sync'); + if (input.scenario === 'edit-display-url') { + assert.equal(localAsset.data.dataUrl, remoteBefore.data.dataUrl, 'comparison must preserve the stored remote media URL'); + assert.equal(localAsset.coverUrl, remoteBefore.coverUrl, 'comparison must preserve the stored remote cover URL'); + } + for (const key of ['clientContext', 'conversationId', 'messageId', 'batchIndex']) { + const expected = remoteBefore.metadata[key] ?? localPatch.metadata[key]; + assert.deepEqual(localAsset.metadata[key], expected, `stored metadata must survive: ${key}`); + assert.deepEqual(remoteAssets.get(id).metadata[key], expected, `remote metadata must survive PUT: ${key}`); + } + assert.equal(localAsset.updatedAt, editedAt, 'merged edits must retain their update timestamp'); + assert.deepEqual(localAsset.tags, input.scenario === 'edit-disjoint' ? ['遠端標籤'] : localPatch.tags ?? generated.tags, 'field merge must preserve the remote-only edit'); + if (input.scenario === 'edit-disjoint') { + assert.deepEqual(localAsset.metadata.projectIds, ['remote-project'], 'business metadata must still merge'); + assert.equal(localAsset.coverUrl, 'https://example.test/custom-cover.png', 'custom covers must still merge'); + } + assert.equal(uploadedAssets.length, 1, 'merged local edits must be saved'); + const expectedPut = input.scenario === 'edit-display-url' + ? { ...localAsset, data: { ...localAsset.data, dataUrl: input.asset.data.dataUrl } } + : localAsset; + assert.deepEqual(uploadedAssets[0], expectedPut, 'PUT must contain the merged content'); + await sync.saveRemoteUserDataNow(); + assert.equal(uploadedAssets.length, 1, 'acknowledged merge must not loop'); + } + await assets.flushAssetStorePersistence(); + process.stdout.write(JSON.stringify({ assets: uploadedAssets, nodeAssetId: id, repairCreated: 0, localAsset: assets.useAssetStore.getState().assets[0] })); + return; + } + if (input.scenario === 'agent-without-id' || input.scenario === 'remote-unavailable') { + if (input.scenario === 'agent-without-id') assert.equal(repair.repairMissingCanvasAssets(undefined, true).createdAssets, 0, '修复不能为受管产物建立随机素材'); + await assert.rejects(pipeline.ensureCanvasNodeAsset({ canvasId: remoteProject.id, node: nodesRef.current[0], source: 'canvas-generation', taskId: input.task.id }), input.scenario === 'remote-unavailable' ? /Unavailable/ : /生成素材尚未加载/); + assert.equal(uploadedAssets.length, 0, '读取失败不能创建素材'); + process.stdout.write(JSON.stringify({ assets: [], nodeAssetId: input.asset.id, repairCreated: 0 })); + return; + } + const saved = await pipeline.ensureCanvasNodeAsset({ canvasId: remoteProject.id, node: nodesRef.current[0], source: 'canvas-generation', taskId: input.task.id }); + if (!input.project) assert.equal(saved.created, false, '自动保存应重用 materialize 记录'); + if (input.project) { + nodesRef.current[0].metadata.assetId = saved.assetId; + canvases.useCanvasStore.getState().updateProject(remoteProject.id, { nodes: nodesRef.current }); + } + repairCreated += repair.repairMissingCanvasAssets(undefined, true).createdAssets; + if (!input.project) await consume(input.task); + await sync.saveRemoteUserDataNow(); + const writes = uploadedAssets.length; + await sync.saveRemoteUserDataNow(); + assert.equal(uploadedAssets.length, writes, 'context-only adoption must not cause another PUT'); + await Promise.all([assets.flushAssetStorePersistence(), canvases.flushCanvasStorePersistence()]); + assert.equal(assets.useAssetStore.getState().assets.length, 1); + assert.equal(nodesRef.current[0].metadata.assetId, saved.assetId); + assert.equal(nodesRef.current[0].metadata.storageKey, input.asset.data.storageKey); + process.stdout.write(JSON.stringify({ assets: uploadedAssets, nodeAssetId: saved.assetId, repairCreated, localAsset: assets.useAssetStore.getState().assets[0] })); +} +run().then(() => process.exit(0), error => { console.error(error); process.exit(1); }); diff --git a/tools/test-gen-asset-mutations.py b/tools/test-gen-asset-mutations.py new file mode 100644 index 000000000..61ebc18e3 --- /dev/null +++ b/tools/test-gen-asset-mutations.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Step 1b guard mutations; use the same cached Go/Node environment as the repro.""" +import os +from pathlib import Path +import subprocess + +root = Path(__file__).resolve().parents[1] +env = dict(os.environ, CANVAS_TEST_GENERATION_BROWSER_RUNNER=str(root / "tools/test-gen-asset-browser.cjs")) + + +def check(name, scenario, evidence, extra=None): + result = subprocess.run( + ["go", "-C", "backend", "test", "./internal/app", "-run", f"^TestGeneratedAssetBrowserIdentity/{scenario}$", "-count=1", "-v"], + cwd=root, env=dict(env, **(extra or {})), text=True, capture_output=True, timeout=120, + ) + output = result.stdout + result.stderr + if result.returncode == 0 or evidence not in output: + raise AssertionError(f"{name}: mutation survived or failed for an unrelated reason\n{output}") + print(f"KILLED {name}: {evidence}", flush=True) + + +for name, scenario, evidence in [ + ("adoption", "agent-live", "生成素材尚未加载"), + ("managed", "agent-without-id", "Missing expected rejection"), + ("repair", "agent-without-id", "修复不能为受管产物建立随机素材"), + ("lookup", "reopen-edited", "browser PUT count"), + ("skipPut", "reopen-edited", "browser PUT count"), + ("lookupFailure", "remote-unavailable", "Missing expected rejection"), + ("dropLocal", "edit-before-get", "local edit must survive sync and retry"), + ("dropRemote", "edit-disjoint", "field merge must preserve the remote-only edit"), + ("ignoreConflict", "edit-conflict", "both edited must surface the normal asset conflict"), + ("dropRemote", "reopen-edited", "browser PUT count"), + ("retryDropsLocal", "edit-during-get", "local edit must survive sync and retry"), + ("staleGet", "edit-during-get", "GET edit must stop stale adoption"), + ("categoryDefault", "edit-category", "generated category must be material"), + ("stableMedia", "edit-display-url", "素材远端版本已变化"), + ("clientMetadata", "edit-metadata", "素材远端版本已变化"), + ("storedMetadata", "edit-before-get", "stored metadata must survive: clientContext"), + ("storedMetadata", "edit-category", "stored metadata must survive: clientContext"), + ("localContext", "edit-disjoint", "stored metadata must survive: clientContext"), +]: + check(name, scenario, evidence, {"CANVAS_TEST_GENERATION_GUARD_MUTATION": name}) + +# Go mutation is restored even on failure; never run this alongside another Go test. +file = root / "backend/internal/app/cloud_agent_media.go" +original = file.read_bytes() +needle = b'meta["assetId"] = assetID' +assert original.count(needle) == 1 +try: + file.write_bytes(original.replace(needle, b'// mutation: omit the registered asset identity')) + check("writeback", "agent-reopen", "Agent writeback identity") +finally: + file.write_bytes(original) + +file = root / "backend/internal/app/task_media_assets.go" +original = file.read_bytes() +for name, needle, replacement, scenario, evidence in [ + ("imageTitle", '"image": "生成图片"', '"image": "生成作品"', "edit-before-get", "backend/frontend title defaults must agree"), + ("videoTitle", '"video": "生成视频"', '"video": "生成作品"', "edit-video", "backend/frontend title defaults must agree"), + ("audioTitle", '"audio": "生成音频"', '"audio": "生成作品"', "edit-audio", "backend/frontend title defaults must agree"), + ("sourceDefault", '"source": "生成任务", ', '', "edit-source", "backend/frontend source defaults must agree"), +]: + needle, replacement = needle.encode(), replacement.encode() + assert original.count(needle) == 1 + try: + file.write_bytes(original.replace(needle, replacement)) + check(name, scenario, evidence) + finally: + file.write_bytes(original) diff --git a/tools/test-gen-asset-upstream.cjs b/tools/test-gen-asset-upstream.cjs new file mode 100644 index 000000000..cbb6e3821 --- /dev/null +++ b/tools/test-gen-asset-upstream.cjs @@ -0,0 +1,135 @@ +// Run from web with a test/*.test.ts argument. Execute upstream suites with Node. +// Assertions and +// module mocks stay in the original suites; unsupported matchers fail closed. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const root = path.resolve(process.cwd(), '..'); +const web = createRequire(path.join(root, 'web/package.json')); +const { transformSync } = web('rolldown/experimental'); +const babel = web('@babel/core'); +const paths = JSON.parse(fs.readFileSync('tsconfig.json', 'utf8')).compilerOptions.paths; +const cache = new Map(), mocks = new Map(), tests = []; +function resolve(name, parent) { + for (const [alias, targets] of Object.entries(paths)) { + if (alias.endsWith('*') ? name.startsWith(alias.slice(0, -1)) : name === alias) { + name = path.resolve(targets[0].replace('*', name.slice(alias.length - 1))); + break; + } + } + if (!name.startsWith('.') && !path.isAbsolute(name)) return name; + const base = path.resolve(path.dirname(parent), name); + for (const ext of ['', '.ts', '.tsx', '.js', '.json', '/index.ts', '/index.tsx']) { + if (fs.existsSync(base + ext) && fs.statSync(base + ext).isFile()) return base + ext; + } + throw Error('Cannot resolve ' + name + ' from ' + parent); +} +const stringPattern = Symbol('stringMatching'); +function resolveMatchers(value, expected) { + if (expected?.[stringPattern]) { + assert.equal(typeof value, 'string'); + assert.match(value, expected[stringPattern]); + return value; + } + if (Array.isArray(expected)) return expected.map((item, index) => resolveMatchers(value?.[index], item)); + if (expected && Object.getPrototypeOf(expected) === Object.prototype) { + return Object.fromEntries(Object.entries(expected).map(([key, item]) => [key, resolveMatchers(value?.[key], item)])); + } + return expected; +} +function expect(value, label = '') { + const matchers = (negate = false) => { + const check = (fn) => { + let error; + try { fn(); } catch (caught) { error = caught; } + if (negate) assert(error, 'expect(...).not ' + label); + else if (error) throw new Error('expect(...) ' + label + ': ' + error.message, { cause: error }); + }; + return { + get not() { return matchers(!negate); }, + toBe: other => check(() => assert.equal(value, other)), + toEqual: other => check(() => assert.deepEqual(value, resolveMatchers(value, other))), + toBeNull: () => check(() => assert.equal(value, null)), + toBeUndefined: () => check(() => assert.equal(value, undefined)), + toBeDefined: () => check(() => assert.notEqual(value, undefined)), + toHaveLength: other => check(() => assert.equal(value.length, other)), + toHaveBeenCalled: () => check(() => assert(value.calls.length > 0)), + toHaveBeenCalledTimes: other => check(() => assert.equal(value.calls.length, other)), + toContain: other => check(() => assert(value.includes(other))), + toBeLessThan: other => check(() => assert(value < other)), + toBeGreaterThanOrEqual: other => check(() => assert(value >= other)), + toMatchObject: other => check(() => assert.partialDeepStrictEqual(value, other)), + toThrow: message => check(() => assert.throws(value, error => message === undefined || (message instanceof RegExp ? message.test(error.message) : error.message.includes(message)))), + get rejects() { return { toThrow: async message => { + let rejected = false; + try { await value; } catch (error) { rejected = true; expect(() => { throw error; }, label).toThrow(message); } + assert(rejected, 'expect(...).rejects ' + label); + } }; }, + get resolves() { return new Proxy({}, { get: (_, method) => async (...args) => expect(await value)[method](...args) }); }, + }; + }; + return matchers(); +} +const entry = path.resolve(process.argv[2]); +expect.stringMatching = pattern => ({ [stringPattern]: pattern instanceof RegExp ? pattern : new RegExp(pattern) }); +let beforeEach = [], afterEach = []; +const api = { expect, test: (name, fn) => { + const setup = [...beforeEach]; + const teardown = [...afterEach]; + tests.push([name, async () => { try { for (const run of setup) await run(); await fn(); } finally { for (const run of teardown) await run(); } }]); +}, spyOn(object, method) { + const original = object[method]; + let implementation = original; + const spy = Object.assign(function (...args) { spy.calls.push(args); return implementation.apply(this, args); }, { calls: [], mockImplementation(fn) { implementation = fn; return spy; }, mockResolvedValue(value) { implementation = async () => value; return spy; }, mockRestore() { object[method] = original; } }); + object[method] = spy; + return spy; +}, afterEach: fn => afterEach.push(fn), beforeEach: fn => beforeEach.push(fn), describe: (_name, run) => { + const parent = beforeEach; + beforeEach = [...parent]; + try { run(); } finally { beforeEach = parent; } +}, mock: { module(name, factory) { + mocks.set(resolve(name, entry), factory()); +} } }; +function compile(file) { + const source = fs.readFileSync(file, 'utf8').replaceAll('import.meta.env', '({})'); + const result = transformSync(file, source, { jsx: { runtime: 'automatic' } }); + assert.equal(result.errors.length, 0, JSON.stringify(result.errors)); + return babel.transformSync(result.code, { filename: file, configFile: false, babelrc: false, + plugins: [web.resolve('@babel/plugin-transform-modules-commonjs'), ({ types: t }) => ({ visitor: { + CallExpression(node) { + if (node.node.callee.type === 'Import') node.replaceWith(t.callExpression( + t.memberExpression(t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('resolve')), []), t.identifier('then')), + [t.arrowFunctionExpression([], t.callExpression(t.identifier('require'), node.node.arguments))])); + }, + } })], + }).code; +} +function load(name, parent = entry) { + if (name === 'bun:test' || name === 'node:test') return api; + const file = resolve(name, parent); + if (mocks.has(file)) return mocks.get(file); + if (!path.isAbsolute(file)) return web(file); + if (file.endsWith('.css')) return {}; + if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes('/node_modules/') || file.endsWith('.mjs')) return createRequire(parent)(file); + if (cache.has(file)) return cache.get(file).exports; + const module = { exports: {} }; + cache.set(file, module); + new Function('require', 'module', 'exports', '__filename', '__dirname', compile(file))( + name => load(name, file), module, module.exports, file, path.dirname(file)); + return module.exports; +} +(async () => { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const module = { exports: {} }; + await new AsyncFunction('require', 'module', 'exports', '__filename', '__dirname', compile(entry))( + name => load(name, entry), module, module.exports, entry, path.dirname(entry)); + assert(tests.length > 0, 'No tests registered'); + let failed = 0; + for (const [name, fn] of tests) { + try { await fn(); console.log('(pass) ' + name); } + catch (error) { failed++; console.error('(fail) ' + name, error); } + } + console.log(`${tests.length - failed} pass\n ${failed} fail (Node)`); + process.exitCode = failed ? 1 : 0; +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/web/src/lib/canvas/agent-canvas-patch.ts b/web/src/lib/canvas/agent-canvas-patch.ts index 5a2a3ac2c..d9329f531 100644 --- a/web/src/lib/canvas/agent-canvas-patch.ts +++ b/web/src/lib/canvas/agent-canvas-patch.ts @@ -26,14 +26,14 @@ function equal(a: unknown, b: unknown): boolean { // Three-way merge: untouched local fields (e.g. a drag or prompt edit) survive. // A concurrently changed field or a deleted node is a conflict, never an overwrite. -function mergeValue(current: unknown, before: unknown, after: unknown): unknown { +export function mergeThreeWayValue(current: unknown, before: unknown, after: unknown): unknown { if (equal(before, after) || equal(current, after)) return current; if (equal(current, before)) return after; if (record(current) && record(before) && record(after)) { const next = { ...current }; for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("无效的画布增量字段"); - const value = mergeValue(current[key], before[key], after[key]); + const value = mergeThreeWayValue(current[key], before[key], after[key]); if (value === undefined) delete next[key]; else next[key] = value; } @@ -48,7 +48,7 @@ function mergeItems(items: T[], changes: Change[]): for (const { before, after } of changes) { const id = after?.id || before?.id; if (!id || (before && after && before.id !== after.id)) throw new Error("无效的画布增量节点"); - const merged = mergeValue(next.get(id) ?? null, before, after) as T | null; + const merged = mergeThreeWayValue(next.get(id) ?? null, before, after) as T | null; if (merged === null) next.delete(id); else next.set(id, merged); } @@ -105,7 +105,7 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas const editor = editorState as CanvasProject & Record; for (const key of new Set([...Object.keys(before), ...Object.keys(after), ...Object.keys(editor)])) { if (["id", "revision", "updatedAt", "remoteContentHash", "viewport", "nodes", "connections"].includes(key)) continue; - const value = mergeValue(editor[key], before[key], after[key]); + const value = mergeThreeWayValue(editor[key], before[key], after[key]); if (value === undefined) delete merged[key]; else merged[key] = value; } diff --git a/web/src/services/canvas-asset-repair.ts b/web/src/services/canvas-asset-repair.ts index 5c2b60772..ce868ba12 100644 --- a/web/src/services/canvas-asset-repair.ts +++ b/web/src/services/canvas-asset-repair.ts @@ -48,6 +48,8 @@ export function repairMissingCanvasAssets(projectIds?: Set, partialAsset } } const repaired = repairProject(project, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId, (node) => { + // 受管生成结果由完成流程绑定身份,后台修复不能另建随机素材。 + if (node.metadata?.taskId && node.metadata.storageKey?.startsWith("resource:")) return undefined; const input = canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-upload" }); if (!input) return undefined; const assetId = useAssetStore.getState().addAsset(input); diff --git a/web/src/services/project-asset-sync.ts b/web/src/services/project-asset-sync.ts index 28a5fd9a7..385ff82a8 100644 --- a/web/src/services/project-asset-sync.ts +++ b/web/src/services/project-asset-sync.ts @@ -13,7 +13,7 @@ import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-st import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink"; import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects"; import { getCachedResourceBlob } from "@/services/resource-blob-cache"; -import { saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { loadAssetsForUse, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { getActiveUserScope } from "@/lib/user-scope"; import { normalizeAssetCategory } from "@/lib/asset-category"; import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; @@ -99,9 +99,16 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr throwIfAborted(options.signal); const store = useAssetStore.getState(); let asset = findCanvasNodeAsset(store.assets, options.node, options.canvasId, options.taskId); + if (!asset && options.node.metadata?.assetId) { + await loadAssetsForUse([options.node.metadata.assetId]); + throwIfAborted(options.signal); + asset = findCanvasNodeAsset(useAssetStore.getState().assets, options.node, options.canvasId, options.taskId); + } const declaredCategory = options.category || declaredCanvasNodeAssetCategory(options.node); let created = false; if (!asset) { + // 受管生成结果必须采用已登记的身份,不能以随机 ID 再建一份。 + if ((options.taskId || options.node.metadata?.taskId) && options.node.metadata?.storageKey?.startsWith("resource:")) throw new Error("生成素材尚未加载,请重新读取生成结果"); const input = canvasNodeToAsset(options.node, { canvasId: options.canvasId, source: options.source, taskId: options.taskId }); if (!input) throw new Error("当前节点没有可保存的素材内容"); const assetId = store.addAsset(options.category ? { ...input, category: options.category } : input); @@ -306,6 +313,7 @@ async function generationOutputAsset(input: Parameters, options: { const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset)); if (!dirtyProjects.length && !dirtyAssets.length) return; + const adoptedAssetIds = new Set(); + const mergedAssets = new Map(); + const assetSyncEpoch = sessionEpoch; + for (const source of dirtyAssets) { + const baseline = acknowledgedAssets.get(source.id); + if (!baseline) { + // 空缓存重建后也可能已有用户编辑;用实际生成默认值三方合并,不能整笔覆盖。 + let remote: Asset; + try { + remote = (await getRemoteAsset(source.id)).asset; + } catch (error) { + if (assetSyncEpoch !== sessionEpoch) throw new Error("账号已切换,已停止保存素材"); + if (error instanceof ApiError && error.status === 404) continue; + throw error; + } + if (assetSyncEpoch !== sessionEpoch) throw new Error("账号已切换,已停止保存素材"); + const current = useAssetStore.getState().assets.find((asset) => asset.id === source.id); + if (!sameEntitySnapshot(current, source)) throw new Error("素材仍在编辑,请重新同步"); + const [asset] = parseAssetRecordList([remote]); + const defaults = getGenerationAssetDefaults(source.id); + let merged: Asset; + let edited: boolean; + try { + const contextKeys = ["clientContext", "conversationId", "messageId", "batchIndex"]; + // 创建/更新时间是各端记账信息,不属于用户编辑的字段。 + const content = (value: Asset): Asset => { + let result = { ...value, createdAt: asset.createdAt, updatedAt: asset.updatedAt }; + if (!defaults) return result; + // 客户端上下文不是素材编辑;保留 projectIds 等业务元数据参与合并。 + const metadata = { ...result.metadata }; + for (const key of contextKeys) delete metadata[key]; + result = { ...result, metadata }; + if (result.kind === "image" || result.kind === "video" || result.kind === "audio") { + const resourceId = resourceIdFromStorageKey(result.data.storageKey); + if (resourceId) { + // 同一不可变资源的授权/缓存 URL 不代表编辑;独立封面仍参与比较。 + const url = resourceFileUrl(resourceId); + const displayUrl = result.kind === "image" ? result.data.dataUrl : result.data.url; + const coverUrl = result.coverUrl === displayUrl ? (result.kind === "image" ? url : "") : result.coverUrl; + result = result.kind === "image" + ? { ...result, coverUrl, data: { ...result.data, dataUrl: url } } + : result.kind === "video" + ? { ...result, coverUrl, data: { ...result.data, url } } + : { ...result, coverUrl, data: { ...result.data, url } }; + } + } + return result; + }; + // 无法证明生成默认值时保持保守;刷新后的旧缓存不能充当编辑基线。 + const remoteContent = content(asset); + const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset; + edited = canonicalize(remoteContent) !== canonicalize(mergedContent); + // 投影只用于判断编辑;未改字段保留远端原值,改动字段取本机真实 payload。 + const applyEdits = (stored: unknown, local: unknown, before: unknown, after: unknown): unknown => { + if (canonicalize(before) === canonicalize(after)) return stored; + if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) { + const result = { ...(stored as Record) }; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + const value = applyEdits(result[key], (local as Record)?.[key], (before as Record)[key], (after as Record)[key]); + if (value === undefined) delete result[key]; + else result[key] = value; + } + return result; + } + return local; + }; + merged = applyEdits(asset, source, remoteContent, mergedContent) as Asset; + if (defaults) { + const metadata = { ...merged.metadata }; + for (const key of contextKeys) { + if (metadata[key] === undefined && source.metadata?.[key] !== undefined) metadata[key] = source.metadata[key]; + } + merged = { ...merged, metadata }; + } + } catch { + throw assetRemoteVersionConflict(); + } + acknowledgedAssets.set(source.id, edited ? asset : merged); + verifiedAssets.add(source.id); + if (!edited) adoptedAssetIds.add(source.id); + else { + merged = { ...merged, updatedAt: source.updatedAt }; + mergedAssets.set(source.id, merged); + } + useAssetStore.setState((state) => ({ assets: state.assets.map((item) => item.id === source.id ? merged : item) })); + } + } + if (incrementalSession) { for (const source of dirtyAssets) { 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 new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库"); + if (!options.force) throw assetRemoteVersionConflict(); // 强制覆盖是用户显式指令:采纳远端版本为新基线后继续用本地内容覆盖。 acknowledgedAssets.set(source.id, asset); } @@ -808,7 +898,9 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { // 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。 // 素材先于画布提交。这样画布中的 resource: 引用一旦成为远端事实, // 对应 Asset 已经存在,刷新或换设备不会出现只占容量、不见素材的窗口。 - for (const source of dirtyAssets) { + for (const dirty of dirtyAssets) { + if (adoptedAssetIds.has(dirty.id)) continue; + const source = mergedAssets.get(dirty.id) ?? dirty; const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded); await upsertRemoteAsset(remotePayload); acknowledgedAssets.set(source.id, source); @@ -934,6 +1026,10 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] }); } +function assetRemoteVersionConflict() { + return new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库"); +} + function requireRemoteUserDataBaseline() { if (remoteUserDataPhase !== "ready") throw new Error("云端数据基线尚未建立,已停止写入"); } diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index b5203e6a8..ccda89256 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -99,6 +99,12 @@ const queuedAssetPersists = new Map(); const assetPersistTokens = new Map(); const assetOperations = new Set>(); const generationAssetFailures = new Map(); +const generationAssetDefaults = new Map>(); + +// 只记录本次实际创建的生成输入,不能把重读的缓存(可能已编辑)当作默认值。 +export function getGenerationAssetDefaults(id: string): Asset | undefined { + return generationAssetDefaults.get(getActiveUserScope())?.get(id); +} function recordAssetStorageDocument(scope: string, document: AssetStorageDocument) { observedAssetPersists.set(scope, { @@ -337,13 +343,17 @@ export const useAssetStore = create()( assetId: id, createAsset: () => { const now = new Date().toISOString(); - return parseAssetRecord({ + const created = parseAssetRecord({ ...asset, id, createdAt: now, updatedAt: now, metadata: { ...asset.metadata, generationEffectKey: effectKey }, }); + const defaults = generationAssetDefaults.get(scope) ?? new Map(); + defaults.set(id, structuredClone(created)); + generationAssetDefaults.set(scope, defaults); + return created; }, updateAssets: (updater) => { withAssetStorePersistenceSuppressed(() => { @@ -408,6 +418,7 @@ export const useAssetStore = create()( removeAsset: async (id) => get().removeAssets([id]), removeAssets: async (ids) => { const removedIds = new Set(ids); + for (const id of ids) generationAssetDefaults.get(getActiveUserScope())?.delete(id); let remainingAssets: Asset[] = []; let hasLocalMedia = false; set((state) => {