feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -12,8 +12,6 @@ export type AgentPanelTab = "chat" | "setup" | "history" | "log";
|
||||
|
||||
type CanvasAgentStore = {
|
||||
width: number;
|
||||
url: string;
|
||||
token: string;
|
||||
connected: boolean;
|
||||
enabled: boolean;
|
||||
prompt: string;
|
||||
@@ -39,8 +37,6 @@ type CanvasAgentStore = {
|
||||
|
||||
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
|
||||
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
|
||||
url: typeof window === "undefined" ? "http://127.0.0.1:17371" : localStorage.getItem("canvas-agent-url") || "http://127.0.0.1:17371",
|
||||
token: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-token") || "",
|
||||
connected: false,
|
||||
enabled: false,
|
||||
prompt: "",
|
||||
|
||||
@@ -2,7 +2,9 @@ import { create } from "zustand";
|
||||
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
@@ -40,44 +42,272 @@ type CanvasStore = {
|
||||
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
|
||||
export const CANVAS_STORE_KEY = "infinite-canvas:canvas_store";
|
||||
type PersistedCanvasState = Pick<CanvasStore, "projects">;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let queuedPersistState: PersistedCanvasState | null = null;
|
||||
let queuedPersistName = CANVAS_STORE_KEY;
|
||||
let queuedPersistValue: string | null = null;
|
||||
type QueuedCanvasPersist = {
|
||||
name: string;
|
||||
scope: string;
|
||||
baseProjects: CanvasProject[];
|
||||
baseRevision: number;
|
||||
state: PersistedCanvasState;
|
||||
token: number;
|
||||
};
|
||||
|
||||
type ObservedCanvasPersist = {
|
||||
projects: CanvasProject[];
|
||||
revision: number;
|
||||
};
|
||||
|
||||
let suppressCanvasStorePersistence = 0;
|
||||
const canvasMemoryStates = new Map<string, PersistedCanvasState>();
|
||||
const observedCanvasPersists = new Map<string, ObservedCanvasPersist>();
|
||||
const queuedCanvasPersists = new Map<string, QueuedCanvasPersist>();
|
||||
const canvasSaveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const canvasPersistTokens = new Map<string, number>();
|
||||
|
||||
type AsyncCanvasStorageLock = {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
||||
};
|
||||
|
||||
type CanvasStorageLockOptions = {
|
||||
requireCrossRealmLock?: boolean;
|
||||
};
|
||||
|
||||
const CANVAS_STORAGE_LOCK_PREFIX = "infinite-canvas:canvas-generation-storage-lock:";
|
||||
const canvasStorageTails = new Map<string, Promise<void>>();
|
||||
|
||||
function runWithBrowserCanvasStorageLock<T>(scope: string, operation: () => Promise<T>, options: CanvasStorageLockOptions) {
|
||||
const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncCanvasStorageLock | undefined) : undefined;
|
||||
const lockName = `${CANVAS_STORAGE_LOCK_PREFIX}${scope}`;
|
||||
if (locks) return locks.request(lockName, operation);
|
||||
if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
throw new Error("当前浏览器不支持跨标签存储锁,已停止画布生成持久化");
|
||||
}
|
||||
return operation();
|
||||
}
|
||||
|
||||
export function withCanvasStorePersistenceLock<T>(scope: string, operation: () => Promise<T>, options: CanvasStorageLockOptions = {}): Promise<T> {
|
||||
const previous = canvasStorageTails.get(scope) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => undefined).then(() => runWithBrowserCanvasStorageLock(scope, operation, options));
|
||||
const tail = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
canvasStorageTails.set(scope, tail);
|
||||
void tail.finally(() => {
|
||||
if (canvasStorageTails.get(scope) === tail) canvasStorageTails.delete(scope);
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
function clearCanvasSaveTimer(scope: string) {
|
||||
const timer = canvasSaveTimers.get(scope);
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
canvasSaveTimers.delete(scope);
|
||||
}
|
||||
|
||||
export function canvasStoreStorageRevision(scope: string) {
|
||||
return observedCanvasPersists.get(scope)?.revision ?? 0;
|
||||
}
|
||||
|
||||
export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) {
|
||||
observedCanvasPersists.set(scope, {
|
||||
projects: document.state.projects,
|
||||
revision: document.storageRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export async function commitPendingCanvasStorePersistenceLocked(scope: string) {
|
||||
const storage = localForageStorageForScope(scope);
|
||||
let committed: CanvasStorageDocument | null = null;
|
||||
|
||||
while (true) {
|
||||
const queued = queuedCanvasPersists.get(scope);
|
||||
if (!queued) return committed;
|
||||
|
||||
const durable = parseCanvasStorageDocument(await storage.getItem(queued.name), queued.baseProjects);
|
||||
const rebased = rebaseCanvasProjects({
|
||||
document: durable,
|
||||
baseProjects: queued.baseProjects,
|
||||
localProjects: queued.state.projects,
|
||||
baseRevision: queued.baseRevision,
|
||||
}).document;
|
||||
await storage.setItem(queued.name, serializeCanvasStorageDocument(rebased));
|
||||
committed = rebased;
|
||||
recordCanvasStorageDocument(scope, rebased);
|
||||
|
||||
const latest = queuedCanvasPersists.get(scope);
|
||||
if (!latest || latest.token === queued.token) {
|
||||
if (latest?.token === queued.token) queuedCanvasPersists.delete(scope);
|
||||
return committed;
|
||||
}
|
||||
|
||||
latest.baseProjects = queued.state.projects;
|
||||
latest.baseRevision = rebased.storageRevision;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeQueuedCanvasPersist(scope: string, _token: number) {
|
||||
await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope));
|
||||
}
|
||||
|
||||
export function pendingCanvasStorePersistence(scope: string) {
|
||||
const queued = queuedCanvasPersists.get(scope);
|
||||
return queued ? { projects: queued.state.projects, token: queued.token } : null;
|
||||
}
|
||||
|
||||
export function rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked(scope: string, committed: CanvasStorageDocument) {
|
||||
const queued = queuedCanvasPersists.get(scope);
|
||||
if (!queued) return;
|
||||
const latestMemory = canvasMemoryStates.get(scope)?.projects ?? queued.state.projects;
|
||||
// Dedicated commit 已确认 generation stamp;用同 scope 最新内存重新投影队列,避免同节点普通编辑被旧 durable 快照替换。
|
||||
queued.baseProjects = committed.state.projects;
|
||||
queued.baseRevision = committed.storageRevision;
|
||||
queued.state = ordinaryCanvasPersistenceState({ projects: latestMemory }, committed.state.projects);
|
||||
}
|
||||
|
||||
export function withCanvasStorePersistenceSuppressed<T>(operation: () => T) {
|
||||
suppressCanvasStorePersistence += 1;
|
||||
try {
|
||||
return operation();
|
||||
} finally {
|
||||
suppressCanvasStorePersistence -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnconfirmedGenerationKey(localKeys?: string[], durableKeys?: string[]) {
|
||||
return Boolean(localKeys?.some((key) => !durableKeys?.includes(key)));
|
||||
}
|
||||
|
||||
function ordinaryCanvasProjectSnapshot(project: CanvasProject, durableProject: CanvasProject | undefined) {
|
||||
const durableNodes = new Map((durableProject?.nodes || []).map((node) => [node.id, node]));
|
||||
const durableSessions = new Map((durableProject?.chatSessions || []).map((session) => [session.id, session]));
|
||||
let changed = false;
|
||||
let hasUnconfirmedGeneration = false;
|
||||
const nodes: CanvasNodeData[] = [];
|
||||
for (const node of project.nodes) {
|
||||
const durableNode = durableNodes.get(node.id);
|
||||
const durableKeys = durableNode?.metadata?.generationEffectKeys;
|
||||
const localKeys = node.metadata?.generationEffectKeys;
|
||||
if (durableProject && hasUnconfirmedGenerationKey(localKeys, durableKeys)) {
|
||||
hasUnconfirmedGeneration = true;
|
||||
changed = true;
|
||||
if (durableNode) nodes.push(durableNode);
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(localKeys) === JSON.stringify(durableKeys)) {
|
||||
nodes.push(node);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
const metadata = { ...(node.metadata || {}) };
|
||||
if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys];
|
||||
else delete metadata.generationEffectKeys;
|
||||
nodes.push({ ...node, metadata });
|
||||
}
|
||||
const chatSessions: CanvasAssistantSession[] = [];
|
||||
for (const session of project.chatSessions) {
|
||||
const durableSession = durableSessions.get(session.id);
|
||||
const durableKeys = durableSession?.generationEffectKeys;
|
||||
if (durableProject && hasUnconfirmedGenerationKey(session.generationEffectKeys, durableKeys)) {
|
||||
hasUnconfirmedGeneration = true;
|
||||
changed = true;
|
||||
if (durableSession) chatSessions.push(durableSession);
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(session.generationEffectKeys) === JSON.stringify(durableKeys)) {
|
||||
chatSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
const nextSession = { ...session };
|
||||
if (durableKeys?.length) nextSession.generationEffectKeys = [...durableKeys];
|
||||
else delete nextSession.generationEffectKeys;
|
||||
chatSessions.push(nextSession);
|
||||
}
|
||||
if (durableProject && hasUnconfirmedGeneration) {
|
||||
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,
|
||||
chatSessions,
|
||||
activeChatId: durableProject.activeChatId,
|
||||
};
|
||||
}
|
||||
return changed ? { ...project, nodes, chatSessions } : project;
|
||||
}
|
||||
|
||||
function ordinaryCanvasPersistenceState(state: PersistedCanvasState, durableProjects: CanvasProject[]) {
|
||||
const durableById = new Map(durableProjects.map((project) => [project.id, project]));
|
||||
return {
|
||||
projects: state.projects.map((project) => ordinaryCanvasProjectSnapshot(project, durableById.get(project.id))),
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileCanvasGenerationFailure(scope: string, durableProjects: CanvasProject[]) {
|
||||
if (getActiveUserScope() !== scope) return;
|
||||
const projects = ordinaryCanvasPersistenceState({ projects: useCanvasStore.getState().projects }, durableProjects).projects;
|
||||
withCanvasStorePersistenceSuppressed(() => {
|
||||
useCanvasStore.setState({ projects });
|
||||
});
|
||||
}
|
||||
|
||||
const canvasStorage: PersistStorage<CanvasStore> = {
|
||||
getItem: async (name) => {
|
||||
const value = await localForageStorage.getItem(name);
|
||||
if (!value) return null;
|
||||
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
|
||||
queuedPersistState = parsed.state as PersistedCanvasState;
|
||||
return parsed;
|
||||
const scope = getActiveUserScope();
|
||||
const value = await localForageStorageForScope(scope).getItem(name);
|
||||
if (!value) {
|
||||
canvasMemoryStates.set(scope, { projects: [] });
|
||||
observedCanvasPersists.set(scope, { projects: [], revision: 0 });
|
||||
return null;
|
||||
}
|
||||
const document = parseCanvasStorageDocument(value);
|
||||
const state = document.state as PersistedCanvasState;
|
||||
canvasMemoryStates.set(scope, state);
|
||||
recordCanvasStorageDocument(scope, document);
|
||||
return document as unknown as StorageValue<CanvasStore>;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
const scope = getActiveUserScope();
|
||||
const nextState = value.state as PersistedCanvasState;
|
||||
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
|
||||
queuedPersistState = nextState;
|
||||
queuedPersistName = name;
|
||||
queuedPersistValue = JSON.stringify(value);
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
const payload = queuedPersistValue;
|
||||
queuedPersistValue = null;
|
||||
if (payload) void localForageStorage.setItem(queuedPersistName, payload);
|
||||
if (canvasMemoryStates.get(scope)?.projects === nextState.projects) return;
|
||||
canvasMemoryStates.set(scope, nextState);
|
||||
if (suppressCanvasStorePersistence) return;
|
||||
|
||||
const queued = queuedCanvasPersists.get(scope);
|
||||
const observed = observedCanvasPersists.get(scope);
|
||||
const token = (canvasPersistTokens.get(scope) || 0) + 1;
|
||||
canvasPersistTokens.set(scope, token);
|
||||
const baseProjects = queued?.baseProjects ?? observed?.projects ?? [];
|
||||
queuedCanvasPersists.set(scope, {
|
||||
name,
|
||||
scope,
|
||||
baseProjects,
|
||||
baseRevision: queued?.baseRevision ?? observed?.revision ?? 0,
|
||||
// generationEffectKeys 由专用 generation durable commit 管理;普通队列只能携带已确认 stamp。
|
||||
state: ordinaryCanvasPersistenceState(nextState, observed?.projects ?? baseProjects),
|
||||
token,
|
||||
});
|
||||
clearCanvasSaveTimer(scope);
|
||||
const timer = setTimeout(() => {
|
||||
if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope);
|
||||
void writeQueuedCanvasPersist(scope, token).catch(() => undefined);
|
||||
}, 400);
|
||||
canvasSaveTimers.set(scope, timer);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
const scope = getActiveUserScope();
|
||||
return localForageStorageForScope(scope).removeItem(name);
|
||||
},
|
||||
removeItem: (name) => localForageStorage.removeItem(name),
|
||||
};
|
||||
|
||||
export async function flushCanvasStorePersistence() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
while (canvasStorageTails.size || queuedCanvasPersists.size || canvasSaveTimers.size) {
|
||||
for (const scope of [...canvasSaveTimers.keys()]) clearCanvasSaveTimer(scope);
|
||||
const writes = [...queuedCanvasPersists.values()].map(({ scope, token }) => writeQueuedCanvasPersist(scope, token));
|
||||
if (writes.length) await Promise.all(writes);
|
||||
else if (canvasStorageTails.size) await Promise.all([...canvasStorageTails.values()]);
|
||||
}
|
||||
const payload = queuedPersistValue;
|
||||
queuedPersistValue = null;
|
||||
if (payload) await localForageStorage.setItem(queuedPersistName, payload);
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>()(
|
||||
|
||||
Reference in New Issue
Block a user