feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -4,8 +4,6 @@
|
||||
"supabaseInternalUrl": "http://127.0.0.1:18000",
|
||||
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
|
||||
"backendUrl": "https://frp-dry.com:44399",
|
||||
"mnoteWebBaseUrl": "http://127.0.0.1:3104",
|
||||
"mnoteWebTreeShellEnabled": false,
|
||||
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
|
||||
"onlyofficeBaseUrlDesktop": "http://localhost:8082",
|
||||
"onlyofficeProxyOrigin": "http://host.docker.internal:3000",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 固定走 OmniRoute。Base 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 }> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user