feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力

This commit is contained in:
ddcat666
2026-10-11 22:19:20 +08:00
parent 6dedc5973c
commit 5f359f2b1d
63 changed files with 3235 additions and 6193 deletions

View File

@@ -151,20 +151,19 @@ function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal:
}
async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType<typeof rebaseCanvasProjects>["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<SyncProjectProgress>) {
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,
};

View File

@@ -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<AssetStore>()(
await new Promise<void>((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<AssetStore>()(
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(

View File

@@ -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<SyncProgressStore>((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");
},
}));