checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -5,6 +5,11 @@ const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartMnoteCliAgentHostRun = vi.fn();
const mockStartCodexJsonRun = vi.fn();
const mockCodexMessagesToPrompt = vi.fn();
const mockFindWorkspaceRoot = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
@@ -51,6 +56,17 @@ vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
}));
vi.mock("@/lib/ai/codex/codexExec", () => ({
codexMessagesToPrompt: mockCodexMessagesToPrompt,
findWorkspaceRoot: mockFindWorkspaceRoot,
startCodexJsonRun: mockStartCodexJsonRun,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
describe("/api/ai-agent/run route", () => {
beforeEach(() => {
mockSafeGetJsonBody.mockReset();
@@ -58,8 +74,15 @@ describe("/api/ai-agent/run route", () => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartMnoteCliAgentHostRun.mockReset();
mockStartCodexJsonRun.mockReset();
mockCodexMessagesToPrompt.mockReset();
mockFindWorkspaceRoot.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockRequireAuthContext.mockReset();
mockGetConvexAuthedHttpClient.mockReset();
mockCodexMessagesToPrompt.mockImplementation((messages) => messages.map((m: any) => m.content).join("\n"));
mockFindWorkspaceRoot.mockResolvedValue("/mnt/Data1T/mnote");
});
it("文档页在线请求应只进入 mnote-cli host", async () => {
@@ -137,8 +160,8 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain("mnote-cli");
});
it.each(["codex", "hermes", "local", "ollama"] as const)(
"provider=%s 也必须统一进入 mnote-cli host",
it.each(["local", "ollama"] as const)(
"provider=%s 继续进入 mnote-cli host",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
@@ -196,6 +219,127 @@ describe("/api/ai-agent/run route", () => {
},
);
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "#chat 继续检查" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "codex",
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
return {
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
kill: vi.fn(),
};
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartCodexJsonRun).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex");
expect(text).toContain("event: codex_session");
expect(text).toContain("codex-thread-1");
expect(text).toContain("Codex 已回复");
});
it("provider=hermes 应进入 Hermes API bridge", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
sessionId: "hermes-session-1",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({ event: "message.delta", delta: "Hermes " });
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
session_id: "hermes-session-1",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
expect(text).toContain("Hermes 已回复");
});
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "ping" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "claudecode",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(501);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(text).toContain("ClaudeCode");
});
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
@@ -1,13 +1,130 @@
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
startMnoteCliAgentHostRun,
type MnoteCliAgentRunPayload,
} from "@/lib/server/mnote-cli-agent-host";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
const found = [...payload.messages].reverse().find((message) => message.role === "user");
return String(found?.content ?? "");
};
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "codex" });
try {
const cwd = await findWorkspaceRoot(process.cwd());
const prompt = codexMessagesToPrompt(payload.messages);
const run = startCodexJsonRun({
cwd,
sandbox: codexSandboxForPayload(payload),
prompt,
model: payload.options?.ai?.model,
sessionId: payload.options?.ai?.sessionId,
onJsonLine: (line) => {
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
send("codex_session", { sessionId: line.thread_id.trim() });
}
},
});
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error || "Codex 执行失败" });
return;
}
const sessionId = result.threadId.trim();
if (sessionId) send("codex_session", { sessionId });
send("assistant_message", { text: result.text || "(无输出)" });
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "codex",
},
});
}
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "hermes" });
try {
const started = await startHermesRun({
input: payload.messages.map((message) => ({
role: message.role,
content: message.content,
})),
conversation_history: payload.messages.slice(0, -1).map((message) => ({
role: message.role,
content: message.content,
})),
session_id: payload.options?.ai?.sessionId,
});
let assistantText = "";
await streamHermesRunEvents(started.runId, (event) => {
if (event.event === "message.delta" && typeof event.delta === "string") {
assistantText += event.delta;
send("assistant_delta", { text: event.delta });
}
if (event.event === "run.completed") {
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
send("assistant_message", { text: output || "(无输出)" });
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
}
if (event.event === "run.failed") {
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
}
});
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "hermes",
},
});
}
export async function POST(request: Request) {
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) {
@@ -32,6 +149,25 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
if (provider === "codex") {
return startCodexAgentRun(payload);
}
if (provider === "hermes") {
return startHermesAgentRun(payload);
}
if (provider === "claudecode") {
return NextResponse.json(
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
{
status: 501,
headers: {
"x-mnote-ai-execution-owner": "claudecode-unavailable",
},
},
);
}
return startMnoteCliAgentHostRun({
request,
userId,
@@ -33,6 +33,7 @@ type PageRuntimeState = {
};
type FileTreeRuntimeState = {
activeRowId: string | null;
selection: {
selectedRowIds: string[];
anchorRowId: string | null;
@@ -283,6 +284,7 @@ export function TreeShellRustDomShellHost({
const [fileTreeState, setFileTreeState] = useState<FileTreeRuntimeState>(() => {
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
return {
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -560,6 +562,10 @@ export function TreeShellRustDomShellHost({
}
continue;
}
if (event.kind === "fileTreeKeyboardCommand") {
window.dispatchEvent(new CustomEvent("tree.filetree.keyboard-command", { detail: event }));
continue;
}
if (event.kind === "fileTreeContextMenu") {
onFileTreeContextMenu?.({
documentId,
@@ -640,6 +646,7 @@ export function TreeShellRustDomShellHost({
if (mode !== "filetree") return;
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
setFileTreeState({
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -858,6 +865,45 @@ export function TreeShellRustDomShellHost({
[reduceFileTreeAction],
);
const handleFileTreeKeyDown = useCallback(
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
ArrowDown: { kind: "focusNext" },
ArrowUp: { kind: "focusPrevious" },
Home: { kind: "focusFirst" },
End: { kind: "focusLast" },
Enter: { kind: "openFocused" },
F2: { kind: "beginRenameFocused" },
Delete: { kind: "deleteSelection" },
Backspace: { kind: "deleteSelection" },
Escape: { kind: "escape" },
};
const shortcut = event.ctrlKey || event.metaKey;
const action = shortcut && event.key.toLowerCase() === "c"
? { kind: "copySelection" }
: shortcut && event.key.toLowerCase() === "x"
? { kind: "cutSelection" }
: shortcut && event.key.toLowerCase() === "v"
? { kind: "pasteIntoFocused" }
: actionByKey[event.key];
if (!action) return;
event.preventDefault();
const rowId = toRowId(item);
const { result } = await reduceFileTreeAction(action, {
...fileTreeState,
selection: {
...fileTreeState.selection,
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
},
});
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
async (item: TreeShellDomProjectionItem, event: DragEvent<HTMLElement>) => {
event.preventDefault();
@@ -998,6 +1044,7 @@ export function TreeShellRustDomShellHost({
const documentId = toDocumentId(item);
const assetId = toAssetId(item);
const selected = fileTreeState.selection.selectedRowIds.includes(rowId);
const active = fileTreeState.activeRowId === rowId || activeDocumentId === documentId;
return (
<div
key={rowId}
@@ -1013,7 +1060,7 @@ export function TreeShellRustDomShellHost({
data-asset-id={assetId ?? ""}
data-shell-mode="filetree"
data-selected={selected}
data-active={activeDocumentId === documentId}
data-active={active}
className={cn(
"tree-row group flex min-h-[27px] w-full cursor-pointer items-center gap-2 rounded-[3px] px-3 py-[2px] text-[14px] font-medium text-wolai-text-primary transition-colors hover:bg-wolai-bg-hover",
selected && "bg-wolai-bg-active text-[#2563eb]",
@@ -1023,6 +1070,7 @@ export function TreeShellRustDomShellHost({
draggable
onClick={(event) => void handleFileTreeSelect(item, event)}
onDoubleClick={() => void handleFileTreeOpen(item)}
onKeyDown={(event) => void handleFileTreeKeyDown(item, event)}
onContextMenu={(event) => void handleFileTreeContextMenu(item, event)}
onDragStart={(event) => {
const rowIds = fileTreeState.selection.selectedRowIds.includes(rowId) ? fileTreeState.selection.selectedRowIds : [rowId];
@@ -1103,7 +1151,15 @@ export function TreeShellRustDomShellHost({
{renderPageRows("")}
</div>
) : mode === "filetree" ? (
<div className="tree-root" role="tree" data-rust-filetree-renderer="dom_v1">
<div
className="tree-root"
role="tree"
data-rust-filetree-renderer="dom_v1"
onMouseDown={(event) => {
if (event.target !== event.currentTarget) return;
void reduceFileTreeAction({ kind: "clearSelection" });
}}
>
{renderFileTreeRows()}
</div>
) : (
@@ -296,7 +296,7 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","fileTreeKeyboardCommand","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
@@ -257,6 +257,7 @@ const TREE_SHELL_RUNTIME_ARTIFACT = {
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"fileTreeKeyboardCommand",
"pickerPickRoot",
"pickerPickDocument",
],
@@ -26,6 +26,7 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const selection = (state.selection ?? {}) as Record<string, unknown>;
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
@@ -49,12 +50,28 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
}]
: [];
const selectedRowId = typeof action.rowId === "string" ? action.rowId : null;
const nextSelection =
action.kind === "selectRow" && selectedRowId
? { selectedRowIds: [selectedRowId], anchorRowId: selectedRowId, focusedRowId: selectedRowId }
: action.kind === "clearSelection"
? { selectedRowIds: [], anchorRowId: null, focusedRowId: null }
: selection;
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
state: {
mode: "fileTree",
state: {
activeRowId: (state as Record<string, unknown>).activeRowId ?? null,
selection: nextSelection,
dragRowIds: (state as Record<string, unknown>).dragRowIds ?? [],
dragEffect: (state as Record<string, unknown>).dragEffect ?? null,
dropTargetRowId: (state as Record<string, unknown>).dropTargetRowId ?? null,
},
},
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
@@ -414,6 +431,69 @@ describe("tree-shell-surface", () => {
});
});
it("file tree surface 应支持空白区清空选择", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
rows={[]}
treeShellItems={[targetProjectionItem]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const targetRow = container.querySelector('[data-testid="filetree-doc-row"][data-row-id="doc:doc_target"]') as HTMLElement | null;
const treeRoot = container.querySelector('[data-rust-filetree-renderer="dom_v1"]') as HTMLElement | null;
expect(targetRow).not.toBeNull();
expect(treeRoot).not.toBeNull();
await act(async () => {
targetRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(targetRow?.getAttribute("data-selected")).toBe("true");
await act(async () => {
treeRoot?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
expect(targetRow?.getAttribute("data-selected")).toBe("false");
});
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn();
@@ -61,7 +61,7 @@ export function buildPageAggregateFromDocumentPayloads(input: {
const pageOptions: PageOptionsState = {
wideLayout: input.meta.wide_layout ?? false,
smallText: input.meta.use_small_text ?? false,
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
showHeadingNumbers: input.meta.show_heading_numbers ?? false,
showToc: input.meta.show_toc ?? false,
showStructure: input.meta.show_structure ?? false,
protectEditing: input.meta.protect_editing ?? false,
@@ -116,6 +116,15 @@ describe("buildVisibleRows", () => {
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/page_root",
relativePath: "documents/page_root",
storageIdentity: "page_root",
operationProfile: "convex_workspace",
},
},
},
iconHint: "page",
},
@@ -138,6 +147,15 @@ describe("buildVisibleRows", () => {
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "index",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/page_root/index",
relativePath: "documents/page_root/index",
storageIdentity: "page_root:index",
operationProfile: "convex_workspace",
},
},
},
iconHint: "index",
},
@@ -162,6 +180,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
extra: {
source: {
sourceKind: "local_folder",
sourceUri: "file:///workspace/ws_1/page_root/mindmap.json",
relativePath: "page_root/mindmap.json",
storageIdentity: "mind_1",
operationProfile: "local_readonly",
},
},
},
iconHint: "mindmap",
},
@@ -186,6 +213,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
extra: {
source: {
sourceKind: "local_folder",
sourceUri: "file:///workspace/ws_1/page_root/assets/node.png",
relativePath: "page_root/assets/node.png",
storageIdentity: "asset_child_1",
operationProfile: "local_readonly",
},
},
},
iconHint: "image",
},
@@ -210,6 +246,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "table",
iconHint: "table",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/assets/table_1",
relativePath: "assets/table_1",
storageIdentity: "table_1",
operationProfile: "convex_workspace",
},
},
},
iconHint: "table",
},
@@ -32,6 +32,14 @@ describe("tree-projection contract", () => {
resourceKind: "document",
documentId: "root",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/root",
storageIdentity: "root",
operationProfile: "convex_workspace",
},
},
},
});
expect(items[0]?.capabilities).toContain("open");
+14
View File
@@ -2,6 +2,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type {
TreeProjectionCapability,
TreeProjectionItemBase,
TreeProjectionSourceMetadata,
} from "@/lib/tree-protocol";
export type PageTreeProjectionItem = {
@@ -35,6 +36,16 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
return capabilities;
}
function buildPageSourceMetadata(node: SidebarTreeNode): TreeProjectionSourceMetadata {
return {
sourceKind: "convex_workspace",
sourceUri: `convex://workspace/${node.workspace_id}/documents/${node.id}`,
relativePath: `documents/${node.id}`,
storageIdentity: node.id,
operationProfile: "convex_workspace",
};
}
export function buildPageTreeProjectionItems(
nodes: SidebarTreeNode[],
): PageTreeProjectionItem[] {
@@ -71,6 +82,9 @@ export function buildPageTreeProjectionItems(
documentId: node.id,
workspaceId: node.workspace_id,
iconHint: "page",
extra: {
source: buildPageSourceMetadata(node),
},
},
iconHint: "page",
node: {
+39 -1
View File
@@ -1,7 +1,29 @@
import { describe, expect, it } from "vitest";
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
import type {
TreeProjectionItemBase,
WorkspaceSource,
} from "@/lib/tree-protocol";
describe("tree-protocol", () => {
it("冻结 WorkspaceSource 合同字段", () => {
const source: WorkspaceSource = {
sourceKind: "convex_workspace",
rootUri: "convex://workspace/ws_1",
workspaceId: "ws_1",
capabilities: [
"load-snapshot",
"watch",
"preflight-command",
"execute-command",
"resolve-page-aggregate",
],
};
expect(source.sourceKind).toBe("convex_workspace");
expect(source.rootUri).toContain("workspace/ws_1");
expect(source.capabilities).toContain("load-snapshot");
});
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
const item: TreeProjectionItemBase = {
nodeId: "doc_1",
@@ -20,12 +42,28 @@ describe("tree-protocol", () => {
documentId: "doc_1",
assetKind: "file",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1",
relativePath: "docs/doc_1.md",
storageIdentity: "doc_1",
operationProfile: "convex_workspace",
},
},
},
iconHint: "page",
};
expect(item.resourceMeta.resourceKind).toBe("document");
expect(item.resourceMeta.assetKind).toBe("file");
expect(item.resourceMeta.extra?.source).toMatchObject({
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1",
relativePath: "docs/doc_1.md",
storageIdentity: "doc_1",
operationProfile: "convex_workspace",
});
expect(item.capabilities).toContain("expand");
expect(item.expandable).toBe(true);
expect(item.iconHint).toBe("page");
+33 -1
View File
@@ -1,5 +1,21 @@
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
export type WorkspaceSourceKind = "local_folder" | "convex_workspace";
export type WorkspaceSourceCapability =
| "load-snapshot"
| "watch"
| "preflight-command"
| "execute-command"
| "resolve-page-aggregate";
export type WorkspaceSource = {
sourceKind: WorkspaceSourceKind;
rootUri: string;
workspaceId: string;
capabilities: WorkspaceSourceCapability[];
};
export type TreeProjectionNodeType =
| "workspace"
| "folder"
@@ -49,6 +65,22 @@ export type TreeProjectionAssetKind =
| "audio"
| "unknown";
export type TreeProjectionSourceOperationProfile =
| "local_readonly"
| "convex_workspace";
export type TreeProjectionSourceMetadata = {
sourceKind: WorkspaceSourceKind;
sourceUri: string;
relativePath?: string | null;
storageIdentity?: string | null;
operationProfile?: TreeProjectionSourceOperationProfile | null;
};
export type TreeProjectionResourceExtra = {
source?: TreeProjectionSourceMetadata | null;
} & Record<string, unknown>;
export type TreeProjectionResourceMeta = {
resourceKind: TreeProjectionResourceKind;
documentId?: string | null;
@@ -56,7 +88,7 @@ export type TreeProjectionResourceMeta = {
workspaceId?: string | null;
assetKind?: TreeProjectionAssetKind | null;
iconHint?: string | null;
extra?: Record<string, unknown>;
extra?: TreeProjectionResourceExtra;
};
export type TreeProjectionItemBase = {