Show nested canvas directory and reduce canvas loading overhead
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user