Files
ttstd-infinite-canvas/web/src/services/canvas-generation-consumer.ts
2026-09-14 01:11:56 +08:00

544 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Dispatch, SetStateAction } from "react";
import { applyMaterializedGenerationTaskResultToNodes } from "@/lib/canvas/canvas-generation-task-sync";
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
import { generationEffectApplied } from "@/services/generation-consumer-dedupe";
import {
CANVAS_STORE_KEY,
canvasStoreStorageRevision,
commitPendingCanvasStorePersistenceLocked,
flushCanvasStorePersistence,
pendingCanvasStorePersistence,
rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked,
reconcileCanvasGenerationFailure,
recordCanvasStorageDocument,
registerCanvasGenerationPersistenceAttempt,
useCanvasStore,
withCanvasStorePersistenceLock,
withCanvasStorePersistenceSuppressed,
type CanvasProject,
} from "@/stores/canvas/use-canvas-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
export class CanvasGenerationDurableAckError extends Error {
readonly cause: unknown;
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : "画布生成副作用持久化失败");
this.name = "CanvasGenerationDurableAckError";
this.cause = cause;
}
}
export function isCanvasGenerationDurableAckError(error: unknown): error is CanvasGenerationDurableAckError {
return error instanceof CanvasGenerationDurableAckError;
}
type CanvasGenerationLiveProjectState = Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">;
type CanvasGenerationLiveProjectAdapter = {
read: () => CanvasGenerationLiveProjectState;
write: (state: CanvasGenerationLiveProjectState) => void;
};
export function createCanvasGenerationLiveProjectAdapter(input: {
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
chatSessionsRef: { current: CanvasAssistantSession[] };
activeChatIdRef: { current: string | null };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
}): CanvasGenerationLiveProjectAdapter {
return {
read: () => ({ nodes: input.nodesRef.current, connections: input.connectionsRef.current, chatSessions: input.chatSessionsRef.current, activeChatId: input.activeChatIdRef.current }),
write: (state) => {
input.nodesRef.current = state.nodes;
input.connectionsRef.current = state.connections;
input.chatSessionsRef.current = state.chatSessions;
input.activeChatIdRef.current = state.activeChatId;
input.setNodes(state.nodes);
input.setConnections(state.connections);
input.setChatSessions(state.chatSessions);
input.setActiveChatId(state.activeChatId);
},
};
}
const canvasGenerationLiveAdapters = new Map<string, CanvasGenerationLiveProjectAdapter>();
function canvasGenerationLiveAdapterKey(scope: string, projectId: string) {
return `${scope}\0${projectId}`;
}
export function registerCanvasGenerationLiveProject(input: { scope: string; projectId: string; adapter: CanvasGenerationLiveProjectAdapter }) {
const key = canvasGenerationLiveAdapterKey(input.scope, input.projectId);
canvasGenerationLiveAdapters.set(key, input.adapter);
return () => {
if (canvasGenerationLiveAdapters.get(key) === input.adapter) canvasGenerationLiveAdapters.delete(key);
};
}
function reconcileCanvasGenerationLiveProject(scope: string, durableDocument: ReturnType<typeof parseCanvasStorageDocument>, baseProject: CanvasProject, attemptedProject: CanvasProject) {
const adapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, attemptedProject.id));
if (!adapter) return;
const durableProject = durableDocument.state.projects.find((project) => project.id === attemptedProject.id);
if (!durableProject) return;
const live = adapter.read();
const liveProject: CanvasProject = { ...attemptedProject, ...live };
const rebased = rebaseCanvasProjects({
document: { ...durableDocument, state: { projects: [durableProject] } },
baseProjects: [attemptedProject],
localProjects: [liveProject],
baseRevision: durableDocument.storageRevision,
});
const reconciled = rebased.document.state.projects.find((project) => project.id === attemptedProject.id) ?? durableProject;
adapter.write({ nodes: reconciled.nodes, connections: reconciled.connections, chatSessions: reconciled.chatSessions, activeChatId: reconciled.activeChatId });
}
export async function applyCanvasGenerationTaskNodeEffect(input: {
projectId: string;
nodeId: string;
task: GenerationTask;
output: GenerationTaskOutput;
effectKey: string;
signal?: AbortSignal;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
}) {
throwIfAborted(input.signal);
const previousNodes = input.nodesRef.current;
const applied = await applyMaterializedGenerationTaskResultToNodes(previousNodes, input.task, input.output, input.effectKey, input.nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
const persistedProject = await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes,
nodes: applied.nodes,
signal: input.signal,
});
input.nodesRef.current = persistedProject.nodes;
input.setNodes(persistedProject.nodes);
}
export async function persistCanvasOperationContinuationEffect(input: {
projectId: string;
nodeId: string;
continuation: NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
effectKey: string;
signal?: AbortSignal;
previousNodes?: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
}) {
throwIfAborted(input.signal);
const previousNodes = input.previousNodes ?? input.nodesRef.current;
const currentNodes = input.nodesRef.current;
const currentNode = currentNodes.find((node) => node.id === input.nodeId);
if (!currentNode) throw new Error("画布中找不到 Agent continuation 节点");
if (generationEffectApplied(currentNode.metadata || {}, input.effectKey) && currentNode.metadata?.agentGenerationContinuation?.status === "completed") return;
const nodes = currentNodes.map((node) =>
node.id === input.nodeId
? {
...node,
metadata: {
...node.metadata,
agentGenerationContinuation: input.continuation,
generationEffectKeys: Array.from(new Set([...(node.metadata?.generationEffectKeys || []), input.effectKey])),
},
}
: node,
);
let persistedProject: CanvasProject;
try {
persistedProject = await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes,
nodes,
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
throw new CanvasGenerationDurableAckError(error);
}
input.nodesRef.current = persistedProject.nodes;
input.setNodes(persistedProject.nodes);
}
type CinematicCanvasRollbackState = Pick<CanvasProject, "nodes" | "connections">;
type IdentifiedCanvasEntity = { id: string };
function sameCanvasEntity(left: unknown, right: unknown) {
return JSON.stringify(left) === JSON.stringify(right);
}
function reconcileCinematicEntities<T extends IdentifiedCanvasEntity>(previous: T[], attempted: T[], live: T[]): T[] {
const previousById = new Map(previous.map((entity) => [entity.id, entity]));
const attemptedById = new Map(attempted.map((entity) => [entity.id, entity]));
const cinematicIds = new Set<string>();
for (const id of new Set([...previousById.keys(), ...attemptedById.keys()])) {
if (!sameCanvasEntity(previousById.get(id), attemptedById.get(id))) cinematicIds.add(id);
}
const reconciled: T[] = [];
const included = new Set<string>();
for (const liveEntity of live) {
if (!cinematicIds.has(liveEntity.id)) {
reconciled.push(liveEntity);
included.add(liveEntity.id);
continue;
}
const previousEntity = previousById.get(liveEntity.id);
if (!previousEntity) continue;
reconciled.push(previousEntity);
included.add(previousEntity.id);
}
for (let previousIndex = 0; previousIndex < previous.length; previousIndex += 1) {
const previousEntity = previous[previousIndex]!;
if (!cinematicIds.has(previousEntity.id) || included.has(previousEntity.id)) continue;
let insertAt = reconciled.length;
for (let index = previousIndex - 1; index >= 0; index -= 1) {
const predecessorIndex = reconciled.findIndex((entity) => entity.id === previous[index]!.id);
if (predecessorIndex >= 0) {
insertAt = predecessorIndex + 1;
break;
}
}
if (insertAt === reconciled.length) {
for (let index = previousIndex + 1; index < previous.length; index += 1) {
const successorIndex = reconciled.findIndex((entity) => entity.id === previous[index]!.id);
if (successorIndex >= 0) {
insertAt = successorIndex;
break;
}
}
}
reconciled.splice(insertAt, 0, previousEntity);
included.add(previousEntity.id);
}
return reconciled;
}
function reconcileCinematicAckFailureCanvas(input: {
previousNodes: CanvasNodeData[];
attemptedNodes: CanvasNodeData[];
liveNodes: CanvasNodeData[];
previousConnections: CanvasConnection[];
attemptedConnections: CanvasConnection[];
liveConnections: CanvasConnection[];
}): CinematicCanvasRollbackState {
return {
nodes: reconcileCinematicEntities(input.previousNodes, input.attemptedNodes, input.liveNodes),
connections: reconcileCinematicEntities(input.previousConnections, input.attemptedConnections, input.liveConnections),
};
}
function reconcileCinematicActiveChatId(previous: string | null, attempted: string | null, live: string | null) {
return live === attempted ? previous : live;
}
function reconcileCinematicAckFailureSessions(input: { previous: CanvasAssistantSession[]; attempted: CanvasAssistantSession[]; live: CanvasAssistantSession[]; effectKey: string }) {
const previousById = new Map(input.previous.map((session) => [session.id, session]));
const attemptedById = new Map(input.attempted.map((session) => [session.id, session]));
return input.live.map((liveSession) => {
const previousSession = previousById.get(liveSession.id);
const attemptedSession = attemptedById.get(liveSession.id);
if (!previousSession || !attemptedSession || !generationEffectApplied(attemptedSession, input.effectKey) || generationEffectApplied(previousSession, input.effectKey)) return liveSession;
const pendingMessageId = previousSession.pendingBackendSession?.messageId;
let messages = liveSession.messages;
if (pendingMessageId) {
const pendingMessage = previousSession.messages.find((message) => message.id === pendingMessageId);
if (pendingMessage) {
let replaced = false;
messages = liveSession.messages.map((message) => {
if (message.id !== pendingMessageId) return message;
replaced = true;
return pendingMessage;
});
if (!replaced) {
const previousIndex = Math.max(
0,
previousSession.messages.findIndex((message) => message.id === pendingMessageId),
);
const insertAt = Math.min(previousIndex, messages.length);
messages = [...messages.slice(0, insertAt), pendingMessage, ...messages.slice(insertAt)];
}
}
}
const generationEffectKeys = Array.from(new Set([...(previousSession.generationEffectKeys || []), ...(liveSession.generationEffectKeys || []).filter((key) => key !== input.effectKey)]));
const restored: CanvasAssistantSession = {
...liveSession,
pendingBackendSession: previousSession.pendingBackendSession,
messages,
updatedAt: liveSession.updatedAt === attemptedSession.updatedAt ? previousSession.updatedAt : liveSession.updatedAt,
};
if (generationEffectKeys.length) restored.generationEffectKeys = generationEffectKeys;
else delete restored.generationEffectKeys;
return restored;
});
}
export async function persistCanvasCinematicSessionContinuationEffect(input: {
projectId: string;
effectKey: string;
previousNodes: CanvasNodeData[];
nodes: CanvasNodeData[];
previousConnections: CanvasConnection[];
connections: CanvasConnection[];
previousChatSessions: CanvasAssistantSession[];
chatSessions: CanvasAssistantSession[];
previousActiveChatId: string | null;
activeChatId: string | null;
signal?: AbortSignal;
readLiveSessionState: () => { sessions: CanvasAssistantSession[]; activeChatId: string | null };
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
restoreLiveSnapshot?: (state: CinematicCanvasRollbackState) => void;
}) {
const scope = getActiveUserScope();
const liveAdapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, input.projectId));
const initialLive = liveAdapter?.read();
const safeOrdinaryState = reconcileCinematicAckFailureCanvas({
previousNodes: input.previousNodes,
attemptedNodes: input.nodes,
liveNodes: initialLive?.nodes ?? input.nodes,
previousConnections: input.previousConnections,
attemptedConnections: input.connections,
liveConnections: initialLive?.connections ?? input.connections,
});
const safeOrdinaryActiveChatId = reconcileCinematicActiveChatId(input.previousActiveChatId, input.activeChatId, initialLive?.activeChatId ?? input.activeChatId);
if (getActiveUserScope() === scope) {
useCanvasStore.getState().updateProject(input.projectId, { ...safeOrdinaryState, activeChatId: safeOrdinaryActiveChatId });
}
try {
return await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes: input.previousNodes,
nodes: input.nodes,
previousConnections: input.previousConnections,
connections: input.connections,
previousChatSessions: input.previousChatSessions,
chatSessions: input.chatSessions,
previousActiveChatId: input.previousActiveChatId,
activeChatId: input.activeChatId,
signal: input.signal,
});
} catch (error) {
const latestLive = liveAdapter?.read();
const liveSessionState = input.readLiveSessionState();
const reconciledSessions = reconcileCinematicAckFailureSessions({
previous: input.previousChatSessions,
attempted: input.chatSessions,
live: liveSessionState.sessions,
effectKey: input.effectKey,
});
const reconciledActiveChatId = reconcileCinematicActiveChatId(input.previousActiveChatId, input.activeChatId, liveSessionState.activeChatId);
const reconciledCanvas = reconcileCinematicAckFailureCanvas({
previousNodes: input.previousNodes,
attemptedNodes: input.nodes,
liveNodes: latestLive?.nodes ?? input.nodes,
previousConnections: input.previousConnections,
attemptedConnections: input.connections,
liveConnections: latestLive?.connections ?? input.connections,
});
if (liveAdapter && latestLive) {
liveAdapter.write({ ...latestLive, nodes: reconciledCanvas.nodes, connections: reconciledCanvas.connections, chatSessions: reconciledSessions, activeChatId: reconciledActiveChatId });
}
input.restoreLiveSnapshot?.(reconciledCanvas);
if (getActiveUserScope() === scope) {
useCanvasStore.getState().updateProject(input.projectId, { ...reconciledCanvas, chatSessions: reconciledSessions, activeChatId: reconciledActiveChatId });
try {
await flushCanvasStorePersistence();
} catch {
// 保留 latest safe snapshot 给后续普通 persistence retry;原始 generation ack 错误仍是本次退出语义。
}
}
input.restoreLiveSessions(reconciledSessions, reconciledActiveChatId);
if (error instanceof Error && error.name === "AbortError") throw error;
throw new CanvasGenerationDurableAckError(error);
}
}
export type CanvasGenerationEffectInput = {
projectId: string;
effectKey: string;
previousNodes?: CanvasNodeData[];
nodes?: CanvasNodeData[];
previousConnections?: CanvasConnection[];
connections?: CanvasConnection[];
previousChatSessions?: CanvasAssistantSession[];
chatSessions?: CanvasAssistantSession[];
previousActiveChatId?: string | null;
activeChatId?: string | null;
signal?: AbortSignal;
};
function generationProjectDelta(input: CanvasGenerationEffectInput, memoryProject: CanvasProject) {
const stampedNodes = (input.nodes || []).filter((node) => generationEffectApplied(node.metadata || {}, input.effectKey));
const stampedSessions = (input.chatSessions || []).filter((session) => generationEffectApplied(session, input.effectKey));
if (input.connections && !input.previousConnections) throw new Error("生成副作用缺少连接前置快照");
const baseProject: CanvasProject = {
...memoryProject,
nodes: input.previousNodes ?? [],
connections: input.previousConnections ?? [],
chatSessions: input.previousChatSessions ?? [],
activeChatId: input.previousActiveChatId !== undefined ? input.previousActiveChatId : memoryProject.activeChatId,
};
const localProject: CanvasProject = {
...baseProject,
nodes: input.nodes ? (input.previousNodes ? input.nodes : stampedNodes) : baseProject.nodes,
connections: input.connections ?? baseProject.connections,
chatSessions: input.chatSessions ? (input.previousChatSessions ? input.chatSessions : stampedSessions) : baseProject.chatSessions,
activeChatId: input.activeChatId !== undefined ? input.activeChatId : baseProject.activeChatId,
updatedAt: new Date().toISOString(),
};
return { baseProject, localProject, stamped: stampedNodes.length > 0 || stampedSessions.length > 0 };
}
function rebaseCommittedCanvasGenerationOntoLiveProject(scope: string, projectId: string, committedDocument: ReturnType<typeof parseCanvasStorageDocument>, liveBaseProject: CanvasProject, baseRevision: number) {
const committedProject = committedDocument.state.projects.find((project) => project.id === projectId);
if (!committedProject) return undefined;
const storeProject = getActiveUserScope() === scope ? useCanvasStore.getState().projects.find((project) => project.id === projectId) : undefined;
const liveAdapter = canvasGenerationLiveAdapters.get(canvasGenerationLiveAdapterKey(scope, projectId));
const liveState = liveAdapter?.read();
const liveProject: CanvasProject = {
...(storeProject ?? committedProject),
...(liveState ?? {}),
};
const liveDocument = {
...committedDocument,
state: {
projects: committedDocument.state.projects.map((project) => (project.id === projectId ? liveProject : project)),
},
};
return rebaseCanvasProjects({
document: liveDocument,
baseProjects: [liveBaseProject],
localProjects: [committedProject],
baseRevision,
}).document.state.projects.find((project) => project.id === projectId);
}
export async function persistCanvasGenerationEffect(input: CanvasGenerationEffectInput) {
throwIfAborted(input.signal);
const scope = getActiveUserScope();
if (!useCanvasStore.getState().projects.some((project) => project.id === input.projectId)) {
const { loadCanvasProjectForEditing } = await import("@/services/user-data-sync");
await loadCanvasProjectForEditing(input.projectId);
throwIfAborted(input.signal);
if (scope !== getActiveUserScope()) throw new Error("账号已切换,无法写入生成结果");
}
const baseRevision = canvasStoreStorageRevision(scope);
const memoryProjects = useCanvasStore.getState().projects;
const memoryProject = memoryProjects.find((candidate) => candidate.id === input.projectId);
if (!memoryProject) throw new Error("画布项目不存在,无法持久化生成副作用");
const delta = generationProjectDelta(input, memoryProject);
if (!delta.stamped) throw new Error("生成副作用缺少持久幂等标记");
const unregisterAttempt = registerCanvasGenerationPersistenceAttempt(scope, input.projectId, input.effectKey, {
previousNodes: input.previousNodes,
nodes: input.nodes,
previousChatSessions: input.previousChatSessions,
chatSessions: input.chatSessions,
});
try {
return await withCanvasStorePersistenceLock(
scope,
async () => {
let latestDurable: ReturnType<typeof parseCanvasStorageDocument> | undefined;
let generationCommittedProject: CanvasProject | undefined;
let reconcileLiveOnFailure = false;
try {
throwIfAborted(input.signal);
const storage = localForageStorageForScope(scope);
await commitPendingCanvasStorePersistenceLocked(scope);
throwIfAborted(input.signal);
const durable = parseCanvasStorageDocument(await storage.getItem(CANVAS_STORE_KEY), memoryProjects);
latestDurable = durable;
throwIfAborted(input.signal);
const rebased = rebaseCanvasProjects({
document: durable,
baseProjects: [delta.baseProject],
localProjects: [delta.localProject],
baseRevision,
});
if (rebased.conflicts.some((conflict) => conflict.reason === "concurrent-update")) {
reconcileLiveOnFailure = true;
throw new Error("画布生成副作用与并发修改冲突");
}
if (rebased.conflicts.length) {
reconcileLiveOnFailure = true;
throw new Error("画布生成副作用与已删除内容冲突");
}
throwIfAborted(input.signal);
await storage.setItem(CANVAS_STORE_KEY, serializeCanvasStorageDocument(rebased.document));
// Dedicated generation setItem resolving is the commit point. From here, abort or ordinary persistence failures cannot negate the committed effect.
latestDurable = rebased.document;
generationCommittedProject = rebased.document.state.projects.find((candidate) => candidate.id === input.projectId);
recordCanvasStorageDocument(scope, rebased.document);
rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked(scope, rebased.document);
while (pendingCanvasStorePersistence(scope)) {
const ordinaryDocument = await commitPendingCanvasStorePersistenceLocked(scope);
if (ordinaryDocument) {
latestDurable = ordinaryDocument;
recordCanvasStorageDocument(scope, ordinaryDocument);
}
}
const finalDocument = parseCanvasStorageDocument(await storage.getItem(CANVAS_STORE_KEY), rebased.document.state.projects);
latestDurable = finalDocument;
recordCanvasStorageDocument(scope, finalDocument);
const persistedProject = rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, finalDocument, memoryProject, baseRevision) ?? generationCommittedProject;
if (!persistedProject) throw new Error("画布项目不存在,无法确认生成副作用");
if (getActiveUserScope() === scope) {
withCanvasStorePersistenceSuppressed(() => {
useCanvasStore.setState((state) => ({
projects: state.projects.map((project) => (project.id === input.projectId ? persistedProject : project)),
}));
});
}
return persistedProject;
} catch (error) {
if (generationCommittedProject) {
if (latestDurable) {
recordCanvasStorageDocument(scope, latestDurable);
return rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, latestDurable, memoryProject, baseRevision) ?? generationCommittedProject;
}
return generationCommittedProject;
}
if (latestDurable) {
recordCanvasStorageDocument(scope, latestDurable);
reconcileCanvasGenerationFailure(scope, latestDurable.state.projects);
if (reconcileLiveOnFailure) reconcileCanvasGenerationLiveProject(scope, latestDurable, delta.baseProject, delta.localProject);
}
throw error;
}
},
{ requireCrossRealmLock: true },
);
} finally {
unregisterAttempt();
}
}