feat: 收口 Rust Web 3000 主链
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 switch(runtime/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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_read:editor 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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_family;React 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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user