feat: 收口 Rust Web 3000 主链

This commit is contained in:
lix-2026
2026-05-11 13:16:34 +08:00
parent 7f3f7d4e2f
commit 17c003976b
61 changed files with 2090 additions and 2256 deletions
+3
View File
@@ -25,6 +25,9 @@ function loadEnvAll() {
loadEnvAll();
const nextConfig: NextConfig = {
// 说明:3000 Rust 网关会把 /_next/* dev 资源代理到 3100,浏览器 Origin 仍是 127.0.0.1:3000。
// Next dev 默认会拦截这类跨 origin HMR 请求,导致客户端 hydration 不执行。
allowedDevOrigins: ["127.0.0.1", "localhost"],
// 供桌面端打包使用(Electron 内置 Next server.js + 最小依赖)。
// 说明:`pnpm run build:desktop:next` 会依赖该产物。
output: "standalone",
+9 -18
View File
@@ -412,14 +412,12 @@ async function main() {
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
await buildLeptosTiptapIsland();
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
// 说明:Next dev 的 HMR 依赖 WebSocket/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
const app = next({ dev, dir: path.join(__dirname, ".."), hostname, port });
const handle = app.getRequestHandler();
await app.prepare();
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-dev-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
@@ -472,16 +470,9 @@ async function main() {
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
// 说明:Next custom server 会在首个 HTTP 请求时自动给同一个 http.Server 绑定 HMR upgrade listener。
// 非 Convex / ONLYOFFICE 的 Upgrade 交给 Next 自己的 listener,避免同一 socket 被处理两次。
});
server.listen(port, hostname, () => {
resolveOnlyOfficeInternalUrl()
@@ -30,14 +30,11 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
? queryHostAliasRaw
: undefined;
const runtimeHostRaw = runtimeConfig.documentEditorHost;
const runtimeBlocknoteKillSwitch =
runtimeConfig.documentEditorBlocknoteKillSwitch === true;
// 说明:优先级保持稳定且可预期:
// 1) query(兼容 editorHost,并支持 host 别名);
// 2) 运行时配置 documentEditorHost
// 3) kill switchruntime/env)回退到 blocknote
// 4) 默认正式主链 leptos_tiptap_island。
// 3) 默认正式主链 leptos_tiptap_island。
const editorHostKind: EditorHostKind = (() => {
if (typeof queryHostRaw === "string") {
return resolveEditorHostKind({
@@ -45,9 +42,6 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
runtimeDefault: runtimeHostRaw,
});
}
if (runtimeBlocknoteKillSwitch) {
return "blocknote";
}
return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND);
})();
@@ -219,18 +219,19 @@ describe("/api/ai-agent/run route", () => {
},
);
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
it.each(["codex", "hermes", "claudecode"] as const)(
"provider=%s 已退场,必须明确失败且不能静默进入 mnote-cli",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "#chat 继续检查" }],
messages: [{ role: "user", content: "继续检查" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "codex",
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
provider,
},
},
});
@@ -241,104 +242,18 @@ describe("/api/ai-agent/run route", () => {
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(response.status).toBe(410);
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");
});
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe(`${provider}-retired`);
expect(mockStartCodexJsonRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain(provider);
},
);
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
@@ -1,6 +1,4 @@
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 {
@@ -11,120 +9,6 @@ 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) {
@@ -150,19 +34,15 @@ export async function POST(request: Request) {
}
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
if (provider === "codex") {
return startCodexAgentRun(payload);
}
if (provider === "hermes") {
return startHermesAgentRun(payload);
}
if (provider === "claudecode") {
if (provider === "codex" || provider === "hermes" || provider === "claudecode") {
return NextResponse.json(
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
{
status: 501,
error: `${provider} 已退出默认系统组件,当前只保留 mnote-cli host 主执行入口。`,
},
{
status: 410,
headers: {
"x-mnote-ai-execution-owner": "claudecode-unavailable",
"x-mnote-ai-execution-owner": `${provider}-retired`,
},
},
);
@@ -1,266 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
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/convex/route", () => ({
getAuthedConvexClient: vi.fn(async () => ({
auth: { userId: "user_1" },
client: {
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
title: "Next fallback 页面",
updated_at: null,
can_edit: true,
})),
},
})),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeQueryPlan: vi.fn(async () => ({
kind: "query",
queryName: "documents.content.get",
functionName: "documents:getContent",
workspaceId: "ws_1",
requestId: "req_next",
traceId: "trace_next",
actorId: "user_1",
payloadJson: "{}",
argsJson: { id: "doc_1" },
})),
executeRustBridgeQueryTransport: vi.fn(async () => ({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 1,
conflict_detection_key: "doc_1:1",
pageSubtree: null,
})),
}));
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
}));
import { describe, expect, it } from "vitest";
import { GET } from "@/app/api/documents/page/route";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
function rustPageAggregateSnapshot() {
return {
schema: "mnote.page_aggregate.v1" as const,
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "Rust 聚合页面",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
tree: { pageSubtree: null },
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("documents/page route", () => {
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: rustPageAggregateSnapshot(),
requestId: "req_rust",
traceId: "trace_rust",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
it("compat 读链应明确返回 410,并指向 page aggregate 正式路由", async () => {
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_route",
"x-trace-id": "trace_route",
"x-session-id": "sess_route",
"x-source-channel": "next-route",
"x-source-client": "vitest",
},
}),
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { schema?: string; identity: { documentId: string }; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
error: string;
redirectTo: string;
};
expect(response.status).toBe(200);
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
signal: expect.any(AbortSignal),
headers: expect.any(Headers),
}),
);
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
expect(getAuthedConvexClient).not.toHaveBeenCalled();
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.page.head.title).toBe("Rust 聚合页面");
expect(payload.meta).toEqual({
requestId: "req_rust",
traceId: "trace_rust",
queryName: "documents.page.get",
});
});
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: {
schema: "mnote.page_aggregate.v1",
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: { title: "错误页面", updatedAt: null },
layout: {},
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
tree: { pageSubtree: null },
stats: null,
},
requestId: "req_bad",
traceId: "trace_bad",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_next",
"x-trace-id": "trace_next",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.page.body.revision).toBe(1);
expect(payload.meta).toEqual({
requestId: "req_next",
traceId: "trace_next",
queryName: "documents.page.get",
});
});
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
const fetchMock = vi.spyOn(globalThis, "fetch");
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_untrusted",
"x-trace-id": "trace_untrusted",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(fetchMock).not.toHaveBeenCalled();
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_untrusted",
traceId: "trace_untrusted",
queryName: "documents.page.get",
});
});
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_timeout",
"x-trace-id": "trace_timeout",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_timeout",
traceId: "trace_timeout",
queryName: "documents.page.get",
});
expect(response.status).toBe(410);
expect(payload.error).toContain("/api/page-aggregate/:documentId");
expect(payload.redirectTo).toBe("/api/page-aggregate/doc_1?workspaceId=ws_1");
});
});
@@ -1,48 +1,24 @@
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);
}
const url = new URL(request.url);
const documentId = url.searchParams.get("documentId")?.trim() || "";
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "";
const redirectTo = new URL(
`/api/page-aggregate/${encodeURIComponent(documentId || ":documentId")}`,
request.url,
);
if (workspaceId) {
redirectTo.searchParams.set("workspaceId", workspaceId);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
return NextResponse.json(
{
error: "Next /api/documents/page compat 读链已退场,请直接使用 /api/page-aggregate/:documentId。",
redirectTo: redirectTo.pathname + redirectTo.search,
},
{ status: 410 },
);
}
@@ -43,46 +43,18 @@ vi.mock("@/lib/documents/page-write-command-adapter", () => ({
})),
}));
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 postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/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,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
afterEach(() => {
vi.restoreAllMocks();
@@ -90,62 +62,26 @@ afterEach(() => {
});
describe("documents route adapters", () => {
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_rename_1",
traceId: "trace_tree_rename_1",
result: {
action: "rename",
workspaceId: "ws_1",
documentId: "doc_1",
title: "新标题",
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
it("title route 在缺少 page head commandName 时返回校验错误", async () => {
const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
headers: {
"authorization": "Bearer test-token",
"content-type": "application/json",
"cookie": "convex-auth=test-cookie",
},
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
})) as {
message: string;
status: number;
details?: { code?: string; details?: Array<{ field: string; reason: string }> };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
const renameCall = fetchMock.mock.calls.find(([, init]) => {
if (!init || typeof init.body !== "string") {
return false;
}
return init.body.includes('"action":"rename"');
expect(response.status).toBe(400);
expect(response.message).toBe("标题保存仅支持 page.head.updateTitle");
expect(response.details).toEqual({
code: "VALIDATION_ERROR",
details: [{ field: "commandName", reason: "expected page.head.updateTitle" }],
});
const forwardedHeaders = renameCall?.[1]?.headers as Headers;
expect(forwardedHeaders.get("authorization")).toBe("Bearer test-token");
expect(forwardedHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
expect(executePageWriteBridgeCommand).not.toHaveBeenCalled();
});
it("creates child route delegates to unified adapter", async () => {
@@ -172,21 +108,6 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).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 在 page head 请求下仍委托 unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
DocumentBridgeError,
assertDocumentId,
assertTitle,
buildDocumentBridgeContext,
@@ -20,10 +21,16 @@ interface RenamePayload {
commandName?: string | null;
}
type TreeRenameResponse = {
requestId?: string;
traceId?: string;
};
function assertPageHeadCommandName(commandName: string | null | undefined) {
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
throw new DocumentBridgeError("标题保存仅支持 page.head.updateTitle", 400, "VALIDATION_ERROR", [
{
field: "commandName",
reason: `expected ${PAGE_COMMAND_NAMES.updateTitle}`,
},
]);
}
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
@@ -32,53 +39,7 @@ export async function POST(request: Request) {
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedTitle = assertTitle(title);
const normalizedWorkspaceId = workspaceId?.trim() || null;
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const upstreamHeaders = new Headers({
"content-type": "application/json",
});
const authorization = request.headers.get("authorization");
const cookie = request.headers.get("cookie");
if (authorization) {
upstreamHeaders.set("authorization", authorization);
}
if (cookie) {
upstreamHeaders.set("cookie", cookie);
}
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: upstreamHeaders,
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
title: normalizedTitle,
}),
});
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "重命名失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.rename",
},
});
}
assertPageHeadCommandName(commandName);
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
@@ -119,7 +119,7 @@ describe("/api/mnote-web/stream route", () => {
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
});
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => {
it("应返回 410,明确要求改用 /api/tree/events,不再代理 mnote-web:3104", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
@@ -130,27 +130,21 @@ describe("/api/mnote-web/stream route", () => {
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-tree-stream-owner")).toBe("rust-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mnote-web-stream-alias");
expect(await response.text()).toContain("event: snapshot");
expect(response.status).toBe(410);
expect(response.headers.get("x-mnote-compat-boundary")).toBe(
"mnote-web-stream-alias-retired",
);
expect(await response.json()).toMatchObject({
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo:
"http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
expect(mockStreamTreeFrames).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
initialCursor: "evt_9",
pollMs: 500,
maxPolls: 0,
}),
);
expect(mockStreamTreeFrames).not.toHaveBeenCalled();
});
it("Convex 未启用时返回 501,而不是探测 3104", async () => {
it("Convex 未启用时返回 retired alias,不探测 3104", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const fetchSpy = vi.spyOn(globalThis, "fetch");
@@ -161,8 +155,11 @@ describe("/api/mnote-web/stream route", () => {
}),
);
expect(response.status).toBe(501);
expect(response.status).toBe(410);
expect(fetchSpy).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
expect(await response.json()).toMatchObject({
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo: "http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1",
});
});
});
@@ -1,165 +1,21 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
import {
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const requestUrl = new URL(request.url);
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
const directUrl = new URL("/api/tree/events", request.url);
directUrl.search = new URL(request.url).search;
return NextResponse.json(
{
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo: directUrl.toString(),
},
});
const loadOverview = async (): Promise<TreeStreamOverview> => {
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
{
status: 410,
headers: {
"x-mnote-compat-boundary": "mnote-web-stream-alias-retired",
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
plan,
});
};
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
data: datasetWithFileTree,
snapshot: {
dataset: datasetWithFileTree,
},
};
};
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
cancel() {
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream-compat",
"x-mnote-web-owner": "mnote-web",
"x-mnote-tree-stream-owner": "rust-web",
"x-mnote-compat-boundary": "mnote-web-stream-alias",
},
});
);
}
@@ -208,7 +208,8 @@ describe("/api/tree/commands route", () => {
expect.arrayContaining([
expect.objectContaining({
id: "tree.commands",
role: "next-thin-proxy",
role: "rust-owned",
owner: "rust-web-gateway",
}),
]),
);
@@ -59,7 +59,7 @@ export const readAiPanelPrefs = (
const parsedSteps = Number(stepsRaw);
const provider: AiProvider =
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex"
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama"
? providerRaw
: defaults.provider;
@@ -0,0 +1,85 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { DocumentReadView } from "./document-read-view";
import type { PageOptionsState } from "@/types/page-options";
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: false,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: false,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
describe("DocumentReadView media attachments", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("location", new URL("https://mnote.example.com/documents/doc_1"));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
vi.unstubAllGlobals();
container.remove();
});
it("renders Office media as a Wolai-like attachment row that opens OnlyOffice in a new window", () => {
act(() => {
root.render(
<DocumentReadView
documentId="doc_1"
options={buildOptions()}
content={{
blocks: [
{
id: "block_asset_1",
type: "media",
props: {
assetType: "file",
assetId: "asset_ppt_1",
documentId: "doc_1",
fileName: "2023自我介绍PPT_李爱波0831.pptx",
fileUrl: "https://storage.example.com/demo.pptx?token=abc",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
fileSize: 12_110_520,
},
},
],
}}
/>,
);
});
const row = container.querySelector<HTMLAnchorElement>('[data-testid="mnote-office-attachment-row"]');
expect(row).not.toBeNull();
expect(row?.target).toBe("_blank");
expect(row?.rel).toContain("noreferrer");
expect(row?.textContent).toContain("2023自我介绍PPT_李爱波0831.pptx");
expect(row?.textContent).toContain("11.55 MB");
expect(row?.getAttribute("href")).toContain("/onlyoffice?");
expect(row?.getAttribute("href")).toContain("fileType=pptx");
expect(row?.getAttribute("href")).toContain("assetId=asset_ppt_1");
expect(row?.getAttribute("href")).toContain("documentId=doc_1");
expect(row?.querySelector('[aria-label="预览"]')).not.toBeNull();
});
});
@@ -2,8 +2,13 @@
import Link from "next/link";
import type { CSSProperties, ReactNode } from "react";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import {
clampHeadingLevel,
extractPageBlocks,
@@ -225,7 +230,51 @@ const renderChildren = (
);
};
const renderMediaBlock = (block: PageSubtreeBlock) => {
const formatFileSize = (size: unknown): string => {
const bytes = typeof size === "number" ? size : Number(size);
if (!Number.isFinite(bytes) || bytes <= 0) return "";
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
};
const getOfficeAttachmentTone = (fileType: string | null) => {
switch (fileType) {
case "ppt":
case "pptx":
case "odp":
return {
badge: "P",
badgeClassName: "bg-[#f97316] text-white",
};
case "xls":
case "xlsx":
case "ods":
case "csv":
return {
badge: "X",
badgeClassName: "bg-[#16a34a] text-white",
};
case "pdf":
return {
badge: "PDF",
badgeClassName: "bg-[#dc2626] text-white",
};
default:
return {
badge: "W",
badgeClassName: "bg-[#2563eb] text-white",
};
}
};
const renderMediaBlock = (block: PageSubtreeBlock, currentDocumentId: string) => {
const props = block.props ?? {};
const assetType = String(props.assetType ?? "image");
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
@@ -234,6 +283,11 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
const fileName =
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
const caption = typeof props.caption === "string" ? props.caption.trim() : "";
const mimeType = typeof props.mimeType === "string" ? props.mimeType : "";
const assetId = typeof props.assetId === "string" ? props.assetId : "";
const documentId = typeof props.documentId === "string" && props.documentId.trim() ? props.documentId : currentDocumentId;
const fileType = inferOnlyOfficeFileType(fileName, mimeType);
const sizeLabel = formatFileSize(props.fileSize ?? props.size ?? props.file_size);
if (!fileUrl) {
return (
@@ -283,6 +337,41 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
);
}
if (fileType) {
const tone = getOfficeAttachmentTone(fileType);
const officeHref = buildOnlyOfficeAssetOpenUrl({
origin: typeof window !== "undefined" ? window.location.origin : "http://localhost",
fileUrl,
fileName,
fileType,
assetId,
documentId,
mode: "edit",
});
return (
<a
href={officeHref}
target="_blank"
rel="noopener noreferrer"
data-testid="mnote-office-attachment-row"
className="inline-flex max-w-full items-center gap-2 rounded px-1 py-0.5 text-[#27272a] transition hover:bg-[#f8fafc]"
>
<span
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[10px] font-semibold leading-none",
tone.badgeClassName,
)}
aria-hidden="true"
>
{tone.badge}
</span>
<span className="min-w-0 truncate text-[15px] leading-6">{caption || fileName}</span>
<Eye className="h-4 w-4 shrink-0 text-[#a1a1aa]" aria-label="预览" />
{sizeLabel ? <span className="shrink-0 text-[12px] text-[#a1a1aa]">{sizeLabel}</span> : null}
</a>
);
}
return (
<a
href={fileUrl}
@@ -465,7 +554,7 @@ const renderBlock = (
case "media":
return (
<div key={key} className="space-y-2">
{renderMediaBlock(block)}
{renderMediaBlock(block, documentId)}
{children}
</div>
);
@@ -7,6 +7,7 @@ import {
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
selectPageAggregateClientTitleState,
} from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options";
@@ -103,11 +104,13 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page);
expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull();
expect(state.options).toEqual(page.layout.pageOptions);
expect(state.content).toBe(page.body.content);
expect(state.serverContentSnapshot).toBe(page.body.content);
expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
expect(state.serverPageSubtreeTitle).toBe("页面标题");
expect(state.contentRevision).toBe(3);
expect(state.conflictDetectionKey).toBe("doc_1:3");
});
@@ -135,12 +138,14 @@ describe("page-aggregate-client-state", () => {
page: reloadedPage,
});
expect(next.serverPageTitle).toBe("刷新后的标题");
expect(next.persistedPageTitle).toBeNull();
expect(next.draftPageTitle).toBeNull();
expect(next.options.wideLayout).toBe(true);
expect(next.options.showToc).toBe(true);
expect(next.content).toBe(reloadedContent);
expect(next.serverContentSnapshot).toBe(reloadedContent);
expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree);
expect(next.serverPageSubtreeTitle).toBe("刷新后的标题");
expect(next.contentRevision).toBe(9);
expect(next.conflictDetectionKey).toBe("doc_1:9");
});
@@ -173,24 +178,89 @@ describe("page-aggregate-client-state", () => {
expect(next.conflictDetectionKey).toBe("doc_1:10");
});
it("标题 committed/draft 应收口到同一份 page aggregate client state", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(
selectPageAggregateClientTitleState(initialState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "树标题",
committedTitle: "树标题",
hasDraft: false,
});
const draftState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
title: " 新标题 ",
});
expect(
selectPageAggregateClientTitleState(draftState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: " 新标题 ",
committedTitle: "树标题",
hasDraft: true,
});
const persistedState = pageAggregateClientStateReducer(draftState, {
type: "commit_persisted_page_title",
title: "新标题",
});
expect(
selectPageAggregateClientTitleState(persistedState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "新标题",
committedTitle: "新标题",
hasDraft: false,
});
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree);
expect(
selectPageAggregateClientPageSubtree(initialState, {
liveSidebarTitle: "页面标题",
}),
).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }],
});
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull();
expect(
selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title",
const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
title: "草稿标题",
});
expect(
selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title",
title: "持久化后的标题",
});
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull();
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree);
const retitledSubtree = selectPageAggregateClientPageSubtree(persistedTitleState, {
liveSidebarTitle: "页面标题",
});
expect(retitledSubtree).not.toBeNull();
expect(retitledSubtree?.rootNode.metadata.title).toBe("持久化后的标题");
expect(retitledSubtree?.outline).toBe(page.tree.pageSubtree?.outline);
});
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
@@ -223,7 +293,7 @@ describe("page-aggregate-client-state", () => {
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1",
pageTitle: "页面标题",
liveSidebarTitle: "页面标题",
});
expect(snapshot).toEqual({
@@ -5,11 +5,13 @@ import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientState = {
serverPageTitle: string;
persistedPageTitle: string | null;
draftPageTitle: string | null;
options: PageOptionsState;
content: unknown;
serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null;
conflictDetectionKey: string | null;
};
@@ -19,6 +21,14 @@ export type PageAggregateClientStateAction =
type: "hydrate_from_page";
page: PageAggregateProjection;
}
| {
type: "set_draft_page_title";
title: string;
}
| {
type: "commit_persisted_page_title";
title: string;
}
| {
type: "patch_page_options";
patch: Partial<PageOptionsState>;
@@ -30,10 +40,6 @@ export type PageAggregateClientStateAction =
| {
type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
};
function normalizePageTitle(title: string | null | undefined): string {
@@ -41,23 +47,37 @@ function normalizePageTitle(title: string | null | undefined): string {
return normalized || "无标题";
}
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string {
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title;
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) {
return subtreeTitle.trim();
function withResolvedPageSubtreeTitle(
pageSubtree: PageSubtreeProjection,
title: string,
): PageSubtreeProjection {
const normalizedTitle = normalizePageTitle(title);
if (normalizePageTitle(pageSubtree.rootNode.metadata.title) === normalizedTitle) {
return pageSubtree;
}
return normalizePageTitle(page.head.title);
return {
...pageSubtree,
rootNode: {
...pageSubtree.rootNode,
metadata: {
...pageSubtree.rootNode.metadata,
title: normalizedTitle,
},
},
};
}
export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null,
draftPageTitle: null,
options: page.layout.pageOptions,
content: page.body.content,
serverContentSnapshot: page.body.content,
serverPageSubtreeSnapshot: page.tree.pageSubtree,
serverPageSubtreeTitle: resolveServerPageSubtreeTitle(page),
contentRevision: page.body.revision,
conflictDetectionKey: page.body.conflictDetectionKey,
};
@@ -70,6 +90,19 @@ export function pageAggregateClientStateReducer(
switch (action.type) {
case "hydrate_from_page":
return createPageAggregateClientState(action.page);
case "set_draft_page_title":
return {
...state,
draftPageTitle: action.title,
};
case "commit_persisted_page_title": {
const normalizedTitle = normalizePageTitle(action.title);
return {
...state,
persistedPageTitle: normalizedTitle,
draftPageTitle: normalizedTitle,
};
}
case "patch_page_options":
return {
...state,
@@ -89,26 +122,49 @@ export function pageAggregateClientStateReducer(
contentRevision: action.meta.revision,
conflictDetectionKey: action.meta.conflictDetectionKey,
};
case "update_server_page_subtree_title":
return {
...state,
serverPageSubtreeTitle: normalizePageTitle(action.title),
};
default:
return state;
}
}
export function selectPageAggregateClientTitleState(
state: PageAggregateClientState,
input: {
liveSidebarTitle: string | null;
},
): {
displayTitle: string;
committedTitle: string;
hasDraft: boolean;
} {
const liveCommittedTitle = normalizePageTitle(input.liveSidebarTitle ?? state.serverPageTitle);
const committedTitle =
state.persistedPageTitle != null &&
normalizePageTitle(state.persistedPageTitle) !== liveCommittedTitle
? normalizePageTitle(state.persistedPageTitle)
: liveCommittedTitle;
const hasDraft =
state.draftPageTitle != null &&
normalizePageTitle(state.draftPageTitle) !== committedTitle;
return {
displayTitle: hasDraft ? state.draftPageTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
};
}
export function selectPageAggregateClientPageSubtree(
state: PageAggregateClientState,
pageTitle: string,
input: {
liveSidebarTitle: string | null;
},
): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const titleState = selectPageAggregateClientTitleState(state, input);
const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return state.serverPageSubtreeSnapshot;
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
}
return null;
}
@@ -117,7 +173,7 @@ export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState,
input: {
workspaceId: string | null;
pageTitle: string;
liveSidebarTitle: string | null;
},
): {
blocks: Json | null;
@@ -133,7 +189,9 @@ export function selectPageAggregateClientAiSnapshot(
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle),
pageSubtree: selectPageAggregateClientPageSubtree(state, {
liveSidebarTitle: input.liveSidebarTitle,
}),
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
@@ -35,6 +35,10 @@ import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
@@ -141,21 +145,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : null;
if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext && ["pdf"].includes(ext)) return ext;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};
interface SidebarProps {
initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
@@ -898,7 +887,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
const officeFileType = inferOnlyOfficeFileType(asset.file_name ?? null, asset.mime_type ?? null);
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
@@ -913,14 +902,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin);
target.searchParams.set("fileUrl", signedUrl);
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
target.searchParams.set("fileType", officeFileType);
target.searchParams.set("assetId", asset.id);
target.searchParams.set("documentId", asset.document_id);
target.searchParams.set("mode", "edit");
window.open(target.toString(), "_blank", "noopener,noreferrer");
const target = buildOnlyOfficeAssetOpenUrl({
origin: window.location.origin,
fileUrl: signedUrl,
fileName: asset.file_name ?? `未命名.${officeFileType}`,
fileType: officeFileType,
assetId: asset.id,
documentId: asset.document_id,
mode: "edit",
});
window.open(target, "_blank", "noopener,noreferrer");
setOpen(false);
} catch (error) {
window.alert((error as Error).message);
@@ -37,7 +37,7 @@ function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
function Harness(props: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData;
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
@@ -174,4 +174,45 @@ describe("usePreferredSidebarSnapshot", () => {
}),
});
});
it("fallback 且 query 不可用时应回到 initial 快照,而不是继续复用旧 stream 数据", async () => {
const initialData = buildSidebarData([buildDocument({ title: "初始标题" })]);
const staleTreeStream = buildSidebarData([
buildDocument({
title: "旧 stream 标题",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={initialData}
treeStreamData={staleTreeStream}
treeStreamStatus="live"
onState={onState}
/>,
);
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={null}
treeStreamData={staleTreeStream}
treeStreamStatus="fallback"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "initial",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "初始标题" })],
}),
});
});
});
@@ -40,7 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
? input.treeStreamData
: source === "query" && input.sidebarQueryData
? input.sidebarQueryData
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
: input.initialData;
const syncKey =
source === "tree_stream"
? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey
@@ -123,65 +123,29 @@ describe("useSidebarData", () => {
expect(secondState).toBe(firstState);
});
it("Convex live 模式下手动 refetch 应强制刷新一份 HTTP sidebar snapshot", async () => {
it("Convex live 模式下手动 refetch 不应再走 HTTP snapshot 补偿链", async () => {
const initialData = buildInitialData();
const refreshedData: SidebarInitialData = {
...buildInitialData(),
documents: [
{
access_scope: "private",
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
},
],
kernelSidebarTree: [
{
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
children: [],
},
],
};
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => refreshedData,
});
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
stableRefetch.mockClear();
await act(async () => {
await state.refetch();
});
expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", {
method: "GET",
credentials: "include",
});
expect(global.fetch).not.toHaveBeenCalled();
expect(stableRefetch).toHaveBeenCalledTimes(1);
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
expect(refreshedState.data.documents[0]?.title).toBe("新标题");
expect(refreshedState.data).toStrictEqual(initialData);
});
it("手动 refetch 只应临时覆盖主链,底层 live 数据变化后应回到新的 live snapshot", async () => {
it("无 live 订阅且允许 HTTP fallback 时应继续走 HTTP refetch", async () => {
const initialData = buildInitialData();
const manualSnapshot: SidebarInitialData = {
const httpRefetch = vi.fn(async () => undefined);
const httpSnapshot: SidebarInitialData = {
...buildInitialData(),
documents: [
{
@@ -213,69 +177,41 @@ describe("useSidebarData", () => {
},
],
};
const nextLiveData: SidebarInitialData = {
...buildInitialData(),
documents: [
{
access_scope: "private",
id: "doc-1",
workspace_id: "ws_1",
title: "Live 标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
},
],
kernelSidebarTree: [
{
id: "doc-1",
workspace_id: "ws_1",
title: "Live 标题",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
children: [],
},
],
};
let liveData = initialData;
mockUseConvexSidebarData.mockImplementation(() => ({
data: liveData,
data: null,
isLoading: false,
isAuthLoading: false,
isAuthenticated: true,
hasLiveSubscription: true,
canUseHttpFallback: false,
isAuthenticated: false,
hasLiveSubscription: false,
canUseHttpFallback: true,
error: null,
refetch: stableRefetch,
}));
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => manualSnapshot,
json: async () => httpSnapshot,
});
mockUseQuery.mockImplementation(() => ({
data: initialData,
isLoading: false,
error: null,
refetch: httpRefetch,
}));
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
stableRefetch.mockClear();
httpRefetch.mockClear();
await act(async () => {
await state.refetch();
});
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("HTTP 标题");
liveData = nextLiveData;
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("Live 标题");
expect(global.fetch).not.toHaveBeenCalled();
expect(stableRefetch).not.toHaveBeenCalled();
expect(httpRefetch).toHaveBeenCalledTimes(1);
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data).toStrictEqual(initialData);
});
});
+1 -57
View File
@@ -50,15 +50,6 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
const convexSidebar = useConvexSidebarData(workspaceId);
const shouldUseHttpFallback =
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
const [manualSnapshotState, setManualSnapshotState] = useState<{
workspaceId: string;
data: SidebarInitialData | null;
baseSyncKey: string | null;
}>({
workspaceId,
data: null,
baseSyncKey: null,
});
const httpQuery = useQuery({
queryKey: ["sidebar", workspaceId],
@@ -68,43 +59,8 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
enabled: shouldUseHttpFallback,
});
const manualSnapshot =
manualSnapshotState.workspaceId === workspaceId
? manualSnapshotState.data
: null;
const manualSnapshotBaseSyncKey =
manualSnapshotState.workspaceId === workspaceId
? manualSnapshotState.baseSyncKey
: null;
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const baseLiveDataSyncKey = useMemo(
() => buildSidebarDataSyncKey(baseLiveData),
[baseLiveData],
);
const manualSnapshotSyncKey = useMemo(
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
[manualSnapshot],
);
const liveData = useMemo(() => {
if (!manualSnapshot) {
return baseLiveData;
}
if (
manualSnapshotBaseSyncKey === baseLiveDataSyncKey &&
manualSnapshotSyncKey &&
manualSnapshotSyncKey !== baseLiveDataSyncKey
) {
return manualSnapshot;
}
return baseLiveData;
}, [
baseLiveData,
baseLiveDataSyncKey,
manualSnapshot,
manualSnapshotBaseSyncKey,
manualSnapshotSyncKey,
]);
const liveData = baseLiveData;
const isLoading =
convexSidebar.hasLiveSubscription
? convexSidebar.isLoading
@@ -135,18 +91,6 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
}, [httpQuery]);
const refetch = useCallback(async () => {
if (workspaceId) {
try {
const refreshedSnapshot = await requestSidebarData(workspaceId);
setManualSnapshotState({
workspaceId,
data: refreshedSnapshot,
baseSyncKey: buildSidebarDataSyncKey(liveDataRef.current),
});
return refreshedSnapshot;
} catch {
}
}
if (convexSidebar.hasLiveSubscription) {
await convexRefetchRef.current();
return liveDataRef.current;
@@ -1,21 +1,6 @@
import { headers } from "next/headers";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import {
buildPageAggregateFromDocumentPayloads,
type DocumentContentPayload,
} from "@/lib/documents/page-aggregate-builder";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const FORWARDED_REQUEST_HEADERS = [
"cookie",
@@ -237,71 +222,12 @@ async function buildServerBridgeRequest(pathname: string): Promise<Request> {
});
}
async function fetchDocumentContentPayload(input: {
context: BridgeContext;
documentId: string;
workspaceId: string;
}): Promise<DocumentContentPayload | null> {
const { client } = await getAuthedConvexClient();
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: input.context,
envelope,
});
return executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
}
export async function loadPageAggregate(input: {
request: Request;
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const rustSnapshot = await loadPageAggregateFromRustSnapshot(input);
if (rustSnapshot) {
return rustSnapshot;
}
const { client } = await getAuthedConvexClient();
const meta = await client.query(api.documents.getMeta, {
id: input.documentId,
});
if (!meta) {
return null;
}
const workspaceId = input.workspaceId?.trim() || meta.workspace_id;
const context = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const contentPayload = await fetchDocumentContentPayload({
context,
documentId: meta.id,
workspaceId,
});
return {
page: buildPageAggregateFromDocumentPayloads({
meta,
contentPayload,
}),
bridge: {
requestId: context.requestId,
traceId: context.traceId,
queryName: "documents.page.get",
},
};
return loadPageAggregateFromRustSnapshot(input);
}
export async function loadPageAggregateFromNextHeaders(input: {
@@ -22,6 +22,11 @@ vi.mock("@/lib/convex/api", () => ({
batchCopy: "mediaAssets.batchCopy",
batchMove: "mediaAssets.batchMove",
},
mindmaps: {
get: "mindmaps.get",
put: "mindmaps.put",
applyCommand: "mindmaps.applyCommand",
},
},
}));
@@ -95,7 +100,7 @@ describe("resolveRustRuntimeProcessEnv", () => {
expect(
resolveRustRuntimeProcessEnv({
RUSTUP_TOOLCHAIN: "nightly",
} as NodeJS.ProcessEnv),
} as unknown as NodeJS.ProcessEnv),
).toMatchObject({
CARGO_TERM_COLOR: "never",
RUSTUP_TOOLCHAIN: "nightly",
@@ -103,7 +108,214 @@ describe("resolveRustRuntimeProcessEnv", () => {
});
});
describe("executeRustBridgeQueryTransport", () => {
it("mindmap.projection.get transport 包装为 Rust projection 合同", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "Rust 导图" },
children: [{ data: { uid: "child", text: "分支主题" }, children: [] }],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.projection.get",
functionName: "mindmaps:getProjection",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap_projection.v1",
projection: "mindmap_subtree",
source: "rust-kernel",
owner: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
title: "Rust 导图",
nodeCount: 2,
meta: {
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
updatedAt: "2026-05-10T00:00:00.000Z",
},
});
});
it("mindmap.simple_mind_map_scene.get transport 包装为 simple-mind-map adapter projection", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "KMIND" },
children: [{ data: { uid: "topic", text: "二级节点" }, children: [] }],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.simple_mind_map_scene.get",
functionName: "mindmaps:getProjection",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
source: "rust-kernel",
owner: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
kernelRevision: 1,
meta: {
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
updatedAt: "2026-05-10T00:00:00.000Z",
},
});
expect((result as { root?: { data?: { text?: string } } }).root?.data?.text).toBe("KMIND");
});
it("mindmap.editor_scene.get transport 包装为 Rust editor scene 合同", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "KMIND" },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.editor_scene.get",
functionName: "mindmaps:getEditorScene",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap_editor_scene.v1",
source: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
capabilities: {
canEditText: true,
canAddChild: true,
canAddSiblingAfter: true,
canDeleteNode: true,
},
});
expect((result as { nodes?: unknown[] }).nodes).toHaveLength(4);
expect((result as { edges?: unknown[] }).edges).toHaveLength(3);
});
});
describe("executeRustBridgeMutationTransport", () => {
it("mindmap.command.apply 应注册为 mindmaps.applyCommand transport", async () => {
const mutation = vi.fn().mockResolvedValue({
ok: true,
applied: 1,
errors: [],
workspace_id: "ws_1",
document_id: "doc_1",
mindmap_id: "mind_1",
updated_at: "2026-05-10T00:00:00.000Z",
});
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "mindmap.command.apply",
commandId: "cmd_mindmap_apply",
functionName: "mindmaps:applyCommand",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
documentId: "doc_1",
mindmapId: "mind_1",
commands: [
{ type: "renameNode", mapId: "mind_1", nodeId: "root", title: "新标题" },
],
canonicalCommand: "mindmap.command.apply",
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).not.toHaveBeenCalledWith("mindmaps.put", expect.anything());
expect(mutation).toHaveBeenCalledWith("mindmaps.applyCommand", {
docId: "doc_1",
mindmapId: "mind_1",
commands: [
{ type: "renameNode", mapId: "mind_1", nodeId: "root", title: "新标题" },
],
});
});
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
const movePlan = {
documentId: "doc_b",
@@ -861,6 +1073,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
traceId: "trace_artifact_1",
actorId: "user_1",
idempotencyKey: "idem_1",
source: {
channel: "next-route",
client: "vitest",
},
payloadJson: "{}",
argsJson: {
domainEventPlan: {
@@ -5,6 +5,11 @@ import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
buildMindmapEditorScene,
buildMindmapProjection,
buildMindmapSimpleMindMapScene,
} from "@/lib/mindmap/mindmap-projection";
import {
DocumentBridgeError,
type BridgeTarget,
@@ -69,6 +74,7 @@ export type RustBridgeCommandPlan = {
traceId: string;
actorId: string;
idempotencyKey: string | null;
source?: Record<string, unknown>;
payloadJson: string;
argsJson: Record<string, unknown>;
};
@@ -1131,6 +1137,67 @@ export async function executeRustBridgeQueryTransport<TResult>(input: {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:getProjection": {
const documentId = assertStringArg(input.plan.argsJson, "docId");
const mindmapId = assertStringArg(input.plan.argsJson, "mindmapId");
// compat_blob_read:当前 transport 仍从 Convex blob substrate 取数,再包装为 Rust projection 合同。
const result = await query(api.mindmaps.get, {
docId: documentId,
mindmapId,
});
const projectionInput = {
documentId,
mindmapId,
data: (result as { data?: unknown } | null)?.data,
source: "rust-kernel",
owner: "rust-kernel",
meta: {
requestId: input.plan.requestId,
traceId: input.plan.traceId,
workspaceId: input.plan.workspaceId,
documentId,
pageId: documentId,
mindmapId,
attachmentId: mindmapId,
updatedAt:
(result as { meta?: { updated_at?: string | null } | null } | null)?.meta?.updated_at ??
null,
},
};
if (input.plan.queryName === "mindmap.simple_mind_map_scene.get") {
return buildMindmapSimpleMindMapScene(projectionInput) as TResult;
}
return buildMindmapProjection(projectionInput) as TResult;
}
case "mindmaps:getEditorScene": {
const documentId = assertStringArg(input.plan.argsJson, "docId");
const mindmapId = assertStringArg(input.plan.argsJson, "mindmapId");
// compat_blob_readeditor scene 由同一 Convex blob substrate 构建,但对外暴露 Rust scene 合同。
const result = await query(api.mindmaps.get, {
docId: documentId,
mindmapId,
});
return buildMindmapEditorScene({
documentId,
mindmapId,
rootNodeId: readOptionalStringArg(input.plan.argsJson, "rootNodeId"),
data: (result as { data?: unknown } | null)?.data,
source: "rust-kernel",
owner: "rust-kernel",
meta: {
requestId: input.plan.requestId,
traceId: input.plan.traceId,
workspaceId: input.plan.workspaceId,
documentId,
pageId: documentId,
mindmapId,
attachmentId: mindmapId,
updatedAt:
(result as { meta?: { updated_at?: string | null } | null } | null)?.meta?.updated_at ??
null,
},
}) as TResult;
}
case "sidebar:datasetList":
return query(api.sidebar.datasetList, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
@@ -1193,6 +1260,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
batchCopy: unknown;
batchMove: unknown;
};
mindmaps: {
applyCommand: unknown;
};
};
switch (input.plan.functionName) {
@@ -1323,6 +1393,7 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"),
});
case "mindmaps:put":
// compat_blob_write:整棵 put 仅保留给导入、恢复和历史调用,不作为 Phase 6 编辑主写链。
return mutation(api.mindmaps.put, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
@@ -1332,6 +1403,15 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
? input.plan.argsJson.createOnly
: undefined,
});
case "mindmaps:applyCommand":
// compat_blob_write:命令 facade 已是主写入口,但本阶段底层仍暂写 Convex blob substrate。
return mutation(runtimeApi.mindmaps.applyCommand, {
docId:
readOptionalStringArg(input.plan.argsJson, "docId") ??
assertStringArg(input.plan.argsJson, "documentId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
commands: Array.isArray(input.plan.argsJson.commands) ? input.plan.argsJson.commands : [],
});
case "mindmaps:softDelete":
return mutation(api.mindmaps.softDelete, {
docId: assertStringArg(input.plan.argsJson, "docId"),
-19
View File
@@ -231,24 +231,5 @@ export function buildSidebarTreeFromKernelProjection(input: {
sortTree(roots);
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
const missingRoots = input.records
.filter((record) => !itemById.has(record.id))
.map((record) => ({
...record,
children: [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: record.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
}));
if (missingRoots.length > 0) {
roots.push(...missingRoots);
sortTree(roots);
}
return roots;
}
+2 -34
View File
@@ -24,17 +24,9 @@ export type MnoteRuntimeConfig = {
onlyofficeCallbackOriginDesktop?: string;
/**
* 编辑器 host 选择。
* 说明:默认主链为 leptos_tiptap_island;可通过运行时配置显式切换
* 说明:文档页正式主链只保留 leptos_tiptap_island。
*/
documentEditorHost?:
| "blocknote"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
/**
* 编辑器 BlockNote 回退总开关(kill switch)。
* 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。
*/
documentEditorBlocknoteKillSwitch?: boolean;
documentEditorHost?: "leptos_tiptap_island";
/**
* 树域 renderer family 选择。
* 说明:默认主路径已切到 rust_familyReact fallback 仍作为过渡兜底保留。
@@ -93,9 +85,6 @@ const parseDocumentEditorHost = (
if (!normalized) {
return undefined;
}
if (normalized === "blocknote") {
return "blocknote";
}
if (
normalized === "leptos_tiptap_island" ||
normalized === "leptos_tiptap_runtime" ||
@@ -104,13 +93,6 @@ const parseDocumentEditorHost = (
) {
return "leptos_tiptap_island";
}
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return undefined;
};
@@ -169,17 +151,6 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
) !== undefined
? {
documentEditorBlocknoteKillSwitch: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
),
}
: {}),
...(parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
@@ -289,8 +260,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
const treeRendererFamily =
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";
@@ -298,7 +267,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
...cfg,
isDesktop,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
treeRendererFamily,
onlyofficeBaseUrl,
onlyofficeStorageHostOverride,
+18 -14
View File
@@ -187,7 +187,7 @@ describe("buildSidebarInitialData", () => {
tables: [],
});
expect(queryResult).toEqual({
expect(queryResult).toMatchObject({
active_workspace_id: "ws_1",
workspaces: [
{
@@ -256,6 +256,11 @@ describe("buildSidebarInitialData", () => {
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
meta: {
search: {
ordering: "kernel_file_tree_preorder",
},
},
items: [
{
rowId: "doc:doc_1",
@@ -335,7 +340,7 @@ describe("buildSidebarInitialData", () => {
mindmap_asset_children: {},
});
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toEqual({
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toMatchObject({
activeWorkspaceId: "ws_1",
workspaces: [
{
@@ -426,6 +431,11 @@ describe("buildSidebarInitialData", () => {
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
meta: {
search: {
ordering: "kernel_file_tree_preorder",
},
},
items: [
{
rowId: "doc:doc_1",
@@ -506,7 +516,7 @@ describe("buildSidebarInitialData", () => {
});
});
it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => {
it("缺少 kernel projection 时不再本地重建第二份 projection", () => {
expect(
mapSidebarDatasetListQueryResultToInitialData({
active_workspace_id: "ws_1",
@@ -550,18 +560,12 @@ describe("buildSidebarInitialData", () => {
).toMatchObject({
activeWorkspaceId: "ws_1",
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projectionId: "kernel_projection:sidebar_tree:missing",
},
kernelSidebarTree: [
{
id: "doc_1",
children: [
{
id: "doc_2",
},
],
},
],
kernelFileTreeProjection: {
projectionId: "kernel_projection:file_tree:missing",
},
kernelSidebarTree: [],
});
});
});
-16
View File
@@ -111,12 +111,6 @@ function readKernelSidebarProjection(
return camelCaseProjection;
}
if (Array.isArray(result.documents) && result.documents.length > 0) {
// 说明:部分 query transport 只回 documents,没有同步附带 projection。
// 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。
return buildProjectionContract(result.documents);
}
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
}
@@ -135,16 +129,6 @@ function readKernelFileTreeProjection(
return camelCaseProjection;
}
if (Array.isArray(result.documents)) {
return buildKernelFileTreeProjection({
documents: result.documents,
mediaAssets: result.media_assets,
mindmapAssets: result.mindmap_assets,
tableAssets: result.table_assets,
mindmapAssetChildren: result.mindmap_asset_children,
});
}
return EMPTY_KERNEL_FILE_TREE_PROJECTION;
}
+3 -3
View File
@@ -11,13 +11,13 @@ import { isDevAuthEnabled } from "@/lib/auth/devUser";
const isPublicRoute = createRouteMatcher([
"/auth",
"/login",
// 说明:仅用于本地/联调的页面选项回归入口(Playwright 会用它验证页面选项是否真正生效)。
// 该路由不写入后端数据,放行可避免 E2E 因鉴权/数据初始化问题被阻塞。
"/dev/page-options-playground",
"/api/auth(.*)",
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
"/api/onlyoffice/proxy(.*)",
"/api/onlyoffice/callback(.*)",
// 说明:媒体附件 API 作为 Rust 3000 的 TS transport 壳保留;
// 先放过 Next middleware,真实鉴权由各 route 内部 requireAuthContext 执行。
"/api/media(.*)",
// 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。
// 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。
"/onlyoffice-server(.*)",
@@ -32,7 +32,6 @@ export interface EditorReferenceBridge {
insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
replaceWithSnapshot: (blocks: Json) => void;
openTableFullScreen?: (tableId: string) => void;
requestFallbackToBlockNote?: () => void;
}
interface EditorBridgeState {