feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
@@ -0,0 +1,42 @@
export type DocumentAiToolRegistryItem = {
name: string;
title: string;
description: string;
scope: "document" | "tree" | "workspace";
mode: "read" | "write";
status: string;
version?: string;
};
export type DocumentAiProfileConfigItem = {
id: string;
title: string;
description: string;
sessionMode: "off" | "page" | "workspace";
toolNames: string[];
status: string;
default?: boolean;
};
export type DocumentAiModelConfigItem = {
key: string;
title: string;
description: string;
gatewayModel: string;
resolvedCombo?: string | null;
resolvedRuntimeModel?: string | null;
status: string;
default?: boolean;
};
export type DocumentAiCapabilityConfig = {
provider: "online";
transport: string;
baseUrl: string;
sessionEnabled: boolean;
defaultModelKey: string;
defaultProfileId: string;
models: DocumentAiModelConfigItem[];
profiles: DocumentAiProfileConfigItem[];
tools: DocumentAiToolRegistryItem[];
};
@@ -134,6 +134,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"page.head.updateTitle": "documents:updateTitle",
"page.layout.updateOptions": "documents:updateOptions",
"page.body.save": "documents:updateContent",
"mindmaps.delete": "mindmaps:softDelete",
"mindmaps.restore": "mindmaps:restore",
"mindmaps.purge": "mindmaps:purge",
@@ -84,6 +84,13 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
title: payload.title,
}),
},
"page.head.updateTitle": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"documents.stats.update": {
convexMutation: api.documents.updateStats,
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
@@ -99,6 +106,10 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.layout.updateOptions": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
};
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
@@ -115,7 +126,10 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
}): Promise<MetadataCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
try {
if (input.envelope.name === "documents.title.update") {
if (
input.envelope.name === "documents.title.update" ||
input.envelope.name === "page.head.updateTitle"
) {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregateFromDocumentPayloads } from "@/lib/documents/page-aggregate-builder";
describe("page-aggregate-builder", () => {
it("会把 documents meta 与 content 收口为统一 page aggregate", () => {
const aggregate = buildPageAggregateFromDocumentPayloads({
meta: {
id: "doc_1",
workspace_id: "ws_1",
title: " 页面标题 ",
updated_at: "2026-04-22T12:00:00.000Z",
can_edit: false,
disable_download: true,
disable_copy: false,
wide_layout: true,
use_small_text: true,
show_heading_numbers: false,
show_toc: true,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: true,
page_font: "default",
layout_density: "compact",
hide_child_pages: true,
show_block_ref_count: true,
embed_default_block_id: "block_1",
word_count: 20,
character_count: 40,
block_count: 2,
todo_total: 3,
todo_done: 1,
},
contentPayload: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflict_detection_key: "doc_1:8",
page_subtree: null,
},
});
expect(aggregate.identity).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
});
expect(aggregate.head).toEqual({
title: "页面标题",
updatedAt: "2026-04-22T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
});
expect(aggregate.layout.pageOptions).toMatchObject({
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
collapseBacklinks: true,
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block_1",
});
expect(aggregate.body).toEqual({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflictDetectionKey: "doc_1:8",
});
expect(aggregate.stats).toEqual({
wordCount: 20,
characterCount: 40,
blockCount: 2,
todoTotal: 3,
todoDone: 1,
});
});
});
@@ -0,0 +1,100 @@
import { buildPageAggregate, type PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type {
DocumentStats,
PageFont,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type DocumentMetaPayload = {
id: string;
workspace_id: string;
title: string | null;
updated_at: string | null;
can_edit?: boolean | null;
disable_download?: boolean | null;
disable_copy?: boolean | null;
wide_layout?: boolean | null;
use_small_text?: boolean | null;
show_heading_numbers?: boolean | null;
show_toc?: boolean | null;
show_structure?: boolean | null;
protect_editing?: boolean | null;
show_word_count?: boolean | null;
collapse_backlinks?: boolean | null;
page_font?: PageFont | null;
layout_density?: PageLayoutDensity | null;
hide_child_pages?: boolean | null;
show_block_ref_count?: boolean | null;
embed_default_block_id?: string | null;
word_count?: number | null;
character_count?: number | null;
block_count?: number | null;
todo_total?: number | null;
todo_total_count?: number | null;
todo_done?: number | null;
todo_done_count?: number | null;
};
export type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
conflictDetectionKey?: string | null;
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
};
export function buildPageAggregateFromDocumentPayloads(input: {
meta: DocumentMetaPayload;
contentPayload?: DocumentContentPayload | null;
}): PageAggregateProjection {
const normalizedContent = normalizeDocumentContentResponse({
documentId: input.meta.id,
title: input.meta.title ?? "无标题",
payload: input.contentPayload,
});
const pageOptions: PageOptionsState = {
wideLayout: input.meta.wide_layout ?? false,
smallText: input.meta.use_small_text ?? false,
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
showToc: input.meta.show_toc ?? false,
showStructure: input.meta.show_structure ?? false,
protectEditing: input.meta.protect_editing ?? false,
showWordCount: input.meta.show_word_count ?? true,
collapseBacklinks: input.meta.collapse_backlinks ?? false,
pageFont: input.meta.page_font ?? "default",
layoutDensity: input.meta.layout_density ?? "normal",
hideChildPages: input.meta.hide_child_pages ?? false,
showBlockRefCount: input.meta.show_block_ref_count ?? false,
embedDefaultBlockId: input.meta.embed_default_block_id ?? null,
};
const stats: DocumentStats = {
wordCount: input.meta.word_count ?? 0,
characterCount: input.meta.character_count ?? 0,
blockCount: input.meta.block_count ?? 0,
todoTotal: input.meta.todo_total ?? input.meta.todo_total_count ?? 0,
todoDone: input.meta.todo_done ?? input.meta.todo_done_count ?? 0,
};
return buildPageAggregate({
documentId: input.meta.id,
workspaceId: input.meta.workspace_id,
title: input.meta.title ?? "无标题",
updatedAt: input.meta.updated_at,
readOnly: input.meta.can_edit === false,
disableDownload: Boolean(input.meta.disable_download),
disableCopy: Boolean(input.meta.disable_copy),
pageOptions,
content: normalizedContent.content,
revision: normalizedContent.revision,
conflictDetectionKey: normalizedContent.conflictDetectionKey,
pageSubtree: normalizedContent.pageSubtree,
stats,
});
}
@@ -0,0 +1,132 @@
import { headers } from "next/headers";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import {
buildPageAggregateFromDocumentPayloads,
type DocumentContentPayload,
type DocumentMetaPayload,
} from "@/lib/documents/page-aggregate-builder";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const FORWARDED_REQUEST_HEADERS = [
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
] as const;
export type LoadedPageAggregate = {
page: PageAggregateProjection;
bridge: {
requestId: string;
traceId: string;
queryName: "documents.page.get";
};
};
function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
const headerList = await headers();
const requestHeaders = new Headers();
FORWARDED_REQUEST_HEADERS.forEach((name) => {
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
});
return new Request(`http://mnote.local${pathname}`, {
method: "GET",
headers: requestHeaders,
});
}
async function fetchDocumentContentPayload(input: {
context: BridgeContext;
documentId: string;
workspaceId: string;
}): Promise<DocumentContentPayload | null> {
const { client } = await getAuthedConvexClient();
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: input.context,
envelope,
});
return executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
}
export async function loadPageAggregate(input: {
request: Request;
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const { client } = await getAuthedConvexClient();
const meta = await client.query(api.documents.getMeta, {
id: input.documentId,
});
if (!meta) {
return null;
}
const workspaceId = input.workspaceId?.trim() || meta.workspace_id;
const context = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const contentPayload = await fetchDocumentContentPayload({
context,
documentId: meta.id,
workspaceId,
});
return {
page: buildPageAggregateFromDocumentPayloads({
meta,
contentPayload,
}),
bridge: {
requestId: context.requestId,
traceId: context.traceId,
queryName: "documents.page.get",
},
};
}
export async function loadPageAggregateFromNextHeaders(input: {
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const request = await buildServerBridgeRequest("/documents/page");
return loadPageAggregate({
request,
documentId: input.documentId,
workspaceId: input.workspaceId,
});
}
@@ -1,76 +1,7 @@
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ApplyPageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function persistPageBodyViaRoute(
payload: DocumentSavePayload,
fetchImpl: typeof fetch,
): Promise<PageBodyPersistedMeta> {
const response = await fetchImpl("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| {
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string;
}
| null;
if (!response.ok) {
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
throw new Error(message);
}
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
export {
executePageBodyCommand as applyPageBodyCommand,
executePageBodySavePayload,
type ExecutePageBodyCommandInput as ApplyPageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-command-client";
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
executePageBodyCommand,
executePageBodySavePayload,
executePageHeadCommand,
executePageLayoutCommand,
} from "./page-command-client";
import { buildDocumentSavePayload } from "./save-contract";
describe("page-command-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("标题命令应走统一 page head command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.head.updateTitle" } }),
} as Response);
await executePageHeadCommand({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/title",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
commandName: "page.head.updateTitle",
});
});
it("页面设置命令应走统一 page layout command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.layout.updateOptions" } }),
} as Response);
await executePageLayoutCommand({
documentId: "doc-1",
workspaceId: "ws-1",
pageOptions: { wideLayout: true },
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/options",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
options: { wideLayout: true },
commandName: "page.layout.updateOptions",
});
});
it("正文保存 payload 应走统一 page body command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }),
} as Response);
const payload = buildDocumentSavePayload({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
content: [{ id: "block_1", type: "paragraph", content: "正文" }] as Json,
blockCount: 1,
});
await expect(executePageBodySavePayload(payload)).resolves.toEqual({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/save",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
});
it("高层正文命令应在保存成功后再回显快照并回传元信息", async () => {
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 9, conflictDetectionKey: "doc-1:9" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const blocks = [{ id: "block_1", type: "paragraph", content: "统一正文" }] as Json;
await expect(
executePageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 8,
conflictDetectionKey: "doc-1:8",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).resolves.toEqual({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
});
});
@@ -0,0 +1,152 @@
import type { PageLayoutCommandInput, PageTitleCommandInput } from "@/lib/documents/page-command-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type PageCommandMeta = {
requestId?: string;
traceId?: string;
commandId?: string;
commandName?: string;
};
type PageCommandErrorPayload = {
error?: string;
};
export type PageHeadCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageLayoutCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ExecutePageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function postPageCommand<TResult>(
path: string,
payload: unknown,
fallbackMessage: string,
fetchImpl: typeof fetch = fetch,
): Promise<TResult> {
const response = await fetchImpl(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as TResult | PageCommandErrorPayload | null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
export async function executePageHeadCommand(
input: PageTitleCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageHeadCommandResult> {
return postPageCommand<PageHeadCommandResult>(
"/api/documents/title",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
title: input.title,
commandName: PAGE_COMMAND_NAMES.updateTitle,
},
"重命名失败,请稍后再试",
fetchImpl,
);
}
export async function executePageLayoutCommand(
input: PageLayoutCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageLayoutCommandResult> {
return postPageCommand<PageLayoutCommandResult>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: input.pageOptions,
commandName: PAGE_COMMAND_NAMES.updateLayout,
},
"更新页面选项失败,请稍后再试",
fetchImpl,
);
}
export async function executePageBodySavePayload(
payload: DocumentSavePayload,
fetchImpl: typeof fetch = fetch,
): Promise<PageBodyPersistedMeta> {
const body = await postPageCommand<{
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
}>(
"/api/documents/save",
payload,
"页面正文保存失败",
fetchImpl,
);
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function executePageBodyCommand(
input: ExecutePageBodyCommandInput,
): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await executePageBodySavePayload(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { PageOptionsState } from "@/types/page-options";
import {
PAGE_OPTION_PANEL_GROUPS,
PAGE_OPTION_SEMANTICS,
pickLeptosTiptapRuntimePageOptions,
} from "@/lib/documents/page-option-semantics";
const options: PageOptionsState = {
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
showStructure: true,
protectEditing: true,
showWordCount: true,
collapseBacklinks: true,
pageFont: "song",
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block-anchor-1",
};
describe("page-option-semantics", () => {
it("应固定页面设置在 inspector 中的分组,而不是让分组散落在 UI 文件里", () => {
expect(PAGE_OPTION_PANEL_GROUPS.page).toEqual([
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
]);
expect(PAGE_OPTION_PANEL_GROUPS.custom).toEqual([
"collapseBacklinks",
"hideChildPages",
"showBlockRefCount",
]);
});
it("应明确 page options 的运行时语义归属", () => {
expect(PAGE_OPTION_SEMANTICS.wideLayout.surfaces).toEqual([
"page_shell_layout",
"editor_runtime",
]);
expect(PAGE_OPTION_SEMANTICS.showToc.surfaces).toEqual(["read_view"]);
expect(PAGE_OPTION_SEMANTICS.protectEditing.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.showBlockRefCount.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
});
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
});
});
});
@@ -0,0 +1,111 @@
import type {
BooleanPageOptionKey,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type PageOptionSurface =
| "page_shell_layout"
| "read_view"
| "editor_runtime"
| "inspector_only";
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
export type PageOptionSemanticDescriptor = {
surfaces: PageOptionSurface[];
runtimeSupport: PageOptionRuntimeSupport;
};
export const PAGE_OPTION_PANEL_GROUPS: {
page: BooleanPageOptionKey[];
custom: BooleanPageOptionKey[];
} = {
page: [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
],
custom: ["collapseBacklinks", "hideChildPages", "showBlockRefCount"],
};
export const PAGE_OPTION_SEMANTICS: Record<
BooleanPageOptionKey | "pageFont" | "layoutDensity" | "showStructure" | "embedDefaultBlockId",
PageOptionSemanticDescriptor
> = {
wideLayout: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
smallText: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
showHeadingNumbers: {
surfaces: ["read_view", "editor_runtime"],
runtimeSupport: "wired",
},
showToc: {
surfaces: ["read_view"],
runtimeSupport: "wired",
},
showStructure: {
surfaces: ["read_view"],
runtimeSupport: "ui_only",
},
protectEditing: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "planned",
},
showWordCount: {
surfaces: ["inspector_only"],
runtimeSupport: "wired",
},
collapseBacklinks: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
pageFont: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
layoutDensity: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
hideChildPages: {
surfaces: ["page_shell_layout", "read_view"],
runtimeSupport: "wired",
},
showBlockRefCount: {
surfaces: ["editor_runtime", "inspector_only"],
runtimeSupport: "planned",
},
embedDefaultBlockId: {
surfaces: ["editor_runtime"],
runtimeSupport: "wired",
},
};
export type LeptosTiptapRuntimePageOptions = {
wideLayout: boolean;
smallText: boolean;
layoutDensity: PageLayoutDensity;
showHeadingNumbers: boolean;
embedDefaultBlockId: string | null;
};
export function pickLeptosTiptapRuntimePageOptions(
pageOptions: PageOptionsState,
): LeptosTiptapRuntimePageOptions {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
import {
buildDocumentCommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
const mockContext: BridgeContext = {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
};
describe("page-write-command-adapter", () => {
it("标题命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.head.updateTitle",
commandId: "cmd_title_1",
functionName: "documents:updateTitle",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
title: "新标题",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.head.updateTitle",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.head.updateTitle",
}),
});
expect(result.commandName).toBe("page.head.updateTitle");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("页面设置命令应走 bridge mutation request", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation } as unknown as ConvexHttpClient,
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.layout.updateOptions",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(result.commandName).toBe("page.layout.updateOptions");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("正文保存命令应返回 revision 与 conflictDetectionKey", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.body.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "conflict_2",
});
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.body.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(result.commandName).toBe("page.body.save");
expect(result.revision).toBe(8);
expect(result.conflictDetectionKey).toBe("conflict_2");
});
});
@@ -0,0 +1,180 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type BridgeContext,
type CommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type PageWritePayload =
| DocumentTitleUpdatePayload
| DocumentOptionsUpdatePayload
| DocumentSavePayload;
type MetadataMutationArgs = Record<string, unknown>;
type PageWriteAdapter<TPayload> = {
kind: "rust_transport" | "convex_mutation";
convexMutation?: unknown;
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
};
export type PageWriteCommandExecutionResult = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
revision: number | null;
conflictDetectionKey: string | null;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
"page.head.updateTitle": {
kind: "rust_transport",
},
"page.layout.updateOptions": {
kind: "convex_mutation",
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.body.save": {
kind: "rust_transport",
},
};
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
const adapter = pageWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面写命令适配器: ${commandName}`);
}
return adapter as PageWriteAdapter<TPayload>;
}
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
return {
revision:
typeof record?.revision === "number" && Number.isInteger(record.revision)
? record.revision
: null,
conflictDetectionKey:
typeof record?.conflict_detection_key === "string" && record.conflict_detection_key.trim()
? record.conflict_detection_key.trim()
: null,
};
}
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<PageWriteCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
try {
if (adapter.kind === "rust_transport") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
}
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs!,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation!,
request: mutationRequest,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: null,
conflictDetectionKey: null,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
if (
input.envelope.name === "page.body.save" &&
error instanceof Error &&
/正文(内容已变更|冲突检测失败)/.test(error.message)
) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",
revision: (input.envelope.payload as DocumentSavePayload).revision,
conflictDetectionKey: (input.envelope.payload as DocumentSavePayload).conflictDetectionKey,
});
}
throw error;
}
}
@@ -0,0 +1,12 @@
export function shouldUseBuiltBridgeRuntimeBinary(input: {
builtBinaryMtimeMs: number | null;
latestSourceMtimeMs: number | null;
}): boolean {
if (typeof input.builtBinaryMtimeMs !== "number" || !Number.isFinite(input.builtBinaryMtimeMs)) {
return false;
}
if (typeof input.latestSourceMtimeMs !== "number" || !Number.isFinite(input.latestSourceMtimeMs)) {
return true;
}
return input.builtBinaryMtimeMs >= input.latestSourceMtimeMs;
}
@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
let runtimeSelection: Record<string, unknown> = {};
beforeAll(async () => {
try {
runtimeSelection = (await import("@/lib/documents/rust-runtime-selection")) as Record<string, unknown>;
} catch {
runtimeSelection = {};
}
});
describe("shouldUseBuiltBridgeRuntimeBinary", () => {
it("当源码比已编译二进制更新时应放弃旧二进制", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 100,
latestSourceMtimeMs: 200,
}),
).toBe(false);
});
it("当二进制不旧于源码时仍可直接复用", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 300,
latestSourceMtimeMs: 200,
}),
).toBe(true);
});
});
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import { access, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import { api } from "@/lib/convex/api";
@@ -11,6 +11,7 @@ import {
type CommandEnvelope,
type QueryEnvelope,
} from "@/lib/documents/bridge";
import { shouldUseBuiltBridgeRuntimeBinary } from "@/lib/documents/rust-runtime-selection";
export type RustRuntimeExecutedQuery<TResult = unknown> = {
ok: true;
@@ -140,6 +141,68 @@ async function resolveRepoRoot() {
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
}
async function readLatestMtimeMs(targetPath: string): Promise<number | null> {
try {
const stats = await stat(targetPath);
if (stats.isFile()) {
return stats.mtimeMs;
}
if (!stats.isDirectory()) {
return null;
}
const entries = await readdir(targetPath, { withFileTypes: true });
const nestedTimes = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith("."))
.map((entry) => readLatestMtimeMs(path.join(targetPath, entry.name))),
);
const latestChild = nestedTimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
return latestChild == null ? stats.mtimeMs : Math.max(stats.mtimeMs, latestChild);
} catch {
return null;
}
}
async function readRuntimeSourceLatestMtimeMs(repoRoot: string) {
const sourceRoots = [
path.join(repoRoot, "rust", "Cargo.toml"),
path.join(repoRoot, "rust", "Cargo.lock"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "src"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "src"),
path.join(repoRoot, "rust", "crates", "core-protocol", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "core-protocol", "src"),
];
const mtimes = await Promise.all(sourceRoots.map((targetPath) => readLatestMtimeMs(targetPath)));
return mtimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
}
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
if (explicitBin) {
@@ -152,10 +215,22 @@ async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const repoRoot = await resolveRepoRoot();
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
if (await pathExists(builtBinary)) {
return {
command: builtBinary,
args: [],
};
const [builtBinaryMtimeMs, latestSourceMtimeMs] = await Promise.all([
readLatestMtimeMs(builtBinary),
readRuntimeSourceLatestMtimeMs(repoRoot),
]);
if (
shouldUseBuiltBridgeRuntimeBinary({
builtBinaryMtimeMs,
latestSourceMtimeMs,
})
) {
return {
command: builtBinary,
args: [],
};
}
}
return {
@@ -4,6 +4,12 @@ import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageHeadCommandResult,
type PageLayoutCommandResult,
} from "@/lib/documents/page-command-client";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = {
@@ -170,23 +176,19 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
): Promise<PageHeadCommandResult> {
return executePageHeadCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
): Promise<PageLayoutCommandResult> {
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: pageOptions,
},
"更新页面选项失败,请稍后再试",
);
return executePageLayoutCommand({
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
pageOptions,
});
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
-12
View File
@@ -1,12 +0,0 @@
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
export async function ensureMnoteWebAuthCookie(): Promise<void> {
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error("mnote-web 鉴权 cookie 准备失败");
}
}
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getMnotePublicRuntimeConfig,
getMnoteRuntimeConfig,
} from "@/lib/runtime-config";
describe("runtime-config public projection", () => {
it("不再暴露 legacy mnote-web runtime 字段", () => {
const runtime = getMnotePublicRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
delete process.env.MNOTE_WEB_BASE_URL;
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
});
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED = "1";
process.env.MNOTE_WEB_TREE_SHELL_ENABLED = "1";
const runtime = getMnoteRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
});
+10 -38
View File
@@ -22,16 +22,6 @@ export type MnoteRuntimeConfig = {
onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string;
/**
* Rust Web 主入口,仅用于客户端渐进增强能力(例如独立 tree shell)。
* 说明:禁止把它作为主页面 SSR 首屏依赖。
*/
mnoteWebBaseUrl?: string;
/**
* 是否启用 Rust Web tree shell 客户端增强。
* 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* 编辑器 host 选择。
* 说明:默认主链为 leptos_tiptap_island;可通过运行时配置显式切换。
@@ -60,6 +50,8 @@ export type MnoteRuntimeConfig = {
onlyofficeCallbackOrigin?: string;
};
export type MnotePublicRuntimeConfig = MnoteRuntimeConfig;
declare global {
interface Window {
__MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
@@ -153,24 +145,6 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined
? {
mnoteWebBaseUrl:
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
process.env.MNOTE_WEB_BASE_URL,
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
) !== undefined
? {
mnoteWebTreeShellEnabled: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
),
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
@@ -278,9 +252,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
@@ -289,8 +260,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl,
@@ -302,7 +271,10 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
// 说明:浏览器侧禁止再兜底读取 NEXT_PUBLIC_MNOTE_WEB_BASE_URL / MNOTE_WEB_BASE_URL
// 否则会把 internal-only 的 mnote-web 边界重新泄漏回客户端。
// 客户端只信任服务端注入的 public runtime 配置。
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? {});
}
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const publicRuntime = readFromPublicJson();
@@ -319,10 +291,10 @@ export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
: {
...publicRuntime,
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
mnoteWebTreeShellEnabled:
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
};
return normalizeRuntimeConfig(merged);
}
export function getMnotePublicRuntimeConfig(): MnotePublicRuntimeConfig {
return getMnoteRuntimeConfig();
}
@@ -0,0 +1,133 @@
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageOptionsState } from "@/types/page-options";
type AgentMessage = { role: "user" | "assistant"; content: string };
type RequestPayload = {
maxSteps?: number;
messages: AgentMessage[];
context?: {
documentId?: string;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
};
options?: {
ai?: {
model?: string;
modelKey?: string;
profileId?: string;
sessionId?: string;
};
};
};
function resolveBackendUrl(): string | null {
const cfg = getMnoteRuntimeConfig();
const backendUrl =
process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
return backendUrl?.trim().replace(/\/+$/, "") || null;
}
function buildErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "detail" in payload) {
return String((payload as Record<string, unknown>).detail ?? fallback);
}
if (payload && typeof payload === "object" && "message" in payload) {
return String((payload as Record<string, unknown>).message ?? fallback);
}
if (payload && typeof payload === "object" && "error" in payload) {
return String((payload as Record<string, unknown>).error ?? fallback);
}
return fallback;
}
export async function startDocumentAiOrchestratorRun(input: {
request?: Request;
userId: string;
payload: RequestPayload;
}): Promise<Response> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/run`, {
method: "POST",
headers,
body: JSON.stringify({
userId: input.userId,
sessionId: String(input.payload.options?.ai?.sessionId ?? "").trim() || null,
model: String(input.payload.options?.ai?.model ?? "").trim() || null,
modelKey: String(input.payload.options?.ai?.modelKey ?? "").trim() || null,
profileId: String(input.payload.options?.ai?.profileId ?? "").trim() || null,
maxSteps: input.payload.maxSteps,
messages: input.payload.messages,
context: {
documentId: input.payload.context?.documentId ?? null,
documentBlocks: input.payload.context?.documentBlocks ?? null,
node: input.payload.context?.node ?? null,
subtree: input.payload.context?.subtree ?? null,
outline: input.payload.context?.outline ?? null,
evidence: input.payload.context?.evidence ?? null,
pageOptions: input.payload.context?.pageOptions ?? null,
editorRuntimePageOptions: input.payload.context?.pageOptions
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
: null,
},
}),
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator 请求失败:HTTP ${response.status}`));
}
if (!response.body) {
throw new Error("AI orchestrator 未返回事件流");
}
return response;
}
export async function fetchDocumentAiOrchestratorConfig(input?: {
request?: Request;
}): Promise<DocumentAiCapabilityConfig> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input?.request);
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/config`, {
method: "GET",
headers,
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator config 请求失败:HTTP ${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
}
@@ -0,0 +1,55 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
@@ -1,79 +0,0 @@
import { buildHermesRuntimeToolResultRequest } from "@/lib/ai-agent/hermes/tool-result-recovery";
import { buildMnoteWebForwardHeaders, getMnoteWebBaseUrl } from "@/lib/server/mnote-web";
type PlainObject = Record<string, unknown>;
function readErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") {
return payload.error;
}
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export async function fetchHermesStructuredToolResultFromMnoteWeb(input: {
request?: Request;
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): Promise<unknown> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const headers = await buildMnoteWebForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const response = await fetch(new URL("/api/hermes/bridge", `${baseUrl}/`).toString(), {
method: "POST",
headers,
body: JSON.stringify(
buildHermesRuntimeToolResultRequest({
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data: input.data,
requestId: input.requestId,
traceId: input.traceId,
workspaceId: input.workspaceId,
target: input.target,
reason: input.reason,
refs: input.refs,
}),
),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
ok?: boolean;
result?: unknown;
error?: string;
message?: string;
}
| null;
if (!response.ok) {
throw new Error(readErrorMessage(payload, "mnote-web Hermes runtime 请求失败"));
}
if (!payload || !("result" in payload)) {
throw new Error("mnote-web Hermes runtime 未返回 result");
}
return payload.result;
}
-143
View File
@@ -1,143 +0,0 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = {
ok?: boolean;
requestId?: string;
traceId?: string;
workspaceId?: string;
result?: SidebarDatasetListQueryResult;
};
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
export function getMnoteWebBaseUrl(): string | null {
const runtime = getMnoteRuntimeConfig();
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
return baseUrl || null;
}
export async function fetchSidebarDatasetFromMnoteWeb(input: {
workspaceId: string;
request?: Request;
}): Promise<{
dataset: SidebarDatasetListQueryResult;
meta: {
requestId: string | null;
traceId: string | null;
workspaceId: string;
};
}> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
method: "GET",
headers: forwardedHeaders,
cache: "no-store",
});
const payload = (await response
.json()
.catch(() => null)) as MnoteWebSidebarCompatResponse | null;
if (!response.ok) {
const message =
payload && typeof (payload as Record<string, unknown>).message === "string"
? String((payload as Record<string, unknown>).message)
: "mnote-web 侧边栏兼容接口请求失败";
throw new Error(message);
}
if (!payload?.result) {
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
}
return {
dataset: payload.result,
meta: {
requestId: payload.requestId ?? null,
traceId: payload.traceId ?? null,
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
},
};
}
export function buildMnoteWebStreamUrl(input: {
workspaceId: string;
cursor?: string | null;
}): URL {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/stream/events", `${baseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
url.searchParams.set("workspaceId", input.workspaceId.trim());
if (typeof input.cursor === "string" && input.cursor.trim()) {
url.searchParams.set("cursor", input.cursor.trim());
}
return url;
}
+3 -16
View File
@@ -64,25 +64,12 @@ function looksLikeSidebarDatasetListQueryResult(
}
export function buildWorkspaceTreeStreamUrl(
baseUrl: string,
workspaceId: string,
cursor?: string | null,
): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const shouldUseSameOriginProxy =
normalizedBaseUrl.length > 0 &&
typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
const baseOrigin =
typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
const url = new URL("/api/mnote-web/stream", baseOrigin);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -19,13 +19,13 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
it("固定走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
@@ -34,7 +34,7 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
buildWorkspaceTreeStreamUrl("ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
@@ -4,16 +4,6 @@ import { createRoot, type Root } from "react-dom/client";
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockRuntimeConfig = vi.hoisted(() => ({
getMnoteRuntimeConfig: vi.fn(),
}));
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
vi.mock("@/lib/mnote-web-auth", () => ({
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
}));
type MockEventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
@@ -114,8 +104,12 @@ describe("useSidebarTreeStream", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
mnoteWebBaseUrl: "http://127.0.0.1:3104",
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
@@ -2,14 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
parseTreeStreamMessage,
} from "@/lib/tree-stream/protocol";
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
export interface SidebarTreeStreamState {
data: SidebarInitialData | null;
@@ -37,10 +35,8 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
}
export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTreeStreamState {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const streamEnabled = Boolean(workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
@@ -133,32 +129,18 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
}
};
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (cancelled) {
return;
}
if (cancelled) {
return undefined;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
} catch {
if (cancelled) {
return;
}
setState((previous) => ({
...previous,
status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 鉴权失败"),
}));
}
})();
const url = buildWorkspaceTreeStreamUrl(workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
return () => {
cancelled = true;
@@ -170,7 +152,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, streamEnabled, workspaceId]);
}, [streamEnabled, workspaceId]);
return state;
}