feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user