feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
@@ -1,14 +1,6 @@
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { DocumentShell } from "@/components/editor/document-shell";
import type { PageFont, PageLayoutDensity, PageOptionsState, DocumentStats } from "@/types/page-options";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
@@ -16,120 +8,13 @@ import {
type EditorHostKind,
} from "@/components/editor/editor-host-config";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import { loadPageAggregateFromNextHeaders } from "@/lib/documents/page-aggregate-loader";
interface DocumentPageProps {
params: Promise<{ id: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
type DocumentMetaPayload = {
id: string;
workspace_id: string;
title: string | null;
updated_at: string | null;
can_edit?: boolean | null;
disable_download?: boolean | null;
disable_copy?: boolean | null;
wide_layout?: boolean | null;
use_small_text?: boolean | null;
show_heading_numbers?: boolean | null;
show_toc?: boolean | null;
show_structure?: boolean | null;
protect_editing?: boolean | null;
show_word_count?: boolean | null;
collapse_backlinks?: boolean | null;
page_font?: PageFont | null;
layout_density?: PageLayoutDensity | null;
hide_child_pages?: boolean | null;
show_block_ref_count?: boolean | null;
embed_default_block_id?: string | null;
word_count?: number | null;
character_count?: number | null;
block_count?: number | null;
todo_total?: number | null;
todo_total_count?: number | null;
todo_done?: number | null;
todo_done_count?: number | null;
};
type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
page_subtree?: PageSubtreeProjection | null;
};
async function fetchDocumentContentOnServer(input: {
documentId: string;
workspaceId: string;
title?: string | null;
}): Promise<{
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
pageSubtree: PageSubtreeProjection | null;
}> {
try {
const headerList = await headers();
const requestHeaders = new Headers();
[
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
].forEach((name) => {
const value = headerList.get(name);
if (value) {
requestHeaders.set(name, value);
}
});
const request = new Request("http://mnote.local/documents/content", {
method: "GET",
headers: requestHeaders,
});
const { client } = await getAuthedConvexClient();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: input.workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext,
envelope,
});
const result = await executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
const normalized = normalizeDocumentContentResponse({
documentId: input.documentId,
title: input.title ?? null,
payload: result,
});
return normalized;
} catch {
return {
content: null,
revision: null,
conflictDetectionKey: null,
pageSubtree: null,
};
}
}
export default async function DocumentPage({ params, searchParams }: DocumentPageProps) {
const { id } = await params;
const resolvedSearch = (await searchParams) ?? {};
@@ -169,68 +54,19 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
if (isConvexEnabled()) {
const workspaceIdRaw = resolvedSearch?.workspaceId;
const workspaceId = typeof workspaceIdRaw === "string" ? workspaceIdRaw : null;
const result = await fetchDocumentMetaViaBridge<DocumentMetaPayload>({
const loaded = await loadPageAggregateFromNextHeaders({
documentId: id,
workspaceId,
});
const doc = result?.doc;
if (!doc) {
if (!loaded) {
notFound();
}
const readOnly = doc.can_edit === false;
const disableDownload = Boolean(doc.disable_download);
const disableCopy = Boolean(doc.disable_copy);
const initialOptions: PageOptionsState = {
wideLayout: doc.wide_layout ?? false,
smallText: doc.use_small_text ?? false,
showHeadingNumbers: doc.show_heading_numbers ?? true,
showToc: doc.show_toc ?? false,
showStructure: doc.show_structure ?? false,
protectEditing: doc.protect_editing ?? false,
showWordCount: doc.show_word_count ?? true,
collapseBacklinks: doc.collapse_backlinks ?? false,
pageFont: doc.page_font ?? "default",
layoutDensity: doc.layout_density ?? "normal",
hideChildPages: doc.hide_child_pages ?? false,
showBlockRefCount: doc.show_block_ref_count ?? false,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
const initialStats: DocumentStats = {
wordCount: doc.word_count ?? 0,
characterCount: doc.character_count ?? 0,
blockCount: doc.block_count ?? 0,
todoTotal: doc.todo_total ?? doc.todo_total_count ?? 0,
todoDone: doc.todo_done ?? doc.todo_done_count ?? 0,
};
const initialDocumentContent = await fetchDocumentContentOnServer({
documentId: doc.id,
workspaceId: doc.workspace_id,
title: doc.title ?? "无标题",
});
const page = buildPageAggregate({
documentId: doc.id,
workspaceId: doc.workspace_id,
title: doc.title ?? "无标题",
updatedAt: doc.updated_at,
readOnly,
disableDownload,
disableCopy,
pageOptions: initialOptions,
content: initialDocumentContent.content,
revision: initialDocumentContent.revision,
conflictDetectionKey: initialDocumentContent.conflictDetectionKey,
pageSubtree: initialDocumentContent.pageSubtree,
stats: initialStats,
});
return (
<div className="flex h-screen flex-col">
<div className="min-h-0 flex-1">
<DocumentShell
page={page}
page={loaded.page}
openTableId={openTableId}
editorHostKind={editorHostKind}
/>
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockFetchDocumentAiOrchestratorConfig = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
fetchDocumentAiOrchestratorConfig: mockFetchDocumentAiOrchestratorConfig,
}));
describe("/api/ai-agent/document/config route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockFetchDocumentAiOrchestratorConfig.mockReset();
});
it("应返回 orchestrator config 能力面", async () => {
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockFetchDocumentAiOrchestratorConfig.mockResolvedValue({
provider: "online",
defaultModelKey: "gpt-5.4",
defaultProfileId: "page_writer_ai_first",
sessionEnabled: true,
models: [{ key: "gpt-5.4", title: "GPT-5.4" }],
profiles: [{ id: "page_writer_ai_first", title: "AI 主写" }],
tools: [{ name: "doc_get", description: "读取当前页摘要" }],
});
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/ai-agent/document/config"));
const payload = await response.json();
expect(response.status).toBe(200);
expect(mockFetchDocumentAiOrchestratorConfig).toHaveBeenCalledTimes(1);
expect(payload.defaultModelKey).toBe("gpt-5.4");
expect(payload.defaultProfileId).toBe("page_writer_ai_first");
expect(payload.tools[0]?.name).toBe("doc_get");
});
});
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { fetchDocumentAiOrchestratorConfig } from "@/lib/server/document-ai-orchestrator";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
let userId = "";
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
}
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await fetchDocumentAiOrchestratorConfig({ request });
return NextResponse.json(payload, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -6,7 +6,9 @@ const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -30,8 +32,16 @@ vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -42,7 +52,9 @@ describe("/api/ai-agent/run route", () => {
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
@@ -55,7 +67,7 @@ describe("/api/ai-agent/run route", () => {
},
options: {
ai: {
provider: "online",
provider: "hermes",
},
},
});
@@ -65,6 +77,28 @@ describe("/api/ai-agent/run route", () => {
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
@@ -86,13 +120,19 @@ describe("/api/ai-agent/run route", () => {
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
@@ -106,13 +146,15 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
@@ -120,4 +162,223 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
context: {
documentId: "doc-1",
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
});
});
+116 -22
View File
@@ -17,14 +17,18 @@ import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "hermes" | "codex";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
@@ -48,6 +52,7 @@ type RequestPayload = {
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
@@ -59,6 +64,8 @@ type RequestPayload = {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
@@ -112,11 +119,46 @@ const clampSteps = (raw: unknown) => {
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (raw: unknown): RuntimeProvider => {
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
@@ -192,6 +234,22 @@ const buildHermesInstructions = (
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
@@ -237,6 +295,9 @@ const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
@@ -263,24 +324,41 @@ const recoverStructuredHermesToolResult = async (input: {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
return buildDocumentBridgeContext({
request: input.request,
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data,
requestId: input.fallbackRequestId,
traceId: input.fallbackTraceId,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}).catch(() => null);
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
@@ -619,13 +697,29 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = normalizeProvider(payload.options?.ai?.provider);
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
const scope = normalizeScope(payload);
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;
@@ -9,9 +9,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentOptionsUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
type OptionsPayload = {
documentId: string;
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
workspaceId: normalizedWorkspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.options.update",
name: PAGE_COMMAND_NAMES.updateLayout,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -43,7 +44,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (isConvexEnabled()) {
try {
const url = new URL(request.url);
const documentId = assertDocumentId(url.searchParams.get("documentId"));
const workspaceId = url.searchParams.get("workspaceId")?.trim() || null;
const loaded = await loadPageAggregate({
request,
documentId,
workspaceId,
});
if (!loaded) {
return NextResponse.json(
{
error: "页面不存在",
meta: {
requestId: "unknown",
traceId: "unknown",
queryName: "documents.page.get",
},
},
{ status: 404 },
);
}
return NextResponse.json({
page: loaded.page,
meta: loaded.bridge,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -4,6 +4,28 @@ vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
@@ -12,11 +34,52 @@ vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
}));
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
executePageWriteBridgeCommand: vi.fn(async () => ({
requestId: "req_1",
traceId: "trace_1",
commandId: "cmd_1",
commandName: "page.command",
revision: 1,
conflictDetectionKey: "doc_1:1",
})),
}));
vi.mock("@/lib/documents/page-aggregate-loader", () => ({
loadPageAggregate: vi.fn(async () => ({
page: {
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "页面标题",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: null, revision: 0, conflictDetectionKey: "doc_1:0" },
tree: { pageSubtree: null },
stats: null,
},
bridge: {
requestId: "req_1",
traceId: "trace_1",
queryName: "documents.page.get",
},
})),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
@@ -24,6 +87,8 @@ import {
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
describe("documents route adapters", () => {
it("creates child route delegates to unified adapter", async () => {
@@ -65,4 +130,46 @@ describe("documents route adapters", () => {
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
});
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { identity: { documentId: string } };
meta: { queryName: string };
};
expect(loadPageAggregate).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route delegates to unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("options route delegates to unified page write adapter", async () => {
await postOptions(new Request("http://localhost/api/documents/options", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", options: { wideLayout: true } }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("save route delegates to unified page write adapter", async () => {
await postSave(new Request("http://localhost/api/documents/save", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", content: [] }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
});
@@ -6,11 +6,12 @@ import {
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import {
buildDocumentSavePayload,
type DocumentSavePayload,
} from "@/lib/documents/save-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
export async function POST(request: Request) {
if (isConvexEnabled()) {
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
name: PAGE_COMMAND_NAMES.saveBody,
payload: payload satisfies DocumentSavePayload,
context: bridgeContext,
target: {
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeSaveBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -8,9 +8,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentTitleUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
interface RenamePayload {
documentId: string;
@@ -27,7 +28,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.title.update",
name: PAGE_COMMAND_NAMES.updateTitle,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockBuildMnoteWebForwardHeaders = vi.fn();
const mockBuildMnoteWebStreamUrl = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
@@ -11,41 +14,99 @@ const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
),
);
vi.mock("@/lib/server/mnote-web", () => ({
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockBuildMnoteWebForwardHeaders.mockReset();
mockBuildMnoteWebStreamUrl.mockReset();
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentQueryEnvelope.mockReset();
mockExecuteRustBridgeQueryTransport.mockReset();
mockResolveRustBridgeQueryPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
vi.unstubAllGlobals();
});
it("透传上游 SSE 并去掉 set-cookie", async () => {
const upstreamHeaders = new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"set-cookie": "secret=1",
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
client: { query: vi.fn() },
});
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
status: 200,
headers: upstreamHeaders,
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_1",
traceId: "trace_stream_1",
workspaceId: "ws_1",
});
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
mockBuildMnoteWebStreamUrl.mockReturnValue(
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
);
const fetchMock = vi.fn(async () => upstreamResponse);
vi.stubGlobal("fetch", fetchMock);
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title: "工作区首页",
parent_id: null,
sort_order: 0,
is_starred: true,
is_template: false,
created_at: "2026-04-22T00:00:00Z",
updated_at: "2026-04-22T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: null,
has_more: false,
generated_at: "2026-04-22T00:00:00Z",
});
const { GET } = await import("./route");
const response = await GET(
@@ -55,19 +116,15 @@ describe("/api/mnote-web/stream route", () => {
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
redirect: "follow",
headers: expect.any(Headers),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("set-cookie")).toBeNull();
await expect(response.text()).resolves.toContain("event: snapshot");
const text = await response.text();
expect(text).toContain("event: snapshot");
expect(text).toContain('"kind":"snapshot"');
expect(text).toContain('"projection":"sidebar_tree"');
expect(text).toContain('"workspaceId":"ws_1"');
expect(text).toContain('"activeWorkspaceId":"ws_1"');
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
});
});
@@ -1,11 +1,21 @@
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl,
} from "@/lib/server/mnote-web";
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
export const dynamic = "force-dynamic";
function toSseFrame(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
@@ -16,26 +26,73 @@ export async function GET(request: Request) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
const headers = await buildMnoteWebForwardHeaders(request);
headers.set("accept", "text/event-stream");
headers.set("x-mnote-workspace-id", workspaceId);
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "follow",
const { client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
const sidebarEnvelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const sidebarPlan = await resolveRustBridgeQueryPlan({
context,
envelope: sidebarEnvelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport({
client,
plan: sidebarPlan,
});
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
const overviewEnvelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 20,
cursor,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
const overviewPlan = await resolveRustBridgeQueryPlan({
context,
envelope: overviewEnvelope,
});
const overview = await executeRustBridgeQueryTransport({
client,
plan: overviewPlan,
});
const payload = {
kind: "snapshot",
stream: "workspace",
projection: "sidebar_tree",
workspaceId,
rootNodeId: null,
cursor,
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
snapshot: {
dataset: sidebarDataset,
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
},
overview,
};
return new Response(toSseFrame("snapshot", payload), {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
+2 -2
View File
@@ -3,7 +3,7 @@ import "./globals.css";
import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider";
import { AppPreferencesHydrator } from "@/components/providers/app-preferences-hydrator";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { getMnotePublicRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
@@ -20,7 +20,7 @@ export default async function RootLayout({
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const useConvex = isConvexEnabled();
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
const runtimeConfig = { ...getMnotePublicRuntimeConfig(), isDesktop, useConvex };
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
return (
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from "vitest";
import { readAiPanelPrefs } from "./panelShared";
describe("panelShared 在线模型真源", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("读取在线偏好时不应再把旧 model 字段回退成 modelKey", () => {
window.localStorage.setItem("doc_ai_provider", "online");
window.localStorage.setItem("doc_ai_model", "gpt-5.4");
const prefs = readAiPanelPrefs("doc_ai", {
provider: "online",
model: "",
modelKey: "",
profileId: "",
maxSteps: 10,
});
expect(prefs.model).toBe("gpt-5.4");
expect(prefs.modelKey).toBe("");
});
it("读取在线偏好时应优先使用显式存储的 modelKey", () => {
window.localStorage.setItem("doc_ai_provider", "online");
window.localStorage.setItem("doc_ai_model", "legacy-model");
window.localStorage.setItem("doc_ai_model_key", "gpt-5.3-codex");
const prefs = readAiPanelPrefs("doc_ai", {
provider: "online",
model: "",
modelKey: "",
profileId: "",
maxSteps: 10,
});
expect(prefs.model).toBe("legacy-model");
expect(prefs.modelKey).toBe("gpt-5.3-codex");
});
});
@@ -5,6 +5,8 @@ export type AiProvider = "online" | "local" | "ollama" | "codex";
export type AiPanelPrefs = {
provider: AiProvider;
model: string;
modelKey?: string;
profileId?: string;
maxSteps: number;
};
@@ -51,6 +53,8 @@ export const readAiPanelPrefs = (
try {
const providerRaw = (window.localStorage.getItem(`${storageKeyPrefix}_provider`) || "").trim();
const model = window.localStorage.getItem(`${storageKeyPrefix}_model`) || defaults.model;
const modelKey = window.localStorage.getItem(`${storageKeyPrefix}_model_key`) || defaults.modelKey || "";
const profileId = window.localStorage.getItem(`${storageKeyPrefix}_profile_id`) || defaults.profileId || "";
const stepsRaw = window.localStorage.getItem(`${storageKeyPrefix}_max_steps`) || "";
const parsedSteps = Number(stepsRaw);
@@ -62,6 +66,8 @@ export const readAiPanelPrefs = (
return {
provider,
model,
modelKey,
profileId,
maxSteps: Number.isFinite(parsedSteps) ? Math.floor(parsedSteps) : defaults.maxSteps,
};
} catch {
@@ -75,6 +81,8 @@ export const writeAiPanelPrefs = (storageKeyPrefix: string, prefs: AiPanelPrefs)
try {
window.localStorage.setItem(`${storageKeyPrefix}_provider`, prefs.provider);
window.localStorage.setItem(`${storageKeyPrefix}_model`, prefs.model);
window.localStorage.setItem(`${storageKeyPrefix}_model_key`, prefs.modelKey || "");
window.localStorage.setItem(`${storageKeyPrefix}_profile_id`, prefs.profileId || "");
window.localStorage.setItem(`${storageKeyPrefix}_max_steps`, String(prefs.maxSteps));
} catch {
// ignore
@@ -87,10 +87,29 @@ describe("applyDocWriteToolResultToPageBody", () => {
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
},
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: [{ id: "block_0", type: "paragraph", content: "旧正文" }] as Json,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot,
onPersistedMetaChange,
@@ -121,10 +140,76 @@ describe("applyDocWriteToolResultToPageBody", () => {
ok: true,
result: { data: [] },
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
applyPageBodyCommandImpl,
}),
).resolves.toBe(false);
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
});
it("doc 写工具返回非 legacy blocks 数组时应直接忽略", async () => {
const applyPageBodyCommandImpl = vi.fn();
await expect(
applyDocWriteToolResultToPageBody({
tool: "doc_replace_range",
ok: true,
result: {
data: {
documentId: "doc-1",
blocks: [],
},
},
documentId: "doc-1",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
@@ -14,13 +14,13 @@ import { Textarea } from "@/components/ui/textarea";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
applyPageBodyCommand,
type ApplyPageBodyCommandInput,
executePageBodyCommand,
type ExecutePageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-body-command";
} from "@/lib/documents/page-command-client";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageAggregateAiSnapshot } from "@/components/editor/DocumentAiAgentPanel";
type AgentMessage = { role: "user" | "assistant"; content: string };
type CodexMode = "chat" | "test" | "dev";
@@ -164,10 +164,10 @@ export async function applyDocWriteToolResultToPageBody(input: {
ok: boolean;
result: unknown;
documentId: string;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
applyPageBodyCommandImpl?: (input: ExecutePageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
}): Promise<boolean> {
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
return false;
@@ -175,12 +175,12 @@ export async function applyDocWriteToolResultToPageBody(input: {
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const dataNode = resultRecord && "data" in resultRecord ? resultRecord.data : null;
if (dataNode == null) {
if (!Array.isArray(dataNode)) {
return false;
}
const latestPersistedMeta = input.getLatestPersistedMeta();
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
const latestPersistedMeta = input.getLatestPageAggregateSnapshot().persistedMeta;
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? executePageBodyCommand;
const persistedMeta = await applyPageBodyCommandImpl({
documentId: input.documentId,
workspaceId: latestPersistedMeta.workspaceId,
@@ -233,16 +233,12 @@ const safeJsonStringify = (value: unknown) => {
export function DocumentAiAgentPanelRuntime({
documentId,
getLatestBlocks,
getLatestPageSubtree,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
onPersistedMetaChange,
onPageHeadTitleChange,
}: {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}) {
@@ -260,7 +256,12 @@ export function DocumentAiAgentPanelRuntime({
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [aiModelKey, setAiModelKey] = useState<string>("");
const [aiProfileId, setAiProfileId] = useState<string>("");
const [page, setPage] = useState<PanelPage>("chat");
const [onlineConfig, setOnlineConfig] = useState<DocumentAiCapabilityConfig | null>(null);
const [onlineConfigLoading, setOnlineConfigLoading] = useState(false);
const [onlineConfigError, setOnlineConfigError] = useState<string>("");
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
@@ -283,25 +284,77 @@ export function DocumentAiAgentPanelRuntime({
}, [flightMode]);
useEffect(() => {
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", modelKey: "", profileId: "", maxSteps: 10 });
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setAiModelKey(prefs.modelKey || "");
setAiProfileId(prefs.profileId || "");
}, []);
useEffect(() => {
writeAiPanelPrefs("doc_ai", {
provider: aiProvider,
model: aiModel,
modelKey: aiModelKey,
profileId: aiProfileId,
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
});
}, [aiModel, aiProvider, maxSteps]);
}, [aiModel, aiModelKey, aiProfileId, aiProvider, maxSteps]);
useEffect(() => {
// 关闭面板时,回到对话页,避免下次打开还停留在设置/历史等子页
if (!open) setPage("chat");
}, [open]);
useEffect(() => {
if (aiProvider !== "online") return;
let cancelled = false;
setOnlineConfigLoading(true);
setOnlineConfigError("");
void fetch("/api/ai-agent/document/config", {
method: "GET",
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
throw new Error(String(payload?.error ?? `读取页面 AI 配置失败:${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
})
.then((payload) => {
if (cancelled) return;
setOnlineConfig(payload);
setAiModelKey((prev) => {
const picked = prev.trim();
if (picked && payload.models.some((item) => item.key === picked)) {
return picked;
}
return payload.defaultModelKey || picked;
});
setAiProfileId((prev) => {
const picked = prev.trim();
if (picked && payload.profiles.some((item) => item.id === picked)) {
return picked;
}
return payload.defaultProfileId || picked;
});
})
.catch((error) => {
if (cancelled) return;
setOnlineConfigError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
if (!cancelled) {
setOnlineConfigLoading(false);
}
});
return () => {
cancelled = true;
};
}, [aiProvider]);
// 会话/历史:按 documentId 隔离持久化
useEffect(() => {
try {
@@ -414,6 +467,18 @@ export function DocumentAiAgentPanelRuntime({
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const selectedOnlineModel = useMemo(
() => onlineConfig?.models.find((item) => item.key === aiModelKey) ?? null,
[aiModelKey, onlineConfig],
);
const selectedOnlineProfile = useMemo(
() => onlineConfig?.profiles.find((item) => item.id === aiProfileId) ?? null,
[aiProfileId, onlineConfig],
);
const onlineSessionId = useMemo(() => {
if (aiProvider !== "online" || !activeSessionId || !documentId) return null;
return `doc_ai:${documentId}:${activeSessionId}`;
}, [activeSessionId, aiProvider, documentId]);
const [codexSessionDraft, setCodexSessionDraft] = useState("");
useEffect(() => {
@@ -578,10 +643,11 @@ export function DocumentAiAgentPanelRuntime({
const payloadMessagesForRequest = payloadMessages;
const blocks = getLatestBlocks();
const pageAggregateSnapshot = getLatestPageAggregateSnapshot();
const blocks = pageAggregateSnapshot.blocks;
const blocksJson = blocks ? safeJsonStringify(blocks) : "";
const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000;
const pageSubtree = getLatestPageSubtree();
const pageSubtree = pageAggregateSnapshot.pageSubtree;
const contextNode = pageSubtree?.rootNode ?? null;
const contextSubtree = pageSubtree
? {
@@ -596,6 +662,16 @@ export function DocumentAiAgentPanelRuntime({
const codexSessionIdForRequest =
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
const onlineModelKeyForRequest =
aiProvider === "online"
? String(aiModelKey || onlineConfig?.defaultModelKey || "")
.trim() || null
: null;
const onlineProfileIdForRequest =
aiProvider === "online"
? String(aiProfileId || onlineConfig?.defaultProfileId || "")
.trim() || null
: null;
if (aiProvider === "codex" && activeSessionId) {
setSessions((prev) =>
@@ -638,13 +714,17 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree,
outline: contextOutline,
evidence: contextEvidence,
pageOptions: pageAggregateSnapshot.pageOptions,
},
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
...(aiProvider === "online" && onlineSessionId ? { sessionId: onlineSessionId } : {}),
...(aiProvider === "online" && onlineModelKeyForRequest ? { modelKey: onlineModelKeyForRequest } : {}),
...(aiProvider === "online" && onlineProfileIdForRequest ? { profileId: onlineProfileIdForRequest } : {}),
...(aiProvider !== "codex" && aiProvider !== "online" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
@@ -723,7 +803,7 @@ export function DocumentAiAgentPanelRuntime({
ok: Boolean(obj.ok),
result,
documentId,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
? (blocks) => {
editorBridge.replaceWithSnapshot(blocks);
@@ -1054,47 +1134,76 @@ export function DocumentAiAgentPanelRuntime({
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
{aiProvider === "online" ? (
<>
<div className="text-xs text-muted-foreground">
online mindmap
</div>
</div>
<div className="space-y-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{tool.title}</div>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{tool.name}</code>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.mode}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.scope}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.status}</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">{tool.description}</div>
</div>
))}
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
)}
</>
)}
</div>
</ScrollArea>
@@ -1254,6 +1363,22 @@ export function DocumentAiAgentPanelRuntime({
VSCode Codex CLI <code className="rounded bg-muted px-1 py-0.5">#dev</code> SessionId
</div>
</div>
) : aiProvider === "online" ? (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
<select
className="h-9 min-w-[260px] rounded border bg-white px-2 text-sm"
value={aiModelKey}
onChange={(e) => setAiModelKey(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.models.map((item) => (
<option key={item.key} value={item.key}>
{item.title} · {item.key}
</option>
))}
</select>
</>
) : (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
@@ -1277,12 +1402,35 @@ export function DocumentAiAgentPanelRuntime({
disabled={loading}
/>
)}
<datalist id="doc-ai-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</>
)}
</div>
{aiProvider === "online" ? (
<div className="space-y-2 rounded border bg-muted/30 p-3 text-xs text-muted-foreground">
<div>
线 provider OmniRouteBase URL
<code className="ml-1 rounded bg-muted px-1 py-0.5">{onlineConfig?.baseUrl || "http://localhost:20128/v1"}</code>
</div>
<div>
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.key || "未选择"}</code>
{" · "}
Combo
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.resolvedCombo || "由网关运行时解析"}</code>
</div>
<div>
Runtime Model
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{selectedOnlineModel?.resolvedRuntimeModel || "由网关运行时解析"}
</code>
</div>
<div>
Runtime Model AI OmniRoute runtime model provider
</div>
{onlineConfigLoading ? <div> AI </div> : null}
{onlineConfigError ? <div className="text-red-600">{onlineConfigError}</div> : null}
</div>
) : null}
<div className="text-xs text-muted-foreground">
线/ BaseURL Key / provider model Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
</div>
@@ -1323,6 +1471,62 @@ export function DocumentAiAgentPanelRuntime({
/>
</label>
</div>
{aiProvider === "online" ? (
<div className="space-y-3 rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground">Soul / Profile</label>
<select
className="h-9 min-w-[240px] rounded border bg-white px-2 text-sm"
value={aiProfileId}
onChange={(e) => setAiProfileId(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title}
</option>
))}
</select>
</div>
<div className="text-xs text-muted-foreground">
{selectedOnlineProfile?.description || "当前未读取到 profile 描述。"}
</div>
<div className="text-xs text-muted-foreground">
Profile registry
</div>
<div className="text-xs text-muted-foreground">
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{onlineConfig?.sessionEnabled ? `已启用 · ${onlineSessionId || "等待会话初始化"}` : "未启用"}
</code>
</div>
<div className="space-y-2 rounded border bg-muted/20 p-3">
<div className="text-xs font-medium text-foreground"> scope </div>
<div className="text-xs text-muted-foreground">
online tool registry mindmap
</div>
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
<div className="flex flex-wrap gap-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white px-2 py-1 text-xs">
<div className="font-medium">{tool.title}</div>
<div className="text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{tool.name}</code>
{" · "}
{tool.mode}
{" / "}
{tool.scope}
</div>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="text-xs text-muted-foreground"></div>
</div>
</ScrollArea>
@@ -2,16 +2,26 @@
import dynamic from "next/dynamic";
import { useEffect } from "react";
import type { PageBodyPersistedMeta } from "@/lib/documents/page-body-command";
import type { PageOptionsState } from "@/types/page-options";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageBodyPersistedMeta, PageBodyPersistedState } from "@/lib/documents/page-body-command";
import type { Json } from "@/types/supabase";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export type PageAggregateAiSnapshot = {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
};
type DocumentAiAgentPanelProps = {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
};
@@ -35,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { BlockNoteEditorProps } from "@/components/editor/editor-host-types";
@@ -306,48 +307,19 @@ export function BlockNoteEditor({
try {
setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
});
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? null;
conflictDetectionKeyRef.current = persistedMeta.conflictDetectionKey ?? null;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
@@ -21,14 +30,17 @@ import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view";
import { emitDocumentsChanged } from "@/lib/events";
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { extractPageBlocks } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageBodyPersistedMeta,
} from "@/lib/documents/page-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import {
DEFAULT_EDITOR_HOST_KIND,
@@ -41,6 +53,12 @@ import type {
} from "@/components/editor/editor-host-types";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -95,21 +113,6 @@ export interface DocumentContentProps {
editorHostKind?: EditorHostKind;
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000;
const FALLBACK_TRIGGER_HISTORY_LIMIT = 20;
@@ -143,16 +146,17 @@ export function DocumentContent({
const readOnly = page.head.permissions.readOnly;
const disableDownload = page.head.permissions.disableDownload;
const disableCopy = page.head.permissions.disableCopy;
const initialOptions = page.layout.pageOptions;
const initialContent = page.body.content;
const initialContentRevision = page.body.revision;
const initialConflictDetectionKey = page.body.conflictDetectionKey;
const initialPageSubtree = page.tree.pageSubtree;
const initialStats = page.stats;
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [pageClientState, dispatchPageClientState] = useReducer(
pageAggregateClientStateReducer,
page,
createPageAggregateClientState,
);
const options = pageClientState.options;
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -171,14 +175,9 @@ export function DocumentContent({
documentId,
fallbackTitle: initialTitle,
});
const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree,
);
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const content = pageClientState.content;
const contentRevision = pageClientState.contentRevision;
const conflictDetectionKey = pageClientState.conflictDetectionKey;
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
@@ -353,33 +352,23 @@ export function DocumentContent({
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setServerPageSubtreeTitle(committedPageTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: committedPageTitle,
});
}, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
}, [page]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
useEffect(() => {
const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
@@ -424,7 +413,10 @@ export function DocumentContent({
const load = async () => {
setContentError(null);
setContentLoading(initialContent == null);
setContent(initialContent);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
setShowContentLoadingIndicator(false);
if (initialContent != null) {
@@ -443,7 +435,7 @@ export function DocumentContent({
try {
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: "GET",
credentials: "include",
@@ -455,31 +447,31 @@ export function DocumentContent({
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
page?: PageAggregateProjection;
};
const reloadedPage = payload.page ?? null;
const reloadedBody = reloadedPage?.body ?? null;
if (canceled) return;
setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: committedPageTitle,
);
if (reloadedPage) {
dispatchPageClientState({
type: "hydrate_from_page",
page: {
...reloadedPage,
body: {
...reloadedBody,
content: reloadedBody?.content ?? null,
revision:
typeof reloadedBody?.revision === "number" && Number.isInteger(reloadedBody.revision)
? reloadedBody.revision
: 0,
conflictDetectionKey:
typeof reloadedBody?.conflictDetectionKey === "string" && reloadedBody.conflictDetectionKey.trim()
? reloadedBody.conflictDetectionKey
: `${documentId}:0`,
},
},
});
}
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
@@ -506,7 +498,7 @@ export function DocumentContent({
contentLoadingTimerRef.current = null;
}
};
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
}, [contentReloadKey, documentId, initialContent, page, workspaceId]);
const persistTitle = useCallback(
async (nextTitle: string) => {
@@ -516,11 +508,14 @@ export function DocumentContent({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
persistTitleCommand: executePageHeadCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: payload,
});
} catch (error) {
console.error("更新页面标题失败", error);
}
@@ -532,10 +527,14 @@ export function DocumentContent({
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: nextTitle,
});
emitDocumentsChanged(documentId);
void persistTitle(nextTitle);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
@@ -565,7 +564,7 @@ export function DocumentContent({
async (patch: Partial<PageOptionsState>) => {
if (readOnly) return;
try {
await updatePageOptionsCommand({
await executePageLayoutCommand({
documentId,
workspaceId,
pageOptions: patch,
@@ -580,37 +579,37 @@ export function DocumentContent({
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
const nextValue = !options[key];
dispatchPageClientState({
type: "patch_page_options",
patch: { [key]: nextValue } as Partial<PageOptionsState>,
});
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
},
[persistOptions, readOnly],
[options, persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
dispatchPageClientState({
type: "patch_page_options",
patch,
});
void persistOptions(patch);
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
if (readOnly) return;
setOptions((prev) => {
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
return next;
if (!options.showToc) return;
dispatchPageClientState({
type: "patch_page_options",
patch: { showToc: false },
});
}, [persistOptions, readOnly]);
void persistOptions({ showToc: false });
}, [options.showToc, persistOptions, readOnly]);
const handleSetPageFont = useCallback(
(font: PageFont) => {
@@ -854,22 +853,10 @@ export function DocumentContent({
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const pageSubtree = useMemo(() => {
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
}
return null;
}, [
content,
pageTitle,
serverContentSnapshot,
serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
const pageSubtree = useMemo(
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
[pageClientState, pageTitle],
);
const readViewTocEntries = useMemo(
() =>
(pageSubtree?.outline ?? [])
@@ -882,16 +869,20 @@ export function DocumentContent({
})),
[pageSubtree],
);
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
const getLatestPersistedMeta = useCallback(
() => ({
workspaceId,
revision: contentRevision,
conflictDetectionKey,
}),
[conflictDetectionKey, contentRevision, workspaceId],
const getLatestPageAggregateSnapshot = useCallback(
() =>
selectPageAggregateClientAiSnapshot(pageClientState, {
workspaceId,
pageTitle,
}),
[pageClientState, pageTitle, workspaceId],
);
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
dispatchPageClientState({
type: "apply_persisted_body_meta",
meta,
});
}, []);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
@@ -903,7 +894,10 @@ export function DocumentContent({
}, [isEditing]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setContent(payload.blocks);
dispatchPageClientState({
type: "apply_local_content_snapshot",
content: payload.blocks,
});
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
const now = Date.now();
@@ -1162,8 +1156,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
@@ -1184,8 +1177,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
/>
)}
@@ -1266,13 +1258,8 @@ export function DocumentContent({
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
onPersistedMetaChange={handlePersistedMetaChange}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/>
</ImagePickerProvider>
@@ -4,6 +4,7 @@ import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import type { ReferenceTarget } from "@/types/search";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
import type { LeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export interface DocumentEditorHostProps {
documentId: string;
@@ -87,7 +88,4 @@ export type LeptosTiptapHostBridgeEventDetail = {
at?: string;
};
export type LeptosTiptapRuntimePageOptions = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
export type { LeptosTiptapRuntimePageOptions };
@@ -11,11 +11,11 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
const RUNTIME_PORT = 8123;
const RUNTIME_NAME = "8123-leptos-tiptap-runtime";
@@ -72,19 +72,6 @@ function resolveRuntimeUrl() {
if (typeof window === "undefined") {
return `http://localhost:${RUNTIME_PORT}/`;
}
const cfg = getMnoteRuntimeConfig();
if (cfg.mnoteWebBaseUrl) {
try {
const url = new URL(cfg.mnoteWebBaseUrl);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
// ignore
}
}
try {
const url = new URL(window.location.href);
url.port = String(RUNTIME_PORT);
@@ -313,37 +300,26 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const saveSnapshot = async (payload: HostDocumentPayload) => {
const normalizedRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
const { blocks } = publishSnapshot(payload);
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
),
});
const nextMeta = await response.json().catch(() => null);
if (!response.ok) {
const errorMessage = typeof nextMeta?.error === "string" ? nextMeta.error : `保存失败(${response.status}`;
throw new Error(errorMessage);
}
const nextRevision = typeof nextMeta?.revision === "number" ? nextMeta.revision : payload.meta?.revision ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
);
const nextRevision = persistedMeta.revision ?? payload.meta?.revision ?? null;
const nextConflictDetectionKey =
typeof nextMeta?.conflictDetectionKey === "string"
? nextMeta.conflictDetectionKey
: payload.meta?.conflict_detection_key ?? null;
persistedMeta.conflictDetectionKey ?? payload.meta?.conflict_detection_key ?? null;
revisionRef.current = nextRevision;
conflictDetectionKeyRef.current = nextConflictDetectionKey;
onPersistedMetaChangeRef.current?.({
@@ -17,6 +17,7 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import { loadLeptosTiptapRuntime } from "@/components/editor/leptos-tiptap-runtime-loader";
type RuntimeSelectionState = Record<string, boolean | number | null | undefined> & {
@@ -601,45 +602,25 @@ export function LeptosTiptapRuntimeEditorHost(props: DocumentEditorHostProps) {
const persistSnapshot = async () => {
emitStatus(INLINE_RUNTIME_STATUS.saving);
const snapshot = latestSnapshotRef.current ?? (await readCurrentSnapshot());
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
),
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
revision?: number | null;
conflictDetectionKey?: string | null;
}
| null;
if (!response.ok) {
throw new Error(payload?.error ?? `保存失败(${response.status}`);
}
revisionRef.current =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string" &&
payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -9,13 +9,15 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { DocumentStats } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import {
loadLeptosTiptapIslandAssets,
} from "@/components/editor/leptos-tiptap-island-loader";
@@ -116,21 +118,7 @@ type IslandBootstrapPayload = {
readOnly: boolean;
pageOptions: RuntimePageOptionsPayload;
};
type RuntimePageOptionsPayload = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
function buildRuntimePageOptions(pageOptions: PageOptionsState): RuntimePageOptionsPayload {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
type RuntimePageOptionsPayload = ReturnType<typeof pickLeptosTiptapRuntimePageOptions>;
function toIsoNow(): string {
return new Date().toISOString();
@@ -316,7 +304,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
@@ -375,7 +363,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
};
}, [
mountIdentity,
@@ -418,35 +406,22 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
const persistDocument = useCallback(async () => {
const normalizedDoc = latestDocRef.current;
const normalizedBlocks = blocksFromTiptapDoc(normalizedDoc) as Json;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
typeof payload?.error === "string" ? payload.error : `保存失败(${response.status}`,
);
}
revisionRef.current =
typeof payload?.revision === "number" ? payload.revision : revisionRef.current;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string"
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -717,7 +692,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
}
dispatchRuntimeCommand(target, {
command: "setPageOptions",
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
}, [props.pageOptions]);
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import type { Json } from "@/types/supabase";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
function createPageSubtree(title: string): PageSubtreeProjection {
return {
projectionId: "projection:page_tree:doc_1",
projection: "page_tree",
rootNodeId: "doc_1",
rootNode: {
id: "doc_1",
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title,
textSnippet: null,
blockType: null,
headingLevel: null,
numbering: null,
childCount: 0,
order: 0,
path: ["doc_1"],
},
},
subtree: {
rootNodeId: "doc_1",
nodes: [],
},
outline: [],
evidence: [],
stats: {
blockCount: 0,
headingCount: 0,
evidenceCount: 0,
maxDepth: 0,
},
};
}
function createPageAggregate(input?: {
title?: string;
content?: unknown;
pageSubtree?: PageSubtreeProjection | null;
pageOptionsPatch?: Partial<PageOptionsState>;
revision?: number | null;
conflictDetectionKey?: string | null;
}) {
const content =
input?.content ??
[
{
id: "block_1",
type: "paragraph",
content: [{ type: "text", text: "正文" }],
},
];
return buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: input?.title ?? "页面标题",
pageOptions: {
...pageOptions,
...input?.pageOptionsPatch,
},
content,
revision: input?.revision ?? 3,
conflictDetectionKey: input?.conflictDetectionKey ?? "doc_1:3",
pageSubtree: input?.pageSubtree ?? createPageSubtree("页面标题"),
});
}
describe("page-aggregate-client-state", () => {
it("应从 page aggregate 初始化 body/layout/tree 本地真相", () => {
const page = createPageAggregate();
const state = createPageAggregateClientState(page);
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");
});
it("路由 reload 后应整体替换 body/layout/tree 的服务端快照", () => {
const initialPage = createPageAggregate();
const reloadedContent = [
{
id: "block_2",
type: "paragraph",
content: [{ type: "text", text: "刷新后的正文" }],
},
];
const reloadedPage = createPageAggregate({
title: "刷新后的标题",
content: reloadedContent,
pageSubtree: createPageSubtree("刷新后的标题"),
pageOptionsPatch: { wideLayout: true, showToc: true },
revision: 9,
conflictDetectionKey: "doc_1:9",
});
const next = pageAggregateClientStateReducer(createPageAggregateClientState(initialPage), {
type: "hydrate_from_page",
page: reloadedPage,
});
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");
});
it("正文保存元信息回写时只更新 persisted meta,不覆盖本地内容快照", () => {
const page = createPageAggregate();
const localContent = [
{
id: "block_local",
type: "paragraph",
content: [{ type: "text", text: "本地正文" }],
},
];
const withLocalSnapshot = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "apply_local_content_snapshot",
content: localContent,
});
const next = pageAggregateClientStateReducer(withLocalSnapshot, {
type: "apply_persisted_body_meta",
meta: {
revision: 10,
conflictDetectionKey: "doc_1:10",
},
});
expect(next.content).toBe(localContent);
expect(next.serverContentSnapshot).toBe(page.body.content);
expect(next.contentRevision).toBe(10);
expect(next.conflictDetectionKey).toBe("doc_1:10");
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }],
});
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title",
title: "持久化后的标题",
});
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull();
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree);
});
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
const page = createPageAggregate();
const next = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "patch_page_options",
patch: {
showToc: true,
layoutDensity: "compact",
},
});
expect(next.options).toEqual({
...page.layout.pageOptions,
showToc: true,
layoutDensity: "compact",
});
expect(next.content).toBe(page.body.content);
expect(next.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
});
it("应能从统一 client state 导出 AI 所需的页面聚合快照", () => {
const page = createPageAggregate({
pageOptionsPatch: {
wideLayout: true,
smallText: true,
},
});
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1",
pageTitle: "页面标题",
});
expect(snapshot).toEqual({
blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree,
persistedMeta: {
workspaceId: "ws_1",
revision: 3,
conflictDetectionKey: "doc_1:3",
},
pageOptions: {
...pageOptions,
wideLayout: true,
smallText: true,
},
});
});
});
@@ -0,0 +1,144 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientState = {
options: PageOptionsState;
content: unknown;
serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null;
conflictDetectionKey: string | null;
};
export type PageAggregateClientStateAction =
| {
type: "hydrate_from_page";
page: PageAggregateProjection;
}
| {
type: "patch_page_options";
patch: Partial<PageOptionsState>;
}
| {
type: "apply_local_content_snapshot";
content: unknown;
}
| {
type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
};
function normalizePageTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string {
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title;
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) {
return subtreeTitle.trim();
}
return normalizePageTitle(page.head.title);
}
export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
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,
};
}
export function pageAggregateClientStateReducer(
state: PageAggregateClientState,
action: PageAggregateClientStateAction,
): PageAggregateClientState {
switch (action.type) {
case "hydrate_from_page":
return createPageAggregateClientState(action.page);
case "patch_page_options":
return {
...state,
options: {
...state.options,
...action.patch,
},
};
case "apply_local_content_snapshot":
return {
...state,
content: action.content,
};
case "apply_persisted_body_meta":
return {
...state,
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 selectPageAggregateClientPageSubtree(
state: PageAggregateClientState,
pageTitle: string,
): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return state.serverPageSubtreeSnapshot;
}
return null;
}
export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState,
input: {
workspaceId: string | null;
pageTitle: string;
},
): {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
} {
const blocks = state.content as Json | null;
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle),
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
conflictDetectionKey: state.conflictDetectionKey,
},
pageOptions: state.options,
};
}
@@ -7,6 +7,7 @@ import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity,
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
import { PAGE_OPTION_PANEL_GROUPS } from "@/lib/documents/page-option-semantics";
type TabId = "page" | "custom" | "global";
@@ -67,17 +68,6 @@ const OPTION_META: Record<
},
};
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
];
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
@@ -163,7 +153,12 @@ export function PageOptionsSidebar({
</div>
</section>
)}
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="页面选项"
optionKeys={PAGE_OPTION_PANEL_GROUPS.page}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
@@ -282,7 +277,12 @@ export function PageOptionsSidebar({
</div>
</section>
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="反向链接"
optionKeys={PAGE_OPTION_PANEL_GROUPS.custom}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
@@ -1,380 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree";
export type MnoteTreeShellMode = "page" | "picker" | "filetree";
type TreeShellMessage =
| {
channel?: string;
type?: string;
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
target?: { documentId?: string };
payload?: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
};
}
| null
| undefined;
type MnoteWebTreeShellProps = {
workspaceId: string | null;
activeDocumentId?: string;
actorId?: string | null;
mode?: MnoteTreeShellMode;
allowRootPick?: boolean;
excludeIds?: string[];
reloadToken?: number;
onNavigate: (documentId: string) => void;
onPick?: (documentId: string | null) => void;
onOpenAsset?: (assetId: string) => void;
onOpenContextMenu?: (args: { documentId: string; x: number; y: number }) => void;
onOpenFileTreeContextMenu?: (args: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => void;
onRefresh: () => Promise<void>;
fallback: React.ReactNode;
};
function buildShellUrl(
baseUrl: string,
workspaceId: string | null,
activeDocumentId: string | undefined,
actorId: string | null | undefined,
mode: MnoteTreeShellMode,
allowRootPick: boolean,
excludeIds: string[],
refreshKey: number,
reloadToken: number,
) {
const url = new URL(TREE_SHELL_PATH, `${baseUrl}/`);
if (workspaceId) {
url.searchParams.set("workspaceId", workspaceId);
}
if (activeDocumentId) {
url.searchParams.set("activeDocumentId", activeDocumentId);
}
if (actorId && actorId.trim()) {
url.searchParams.set("actorId", actorId.trim());
}
url.searchParams.set("mode", mode);
if (mode === "picker") {
url.searchParams.set("allowRootPick", allowRootPick ? "1" : "0");
if (excludeIds.length > 0) {
url.searchParams.set("excludeIds", excludeIds.join(","));
}
}
url.searchParams.set("host", "wolai-frontend");
url.searchParams.set("channel", TREE_SHELL_CHANNEL);
url.searchParams.set("v", `${refreshKey}-${reloadToken}`);
return url.toString();
}
function extractDocumentId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.documentId === "string" ? message.documentId.trim() : "";
if (direct) return direct;
const fromTarget =
message.target && typeof message.target.documentId === "string"
? message.target.documentId.trim()
: "";
if (fromTarget) return fromTarget;
const fromPayload =
message.payload && typeof message.payload.documentId === "string"
? message.payload.documentId.trim()
: "";
return fromPayload;
}
function extractAssetId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.assetId === "string" ? message.assetId.trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload.assetId === "string"
? message.payload.assetId.trim()
: "";
return fromPayload;
}
function extractCoordinate(
message: Exclude<TreeShellMessage, null | undefined>,
axis: "x" | "y",
) {
const direct = typeof message[axis] === "number" ? message[axis] : null;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
const fromPayload =
message.payload && typeof message.payload[axis] === "number"
? message.payload[axis]
: null;
if (typeof fromPayload === "number" && Number.isFinite(fromPayload)) return fromPayload;
return null;
}
function extractTextField(
message: Exclude<TreeShellMessage, null | undefined>,
field: "rowId" | "rowKind",
) {
const direct = typeof message[field] === "string" ? message[field].trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload[field] === "string"
? message.payload[field].trim()
: "";
return fromPayload;
}
export function MnoteWebTreeShell({
workspaceId,
activeDocumentId,
actorId,
mode = "page",
allowRootPick = false,
excludeIds = [],
reloadToken = 0,
onNavigate,
onPick,
onOpenAsset,
onOpenContextMenu,
onOpenFileTreeContextMenu,
onRefresh,
fallback,
}: MnoteWebTreeShellProps) {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const treeShellEnabled = runtime.mnoteWebTreeShellEnabled === true;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const [authCookieReady, setAuthCookieReady] = useState(false);
const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null;
return buildShellUrl(
baseUrl,
workspaceId,
activeDocumentId,
actorId,
mode,
allowRootPick,
excludeIds,
refreshKey,
reloadToken,
);
}, [
activeDocumentId,
actorId,
allowRootPick,
baseUrl,
excludeIds,
mode,
reloadToken,
refreshKey,
treeShellEnabled,
workspaceId,
]);
useEffect(() => {
if (!shellUrl) {
setAuthCookieReady(false);
return;
}
let cancelled = false;
setAuthCookieReady(false);
setFailedShellUrl(null);
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (!cancelled) {
setAuthCookieReady(true);
}
} catch {
if (!cancelled) {
setFailedShellUrl(shellUrl);
}
}
})();
return () => {
cancelled = true;
};
}, [shellUrl]);
useEffect(() => {
if (!shellUrl || !authCookieReady) return;
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
}
// 说明:当前 shell 仍处于渐进接入期,若路由不存在或页面未按协议 ready,
// 前端应自动回退到旧 React 树,而不是让用户看到空白 iframe。
readyTimerRef.current = window.setTimeout(() => {
setFailedShellUrl(shellUrl);
}, 2500);
return () => {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
};
}, [authCookieReady, shellUrl]);
useEffect(() => {
if (!baseUrl || !treeShellEnabled) return;
const expectedOrigin = (() => {
try {
return new URL(baseUrl).origin;
} catch {
return "";
}
})();
const onMessage = (event: MessageEvent<TreeShellMessage>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.channel !== TREE_SHELL_CHANNEL) return;
const type = typeof message.type === "string" ? message.type.trim() : "";
if (!type) return;
if (type === "tree.ready" || type === "ready") {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
return;
}
if (type === "tree.navigate" || type === "navigate") {
const documentId = extractDocumentId(message);
if (documentId) {
onNavigate(documentId);
}
return;
}
if (type === "tree.pick" || type === "picker.pick") {
onPick?.(extractDocumentId(message) || null);
return;
}
if (type === "tree.pick.root" || type === "picker.pick.root") {
onPick?.(null);
return;
}
if (type === "tree.asset.open" || type === "filetree.asset.open") {
const assetId = extractAssetId(message);
if (assetId) {
onOpenAsset?.(assetId);
}
return;
}
if (type === "tree.context-menu" || type === "tree.page.context-menu") {
const documentId = extractDocumentId(message);
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (!documentId || x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenContextMenu?.({
documentId,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (type === "tree.filetree.context-menu") {
const documentId = extractDocumentId(message) || undefined;
const assetId = extractAssetId(message) || undefined;
const rowId = extractTextField(message, "rowId") || undefined;
const rowKind = extractTextField(message, "rowKind") || undefined;
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenFileTreeContextMenu?.({
documentId,
assetId,
rowId,
rowKind,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (
type === "tree.node.created" ||
type === "tree.node.renamed" ||
type === "tree.subtree.moved" ||
type === "tree.refresh" ||
type === "refresh"
) {
void onRefresh().finally(() => {
setRefreshKey((value) => value + 1);
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) {
return <>{fallback}</>;
}
return (
<div className="h-full w-full min-w-0 overflow-hidden bg-white">
<iframe
ref={iframeRef}
title="mnote-web tree shell"
src={shellUrl}
className="h-full w-full border-0 bg-white"
loading="lazy"
onError={() => {
setFailedShellUrl(shellUrl);
}}
/>
</div>
);
}
@@ -0,0 +1,42 @@
export type DocumentAiToolRegistryItem = {
name: string;
title: string;
description: string;
scope: "document" | "tree" | "workspace";
mode: "read" | "write";
status: string;
version?: string;
};
export type DocumentAiProfileConfigItem = {
id: string;
title: string;
description: string;
sessionMode: "off" | "page" | "workspace";
toolNames: string[];
status: string;
default?: boolean;
};
export type DocumentAiModelConfigItem = {
key: string;
title: string;
description: string;
gatewayModel: string;
resolvedCombo?: string | null;
resolvedRuntimeModel?: string | null;
status: string;
default?: boolean;
};
export type DocumentAiCapabilityConfig = {
provider: "online";
transport: string;
baseUrl: string;
sessionEnabled: boolean;
defaultModelKey: string;
defaultProfileId: string;
models: DocumentAiModelConfigItem[];
profiles: DocumentAiProfileConfigItem[];
tools: DocumentAiToolRegistryItem[];
};
@@ -134,6 +134,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"page.head.updateTitle": "documents:updateTitle",
"page.layout.updateOptions": "documents:updateOptions",
"page.body.save": "documents:updateContent",
"mindmaps.delete": "mindmaps:softDelete",
"mindmaps.restore": "mindmaps:restore",
"mindmaps.purge": "mindmaps:purge",
@@ -84,6 +84,13 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
title: payload.title,
}),
},
"page.head.updateTitle": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"documents.stats.update": {
convexMutation: api.documents.updateStats,
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
@@ -99,6 +106,10 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.layout.updateOptions": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
};
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
@@ -115,7 +126,10 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
}): Promise<MetadataCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
try {
if (input.envelope.name === "documents.title.update") {
if (
input.envelope.name === "documents.title.update" ||
input.envelope.name === "page.head.updateTitle"
) {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregateFromDocumentPayloads } from "@/lib/documents/page-aggregate-builder";
describe("page-aggregate-builder", () => {
it("会把 documents meta 与 content 收口为统一 page aggregate", () => {
const aggregate = buildPageAggregateFromDocumentPayloads({
meta: {
id: "doc_1",
workspace_id: "ws_1",
title: " 页面标题 ",
updated_at: "2026-04-22T12:00:00.000Z",
can_edit: false,
disable_download: true,
disable_copy: false,
wide_layout: true,
use_small_text: true,
show_heading_numbers: false,
show_toc: true,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: true,
page_font: "default",
layout_density: "compact",
hide_child_pages: true,
show_block_ref_count: true,
embed_default_block_id: "block_1",
word_count: 20,
character_count: 40,
block_count: 2,
todo_total: 3,
todo_done: 1,
},
contentPayload: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflict_detection_key: "doc_1:8",
page_subtree: null,
},
});
expect(aggregate.identity).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
});
expect(aggregate.head).toEqual({
title: "页面标题",
updatedAt: "2026-04-22T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
});
expect(aggregate.layout.pageOptions).toMatchObject({
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
collapseBacklinks: true,
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block_1",
});
expect(aggregate.body).toEqual({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflictDetectionKey: "doc_1:8",
});
expect(aggregate.stats).toEqual({
wordCount: 20,
characterCount: 40,
blockCount: 2,
todoTotal: 3,
todoDone: 1,
});
});
});
@@ -0,0 +1,100 @@
import { buildPageAggregate, type PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type {
DocumentStats,
PageFont,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type DocumentMetaPayload = {
id: string;
workspace_id: string;
title: string | null;
updated_at: string | null;
can_edit?: boolean | null;
disable_download?: boolean | null;
disable_copy?: boolean | null;
wide_layout?: boolean | null;
use_small_text?: boolean | null;
show_heading_numbers?: boolean | null;
show_toc?: boolean | null;
show_structure?: boolean | null;
protect_editing?: boolean | null;
show_word_count?: boolean | null;
collapse_backlinks?: boolean | null;
page_font?: PageFont | null;
layout_density?: PageLayoutDensity | null;
hide_child_pages?: boolean | null;
show_block_ref_count?: boolean | null;
embed_default_block_id?: string | null;
word_count?: number | null;
character_count?: number | null;
block_count?: number | null;
todo_total?: number | null;
todo_total_count?: number | null;
todo_done?: number | null;
todo_done_count?: number | null;
};
export type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
conflictDetectionKey?: string | null;
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
};
export function buildPageAggregateFromDocumentPayloads(input: {
meta: DocumentMetaPayload;
contentPayload?: DocumentContentPayload | null;
}): PageAggregateProjection {
const normalizedContent = normalizeDocumentContentResponse({
documentId: input.meta.id,
title: input.meta.title ?? "无标题",
payload: input.contentPayload,
});
const pageOptions: PageOptionsState = {
wideLayout: input.meta.wide_layout ?? false,
smallText: input.meta.use_small_text ?? false,
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
showToc: input.meta.show_toc ?? false,
showStructure: input.meta.show_structure ?? false,
protectEditing: input.meta.protect_editing ?? false,
showWordCount: input.meta.show_word_count ?? true,
collapseBacklinks: input.meta.collapse_backlinks ?? false,
pageFont: input.meta.page_font ?? "default",
layoutDensity: input.meta.layout_density ?? "normal",
hideChildPages: input.meta.hide_child_pages ?? false,
showBlockRefCount: input.meta.show_block_ref_count ?? false,
embedDefaultBlockId: input.meta.embed_default_block_id ?? null,
};
const stats: DocumentStats = {
wordCount: input.meta.word_count ?? 0,
characterCount: input.meta.character_count ?? 0,
blockCount: input.meta.block_count ?? 0,
todoTotal: input.meta.todo_total ?? input.meta.todo_total_count ?? 0,
todoDone: input.meta.todo_done ?? input.meta.todo_done_count ?? 0,
};
return buildPageAggregate({
documentId: input.meta.id,
workspaceId: input.meta.workspace_id,
title: input.meta.title ?? "无标题",
updatedAt: input.meta.updated_at,
readOnly: input.meta.can_edit === false,
disableDownload: Boolean(input.meta.disable_download),
disableCopy: Boolean(input.meta.disable_copy),
pageOptions,
content: normalizedContent.content,
revision: normalizedContent.revision,
conflictDetectionKey: normalizedContent.conflictDetectionKey,
pageSubtree: normalizedContent.pageSubtree,
stats,
});
}
@@ -0,0 +1,132 @@
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 type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import {
buildPageAggregateFromDocumentPayloads,
type DocumentContentPayload,
type DocumentMetaPayload,
} from "@/lib/documents/page-aggregate-builder";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const FORWARDED_REQUEST_HEADERS = [
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
] as const;
export type LoadedPageAggregate = {
page: PageAggregateProjection;
bridge: {
requestId: string;
traceId: string;
queryName: "documents.page.get";
};
};
function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
const headerList = await headers();
const requestHeaders = new Headers();
FORWARDED_REQUEST_HEADERS.forEach((name) => {
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
});
return new Request(`http://mnote.local${pathname}`, {
method: "GET",
headers: requestHeaders,
});
}
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 { 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",
},
};
}
export async function loadPageAggregateFromNextHeaders(input: {
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const request = await buildServerBridgeRequest("/documents/page");
return loadPageAggregate({
request,
documentId: input.documentId,
workspaceId: input.workspaceId,
});
}
@@ -1,76 +1,7 @@
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ApplyPageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function persistPageBodyViaRoute(
payload: DocumentSavePayload,
fetchImpl: typeof fetch,
): Promise<PageBodyPersistedMeta> {
const response = await fetchImpl("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| {
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string;
}
| null;
if (!response.ok) {
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
throw new Error(message);
}
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
export {
executePageBodyCommand as applyPageBodyCommand,
executePageBodySavePayload,
type ExecutePageBodyCommandInput as ApplyPageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-command-client";
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
executePageBodyCommand,
executePageBodySavePayload,
executePageHeadCommand,
executePageLayoutCommand,
} from "./page-command-client";
import { buildDocumentSavePayload } from "./save-contract";
describe("page-command-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("标题命令应走统一 page head command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.head.updateTitle" } }),
} as Response);
await executePageHeadCommand({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/title",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
commandName: "page.head.updateTitle",
});
});
it("页面设置命令应走统一 page layout command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.layout.updateOptions" } }),
} as Response);
await executePageLayoutCommand({
documentId: "doc-1",
workspaceId: "ws-1",
pageOptions: { wideLayout: true },
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/options",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
options: { wideLayout: true },
commandName: "page.layout.updateOptions",
});
});
it("正文保存 payload 应走统一 page body command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }),
} as Response);
const payload = buildDocumentSavePayload({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
content: [{ id: "block_1", type: "paragraph", content: "正文" }] as Json,
blockCount: 1,
});
await expect(executePageBodySavePayload(payload)).resolves.toEqual({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/save",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
});
it("高层正文命令应在保存成功后再回显快照并回传元信息", async () => {
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 9, conflictDetectionKey: "doc-1:9" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const blocks = [{ id: "block_1", type: "paragraph", content: "统一正文" }] as Json;
await expect(
executePageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 8,
conflictDetectionKey: "doc-1:8",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).resolves.toEqual({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
});
});
@@ -0,0 +1,152 @@
import type { PageLayoutCommandInput, PageTitleCommandInput } from "@/lib/documents/page-command-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type PageCommandMeta = {
requestId?: string;
traceId?: string;
commandId?: string;
commandName?: string;
};
type PageCommandErrorPayload = {
error?: string;
};
export type PageHeadCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageLayoutCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ExecutePageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function postPageCommand<TResult>(
path: string,
payload: unknown,
fallbackMessage: string,
fetchImpl: typeof fetch = fetch,
): Promise<TResult> {
const response = await fetchImpl(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as TResult | PageCommandErrorPayload | null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
export async function executePageHeadCommand(
input: PageTitleCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageHeadCommandResult> {
return postPageCommand<PageHeadCommandResult>(
"/api/documents/title",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
title: input.title,
commandName: PAGE_COMMAND_NAMES.updateTitle,
},
"重命名失败,请稍后再试",
fetchImpl,
);
}
export async function executePageLayoutCommand(
input: PageLayoutCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageLayoutCommandResult> {
return postPageCommand<PageLayoutCommandResult>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: input.pageOptions,
commandName: PAGE_COMMAND_NAMES.updateLayout,
},
"更新页面选项失败,请稍后再试",
fetchImpl,
);
}
export async function executePageBodySavePayload(
payload: DocumentSavePayload,
fetchImpl: typeof fetch = fetch,
): Promise<PageBodyPersistedMeta> {
const body = await postPageCommand<{
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
}>(
"/api/documents/save",
payload,
"页面正文保存失败",
fetchImpl,
);
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function executePageBodyCommand(
input: ExecutePageBodyCommandInput,
): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await executePageBodySavePayload(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { PageOptionsState } from "@/types/page-options";
import {
PAGE_OPTION_PANEL_GROUPS,
PAGE_OPTION_SEMANTICS,
pickLeptosTiptapRuntimePageOptions,
} from "@/lib/documents/page-option-semantics";
const options: PageOptionsState = {
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
showStructure: true,
protectEditing: true,
showWordCount: true,
collapseBacklinks: true,
pageFont: "song",
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block-anchor-1",
};
describe("page-option-semantics", () => {
it("应固定页面设置在 inspector 中的分组,而不是让分组散落在 UI 文件里", () => {
expect(PAGE_OPTION_PANEL_GROUPS.page).toEqual([
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
]);
expect(PAGE_OPTION_PANEL_GROUPS.custom).toEqual([
"collapseBacklinks",
"hideChildPages",
"showBlockRefCount",
]);
});
it("应明确 page options 的运行时语义归属", () => {
expect(PAGE_OPTION_SEMANTICS.wideLayout.surfaces).toEqual([
"page_shell_layout",
"editor_runtime",
]);
expect(PAGE_OPTION_SEMANTICS.showToc.surfaces).toEqual(["read_view"]);
expect(PAGE_OPTION_SEMANTICS.protectEditing.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.showBlockRefCount.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
});
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
});
});
});
@@ -0,0 +1,111 @@
import type {
BooleanPageOptionKey,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type PageOptionSurface =
| "page_shell_layout"
| "read_view"
| "editor_runtime"
| "inspector_only";
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
export type PageOptionSemanticDescriptor = {
surfaces: PageOptionSurface[];
runtimeSupport: PageOptionRuntimeSupport;
};
export const PAGE_OPTION_PANEL_GROUPS: {
page: BooleanPageOptionKey[];
custom: BooleanPageOptionKey[];
} = {
page: [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
],
custom: ["collapseBacklinks", "hideChildPages", "showBlockRefCount"],
};
export const PAGE_OPTION_SEMANTICS: Record<
BooleanPageOptionKey | "pageFont" | "layoutDensity" | "showStructure" | "embedDefaultBlockId",
PageOptionSemanticDescriptor
> = {
wideLayout: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
smallText: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
showHeadingNumbers: {
surfaces: ["read_view", "editor_runtime"],
runtimeSupport: "wired",
},
showToc: {
surfaces: ["read_view"],
runtimeSupport: "wired",
},
showStructure: {
surfaces: ["read_view"],
runtimeSupport: "ui_only",
},
protectEditing: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "planned",
},
showWordCount: {
surfaces: ["inspector_only"],
runtimeSupport: "wired",
},
collapseBacklinks: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
pageFont: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
layoutDensity: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
hideChildPages: {
surfaces: ["page_shell_layout", "read_view"],
runtimeSupport: "wired",
},
showBlockRefCount: {
surfaces: ["editor_runtime", "inspector_only"],
runtimeSupport: "planned",
},
embedDefaultBlockId: {
surfaces: ["editor_runtime"],
runtimeSupport: "wired",
},
};
export type LeptosTiptapRuntimePageOptions = {
wideLayout: boolean;
smallText: boolean;
layoutDensity: PageLayoutDensity;
showHeadingNumbers: boolean;
embedDefaultBlockId: string | null;
};
export function pickLeptosTiptapRuntimePageOptions(
pageOptions: PageOptionsState,
): LeptosTiptapRuntimePageOptions {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
import {
buildDocumentCommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
const mockContext: BridgeContext = {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
};
describe("page-write-command-adapter", () => {
it("标题命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.head.updateTitle",
commandId: "cmd_title_1",
functionName: "documents:updateTitle",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
title: "新标题",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.head.updateTitle",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.head.updateTitle",
}),
});
expect(result.commandName).toBe("page.head.updateTitle");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("页面设置命令应走 bridge mutation request", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation } as unknown as ConvexHttpClient,
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.layout.updateOptions",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(result.commandName).toBe("page.layout.updateOptions");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("正文保存命令应返回 revision 与 conflictDetectionKey", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.body.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "conflict_2",
});
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.body.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(result.commandName).toBe("page.body.save");
expect(result.revision).toBe(8);
expect(result.conflictDetectionKey).toBe("conflict_2");
});
});
@@ -0,0 +1,180 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type BridgeContext,
type CommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type PageWritePayload =
| DocumentTitleUpdatePayload
| DocumentOptionsUpdatePayload
| DocumentSavePayload;
type MetadataMutationArgs = Record<string, unknown>;
type PageWriteAdapter<TPayload> = {
kind: "rust_transport" | "convex_mutation";
convexMutation?: unknown;
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
};
export type PageWriteCommandExecutionResult = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
revision: number | null;
conflictDetectionKey: string | null;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
"page.head.updateTitle": {
kind: "rust_transport",
},
"page.layout.updateOptions": {
kind: "convex_mutation",
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.body.save": {
kind: "rust_transport",
},
};
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
const adapter = pageWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面写命令适配器: ${commandName}`);
}
return adapter as PageWriteAdapter<TPayload>;
}
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
return {
revision:
typeof record?.revision === "number" && Number.isInteger(record.revision)
? record.revision
: null,
conflictDetectionKey:
typeof record?.conflict_detection_key === "string" && record.conflict_detection_key.trim()
? record.conflict_detection_key.trim()
: null,
};
}
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<PageWriteCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
try {
if (adapter.kind === "rust_transport") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
}
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs!,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation!,
request: mutationRequest,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: null,
conflictDetectionKey: null,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
if (
input.envelope.name === "page.body.save" &&
error instanceof Error &&
/正文(内容已变更|冲突检测失败)/.test(error.message)
) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",
revision: (input.envelope.payload as DocumentSavePayload).revision,
conflictDetectionKey: (input.envelope.payload as DocumentSavePayload).conflictDetectionKey,
});
}
throw error;
}
}
@@ -0,0 +1,12 @@
export function shouldUseBuiltBridgeRuntimeBinary(input: {
builtBinaryMtimeMs: number | null;
latestSourceMtimeMs: number | null;
}): boolean {
if (typeof input.builtBinaryMtimeMs !== "number" || !Number.isFinite(input.builtBinaryMtimeMs)) {
return false;
}
if (typeof input.latestSourceMtimeMs !== "number" || !Number.isFinite(input.latestSourceMtimeMs)) {
return true;
}
return input.builtBinaryMtimeMs >= input.latestSourceMtimeMs;
}
@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
let runtimeSelection: Record<string, unknown> = {};
beforeAll(async () => {
try {
runtimeSelection = (await import("@/lib/documents/rust-runtime-selection")) as Record<string, unknown>;
} catch {
runtimeSelection = {};
}
});
describe("shouldUseBuiltBridgeRuntimeBinary", () => {
it("当源码比已编译二进制更新时应放弃旧二进制", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 100,
latestSourceMtimeMs: 200,
}),
).toBe(false);
});
it("当二进制不旧于源码时仍可直接复用", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 300,
latestSourceMtimeMs: 200,
}),
).toBe(true);
});
});
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import { access, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import { api } from "@/lib/convex/api";
@@ -11,6 +11,7 @@ import {
type CommandEnvelope,
type QueryEnvelope,
} from "@/lib/documents/bridge";
import { shouldUseBuiltBridgeRuntimeBinary } from "@/lib/documents/rust-runtime-selection";
export type RustRuntimeExecutedQuery<TResult = unknown> = {
ok: true;
@@ -140,6 +141,68 @@ async function resolveRepoRoot() {
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
}
async function readLatestMtimeMs(targetPath: string): Promise<number | null> {
try {
const stats = await stat(targetPath);
if (stats.isFile()) {
return stats.mtimeMs;
}
if (!stats.isDirectory()) {
return null;
}
const entries = await readdir(targetPath, { withFileTypes: true });
const nestedTimes = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith("."))
.map((entry) => readLatestMtimeMs(path.join(targetPath, entry.name))),
);
const latestChild = nestedTimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
return latestChild == null ? stats.mtimeMs : Math.max(stats.mtimeMs, latestChild);
} catch {
return null;
}
}
async function readRuntimeSourceLatestMtimeMs(repoRoot: string) {
const sourceRoots = [
path.join(repoRoot, "rust", "Cargo.toml"),
path.join(repoRoot, "rust", "Cargo.lock"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "src"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "src"),
path.join(repoRoot, "rust", "crates", "core-protocol", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "core-protocol", "src"),
];
const mtimes = await Promise.all(sourceRoots.map((targetPath) => readLatestMtimeMs(targetPath)));
return mtimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
}
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
if (explicitBin) {
@@ -152,10 +215,22 @@ async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const repoRoot = await resolveRepoRoot();
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
if (await pathExists(builtBinary)) {
return {
command: builtBinary,
args: [],
};
const [builtBinaryMtimeMs, latestSourceMtimeMs] = await Promise.all([
readLatestMtimeMs(builtBinary),
readRuntimeSourceLatestMtimeMs(repoRoot),
]);
if (
shouldUseBuiltBridgeRuntimeBinary({
builtBinaryMtimeMs,
latestSourceMtimeMs,
})
) {
return {
command: builtBinary,
args: [],
};
}
}
return {
@@ -4,6 +4,12 @@ import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageHeadCommandResult,
type PageLayoutCommandResult,
} from "@/lib/documents/page-command-client";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = {
@@ -170,23 +176,19 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
): Promise<PageHeadCommandResult> {
return executePageHeadCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
): Promise<PageLayoutCommandResult> {
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: pageOptions,
},
"更新页面选项失败,请稍后再试",
);
return executePageLayoutCommand({
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
pageOptions,
});
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
-12
View File
@@ -1,12 +0,0 @@
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
export async function ensureMnoteWebAuthCookie(): Promise<void> {
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error("mnote-web 鉴权 cookie 准备失败");
}
}
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getMnotePublicRuntimeConfig,
getMnoteRuntimeConfig,
} from "@/lib/runtime-config";
describe("runtime-config public projection", () => {
it("不再暴露 legacy mnote-web runtime 字段", () => {
const runtime = getMnotePublicRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
delete process.env.MNOTE_WEB_BASE_URL;
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
});
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED = "1";
process.env.MNOTE_WEB_TREE_SHELL_ENABLED = "1";
const runtime = getMnoteRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
});
+10 -38
View File
@@ -22,16 +22,6 @@ export type MnoteRuntimeConfig = {
onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string;
/**
* Rust Web tree shell
* SSR
*/
mnoteWebBaseUrl?: string;
/**
* Rust Web tree shell
* mnoteWebBaseUrl
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* host
* leptos_tiptap_island
@@ -60,6 +50,8 @@ export type MnoteRuntimeConfig = {
onlyofficeCallbackOrigin?: string;
};
export type MnotePublicRuntimeConfig = MnoteRuntimeConfig;
declare global {
interface Window {
__MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
@@ -153,24 +145,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,
...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined
? {
mnoteWebBaseUrl:
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
process.env.MNOTE_WEB_BASE_URL,
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
) !== undefined
? {
mnoteWebTreeShellEnabled: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
),
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
@@ -278,9 +252,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
@@ -289,8 +260,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl,
@@ -302,7 +271,10 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
// 说明:浏览器侧禁止再兜底读取 NEXT_PUBLIC_MNOTE_WEB_BASE_URL / MNOTE_WEB_BASE_URL
// 否则会把 internal-only 的 mnote-web 边界重新泄漏回客户端。
// 客户端只信任服务端注入的 public runtime 配置。
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? {});
}
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const publicRuntime = readFromPublicJson();
@@ -319,10 +291,10 @@ export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
: {
...publicRuntime,
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
mnoteWebTreeShellEnabled:
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
};
return normalizeRuntimeConfig(merged);
}
export function getMnotePublicRuntimeConfig(): MnotePublicRuntimeConfig {
return getMnoteRuntimeConfig();
}
@@ -0,0 +1,133 @@
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageOptionsState } from "@/types/page-options";
type AgentMessage = { role: "user" | "assistant"; content: string };
type RequestPayload = {
maxSteps?: number;
messages: AgentMessage[];
context?: {
documentId?: string;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
};
options?: {
ai?: {
model?: string;
modelKey?: string;
profileId?: string;
sessionId?: string;
};
};
};
function resolveBackendUrl(): string | null {
const cfg = getMnoteRuntimeConfig();
const backendUrl =
process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
return backendUrl?.trim().replace(/\/+$/, "") || null;
}
function buildErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "detail" in payload) {
return String((payload as Record<string, unknown>).detail ?? fallback);
}
if (payload && typeof payload === "object" && "message" in payload) {
return String((payload as Record<string, unknown>).message ?? fallback);
}
if (payload && typeof payload === "object" && "error" in payload) {
return String((payload as Record<string, unknown>).error ?? fallback);
}
return fallback;
}
export async function startDocumentAiOrchestratorRun(input: {
request?: Request;
userId: string;
payload: RequestPayload;
}): Promise<Response> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/run`, {
method: "POST",
headers,
body: JSON.stringify({
userId: input.userId,
sessionId: String(input.payload.options?.ai?.sessionId ?? "").trim() || null,
model: String(input.payload.options?.ai?.model ?? "").trim() || null,
modelKey: String(input.payload.options?.ai?.modelKey ?? "").trim() || null,
profileId: String(input.payload.options?.ai?.profileId ?? "").trim() || null,
maxSteps: input.payload.maxSteps,
messages: input.payload.messages,
context: {
documentId: input.payload.context?.documentId ?? null,
documentBlocks: input.payload.context?.documentBlocks ?? null,
node: input.payload.context?.node ?? null,
subtree: input.payload.context?.subtree ?? null,
outline: input.payload.context?.outline ?? null,
evidence: input.payload.context?.evidence ?? null,
pageOptions: input.payload.context?.pageOptions ?? null,
editorRuntimePageOptions: input.payload.context?.pageOptions
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
: null,
},
}),
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator 请求失败:HTTP ${response.status}`));
}
if (!response.body) {
throw new Error("AI orchestrator 未返回事件流");
}
return response;
}
export async function fetchDocumentAiOrchestratorConfig(input?: {
request?: Request;
}): Promise<DocumentAiCapabilityConfig> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input?.request);
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/config`, {
method: "GET",
headers,
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator config 请求失败:HTTP ${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
}
@@ -0,0 +1,55 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
@@ -1,79 +0,0 @@
import { buildHermesRuntimeToolResultRequest } from "@/lib/ai-agent/hermes/tool-result-recovery";
import { buildMnoteWebForwardHeaders, getMnoteWebBaseUrl } from "@/lib/server/mnote-web";
type PlainObject = Record<string, unknown>;
function readErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") {
return payload.error;
}
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export async function fetchHermesStructuredToolResultFromMnoteWeb(input: {
request?: Request;
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): Promise<unknown> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const headers = await buildMnoteWebForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const response = await fetch(new URL("/api/hermes/bridge", `${baseUrl}/`).toString(), {
method: "POST",
headers,
body: JSON.stringify(
buildHermesRuntimeToolResultRequest({
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data: input.data,
requestId: input.requestId,
traceId: input.traceId,
workspaceId: input.workspaceId,
target: input.target,
reason: input.reason,
refs: input.refs,
}),
),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
ok?: boolean;
result?: unknown;
error?: string;
message?: string;
}
| null;
if (!response.ok) {
throw new Error(readErrorMessage(payload, "mnote-web Hermes runtime 请求失败"));
}
if (!payload || !("result" in payload)) {
throw new Error("mnote-web Hermes runtime 未返回 result");
}
return payload.result;
}
-143
View File
@@ -1,143 +0,0 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = {
ok?: boolean;
requestId?: string;
traceId?: string;
workspaceId?: string;
result?: SidebarDatasetListQueryResult;
};
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
export function getMnoteWebBaseUrl(): string | null {
const runtime = getMnoteRuntimeConfig();
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
return baseUrl || null;
}
export async function fetchSidebarDatasetFromMnoteWeb(input: {
workspaceId: string;
request?: Request;
}): Promise<{
dataset: SidebarDatasetListQueryResult;
meta: {
requestId: string | null;
traceId: string | null;
workspaceId: string;
};
}> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
method: "GET",
headers: forwardedHeaders,
cache: "no-store",
});
const payload = (await response
.json()
.catch(() => null)) as MnoteWebSidebarCompatResponse | null;
if (!response.ok) {
const message =
payload && typeof (payload as Record<string, unknown>).message === "string"
? String((payload as Record<string, unknown>).message)
: "mnote-web 侧边栏兼容接口请求失败";
throw new Error(message);
}
if (!payload?.result) {
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
}
return {
dataset: payload.result,
meta: {
requestId: payload.requestId ?? null,
traceId: payload.traceId ?? null,
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
},
};
}
export function buildMnoteWebStreamUrl(input: {
workspaceId: string;
cursor?: string | null;
}): URL {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/stream/events", `${baseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
url.searchParams.set("workspaceId", input.workspaceId.trim());
if (typeof input.cursor === "string" && input.cursor.trim()) {
url.searchParams.set("cursor", input.cursor.trim());
}
return url;
}
+3 -16
View File
@@ -64,25 +64,12 @@ function looksLikeSidebarDatasetListQueryResult(
}
export function buildWorkspaceTreeStreamUrl(
baseUrl: string,
workspaceId: string,
cursor?: string | null,
): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const shouldUseSameOriginProxy =
normalizedBaseUrl.length > 0 &&
typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
const baseOrigin =
typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
const url = new URL("/api/mnote-web/stream", baseOrigin);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -19,13 +19,13 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
it("固定走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
@@ -34,7 +34,7 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
buildWorkspaceTreeStreamUrl("ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
@@ -4,16 +4,6 @@ import { createRoot, type Root } from "react-dom/client";
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockRuntimeConfig = vi.hoisted(() => ({
getMnoteRuntimeConfig: vi.fn(),
}));
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
vi.mock("@/lib/mnote-web-auth", () => ({
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
}));
type MockEventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
@@ -114,8 +104,12 @@ describe("useSidebarTreeStream", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
mnoteWebBaseUrl: "http://127.0.0.1:3104",
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
@@ -2,14 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
parseTreeStreamMessage,
} from "@/lib/tree-stream/protocol";
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
export interface SidebarTreeStreamState {
data: SidebarInitialData | null;
@@ -37,10 +35,8 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
}
export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTreeStreamState {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const streamEnabled = Boolean(workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
@@ -133,32 +129,18 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
}
};
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (cancelled) {
return;
}
if (cancelled) {
return undefined;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
} catch {
if (cancelled) {
return;
}
setState((previous) => ({
...previous,
status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 鉴权失败"),
}));
}
})();
const url = buildWorkspaceTreeStreamUrl(workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
return () => {
cancelled = true;
@@ -170,7 +152,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, streamEnabled, workspaceId]);
}, [streamEnabled, workspaceId]);
return state;
}