Show nested canvas directory and reduce canvas loading overhead

This commit is contained in:
2026-11-06 20:48:08 +08:00
parent 05d96abd0e
commit af844131ed
15 changed files with 254 additions and 82 deletions
+7
View File
@@ -108,6 +108,13 @@ async function handler(
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const body = upstream.status === 204 ? null : await upstream.json();
responseHeaders.set(
"Server-Timing",
+16 -12
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { Check, ChevronsUpDown, FilePlus2, Home, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { boardHref, createBoard, renameBoard, matchesRef, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
@@ -14,6 +14,8 @@ import { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
import { loadBoardDirectory } from "@/lib/board-directory";
import { BoardDirectory } from "@/components/workspace/board-directory";
import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
@@ -26,6 +28,7 @@ export default function DashboardSidebar() {
const [profile, setProfile] = useState<Profile | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [selectedWorkspace, setSelectedWorkspace] = useState("");
const [directoryRevision, setDirectoryRevision] = useState(0);
const [boards, setBoards] = useState<Board[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
@@ -75,17 +78,24 @@ export default function DashboardSidebar() {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
let active = true;
setBoards([]);
setBoards((list) => list.filter((board) => board.workspace_id === workspace));
if (demo) {
setBoards(readDemoBoards(workspace));
return;
}
const controller = new AbortController();
api<{ items: Board[] }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&limit=100`, { signal: controller.signal })
.then(({ items }) => { if (active && version === listVersion.current) setBoards(items); })
loadBoardDirectory(api, workspace, controller.signal)
.then((items) => { if (active && version === listVersion.current) setBoards(items); })
.catch((e) => { if (active) setError(errorText(e)); });
return () => { active = false; controller.abort(); };
}, [workspace, demo, status]);
}, [workspace, demo, status, directoryRevision]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
window.addEventListener("endoleap:board-directory-changed", refresh);
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
}, [workspace]);
async function newPage() {
if (createLock.current || status !== "ready") return;
@@ -206,13 +216,7 @@ export default function DashboardSidebar() {
<div className={styles.sectionLabel}>{currentWorkspace?.name || "工作空间"}<span>{filtered.length}</span></div>
{status === "loading" && <p className={styles.hint} role="status">正在读取工作空间…</p>}
{status === "ready" && !workspace && <p className={styles.hint}>新建页面时会自动建立个人空间。</p>}
{filtered.map((board) => (
<Link key={board.id} className={styles.pageLink} aria-current={matchesRef(board, params.board) ? "page" : undefined} href={boardHref(currentWorkspace?.slug || workspace, board.slug || board.id, demo)}>
<Network size={16} strokeWidth={1.6} aria-hidden="true" />
<span>{board.title}</span>
</Link>
))}
{query && !filtered.length && <p className={styles.hint}>没有找到相关页面</p>}
<BoardDirectory key={workspace} boards={boards} workspace={currentWorkspace?.slug || workspace} active={params.board} query={query} demo={demo} />
</nav>
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.accountArea}>
+21 -10
View File
@@ -20,6 +20,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [conflicts, setConflicts] = useState(new Set<string>());
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
const pendingRefresh = useRef(new Map<string, number>());
@@ -27,20 +28,24 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
const page = await request<{ runs: { run_id: string; board_id: string; status: string }[]; next_cursor?: string }>(canvasPath(workspace, root) + "/ai/runs?status=active&include_descendants=true&limit=50" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) cache.current.set(run.run_id, { ...run, next_seq: 0 });
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) { cache.current.set(run.run_id, { ...run, next_seq: 0 }); dirty = true; }
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
let polls = 0;
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
polling = true;
try {
if (++polls % 4 === 0) await discover();
if (Date.now() >= nextDiscovery) { await discover(); nextDiscovery = Date.now() + (hasActive() ? 6000 : 30000); }
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
@@ -54,6 +59,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
cache.current.set(id, current);
} while (current.has_more && alive);
}
@@ -68,27 +74,32 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
pendingRefresh.current.delete(board);
}
if (alive) { changed(); setError(""); }
if (alive) { if (dirty) { changed(); dirty = false; } setError(""); }
} catch (error) { if (alive && !controller.signal.aborted) { changed(); setError(error instanceof Error ? error.message : "request_failed"); } }
if (alive) timer = setTimeout(poll, 1200);
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
void discover().catch((error) => { if (alive) setError(error.message); }).finally(() => { if (alive) void poll(); });
return () => { alive = false; controller.abort(); if (timer) clearTimeout(timer); };
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed();
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
}
async function cancel(run: AIRun) {
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } // Poll canonical terminal state, including already applied steps.
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
await reference.current.prepareApply(targetBoard, node);
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); }
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
+1
View File
@@ -205,6 +205,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronRight, Network } from "lucide-react";
import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardBranch } from "@/lib/board-directory";
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
const [closed, setClosed] = useState(new Set<string>());
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
<Link className={styles.pageLink} aria-current={matchesRef(board, active) ? "page" : undefined} href={boardHref(workspace, board.slug || board.id, demo)}><Network size={15} strokeWidth={1.6} aria-hidden="true" /><span>{board.title}</span></Link>
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
+4 -1
View File
@@ -9,7 +9,8 @@ import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
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 };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
@@ -22,6 +23,8 @@ 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 });
@@ -124,3 +124,12 @@
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; }
.directoryToggle:focus-visible { outline: 2px solid #9b877a; outline-offset: -2px; }
.directoryToggle svg { transition: transform 120ms ease; }
+50
View File
@@ -0,0 +1,50 @@
import { matchesRef, type Board } from "./workspace.ts";
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** Include children and finish pagination before building ownership paths. */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
const boards = new Map<string, Board>();
let offset = 0;
const seen = new Set<number>();
while (true) {
if (seen.has(offset)) throw new Error("invalid_pagination");
seen.add(offset);
const page = await request<{ items: Board[]; next_offset?: number | null }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&include_subgraphs=true&limit=100&offset=${offset}`, { signal });
for (const board of page.items) boards.set(board.id, board);
if (page.next_offset == null) return [...boards.values()];
if (!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) throw new Error("invalid_pagination");
offset = page.next_offset;
}
}
export type BoardBranch = { board: Board; children: BoardBranch[] };
export function boardDirectory(boards: Board[]): BoardBranch[] {
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = [];
for (const entry of entries.values()) {
const seen = new Set([entry.board.id]);
let parent = entry.board.parent_board_id;
let cyclic = false;
while (parent && entries.has(parent)) {
if (seen.has(parent)) { cyclic = true; break; }
seen.add(parent); parent = entries.get(parent)!.board.parent_board_id;
}
const owner = entry.board.parent_board_id && entries.get(entry.board.parent_board_id);
if (owner && !cyclic) owner.children.push(entry); else roots.push(entry);
}
return roots;
}
export function activeBoardAncestors(boards: Board[], ref?: string) {
const result = new Set<string>();
let item = boards.find((board) => matchesRef(board, ref));
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
return result;
}
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
const term = query.trim().toLocaleLowerCase();
if (!term) return branches;
return branches.flatMap((branch) => {
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
const children = filterBoardDirectory(branch.children, term);
return children.length ? [{ ...branch, children }] : [];
});
}