feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent 8f2232feee
commit 57e1820f41
179 changed files with 49746 additions and 1897 deletions

View File

@@ -0,0 +1,525 @@
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,
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 persistCanvasAgentGenerationContinuationEffect(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();
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("生成副作用缺少持久幂等标记");
return 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 },
);
}