Connect canonical projects to persistent workspace runtimes and canvas views

This commit is contained in:
2026-11-08 00:07:27 +08:00
parent 8a76e53a9f
commit e2987de21a
27 changed files with 970 additions and 57 deletions
@@ -0,0 +1,41 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
import { createRuntimeHost, type RuntimeHost } from "@/lib/canvas-runtime/host";
import { createWorkspaceRuntime } from "@/lib/canvas-runtime/workspace";
import { createBrowserStorage } from "@/lib/canvas-runtime/storage";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport } from "@/lib/canvas-service/index";
const Context = createContext<RuntimeHost | null>(null);
export function CanvasRuntimeHost({ children }: { children: ReactNode }) {
const [host] = useState(() => {
const registry = createRegistry(builtinComponents());
return createRuntimeHost({ registry,
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
},
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
});
},
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
});
});
const lifetime = useRef(0);
useEffect(() => {
const generation = ++lifetime.current;
// Strict Mode replays effects synchronously; only a real detach retires the host.
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
}, [host]);
return <Context.Provider value={host}>{children}</Context.Provider>;
}
export function useCanvasRuntimeHost() {
const host = useContext(Context);
if (!host) throw new Error("CanvasRuntimeHost is missing");
return host;
}
+17 -8
View File
@@ -5,17 +5,22 @@ import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import { loadCanvasEntry } from "@/lib/canvas-entry";
import { createFoundryTransport } from "@/lib/canvas-service/foundry";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { useCanvasRuntimeHost } from "./canvas-runtime-host";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import dynamic from "next/dynamic";
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState<Loaded | null>(null);
@@ -23,8 +28,6 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const [retry, setRetry] = useState(0);
useEffect(() => {
setLoaded(null); setError("");
// Fetch data while the much larger editor chunk loads.
void import("@/components/canvas/graph-canvas").catch(() => {});
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -33,11 +36,17 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}
let active = true;
const controller = new AbortController();
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,
transport(accountId) { return createFoundryTransport({ accountId, isCurrentIdentity: runtimeHost.bindAccount(accountId) }); },
}).then(async result => {
if (!active) return;
if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route }); return; }
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions);
if (active) setLoaded({ board: result.board, engine, route });
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, route, demo, retry, router]);
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
useEffect(() => {
@@ -46,7 +55,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} /> : displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>;
}