fix(assets): 复用生成素材身份并保留空缓存同步前的编辑 (#690)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T extends { id: string }>(items: T[], changes: Change<T>[]):
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export function repairMissingCanvasAssets(projectIds?: Set<string>, 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);
|
||||
|
||||
@@ -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<MaterializeGenerationTask
|
||||
return {
|
||||
kind: "image",
|
||||
title: "生成图片",
|
||||
category: "material",
|
||||
coverUrl: stored.url,
|
||||
tags: ["生成"],
|
||||
status: "confirmed",
|
||||
@@ -345,6 +353,7 @@ async function generationOutputAsset(input: Parameters<MaterializeGenerationTask
|
||||
return {
|
||||
kind: "video",
|
||||
title: "生成视频",
|
||||
category: "material",
|
||||
coverUrl: canvasVideoAssetPreviewUrl(stored.url),
|
||||
tags: ["生成"],
|
||||
status: "confirmed",
|
||||
@@ -388,6 +397,7 @@ async function generationOutputAsset(input: Parameters<MaterializeGenerationTask
|
||||
return {
|
||||
kind: "audio",
|
||||
title: "生成音频",
|
||||
category: "material",
|
||||
coverUrl: "",
|
||||
tags: ["生成"],
|
||||
status: "confirmed",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
deleteRemoteAssets,
|
||||
deleteRemoteCanvasProject,
|
||||
@@ -18,14 +20,14 @@ import { appQueryClient } from "@/lib/query-client";
|
||||
import { parseAssetRecordList } from "@/lib/asset-record";
|
||||
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { flushAssetStorePersistence, getGenerationAssetDefaults, useAssetStore } from "@/stores/use-asset-store";
|
||||
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, repairMissingCanvasVideoPreviews, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
|
||||
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { applyAgentCanvasPatch, mergeAgentCanvasEditor, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
|
||||
import { applyAgentCanvasPatch, mergeAgentCanvasEditor, mergeThreeWayValue, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
|
||||
import { collectLocalMediaKeys, ensureRemoteResourceReferences } from "./user-data-sync-media";
|
||||
|
||||
export { numberValue } from "./user-data-sync-media";
|
||||
@@ -790,13 +792,101 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
|
||||
if (!dirtyProjects.length && !dirtyAssets.length) return;
|
||||
|
||||
const adoptedAssetIds = new Set<string>();
|
||||
const mergedAssets = new Map<string, Asset>();
|
||||
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<string, unknown>) };
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
const value = applyEdits(result[key], (local as Record<string, unknown>)?.[key], (before as Record<string, unknown>)[key], (after as Record<string, unknown>)[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<string, string>, 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<string, string>, options: {
|
||||
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
|
||||
}
|
||||
|
||||
function assetRemoteVersionConflict() {
|
||||
return new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库");
|
||||
}
|
||||
|
||||
function requireRemoteUserDataBaseline() {
|
||||
if (remoteUserDataPhase !== "ready") throw new Error("云端数据基线尚未建立,已停止写入");
|
||||
}
|
||||
|
||||
@@ -99,6 +99,12 @@ const queuedAssetPersists = new Map<string, QueuedAssetPersist>();
|
||||
const assetPersistTokens = new Map<string, number>();
|
||||
const assetOperations = new Set<Promise<unknown>>();
|
||||
const generationAssetFailures = new Map<string, unknown>();
|
||||
const generationAssetDefaults = new Map<string, Map<string, Asset>>();
|
||||
|
||||
// 只记录本次实际创建的生成输入,不能把重读的缓存(可能已编辑)当作默认值。
|
||||
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<AssetStore>()(
|
||||
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<string, Asset>();
|
||||
defaults.set(id, structuredClone(created));
|
||||
generationAssetDefaults.set(scope, defaults);
|
||||
return created;
|
||||
},
|
||||
updateAssets: (updater) => {
|
||||
withAssetStorePersistenceSuppressed(() => {
|
||||
@@ -408,6 +418,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user