feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
buildHermesRuntimeToolResultRequest,
parseHermesToolPreviewArgs,
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "./tool-result-recovery";
describe("tool-result-recovery", () => {
it("应从 JSON preview 中解析工具参数", () => {
expect(
parseHermesToolPreviewArgs('{"blockId":"block_1","text":"新的正文","mode":"replace"}', "doc_replace_range"),
).toEqual({
blockId: "block_1",
text: "新的正文",
mode: "replace",
});
});
it("应从 slash preview 中恢复 text 参数", () => {
expect(parseHermesToolPreviewArgs("slash_run /rename doc-1 新标题", "slash_run")).toEqual({
text: "/rename doc-1 新标题",
});
});
it("应优先读取 Hermes 事件里直接给出的 argsJson", () => {
expect(
readHermesToolArgsFromEvent(
{
preview: "ignored",
argsJson: { text: "/rename doc-1 直接参数" },
},
"slash_run",
),
).toEqual({
text: "/rename doc-1 直接参数",
});
});
it("应读取 Hermes 事件里直接给出的结构化 result", () => {
expect(
readHermesToolResultFromEvent({
result: {
ok: true,
parsed: {
command: "rename_doc",
},
},
}),
).toEqual({
ok: true,
parsed: {
command: "rename_doc",
},
});
});
it("应构造 mnote-web runtime tool result 请求体", () => {
expect(
buildHermesRuntimeToolResultRequest({
userId: "user-1",
tool: "doc_replace_range",
argsJson: {
blockId: "block_1",
text: "新的正文",
mode: "replace",
},
data: [
{
id: "block_1",
type: "paragraph",
content: "旧正文",
},
],
requestId: "req-1",
traceId: "trace-1",
target: {
pageId: "doc-1",
blockId: "block_1",
},
}),
).toEqual({
kind: "tool",
context: {
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,
},
tool: {
tool: "doc_replace_range",
kind: "command",
mode: "result",
argsJson: {
blockId: "block_1",
text: "新的正文",
mode: "replace",
},
target: {
workspaceId: null,
pageId: "doc-1",
blockId: "block_1",
},
reason: null,
refs: [],
},
data: [
{
id: "block_1",
type: "paragraph",
content: "旧正文",
},
],
});
});
});
@@ -0,0 +1,165 @@
type PlainObject = Record<string, unknown>;
function isPlainObject(value: unknown): value is PlainObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeParseObject(raw: string): PlainObject | null {
const text = raw.trim();
if (!text) return null;
try {
const parsed = JSON.parse(text) as unknown;
if (typeof parsed === "string") {
return safeParseObject(parsed);
}
return isPlainObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function extractJsonCandidate(raw: string): string | null {
const text = raw.trim();
if (!text) return null;
if (text.startsWith("{") && text.endsWith("}")) {
return text;
}
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) {
return null;
}
return text.slice(start, end + 1);
}
export function parseHermesToolPreviewArgs(
preview: string,
tool?: string | null,
): PlainObject | null {
const direct = safeParseObject(preview);
if (direct) {
return direct;
}
const candidate = extractJsonCandidate(preview);
if (candidate) {
const parsed = safeParseObject(candidate);
if (parsed) {
return parsed;
}
}
if (tool === "slash_run") {
const slashMatch = preview.match(/\/(?:new|new-doc|newdoc|rename|rename-doc|renamedoc)\b[\s\S]*/i);
if (slashMatch) {
return { text: slashMatch[0].trim() };
}
}
return null;
}
export function readHermesToolArgsFromEvent(
event: unknown,
tool?: string | null,
): PlainObject | null {
if (!isPlainObject(event)) {
return null;
}
const directArgs =
event.argsJson ??
event.args_json ??
event.args;
if (isPlainObject(directArgs)) {
return directArgs;
}
if (typeof directArgs === "string") {
const parsed = parseHermesToolPreviewArgs(directArgs, tool);
if (parsed) {
return parsed;
}
}
const preview = typeof event.preview === "string" ? event.preview : "";
return parseHermesToolPreviewArgs(preview, tool);
}
export function readHermesToolResultFromEvent(event: unknown): unknown | null {
if (!isPlainObject(event)) {
return null;
}
if ("result" in event && event.result != null) {
return event.result;
}
if ("data" in event && event.data != null) {
return event.data;
}
if (typeof event.output === "string") {
const parsed = safeParseObject(event.output);
if (parsed) {
return parsed;
}
}
return null;
}
export function buildHermesRuntimeToolResultRequest(input: {
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[];
}): PlainObject {
return {
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: input.workspaceId ?? null,
requestId: input.requestId,
traceId: input.traceId,
actor: {
actorType: "user",
actorId: input.userId,
sessionId: null,
},
source: {
channel: "next_route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: input.tool,
kind: "command",
mode: "result",
argsJson: input.argsJson,
target: input.target
? {
workspaceId: input.target.workspaceId ?? null,
pageId: input.target.pageId ?? null,
blockId: input.target.blockId ?? null,
}
: null,
reason: input.reason ?? null,
refs: Array.isArray(input.refs) ? input.refs : [],
},
...(typeof input.data === "undefined" ? {} : { data: input.data }),
};
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
wideLayout: true,
smallText: false,
showHeadingNumbers: true,
showToc: true,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
describe("page-aggregate", () => {
it("将页面 route 所需真相收口为统一聚合对象", () => {
const aggregate = buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: " 页面标题 ",
updatedAt: "2026-04-21T12:00:00.000Z",
readOnly: true,
disableDownload: true,
disableCopy: false,
pageOptions,
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
pageSubtree: null,
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
expect(aggregate).toEqual({
identity: {
documentId: "doc_1",
workspaceId: "ws_1",
},
head: {
title: "页面标题",
updatedAt: "2026-04-21T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
},
layout: {
pageOptions,
},
body: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
},
tree: {
pageSubtree: null,
},
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
});
it("缺省标题与可选字段时回退到稳定默认值", () => {
const aggregate = buildPageAggregate({
documentId: "doc_2",
workspaceId: "ws_2",
pageOptions,
content: null,
});
expect(aggregate.head.title).toBe("无标题");
expect(aggregate.head.permissions).toEqual({
readOnly: false,
disableDownload: false,
disableCopy: false,
});
expect(aggregate.body).toEqual({
content: null,
revision: null,
conflictDetectionKey: null,
});
expect(aggregate.tree.pageSubtree).toBeNull();
expect(aggregate.stats).toBeNull();
});
});
@@ -0,0 +1,86 @@
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
export interface PageAggregateIdentity {
documentId: string;
workspaceId: string;
}
export interface PageAggregatePermissions {
readOnly: boolean;
disableDownload: boolean;
disableCopy: boolean;
}
export interface PageAggregateHead {
title: string;
updatedAt: string | null;
permissions: PageAggregatePermissions;
}
export interface PageAggregateLayout {
pageOptions: PageOptionsState;
}
export interface PageAggregateBody {
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
}
export interface PageAggregateTree {
pageSubtree: PageSubtreeProjection | null;
}
export interface PageAggregateProjection {
identity: PageAggregateIdentity;
head: PageAggregateHead;
layout: PageAggregateLayout;
body: PageAggregateBody;
tree: PageAggregateTree;
stats: DocumentStats | null;
}
export function buildPageAggregate(input: {
documentId: string;
workspaceId: string;
title?: string | null;
updatedAt?: string | null;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
pageOptions: PageOptionsState;
content: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
stats?: DocumentStats | null;
}): PageAggregateProjection {
return {
identity: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
head: {
title: input.title?.trim() || "无标题",
updatedAt: input.updatedAt ?? null,
permissions: {
readOnly: Boolean(input.readOnly),
disableDownload: Boolean(input.disableDownload),
disableCopy: Boolean(input.disableCopy),
},
},
layout: {
pageOptions: input.pageOptions,
},
body: {
content: input.content ?? null,
revision: input.revision ?? null,
conflictDetectionKey: input.conflictDetectionKey ?? null,
},
tree: {
pageSubtree: input.pageSubtree ?? null,
},
stats: input.stats ?? null,
};
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import { applyPageBodyCommand } from "./page-body-command";
describe("applyPageBodyCommand", () => {
it("应先保存 page body,再回显编辑器快照并回传持久化元信息", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "AI 生成正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("/api/documents/save");
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(String(init?.body ?? "{}")) as {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
content: Json;
blockCount: number | null;
};
expect(body.documentId).toBe("doc-1");
expect(body.workspaceId).toBe("ws-1");
expect(body.revision).toBe(4);
expect(body.conflictDetectionKey).toBe("doc-1:4");
expect(body.content).toEqual(blocks);
expect(body.blockCount).toBe(1);
expect(result).toEqual({ revision: 5, conflictDetectionKey: "doc-1:5" });
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
});
it("保存失败时不应回显未持久化的正文", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "失败正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ error: "conflict" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).rejects.toThrow("conflict");
expect(applyEditorSnapshot).not.toHaveBeenCalled();
expect(onPersistedMetaChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
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;
}
@@ -0,0 +1,19 @@
import type { PageOptionsState } from "@/types/page-options";
export type PageTitleCommandInput = {
documentId: string;
workspaceId?: string | null;
title: string;
};
export type PageLayoutCommandInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
export const PAGE_COMMAND_NAMES = {
updateTitle: "page.head.updateTitle",
updateLayout: "page.layout.updateOptions",
saveBody: "page.body.save",
} as const;
@@ -3,7 +3,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import { createDocumentCommand } from "@/lib/documents/tree-command-client";
export function SidebarCreateDocumentEntry() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
@@ -17,9 +16,8 @@ export function SidebarCreateDocumentEntry() {
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
const payload = await createDocumentCommand(parentId);
const query = new URLSearchParams();
query.set("edit", "1");
const workspaceId = payload.workspace_id ?? null;
if (workspaceId) query.set("workspaceId", workspaceId);
router.push(`/documents/${payload.id}?${query.toString()}`);
const nextQuery = query.toString();
router.push(nextQuery ? `/documents/${payload.id}?${nextQuery}` : `/documents/${payload.id}`);
}
@@ -8,6 +8,8 @@ import {
purgeDocumentCommand,
renameDocumentCommand,
restoreDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
describe("tree-command-client", () => {
@@ -29,8 +31,15 @@ describe("tree-command-client", () => {
await purgeDocumentCommand({ documentId: "doc_1" });
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
await updatePageTitleCommand({ documentId: "doc_1", title: "页面标题" });
await updatePageOptionsCommand({
documentId: "doc_1",
pageOptions: {
wideLayout: true,
},
});
expect(fetchMock).toHaveBeenCalledTimes(8);
expect(fetchMock).toHaveBeenCalledTimes(10);
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
"/api/documents/create",
"/api/documents/title",
@@ -40,6 +49,8 @@ describe("tree-command-client", () => {
"/api/documents/purge",
"/api/documents/embed",
"/api/documents/copy-tree",
"/api/documents/title",
"/api/documents/options",
]);
});
@@ -1,5 +1,11 @@
"use client";
import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = {
requestId?: string;
traceId?: string;
@@ -71,6 +77,12 @@ type RenameDocumentInput = {
title: string;
};
type UpdatePageOptionsInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
type MoveDocumentInput = {
documentId: string;
parentId?: string | null;
@@ -156,6 +168,27 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
);
}
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
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,
},
"更新页面选项失败,请稍后再试",
);
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/move",
+66 -2
View File
@@ -34,12 +34,17 @@ export type MnoteRuntimeConfig = {
mnoteWebTreeShellEnabled?: boolean;
/**
* 编辑器 host 选择。
* 说明:这里只允许显式选择实验 host,默认主链仍由 BlockNote 承担
* 说明:默认主链为 leptos_tiptap_island;可通过运行时配置显式切换
*/
documentEditorHost?:
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
/**
* 编辑器 BlockNote 回退总开关(kill switch)。
* 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。
*/
documentEditorBlocknoteKillSwitch?: boolean;
/**
* 是否为桌面端(Electron)运行。
*/
@@ -81,6 +86,37 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
return undefined;
};
const parseDocumentEditorHost = (
value: unknown,
): MnoteRuntimeConfig["documentEditorHost"] | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "blocknote") {
return "blocknote";
}
if (
normalized === "leptos_tiptap_island" ||
normalized === "leptos_tiptap_runtime" ||
normalized === "leptos_tiptap_inline" ||
normalized === "leptos_tiptap"
) {
return "leptos_tiptap_island";
}
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return undefined;
};
function getServerNodeBuiltin<T>(moduleName: string): T | null {
if (typeof window !== "undefined") {
return null;
@@ -135,6 +171,28 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
),
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
) !== undefined
? {
documentEditorBlocknoteKillSwitch: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
),
}
: {}),
...(parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
) !== undefined
? {
documentEditorHost: parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
),
}
: {}),
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -223,12 +281,18 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl,
onlyofficeStorageHostOverride,
onlyofficeProxyOrigin,
@@ -0,0 +1,79 @@
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;
}
+21 -2
View File
@@ -20,7 +20,7 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
}
}
async function buildForwardHeaders(request?: Request): Promise<Headers> {
export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
@@ -89,7 +89,7 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildForwardHeaders(input.request);
const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
@@ -122,3 +122,22 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
},
};
}
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;
}
+14 -3
View File
@@ -69,9 +69,20 @@ export function buildWorkspaceTreeStreamUrl(
cursor?: string | null,
): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
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}/`);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
@@ -6,14 +6,38 @@ import {
} from "./protocol";
describe("tree-stream/protocol", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("构造 workspace sidebar stream url", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9",
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
it("解析 snapshot / delta / resync 协议消息", () => {
expect(
parseTreeStreamMessage({
@@ -0,0 +1,184 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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 {
static instances: MockEventSource[] = [];
readonly url: string;
readonly withCredentials: boolean;
readonly listeners = new Map<string, Set<MockEventListener>>();
onmessage: MockEventListener | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string, options?: EventSourceInit) {
this.url = url;
this.withCredentials = Boolean(options?.withCredentials);
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
const next = this.listeners.get(type) ?? new Set<MockEventListener>();
next.add(typedListener);
this.listeners.set(type, next);
}
removeEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
this.listeners.get(type)?.delete(typedListener);
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
const event = {
type,
data: JSON.stringify(payload),
} as MessageEvent<string>;
this.listeners.get(type)?.forEach((listener) => listener(event));
if (type === "message" && this.onmessage) {
this.onmessage(event);
}
}
}
declare global {
// eslint-disable-next-line no-var
var EventSource: typeof MockEventSource;
}
function flush() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function buildInitialData(): SidebarInitialData {
return {
activeWorkspaceId: "ws_1",
workspaces: [],
documents: [],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelSidebarTree: [],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
trashedTableAssets: [],
tableAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
mediaAssets: [],
};
}
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
const state = useSidebarTreeStream(buildInitialData());
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
}
describe("useSidebarTreeStream", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
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",
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("收到带 cursor 的 snapshot 后不应重建 EventSource", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.emit("snapshot", {
stream: "workspace",
workspaceId: "ws_1",
cursor: "evt_2",
projection: "sidebar_tree",
data: {
active_workspace_id: "ws_1",
workspaces: [],
documents: [],
kernel_sidebar_projection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
},
});
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
cursor: "evt_2",
status: "live",
}),
);
});
});
@@ -41,6 +41,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
data: null,
@@ -50,8 +51,13 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
});
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
cursorRef.current = state.cursor;
}, [state.cursor]);
useEffect(() => {
if (!streamEnabled) {
cursorRef.current = null;
setState({
data: null,
status: "idle",
@@ -134,7 +140,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
return;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
@@ -164,7 +170,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, state.cursor, streamEnabled, workspaceId]);
}, [baseUrl, streamEnabled, workspaceId]);
return state;
}