feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockFetchDocumentAiOrchestratorConfig = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
fetchDocumentAiOrchestratorConfig: mockFetchDocumentAiOrchestratorConfig,
}));
describe("/api/ai-agent/document/config route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockFetchDocumentAiOrchestratorConfig.mockReset();
});
it("应返回 orchestrator config 能力面", async () => {
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockFetchDocumentAiOrchestratorConfig.mockResolvedValue({
provider: "online",
defaultModelKey: "gpt-5.4",
defaultProfileId: "page_writer_ai_first",
sessionEnabled: true,
models: [{ key: "gpt-5.4", title: "GPT-5.4" }],
profiles: [{ id: "page_writer_ai_first", title: "AI 主写" }],
tools: [{ name: "doc_get", description: "读取当前页摘要" }],
});
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/ai-agent/document/config"));
const payload = await response.json();
expect(response.status).toBe(200);
expect(mockFetchDocumentAiOrchestratorConfig).toHaveBeenCalledTimes(1);
expect(payload.defaultModelKey).toBe("gpt-5.4");
expect(payload.defaultProfileId).toBe("page_writer_ai_first");
expect(payload.tools[0]?.name).toBe("doc_get");
});
});
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { fetchDocumentAiOrchestratorConfig } from "@/lib/server/document-ai-orchestrator";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
let userId = "";
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
}
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await fetchDocumentAiOrchestratorConfig({ request });
return NextResponse.json(payload, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -6,7 +6,9 @@ const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -30,8 +32,16 @@ vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -42,7 +52,9 @@ describe("/api/ai-agent/run route", () => {
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
@@ -55,7 +67,7 @@ describe("/api/ai-agent/run route", () => {
},
options: {
ai: {
provider: "online",
provider: "hermes",
},
},
});
@@ -65,6 +77,28 @@ describe("/api/ai-agent/run route", () => {
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
@@ -86,13 +120,19 @@ describe("/api/ai-agent/run route", () => {
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
@@ -106,13 +146,15 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
@@ -120,4 +162,223 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
context: {
documentId: "doc-1",
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
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(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
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(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
});
});
+116 -22
View File
@@ -17,14 +17,18 @@ import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "hermes" | "codex";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
@@ -48,6 +52,7 @@ type RequestPayload = {
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
@@ -59,6 +64,8 @@ type RequestPayload = {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
@@ -112,11 +119,46 @@ const clampSteps = (raw: unknown) => {
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (raw: unknown): RuntimeProvider => {
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
@@ -192,6 +234,22 @@ const buildHermesInstructions = (
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
@@ -237,6 +295,9 @@ const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
@@ -263,24 +324,41 @@ const recoverStructuredHermesToolResult = async (input: {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
return buildDocumentBridgeContext({
request: input.request,
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data,
requestId: input.fallbackRequestId,
traceId: input.fallbackTraceId,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}).catch(() => null);
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
@@ -619,13 +697,29 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = normalizeProvider(payload.options?.ai?.provider);
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
const scope = normalizeScope(payload);
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;
@@ -9,9 +9,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentOptionsUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
type OptionsPayload = {
documentId: string;
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
workspaceId: normalizedWorkspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.options.update",
name: PAGE_COMMAND_NAMES.updateLayout,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -43,7 +44,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (isConvexEnabled()) {
try {
const url = new URL(request.url);
const documentId = assertDocumentId(url.searchParams.get("documentId"));
const workspaceId = url.searchParams.get("workspaceId")?.trim() || null;
const loaded = await loadPageAggregate({
request,
documentId,
workspaceId,
});
if (!loaded) {
return NextResponse.json(
{
error: "页面不存在",
meta: {
requestId: "unknown",
traceId: "unknown",
queryName: "documents.page.get",
},
},
{ status: 404 },
);
}
return NextResponse.json({
page: loaded.page,
meta: loaded.bridge,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -4,6 +4,28 @@ vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
@@ -12,11 +34,52 @@ vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
}));
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
executePageWriteBridgeCommand: vi.fn(async () => ({
requestId: "req_1",
traceId: "trace_1",
commandId: "cmd_1",
commandName: "page.command",
revision: 1,
conflictDetectionKey: "doc_1:1",
})),
}));
vi.mock("@/lib/documents/page-aggregate-loader", () => ({
loadPageAggregate: vi.fn(async () => ({
page: {
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "页面标题",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: null, revision: 0, conflictDetectionKey: "doc_1:0" },
tree: { pageSubtree: null },
stats: null,
},
bridge: {
requestId: "req_1",
traceId: "trace_1",
queryName: "documents.page.get",
},
})),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
@@ -24,6 +87,8 @@ import {
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
describe("documents route adapters", () => {
it("creates child route delegates to unified adapter", async () => {
@@ -65,4 +130,46 @@ describe("documents route adapters", () => {
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
});
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { identity: { documentId: string } };
meta: { queryName: string };
};
expect(loadPageAggregate).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route delegates to unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("options route delegates to unified page write adapter", async () => {
await postOptions(new Request("http://localhost/api/documents/options", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", options: { wideLayout: true } }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("save route delegates to unified page write adapter", async () => {
await postSave(new Request("http://localhost/api/documents/save", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", content: [] }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
});
@@ -6,11 +6,12 @@ import {
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import {
buildDocumentSavePayload,
type DocumentSavePayload,
} from "@/lib/documents/save-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
export async function POST(request: Request) {
if (isConvexEnabled()) {
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
name: PAGE_COMMAND_NAMES.saveBody,
payload: payload satisfies DocumentSavePayload,
context: bridgeContext,
target: {
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeSaveBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -8,9 +8,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentTitleUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
interface RenamePayload {
documentId: string;
@@ -27,7 +28,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.title.update",
name: PAGE_COMMAND_NAMES.updateTitle,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockBuildMnoteWebForwardHeaders = vi.fn();
const mockBuildMnoteWebStreamUrl = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
@@ -11,41 +14,99 @@ const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
),
);
vi.mock("@/lib/server/mnote-web", () => ({
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockBuildMnoteWebForwardHeaders.mockReset();
mockBuildMnoteWebStreamUrl.mockReset();
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentQueryEnvelope.mockReset();
mockExecuteRustBridgeQueryTransport.mockReset();
mockResolveRustBridgeQueryPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
vi.unstubAllGlobals();
});
it("透传上游 SSE 并去掉 set-cookie", async () => {
const upstreamHeaders = new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"set-cookie": "secret=1",
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
client: { query: vi.fn() },
});
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
status: 200,
headers: upstreamHeaders,
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_1",
traceId: "trace_stream_1",
workspaceId: "ws_1",
});
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
mockBuildMnoteWebStreamUrl.mockReturnValue(
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
);
const fetchMock = vi.fn(async () => upstreamResponse);
vi.stubGlobal("fetch", fetchMock);
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title: "工作区首页",
parent_id: null,
sort_order: 0,
is_starred: true,
is_template: false,
created_at: "2026-04-22T00:00:00Z",
updated_at: "2026-04-22T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: null,
has_more: false,
generated_at: "2026-04-22T00:00:00Z",
});
const { GET } = await import("./route");
const response = await GET(
@@ -55,19 +116,15 @@ describe("/api/mnote-web/stream route", () => {
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
redirect: "follow",
headers: expect.any(Headers),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("set-cookie")).toBeNull();
await expect(response.text()).resolves.toContain("event: snapshot");
const text = await response.text();
expect(text).toContain("event: snapshot");
expect(text).toContain('"kind":"snapshot"');
expect(text).toContain('"projection":"sidebar_tree"');
expect(text).toContain('"workspaceId":"ws_1"');
expect(text).toContain('"activeWorkspaceId":"ws_1"');
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
});
});
@@ -1,11 +1,21 @@
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl,
} from "@/lib/server/mnote-web";
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
export const dynamic = "force-dynamic";
function toSseFrame(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
@@ -16,26 +26,73 @@ export async function GET(request: Request) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
const headers = await buildMnoteWebForwardHeaders(request);
headers.set("accept", "text/event-stream");
headers.set("x-mnote-workspace-id", workspaceId);
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "follow",
const { client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
const sidebarEnvelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const sidebarPlan = await resolveRustBridgeQueryPlan({
context,
envelope: sidebarEnvelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport({
client,
plan: sidebarPlan,
});
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
const overviewEnvelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 20,
cursor,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
const overviewPlan = await resolveRustBridgeQueryPlan({
context,
envelope: overviewEnvelope,
});
const overview = await executeRustBridgeQueryTransport({
client,
plan: overviewPlan,
});
const payload = {
kind: "snapshot",
stream: "workspace",
projection: "sidebar_tree",
workspaceId,
rootNodeId: null,
cursor,
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
snapshot: {
dataset: sidebarDataset,
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
},
overview,
};
return new Response(toSseFrame("snapshot", payload), {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);