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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user