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,115 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const SPIKE_ROOT = path.join(REPO_ROOT, "rust", "spikes", "leptos-tiptap-spike");
|
||||
const GENERATED_ROOT = path.join(SPIKE_ROOT, "generated", "island");
|
||||
const TOOLCHAIN = "1.89.0-x86_64-unknown-linux-gnu";
|
||||
const TARGET = "wasm32-unknown-unknown";
|
||||
const OUT_NAME = "mnote-leptos-tiptap-spike-island";
|
||||
const MANIFEST_PATH = path.join(SPIKE_ROOT, "Cargo.toml");
|
||||
const WASM_PATH = path.join(
|
||||
SPIKE_ROOT,
|
||||
"target",
|
||||
TARGET,
|
||||
"debug",
|
||||
"mnote_leptos_tiptap_spike.wasm",
|
||||
);
|
||||
const ENTRY_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}.js`);
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? REPO_ROOT,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
if (result.status === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const stdout = (result.stdout || "").trim();
|
||||
const stderr = (result.stderr || "").trim();
|
||||
const details = [stdout, stderr].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
[`命令失败:${command} ${args.join(" ")}`, details].filter(Boolean).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureGeneratedDir() {
|
||||
await fs.rm(GENERATED_ROOT, { recursive: true, force: true });
|
||||
await fs.mkdir(GENERATED_ROOT, { recursive: true });
|
||||
}
|
||||
|
||||
async function verifyEntry() {
|
||||
const source = await fs.readFile(ENTRY_PATH, "utf8");
|
||||
if (!source.includes("export function mount(container, options)")) {
|
||||
throw new Error("island 入口缺少 mount 导出");
|
||||
}
|
||||
if (!source.includes("export function unmount(mount_id)")) {
|
||||
throw new Error("island 入口缺少 unmount 导出");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLeptosTiptapIsland() {
|
||||
// 说明:正式主链直接消费 lib.rs 的 wasm-bindgen 产物,不再把 trunk 演示页产物当作页面内 island 入口。
|
||||
run("rustup", ["target", "add", TARGET, "--toolchain", TOOLCHAIN]);
|
||||
run(
|
||||
"cargo",
|
||||
[
|
||||
`+${TOOLCHAIN}`,
|
||||
"build",
|
||||
"--manifest-path",
|
||||
MANIFEST_PATH,
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
],
|
||||
{ cwd: SPIKE_ROOT },
|
||||
);
|
||||
|
||||
await ensureGeneratedDir();
|
||||
|
||||
run(
|
||||
"wasm-bindgen",
|
||||
[
|
||||
WASM_PATH,
|
||||
"--target",
|
||||
"web",
|
||||
"--out-dir",
|
||||
GENERATED_ROOT,
|
||||
"--out-name",
|
||||
OUT_NAME,
|
||||
],
|
||||
{ cwd: SPIKE_ROOT },
|
||||
);
|
||||
|
||||
await verifyEntry();
|
||||
return {
|
||||
generatedRoot: GENERATED_ROOT,
|
||||
entryPath: ENTRY_PATH,
|
||||
wasmPath: path.join(GENERATED_ROOT, `${OUT_NAME}_bg.wasm`),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildLeptosTiptapIsland,
|
||||
GENERATED_ROOT,
|
||||
OUT_NAME,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
buildLeptosTiptapIsland()
|
||||
.then(({ generatedRoot }) => {
|
||||
process.stdout.write(`leptos-tiptap island 已生成:${generatedRoot}\n`);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -12,11 +12,12 @@
|
||||
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时优先探测 8082,再回退 8081
|
||||
*/
|
||||
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const next = require("next");
|
||||
const { parse: parseUrl } = require("url");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const next = require("next");
|
||||
const { parse: parseUrl } = require("url");
|
||||
const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island");
|
||||
|
||||
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||||
const CONVEX_PREFIX = "/convex";
|
||||
@@ -384,12 +385,15 @@ function proxyConvexHttp(req, res) {
|
||||
req.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = resolvePort();
|
||||
const hostname = resolveHostname();
|
||||
const dev = true;
|
||||
|
||||
const app = next({ dev, dir: path.join(__dirname, "..") });
|
||||
async function main() {
|
||||
const port = resolvePort();
|
||||
const hostname = resolveHostname();
|
||||
const dev = true;
|
||||
|
||||
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
|
||||
await buildLeptosTiptapIsland();
|
||||
|
||||
const app = next({ dev, dir: path.join(__dirname, "..") });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
await app.prepare();
|
||||
|
||||
@@ -9,7 +9,14 @@ import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/do
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||
import { normalizeEditorHostKind, type EditorHostKind } from "@/components/editor/editor-host-config";
|
||||
import {
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
normalizeEditorHostKind,
|
||||
resolveEditorHostKind,
|
||||
type EditorHostKind,
|
||||
} from "@/components/editor/editor-host-config";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -128,9 +135,36 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
const openTableIdRaw = resolvedSearch?.openTableId;
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
const editorHostRaw = resolvedSearch?.editorHost;
|
||||
const editorHostKind: EditorHostKind =
|
||||
typeof editorHostRaw === "string" ? normalizeEditorHostKind(editorHostRaw) : "blocknote";
|
||||
const runtimeConfig = getMnoteRuntimeConfig();
|
||||
const queryEditorHostRaw = resolvedSearch?.editorHost;
|
||||
const queryHostAliasRaw = resolvedSearch?.host;
|
||||
const queryHostRaw =
|
||||
typeof queryEditorHostRaw === "string"
|
||||
? queryEditorHostRaw
|
||||
: typeof queryHostAliasRaw === "string"
|
||||
? queryHostAliasRaw
|
||||
: undefined;
|
||||
const runtimeHostRaw = runtimeConfig.documentEditorHost;
|
||||
const runtimeBlocknoteKillSwitch =
|
||||
runtimeConfig.documentEditorBlocknoteKillSwitch === true;
|
||||
|
||||
// 说明:优先级保持稳定且可预期:
|
||||
// 1) query(兼容 editorHost,并支持 host 别名);
|
||||
// 2) 运行时配置 documentEditorHost;
|
||||
// 3) kill switch(runtime/env)回退到 blocknote;
|
||||
// 4) 默认正式主链 leptos_tiptap_island。
|
||||
const editorHostKind: EditorHostKind = (() => {
|
||||
if (typeof queryHostRaw === "string") {
|
||||
return resolveEditorHostKind({
|
||||
override: queryHostRaw,
|
||||
runtimeDefault: runtimeHostRaw,
|
||||
});
|
||||
}
|
||||
if (runtimeBlocknoteKillSwitch) {
|
||||
return "blocknote";
|
||||
}
|
||||
return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND);
|
||||
})();
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const workspaceIdRaw = resolvedSearch?.workspaceId;
|
||||
@@ -176,25 +210,29 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
title: doc.title ?? "无标题",
|
||||
});
|
||||
|
||||
const page = buildPageAggregate({
|
||||
documentId: doc.id,
|
||||
workspaceId: doc.workspace_id,
|
||||
title: doc.title ?? "无标题",
|
||||
updatedAt: doc.updated_at,
|
||||
readOnly,
|
||||
disableDownload,
|
||||
disableCopy,
|
||||
pageOptions: initialOptions,
|
||||
content: initialDocumentContent.content,
|
||||
revision: initialDocumentContent.revision,
|
||||
conflictDetectionKey: initialDocumentContent.conflictDetectionKey,
|
||||
pageSubtree: initialDocumentContent.pageSubtree,
|
||||
stats: initialStats,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<DocumentShell
|
||||
documentId={doc.id}
|
||||
workspaceId={doc.workspace_id}
|
||||
title={doc.title ?? "无标题"}
|
||||
updatedAt={doc.updated_at}
|
||||
initialContent={initialDocumentContent.content}
|
||||
initialContentRevision={initialDocumentContent.revision}
|
||||
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
||||
initialPageSubtree={initialDocumentContent.pageSubtree}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
page={page}
|
||||
openTableId={openTableId}
|
||||
editorHostKind={editorHostKind}
|
||||
readOnly={readOnly}
|
||||
disableDownload={disableDownload}
|
||||
disableCopy={disableCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
import { AppLayoutShell } from "@/components/app-layout-shell";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||
@@ -12,25 +9,26 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const {
|
||||
documents,
|
||||
sidebarInitialData,
|
||||
} = await loadSidebarDataFromConvex({
|
||||
client,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden">
|
||||
<header className="h-[44px] w-full flex items-center px-4 text-wolai-text-secondary text-sm bg-white/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
if (!sidebarInitialData) {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayoutShell initialData={sidebarInitialData}>
|
||||
{children}
|
||||
</AppLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSafeGetJsonBody = vi.fn();
|
||||
const mockValidateRequestBody = vi.fn();
|
||||
const mockIsConvexEnabled = vi.fn();
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
const mockStartHermesRun = vi.fn();
|
||||
const mockStreamHermesRunEvents = vi.fn();
|
||||
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api-utils", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
|
||||
return {
|
||||
...actual,
|
||||
safeGetJsonBody: mockSafeGetJsonBody,
|
||||
validateRequestBody: mockValidateRequestBody,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: mockIsConvexEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: mockGetAuthedConvexClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
|
||||
startHermesRun: mockStartHermesRun,
|
||||
streamHermesRunEvents: mockStreamHermesRunEvents,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/mnote-web-hermes", () => ({
|
||||
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
|
||||
}));
|
||||
|
||||
describe("/api/ai-agent/run route", () => {
|
||||
beforeEach(() => {
|
||||
mockSafeGetJsonBody.mockReset();
|
||||
mockValidateRequestBody.mockReset();
|
||||
mockIsConvexEnabled.mockReset();
|
||||
mockGetAuthedConvexClient.mockReset();
|
||||
mockStartHermesRun.mockReset();
|
||||
mockStreamHermesRunEvents.mockReset();
|
||||
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
|
||||
});
|
||||
|
||||
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "online",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartHermesRun.mockResolvedValue({
|
||||
runId: "run-1",
|
||||
});
|
||||
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
|
||||
await onEvent({
|
||||
event: "tool.started",
|
||||
tool: "slash_run",
|
||||
preview: '{"text":"/rename doc-1 AI 标题"}',
|
||||
});
|
||||
await onEvent({
|
||||
event: "tool.completed",
|
||||
tool: "slash_run",
|
||||
duration: 0.12,
|
||||
error: false,
|
||||
});
|
||||
await onEvent({
|
||||
event: "run.completed",
|
||||
output: "已完成",
|
||||
});
|
||||
});
|
||||
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
|
||||
ok: true,
|
||||
parsed: {
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: "doc-1",
|
||||
title: "AI 标题",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
tool: "slash_run",
|
||||
argsJson: {
|
||||
text: "/rename doc-1 AI 标题",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("event: tool_result");
|
||||
expect(text).toContain('"tool":"slash_run"');
|
||||
expect(text).toContain('"command":"rename_doc"');
|
||||
expect(text).toContain('"title":"AI 标题"');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
startCodexJsonRun,
|
||||
} from "@/lib/ai/codex/codexExec";
|
||||
import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge";
|
||||
import {
|
||||
readHermesToolArgsFromEvent,
|
||||
readHermesToolResultFromEvent,
|
||||
} from "@/lib/ai-agent/hermes/tool-result-recovery";
|
||||
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -223,15 +228,76 @@ const buildHermesInput = (messages: AgentMessage[]) => {
|
||||
.map((item) => ({ role: item.role, content: String(item.content ?? "") }));
|
||||
};
|
||||
|
||||
type PendingHermesToolCall = {
|
||||
preview: string;
|
||||
argsJson: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const recoverStructuredHermesToolResult = async (input: {
|
||||
request: Request;
|
||||
payload: RequestPayload;
|
||||
userId: string;
|
||||
tool: string;
|
||||
argsJson: Record<string, unknown> | null;
|
||||
fallbackRequestId: string;
|
||||
fallbackTraceId: string;
|
||||
}): Promise<unknown | null> => {
|
||||
if (!input.argsJson) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
input.tool !== "slash_run" &&
|
||||
input.tool !== "doc_insert_blocks" &&
|
||||
input.tool !== "doc_replace_range"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentId = String(input.payload.context?.documentId ?? "").trim() || null;
|
||||
const data =
|
||||
input.tool === "slash_run"
|
||||
? { source: "ai-agent-route" }
|
||||
: input.payload.context?.documentBlocks ?? null;
|
||||
|
||||
if ((input.tool === "doc_insert_blocks" || input.tool === "doc_replace_range") && data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fetchHermesStructuredToolResultFromMnoteWeb({
|
||||
request: input.request,
|
||||
userId: input.userId,
|
||||
tool: input.tool,
|
||||
argsJson: input.argsJson,
|
||||
data,
|
||||
requestId: input.fallbackRequestId,
|
||||
traceId: input.fallbackTraceId,
|
||||
target: documentId
|
||||
? {
|
||||
pageId: documentId,
|
||||
blockId:
|
||||
input.tool === "doc_replace_range"
|
||||
? String(input.argsJson.blockId ?? "").trim() || null
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
}).catch(() => null);
|
||||
};
|
||||
|
||||
const streamHermesLegacyEvents = async ({
|
||||
messages,
|
||||
instructions,
|
||||
sessionId,
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
onEvent,
|
||||
}: {
|
||||
messages: AgentMessage[];
|
||||
instructions: string;
|
||||
sessionId: string | null;
|
||||
request: Request;
|
||||
payload: RequestPayload;
|
||||
userId: string;
|
||||
onEvent: (event: LegacyStreamEvent) => Promise<void> | void;
|
||||
}) => {
|
||||
const input = buildHermesInput(messages);
|
||||
@@ -242,7 +308,7 @@ const streamHermesLegacyEvents = async ({
|
||||
});
|
||||
|
||||
const pendingToolIds = new Map<string, string[]>();
|
||||
const previewById = new Map<string, string>();
|
||||
const pendingToolCalls = new Map<string, PendingHermesToolCall>();
|
||||
let toolCount = 0;
|
||||
let assistantBuffer = "";
|
||||
let failureMessage = "";
|
||||
@@ -256,7 +322,10 @@ const streamHermesLegacyEvents = async ({
|
||||
queue.push(id);
|
||||
pendingToolIds.set(tool, queue);
|
||||
const preview = typeof event.preview === "string" ? event.preview : "";
|
||||
previewById.set(id, preview);
|
||||
pendingToolCalls.set(id, {
|
||||
preview,
|
||||
argsJson: readHermesToolArgsFromEvent(event, tool),
|
||||
});
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
data: {
|
||||
@@ -273,8 +342,22 @@ const streamHermesLegacyEvents = async ({
|
||||
const queue = pendingToolIds.get(tool) ?? [];
|
||||
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
|
||||
pendingToolIds.set(tool, queue);
|
||||
const preview = previewById.get(id) ?? "";
|
||||
const pendingToolCall = pendingToolCalls.get(id) ?? null;
|
||||
pendingToolCalls.delete(id);
|
||||
const preview = pendingToolCall?.preview ?? "";
|
||||
const duration = Number(event.duration ?? 0);
|
||||
const structuredResultFromEvent = !Boolean(event.error) ? readHermesToolResultFromEvent(event) : null;
|
||||
const recoveredResult =
|
||||
structuredResultFromEvent ??
|
||||
(await recoverStructuredHermesToolResult({
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
tool,
|
||||
argsJson: pendingToolCall?.argsJson ?? null,
|
||||
fallbackRequestId: makeRunId(),
|
||||
fallbackTraceId: makeRunId(),
|
||||
}));
|
||||
await onEvent({
|
||||
type: "tool_result",
|
||||
data: {
|
||||
@@ -282,7 +365,9 @@ const streamHermesLegacyEvents = async ({
|
||||
tool,
|
||||
ok: !Boolean(event.error),
|
||||
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
|
||||
result: preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) },
|
||||
result:
|
||||
recoveredResult ??
|
||||
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -551,6 +636,9 @@ export async function POST(request: Request) {
|
||||
messages: payload.messages,
|
||||
instructions,
|
||||
sessionId,
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
onEvent: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
@@ -589,6 +677,9 @@ export async function POST(request: Request) {
|
||||
messages: payload.messages,
|
||||
instructions,
|
||||
sessionId,
|
||||
request,
|
||||
payload,
|
||||
userId,
|
||||
onEvent: (event) => {
|
||||
send(event.type, event.data ?? null);
|
||||
},
|
||||
|
||||
@@ -5,18 +5,24 @@ import { NextResponse } from "next/server";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RuntimeManifest = {
|
||||
bridgeRuntimePath: string | null;
|
||||
extensionModulePaths: string[];
|
||||
type IslandManifest = {
|
||||
entryAssetPath: string | null;
|
||||
wasmAssetPath: string | null;
|
||||
assetPaths: string[];
|
||||
generatedRootPath: string | null;
|
||||
entryScriptPath: string | null;
|
||||
wasmPath: string | null;
|
||||
};
|
||||
|
||||
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist");
|
||||
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/;
|
||||
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/;
|
||||
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/;
|
||||
const GENERATED_ROOT = path.resolve(
|
||||
process.cwd(),
|
||||
"..",
|
||||
"rust",
|
||||
"spikes",
|
||||
"leptos-tiptap-spike",
|
||||
"generated",
|
||||
"island",
|
||||
);
|
||||
const ENTRY_ASSET_PATTERN = /^mnote-leptos-tiptap-spike-island\.js$/;
|
||||
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-island_bg\.wasm$/;
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
@@ -65,29 +71,26 @@ async function walkFiles(rootDir: string): Promise<string[]> {
|
||||
return output.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function buildRuntimeManifest(): Promise<RuntimeManifest> {
|
||||
const files = await walkFiles(DIST_ROOT);
|
||||
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null;
|
||||
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null;
|
||||
const extensionModulePaths = generatedRootPath
|
||||
? files.filter((item) => {
|
||||
if (!item.startsWith(`${generatedRootPath}/`)) {
|
||||
return false;
|
||||
}
|
||||
const basename = path.posix.basename(item);
|
||||
return EXTENSION_MODULE_PATTERN.test(basename);
|
||||
})
|
||||
: [];
|
||||
const entryScriptPath =
|
||||
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
async function buildIslandManifest(): Promise<IslandManifest> {
|
||||
const files = await walkFiles(GENERATED_ROOT);
|
||||
const entryAssetPath = files.find((item) => ENTRY_ASSET_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
const wasmAssetPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
const generatedRootPath = entryAssetPath ? path.posix.dirname(entryAssetPath) : null;
|
||||
const assetPaths = files.filter((item) => {
|
||||
const basename = path.posix.basename(item);
|
||||
return (
|
||||
basename.endsWith(".js") ||
|
||||
basename.endsWith(".wasm") ||
|
||||
basename.endsWith(".css") ||
|
||||
basename.endsWith(".json")
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
bridgeRuntimePath,
|
||||
extensionModulePaths,
|
||||
entryAssetPath,
|
||||
wasmAssetPath,
|
||||
assetPaths,
|
||||
generatedRootPath,
|
||||
entryScriptPath,
|
||||
wasmPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,14 +123,14 @@ export async function GET(
|
||||
|
||||
if (asset.length === 1 && asset[0] === "manifest.json") {
|
||||
try {
|
||||
const manifest = await buildRuntimeManifest();
|
||||
const manifest = await buildIslandManifest();
|
||||
return NextResponse.json(manifest, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法生成 leptos-tiptap runtime 清单",
|
||||
error: "无法生成 leptos-tiptap island 清单",
|
||||
detail: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
{ status: 500 },
|
||||
@@ -137,12 +140,12 @@ export async function GET(
|
||||
|
||||
const relativePath = sanitizeRelativePath(asset);
|
||||
if (!relativePath) {
|
||||
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 });
|
||||
return NextResponse.json({ error: "非法 island 资源路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(DIST_ROOT, relativePath);
|
||||
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) {
|
||||
return NextResponse.json({ error: "越界访问 runtime 资源被拒绝" }, { status: 403 });
|
||||
const absolutePath = path.resolve(GENERATED_ROOT, relativePath);
|
||||
if (!absolutePath.startsWith(GENERATED_ROOT + path.sep)) {
|
||||
return NextResponse.json({ error: "越界访问 island 资源被拒绝" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -156,11 +159,11 @@ export async function GET(
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return NextResponse.json({ error: "runtime 资源不存在" }, { status: 404 });
|
||||
return NextResponse.json({ error: "island 资源不存在" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "读取 runtime 资源失败",
|
||||
error: "读取 island 资源失败",
|
||||
detail: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
{ status: 500 },
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockBuildMnoteWebForwardHeaders = vi.fn();
|
||||
const mockBuildMnoteWebStreamUrl = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/server/mnote-web", () => ({
|
||||
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
|
||||
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
|
||||
}));
|
||||
|
||||
describe("/api/mnote-web/stream route", () => {
|
||||
beforeEach(() => {
|
||||
mockBuildMnoteWebForwardHeaders.mockReset();
|
||||
mockBuildMnoteWebStreamUrl.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("透传上游 SSE 并去掉 set-cookie", async () => {
|
||||
const upstreamHeaders = new Headers({
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"set-cookie": "secret=1",
|
||||
});
|
||||
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
|
||||
status: 200,
|
||||
headers: upstreamHeaders,
|
||||
});
|
||||
|
||||
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
|
||||
mockBuildMnoteWebStreamUrl.mockReturnValue(
|
||||
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn(async () => upstreamResponse);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
|
||||
method: "GET",
|
||||
headers: { cookie: "a=1" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
await expect(response.text()).resolves.toContain("event: snapshot");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import {
|
||||
buildMnoteWebForwardHeaders,
|
||||
buildMnoteWebStreamUrl,
|
||||
} from "@/lib/server/mnote-web";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
|
||||
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
|
||||
|
||||
if (!workspaceId) {
|
||||
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
|
||||
const headers = await buildMnoteWebForwardHeaders(request);
|
||||
headers.set("accept", "text/event-stream");
|
||||
headers.set("x-mnote-workspace-id", workspaceId);
|
||||
|
||||
const upstream = await fetch(targetUrl.toString(), {
|
||||
method: "GET",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
responseHeaders.delete("set-cookie");
|
||||
responseHeaders.set("cache-control", "no-store");
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -130,12 +130,24 @@ body {
|
||||
}
|
||||
|
||||
/* 调整编辑器内容宽度 */
|
||||
.bn-editor {
|
||||
padding-left: 5rem;
|
||||
padding-right: 5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bn-editor {
|
||||
padding-left: 5rem;
|
||||
padding-right: 5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 说明:leptos-tiptap / ProseMirror 需要最基础的 white-space 与换行样式,
|
||||
否则浏览器输入与光标行为会不稳定,并触发 ProseMirror 警告。 */
|
||||
.ProseMirror {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
@@ -8,11 +7,6 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wolai Clone",
|
||||
description: "Convex 自部署模式",
|
||||
@@ -40,7 +34,7 @@ export default async function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<body className="antialiased">
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<AppPreferencesHydrator />
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { usePreferredSidebarSnapshotData } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { AppLayoutShell } from "./app-layout-shell";
|
||||
|
||||
const mockUseSidebarData = vi.fn();
|
||||
const mockUseSidebarTreeStream = vi.fn();
|
||||
const mockUsePreferredSidebarSnapshot = vi.fn();
|
||||
|
||||
let capturedSidebarProps: Record<string, unknown> | null = null;
|
||||
let capturedBreadcrumbProps: Record<string, unknown> | null = null;
|
||||
let capturedSearchPaletteProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("@/hooks/use-sidebar-data", () => ({
|
||||
useSidebarData: (...args: unknown[]) => mockUseSidebarData(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tree-stream/use-sidebar-tree-stream", () => ({
|
||||
useSidebarTreeStream: (...args: unknown[]) => mockUseSidebarTreeStream(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/use-preferred-sidebar-snapshot", () => ({
|
||||
usePreferredSidebarSnapshot: (...args: unknown[]) => mockUsePreferredSidebarSnapshot(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar", () => ({
|
||||
Sidebar: (props: Record<string, unknown>) => {
|
||||
capturedSidebarProps = props;
|
||||
return <div data-testid="sidebar" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/breadcrumb", () => ({
|
||||
Breadcrumb: (props: Record<string, unknown>) => {
|
||||
capturedBreadcrumbProps = props;
|
||||
return <div data-testid="breadcrumb" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mobile-sidebar-trigger", () => ({
|
||||
MobileSidebarTrigger: () => <div data-testid="mobile-trigger" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/search/search-palette", () => ({
|
||||
SearchPalette: (props: Record<string, unknown>) => {
|
||||
capturedSearchPaletteProps = props;
|
||||
return <div data-testid="search-palette" />;
|
||||
},
|
||||
}));
|
||||
|
||||
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题 A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
||||
return buildSidebarInitialData({
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents,
|
||||
trashedDocuments: [],
|
||||
mindmaps: [],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
}
|
||||
|
||||
function SnapshotProbe() {
|
||||
const snapshot = usePreferredSidebarSnapshotData();
|
||||
return <div data-testid="snapshot-probe">{snapshot.documents[0]?.title ?? "空"}</div>;
|
||||
}
|
||||
|
||||
describe("AppLayoutShell", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
capturedSidebarProps = null;
|
||||
capturedBreadcrumbProps = null;
|
||||
capturedSearchPaletteProps = null;
|
||||
mockUseSidebarData.mockReset();
|
||||
mockUseSidebarTreeStream.mockReset();
|
||||
mockUsePreferredSidebarSnapshot.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("应把同一份 preferred sidebar snapshot 同时提供给 Sidebar 与 Breadcrumb", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const preferredData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const sidebarQuery = {
|
||||
data: preferredData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(async () => preferredData),
|
||||
source: "convex-live" as const,
|
||||
};
|
||||
const treeStream = {
|
||||
data: preferredData,
|
||||
status: "live" as const,
|
||||
cursor: "evt-1",
|
||||
error: null,
|
||||
};
|
||||
|
||||
mockUseSidebarData.mockReturnValue(sidebarQuery);
|
||||
mockUseSidebarTreeStream.mockReturnValue(treeStream);
|
||||
mockUsePreferredSidebarSnapshot.mockReturnValue({
|
||||
data: preferredData,
|
||||
source: "tree_stream",
|
||||
syncKey: "sync-key-1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AppLayoutShell initialData={initialData}>
|
||||
<div>正文</div>
|
||||
</AppLayoutShell>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(capturedSidebarProps).toMatchObject({
|
||||
initialData,
|
||||
sidebarQuery,
|
||||
sidebarData: preferredData,
|
||||
treeStream,
|
||||
});
|
||||
expect(capturedBreadcrumbProps).toMatchObject({
|
||||
documents: preferredData.documents,
|
||||
});
|
||||
expect(capturedSearchPaletteProps).toMatchObject({
|
||||
workspaceId: "ws-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("应把同一份 preferred sidebar snapshot 继续提供给页面 children", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const preferredData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUseSidebarData.mockReturnValue({
|
||||
data: preferredData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(async () => preferredData),
|
||||
source: "convex-live" as const,
|
||||
});
|
||||
mockUseSidebarTreeStream.mockReturnValue({
|
||||
data: preferredData,
|
||||
status: "live" as const,
|
||||
cursor: "evt-1",
|
||||
error: null,
|
||||
});
|
||||
mockUsePreferredSidebarSnapshot.mockReturnValue({
|
||||
data: preferredData,
|
||||
source: "tree_stream",
|
||||
syncKey: "sync-key-2",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AppLayoutShell initialData={initialData}>
|
||||
<SnapshotProbe />
|
||||
</AppLayoutShell>,
|
||||
);
|
||||
});
|
||||
|
||||
const probe = container.querySelector("[data-testid='snapshot-probe']");
|
||||
expect(probe?.textContent).toBe("标题 B");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
|
||||
|
||||
interface AppLayoutShellProps {
|
||||
initialData: SidebarInitialData;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
|
||||
const sidebarQuery = useSidebarData(initialData);
|
||||
const treeStream = useSidebarTreeStream(initialData);
|
||||
const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
|
||||
initialData,
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
});
|
||||
|
||||
return (
|
||||
<PreferredSidebarSnapshotProvider data={preferredSidebarSnapshot.data}>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
<Sidebar
|
||||
initialData={initialData}
|
||||
sidebarData={preferredSidebarSnapshot.data}
|
||||
sidebarQuery={sidebarQuery}
|
||||
treeStream={treeStream}
|
||||
/>
|
||||
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
|
||||
<header className="sticky top-0 z-50 flex h-[44px] w-full items-center bg-white/80 px-4 text-sm text-wolai-text-secondary backdrop-blur-sm">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={preferredSidebarSnapshot.data.documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<SearchPalette workspaceId={preferredSidebarSnapshot.data.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
</PreferredSidebarSnapshotProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import {
|
||||
applyDocWriteToolResultToPageBody,
|
||||
buildAiAgentSessionsStoragePayload,
|
||||
extractCurrentPageTitleFromSlashToolResult,
|
||||
shouldSyncActiveSessionSnapshot,
|
||||
} from "./DocumentAiAgentPanel.runtime";
|
||||
|
||||
type TestSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
toolLogs: Array<{ type: string; id?: string; tool?: string; ok?: boolean; ms?: number; result?: unknown }>;
|
||||
codexSessionId?: string | null;
|
||||
codexMode?: "chat" | "test" | "dev" | null;
|
||||
};
|
||||
|
||||
function buildSession(overrides: Partial<TestSession> = {}): TestSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
title: "新会话",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
messages: [{ role: "assistant", content: "欢迎语" }],
|
||||
toolLogs: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DocumentAiAgentPanel.runtime 会话回写保护", () => {
|
||||
it("当前会话快照与本地消息和日志一致时,不应触发回写", () => {
|
||||
const session = buildSession();
|
||||
expect(shouldSyncActiveSessionSnapshot(session, session.messages, session.toolLogs)).toBe(false);
|
||||
});
|
||||
|
||||
it("当前会话快照与本地消息或日志不同时时,才应触发回写", () => {
|
||||
const session = buildSession({
|
||||
messages: [{ role: "assistant", content: "旧内容" }],
|
||||
toolLogs: [{ type: "info", message: "old" }],
|
||||
});
|
||||
expect(
|
||||
shouldSyncActiveSessionSnapshot(
|
||||
session,
|
||||
[{ role: "assistant", content: "新内容" }],
|
||||
[{ type: "info", message: "new" }],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("持久化 payload 应稳定包含标准化后的会话列表", () => {
|
||||
const payload = buildAiAgentSessionsStoragePayload("session-1", [
|
||||
buildSession({
|
||||
updatedAt: 999,
|
||||
messages: [{ role: "assistant", content: "欢迎语" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const parsed = JSON.parse(payload) as {
|
||||
activeSessionId: string;
|
||||
sessions: Array<{ id: string; title: string; createdAt: number; updatedAt: number; messages: Json; toolLogs: Json }>;
|
||||
};
|
||||
expect(parsed.activeSessionId).toBe("session-1");
|
||||
expect(parsed.sessions).toHaveLength(1);
|
||||
expect(parsed.sessions[0]?.id).toBe("session-1");
|
||||
expect(parsed.sessions[0]?.messages).toEqual([{ role: "assistant", content: "欢迎语" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDocWriteToolResultToPageBody", () => {
|
||||
it("doc 写工具成功时应按最新持久化元信息执行 page body save", async () => {
|
||||
const applyPageBodyCommandImpl = vi.fn(async () => ({
|
||||
revision: 6,
|
||||
conflictDetectionKey: "doc-1:6",
|
||||
}));
|
||||
const applyEditorSnapshot = vi.fn();
|
||||
const onPersistedMetaChange = vi.fn();
|
||||
|
||||
const applied = await applyDocWriteToolResultToPageBody({
|
||||
tool: "doc_insert_blocks",
|
||||
ok: true,
|
||||
result: {
|
||||
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
|
||||
},
|
||||
documentId: "doc-1",
|
||||
getLatestPersistedMeta: () => ({
|
||||
workspaceId: "ws-1",
|
||||
revision: 5,
|
||||
conflictDetectionKey: "doc-1:5",
|
||||
}),
|
||||
applyEditorSnapshot,
|
||||
onPersistedMetaChange,
|
||||
applyPageBodyCommandImpl,
|
||||
});
|
||||
|
||||
expect(applied).toBe(true);
|
||||
expect(applyPageBodyCommandImpl).toHaveBeenCalledWith({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
revision: 5,
|
||||
conflictDetectionKey: "doc-1:5",
|
||||
blocks: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
|
||||
applyEditorSnapshot,
|
||||
});
|
||||
expect(onPersistedMetaChange).toHaveBeenCalledWith({
|
||||
revision: 6,
|
||||
conflictDetectionKey: "doc-1:6",
|
||||
});
|
||||
});
|
||||
|
||||
it("非 doc 写工具或无 data 时应直接忽略", async () => {
|
||||
const applyPageBodyCommandImpl = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyDocWriteToolResultToPageBody({
|
||||
tool: "docs_read",
|
||||
ok: true,
|
||||
result: { data: [] },
|
||||
documentId: "doc-1",
|
||||
getLatestPersistedMeta: () => ({
|
||||
workspaceId: "ws-1",
|
||||
revision: 5,
|
||||
conflictDetectionKey: "doc-1:5",
|
||||
}),
|
||||
applyEditorSnapshot: vi.fn(),
|
||||
onPersistedMetaChange: vi.fn(),
|
||||
applyPageBodyCommandImpl,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCurrentPageTitleFromSlashToolResult", () => {
|
||||
it("slash_run 成功重命名当前页时应返回新标题", () => {
|
||||
expect(
|
||||
extractCurrentPageTitleFromSlashToolResult({
|
||||
tool: "slash_run",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
parsed: {
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: "doc-1",
|
||||
title: "AI 新标题",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("AI 新标题");
|
||||
});
|
||||
|
||||
it("非当前页或非 rename 结果时应忽略", () => {
|
||||
expect(
|
||||
extractCurrentPageTitleFromSlashToolResult({
|
||||
tool: "slash_run",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
parsed: {
|
||||
command: "rename_doc",
|
||||
params: {
|
||||
documentId: "doc-2",
|
||||
title: "别的页面",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import {
|
||||
applyPageBodyCommand,
|
||||
type ApplyPageBodyCommandInput,
|
||||
type PageBodyPersistedMeta,
|
||||
type PageBodyPersistedState,
|
||||
} from "@/lib/documents/page-body-command";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
@@ -113,6 +119,110 @@ const normalizeSessions = (sessions: ChatSession[]) => {
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
|
||||
function buildSessionSnapshotKey(input: {
|
||||
messages: ChatSession["messages"];
|
||||
toolLogs: ChatSession["toolLogs"];
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
messages: input.messages,
|
||||
toolLogs: input.toolLogs,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldSyncActiveSessionSnapshot(
|
||||
session: Pick<ChatSession, "messages" | "toolLogs"> | null | undefined,
|
||||
messages: ChatSession["messages"],
|
||||
toolLogs: ChatSession["toolLogs"],
|
||||
): boolean {
|
||||
if (!session) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
buildSessionSnapshotKey({
|
||||
messages: session.messages,
|
||||
toolLogs: session.toolLogs,
|
||||
}) !==
|
||||
buildSessionSnapshotKey({
|
||||
messages,
|
||||
toolLogs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAiAgentSessionsStoragePayload(
|
||||
activeSessionId: string,
|
||||
sessions: ChatSession[],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
activeSessionId,
|
||||
sessions: normalizeSessions(sessions),
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyDocWriteToolResultToPageBody(input: {
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
result: unknown;
|
||||
documentId: string;
|
||||
getLatestPersistedMeta: () => PageBodyPersistedState;
|
||||
applyEditorSnapshot?: (blocks: Json) => void;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
|
||||
}): Promise<boolean> {
|
||||
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
|
||||
return false;
|
||||
}
|
||||
const resultRecord =
|
||||
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
|
||||
const dataNode = resultRecord && "data" in resultRecord ? resultRecord.data : null;
|
||||
if (dataNode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const latestPersistedMeta = input.getLatestPersistedMeta();
|
||||
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
|
||||
const persistedMeta = await applyPageBodyCommandImpl({
|
||||
documentId: input.documentId,
|
||||
workspaceId: latestPersistedMeta.workspaceId,
|
||||
revision: latestPersistedMeta.revision,
|
||||
conflictDetectionKey: latestPersistedMeta.conflictDetectionKey,
|
||||
blocks: dataNode as Json,
|
||||
applyEditorSnapshot: input.applyEditorSnapshot,
|
||||
});
|
||||
input.onPersistedMetaChange?.(persistedMeta);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function extractCurrentPageTitleFromSlashToolResult(input: {
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
result: unknown;
|
||||
documentId: string;
|
||||
}): string | null {
|
||||
if (!input.ok || input.tool !== "slash_run") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultRecord =
|
||||
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
|
||||
const parsed =
|
||||
resultRecord?.parsed && typeof resultRecord.parsed === "object"
|
||||
? (resultRecord.parsed as Record<string, unknown>)
|
||||
: null;
|
||||
if (!parsed || String(parsed.command ?? "") !== "rename_doc") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params =
|
||||
parsed.params && typeof parsed.params === "object" ? (parsed.params as Record<string, unknown>) : null;
|
||||
if (!params || String(params.documentId ?? "") !== input.documentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = String(params.title ?? "").trim();
|
||||
return title || null;
|
||||
}
|
||||
|
||||
const safeJsonStringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
@@ -125,10 +235,16 @@ export function DocumentAiAgentPanelRuntime({
|
||||
documentId,
|
||||
getLatestBlocks,
|
||||
getLatestPageSubtree,
|
||||
getLatestPersistedMeta,
|
||||
onPersistedMetaChange,
|
||||
onPageHeadTitleChange,
|
||||
}: {
|
||||
documentId: string;
|
||||
getLatestBlocks: () => Json | null;
|
||||
getLatestPageSubtree: () => PageSubtreeProjection | null;
|
||||
getLatestPersistedMeta: () => PageBodyPersistedState;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
onPageHeadTitleChange?: (title: string) => void;
|
||||
}) {
|
||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||
|
||||
@@ -158,6 +274,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const syncTimerRef = useRef<number | null>(null);
|
||||
const hydratedSessionsRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
@@ -207,6 +324,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
setActiveSessionId(id);
|
||||
setMessages(session.messages);
|
||||
setToolLogs([]);
|
||||
hydratedSessionsRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,8 +355,10 @@ export function DocumentAiAgentPanelRuntime({
|
||||
const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!;
|
||||
setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES);
|
||||
setToolLogs(cur.toolLogs ?? []);
|
||||
hydratedSessionsRef.current = true;
|
||||
} catch {
|
||||
// ignore
|
||||
hydratedSessionsRef.current = true;
|
||||
}
|
||||
// 只在 documentId 变化时读取一次
|
||||
|
||||
@@ -246,9 +366,14 @@ export function DocumentAiAgentPanelRuntime({
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
if (!hydratedSessionsRef.current) return;
|
||||
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = window.setTimeout(() => {
|
||||
setSessions((prev) => {
|
||||
const current = prev.find((s) => s.id === activeSessionId) ?? null;
|
||||
if (shouldSyncActiveSessionSnapshot(current, messages, toolLogs) === false) {
|
||||
return prev;
|
||||
}
|
||||
const now = Date.now();
|
||||
const next = prev.some((s) => s.id === activeSessionId)
|
||||
? prev.map((s) =>
|
||||
@@ -277,9 +402,10 @@ export function DocumentAiAgentPanelRuntime({
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId) return;
|
||||
if (!hydratedSessionsRef.current) return;
|
||||
try {
|
||||
const key = `doc_ai_sessions:${documentId}`;
|
||||
const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) });
|
||||
const payload = buildAiAgentSessionsStoragePayload(activeSessionId, sessions);
|
||||
if (payload.length <= 900_000) window.localStorage.setItem(key, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -582,19 +708,32 @@ export function DocumentAiAgentPanelRuntime({
|
||||
{ type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result },
|
||||
]);
|
||||
|
||||
// doc 写工具返回 data=blocks 时,立即落入编辑器
|
||||
if ((tool === "doc_insert_blocks" || tool === "doc_replace_range") && obj.ok) {
|
||||
const r = result as unknown;
|
||||
const dataNode =
|
||||
typeof r === "object" && r && "data" in (r as Record<string, unknown>) ? (r as Record<string, unknown>).data : null;
|
||||
if (dataNode && editorBridge?.replaceWithSnapshot) {
|
||||
try {
|
||||
editorBridge.replaceWithSnapshot(dataNode as Json);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const nextPageTitle = extractCurrentPageTitleFromSlashToolResult({
|
||||
tool,
|
||||
ok: Boolean(obj.ok),
|
||||
result,
|
||||
documentId,
|
||||
});
|
||||
if (nextPageTitle) {
|
||||
onPageHeadTitleChange?.(nextPageTitle);
|
||||
}
|
||||
|
||||
void applyDocWriteToolResultToPageBody({
|
||||
tool,
|
||||
ok: Boolean(obj.ok),
|
||||
result,
|
||||
documentId,
|
||||
getLatestPersistedMeta,
|
||||
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
|
||||
? (blocks) => {
|
||||
editorBridge.replaceWithSnapshot(blocks);
|
||||
}
|
||||
: undefined,
|
||||
onPersistedMetaChange,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "AI 页面正文保存失败";
|
||||
setToolLogs((prev) => [...prev, { type: "error", message }]);
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import type { PageBodyPersistedMeta, PageBodyPersistedState } from "@/lib/documents/page-body-command";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
|
||||
@@ -10,6 +11,9 @@ type DocumentAiAgentPanelProps = {
|
||||
documentId: string;
|
||||
getLatestBlocks: () => Json | null;
|
||||
getLatestPageSubtree: () => PageSubtreeProjection | null;
|
||||
getLatestPersistedMeta: () => PageBodyPersistedState;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
onPageHeadTitleChange?: (title: string) => void;
|
||||
};
|
||||
|
||||
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { persistPageTitleAndNotifyDocumentsChanged } from "@/components/editor/document-content";
|
||||
|
||||
describe("persistPageTitleAndNotifyDocumentsChanged", () => {
|
||||
it("标题提交成功后应广播 documents-changed", async () => {
|
||||
const persistTitleCommand = vi.fn(async () => ({ ok: true as const }));
|
||||
const emitDocumentsChanged = vi.fn();
|
||||
|
||||
const result = await persistPageTitleAndNotifyDocumentsChanged({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
title: " 新标题 ",
|
||||
persistTitleCommand,
|
||||
notifyDocumentsChanged: emitDocumentsChanged,
|
||||
});
|
||||
|
||||
expect(result).toBe("新标题");
|
||||
expect(persistTitleCommand).toHaveBeenCalledWith({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
title: "新标题",
|
||||
});
|
||||
expect(emitDocumentsChanged).toHaveBeenCalledWith("doc-1");
|
||||
});
|
||||
});
|
||||
@@ -20,15 +20,27 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DocumentToc } from "@/components/editor/document-toc";
|
||||
import { DocumentReadView } from "@/components/editor/document-read-view";
|
||||
import { emitDocumentsChanged } from "@/lib/events";
|
||||
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import {
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
updatePageOptionsCommand,
|
||||
updatePageTitleCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
import { EditorHost } from "@/components/editor/editor-host";
|
||||
import type { EditorHostKind } from "@/components/editor/editor-host-config";
|
||||
import {
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
isLeptosTiptapHostKind,
|
||||
type EditorHostKind,
|
||||
} from "@/components/editor/editor-host-config";
|
||||
import type {
|
||||
EditorHostEvent,
|
||||
EditorHostFallbackReason,
|
||||
} from "@/components/editor/editor-host-types";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -78,21 +90,9 @@ const DocumentCommentsDrawer = dynamic(
|
||||
);
|
||||
|
||||
export interface DocumentContentProps {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title: string | null;
|
||||
updatedAt: string | null;
|
||||
initialContent: unknown;
|
||||
initialContentRevision?: number | null;
|
||||
initialConflictDetectionKey?: string | null;
|
||||
initialPageSubtree?: PageSubtreeProjection | null;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
page: PageAggregateProjection;
|
||||
openTableId?: string | null;
|
||||
editorHostKind?: EditorHostKind;
|
||||
readOnly?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableCopy?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
@@ -112,24 +112,43 @@ const defaultOptions: PageOptionsState = {
|
||||
};
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
|
||||
const EDITOR_UNMOUNT_GRACE_MS = 1000;
|
||||
const FALLBACK_TRIGGER_HISTORY_LIMIT = 20;
|
||||
|
||||
export async function persistPageTitleAndNotifyDocumentsChanged(input: {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
persistTitleCommand: (payload: { documentId: string; workspaceId: string; title: string }) => Promise<unknown>;
|
||||
notifyDocumentsChanged: (documentId?: string) => void;
|
||||
}): Promise<string> {
|
||||
const payload = input.title.trim() || "无标题";
|
||||
await input.persistTitleCommand({
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
title: payload,
|
||||
});
|
||||
input.notifyDocumentsChanged(input.documentId);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function DocumentContent({
|
||||
documentId,
|
||||
workspaceId,
|
||||
title,
|
||||
updatedAt,
|
||||
initialContent,
|
||||
initialContentRevision = null,
|
||||
initialConflictDetectionKey = null,
|
||||
initialPageSubtree = null,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
page,
|
||||
openTableId,
|
||||
editorHostKind = "blocknote",
|
||||
readOnly = false,
|
||||
disableDownload = false,
|
||||
disableCopy = false,
|
||||
editorHostKind = DEFAULT_EDITOR_HOST_KIND,
|
||||
}: DocumentContentProps) {
|
||||
const documentId = page.identity.documentId;
|
||||
const workspaceId = page.identity.workspaceId;
|
||||
const initialTitle = page.head.title;
|
||||
const updatedAt = page.head.updatedAt;
|
||||
const readOnly = page.head.permissions.readOnly;
|
||||
const disableDownload = page.head.permissions.disableDownload;
|
||||
const disableCopy = page.head.permissions.disableCopy;
|
||||
const initialOptions = page.layout.pageOptions;
|
||||
const initialContent = page.body.content;
|
||||
const initialContentRevision = page.body.revision;
|
||||
const initialConflictDetectionKey = page.body.conflictDetectionKey;
|
||||
const initialPageSubtree = page.tree.pageSubtree;
|
||||
const initialStats = page.stats;
|
||||
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
|
||||
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
|
||||
const canEditDocument = !readOnly;
|
||||
@@ -143,22 +162,42 @@ export function DocumentContent({
|
||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const {
|
||||
displayTitle: pageTitle,
|
||||
committedTitle: committedPageTitle,
|
||||
setDraftTitle: setPageTitleDraft,
|
||||
commitPersistedTitle,
|
||||
} = usePageHeadTitle({
|
||||
documentId,
|
||||
fallbackTitle: initialTitle,
|
||||
});
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
|
||||
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
|
||||
initialPageSubtree,
|
||||
);
|
||||
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(title ?? "无标题");
|
||||
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
|
||||
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
|
||||
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||
const shouldUseExperimentalHost =
|
||||
editorHostKind === "leptos_tiptap_inline" ||
|
||||
editorHostKind === "leptos_tiptap_iframe_debug";
|
||||
const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind);
|
||||
const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote";
|
||||
const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() =>
|
||||
requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind,
|
||||
);
|
||||
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
|
||||
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
|
||||
const [hostCommandFailure, setHostCommandFailure] = useState<string | null>(null);
|
||||
const [hostSaveFailure, setHostSaveFailure] = useState<string | null>(null);
|
||||
const [hostFallbackCount, setHostFallbackCount] = useState<number>(0);
|
||||
const [lastFallbackReason, setLastFallbackReason] = useState<EditorHostFallbackReason | null>(null);
|
||||
const [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
|
||||
const [hostStatus, setHostStatus] = useState<string>("idle");
|
||||
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
|
||||
const fallbackTriggerHistoryRef = useRef<string[]>([]);
|
||||
const shouldStartEditing = canEditDocument;
|
||||
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
|
||||
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
|
||||
@@ -170,6 +209,51 @@ export function DocumentContent({
|
||||
const readViewRootRef = useRef<HTMLDivElement>(null);
|
||||
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
|
||||
const lastCopyBlockedAtRef = useRef<number>(0);
|
||||
const hasRequestedFallbackRef = useRef(false);
|
||||
|
||||
const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => {
|
||||
setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting");
|
||||
setHostEventAt(new Date().toISOString());
|
||||
setHostRuntimeLoadFailure(null);
|
||||
setHostInitFailure(null);
|
||||
setHostCommandFailure(null);
|
||||
setHostSaveFailure(null);
|
||||
setHostFallbackCount(0);
|
||||
setLastFallbackReason(null);
|
||||
setLastFallbackAt(null);
|
||||
fallbackTriggerHistoryRef.current = [];
|
||||
hasRequestedFallbackRef.current = false;
|
||||
}, []);
|
||||
|
||||
const requestFallbackToBlockNote = useCallback(
|
||||
(reason: EditorHostFallbackReason, error?: string | null) => {
|
||||
if (hasRequestedFallbackRef.current) {
|
||||
return;
|
||||
}
|
||||
hasRequestedFallbackRef.current = true;
|
||||
const now = new Date().toISOString();
|
||||
setActiveHostKind("blocknote");
|
||||
setHostFallbackCount((prev) => prev + 1);
|
||||
setLastFallbackReason(reason);
|
||||
setLastFallbackAt(now);
|
||||
setHostStatus("blocknote_fallback");
|
||||
setHostEventAt(now);
|
||||
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
|
||||
0,
|
||||
FALLBACK_TRIGGER_HISTORY_LIMIT,
|
||||
);
|
||||
if (reason === "runtime_load_failed") {
|
||||
setHostRuntimeLoadFailure(error ?? "runtime 加载失败");
|
||||
} else if (reason === "host_init_failed") {
|
||||
setHostInitFailure(error ?? "host 初始化失败");
|
||||
} else if (reason === "command_failed") {
|
||||
setHostCommandFailure(error ?? "命令执行失败");
|
||||
} else if (reason === "save_failed") {
|
||||
setHostSaveFailure(error ?? "保存失败");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDocument(documentId, readOnly, disableDownload, disableCopy);
|
||||
@@ -243,6 +327,11 @@ export function DocumentContent({
|
||||
};
|
||||
}, [disableCopy]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
||||
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
||||
}, [requestedHostKind, resetHostObservability]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableId = (openTableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
@@ -264,12 +353,8 @@ export function DocumentContent({
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
setServerPageSubtreeTitle(committedPageTitle);
|
||||
}, [committedPageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeSnapshot(initialPageSubtree);
|
||||
@@ -393,7 +478,7 @@ export function DocumentContent({
|
||||
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
|
||||
payload.pageSubtree.rootNode.metadata.title.trim()
|
||||
? payload.pageSubtree.rootNode.metadata.title
|
||||
: title ?? "无标题",
|
||||
: committedPageTitle,
|
||||
);
|
||||
} catch (error) {
|
||||
if (canceled) return;
|
||||
@@ -421,19 +506,36 @@ export function DocumentContent({
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [documentId, initialContent, contentReloadKey, title, workspaceId]);
|
||||
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
if (readOnly) return;
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
try {
|
||||
await renameDocumentCommand({ documentId, workspaceId, title: payload });
|
||||
const payload = await persistPageTitleAndNotifyDocumentsChanged({
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: nextTitle,
|
||||
persistTitleCommand: updatePageTitleCommand,
|
||||
notifyDocumentsChanged: emitDocumentsChanged,
|
||||
});
|
||||
commitPersistedTitle(payload);
|
||||
setServerPageSubtreeTitle(payload);
|
||||
} catch (error) {
|
||||
console.error("更新页面标题失败", error);
|
||||
}
|
||||
},
|
||||
[documentId, readOnly, workspaceId],
|
||||
[commitPersistedTitle, documentId, readOnly, workspaceId],
|
||||
);
|
||||
|
||||
const handleAiPageHeadTitleChange = useCallback(
|
||||
(nextTitle: string) => {
|
||||
setPageTitleDraft(nextTitle);
|
||||
commitPersistedTitle(nextTitle);
|
||||
setServerPageSubtreeTitle(nextTitle);
|
||||
emitDocumentsChanged(documentId);
|
||||
},
|
||||
[commitPersistedTitle, documentId, setPageTitleDraft],
|
||||
);
|
||||
|
||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||
@@ -443,7 +545,7 @@ export function DocumentContent({
|
||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canEditDocument) return;
|
||||
const value = event.target.value;
|
||||
setPageTitle(value);
|
||||
setPageTitleDraft(value);
|
||||
debouncedPersistTitle(value);
|
||||
};
|
||||
|
||||
@@ -463,15 +565,11 @@ export function DocumentContent({
|
||||
async (patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
try {
|
||||
const response = await fetch("/api/documents/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, workspaceId, options: patch }),
|
||||
await updatePageOptionsCommand({
|
||||
documentId,
|
||||
workspaceId,
|
||||
pageOptions: patch,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
console.error(payload?.error ?? "更新页面选项失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -742,10 +840,10 @@ export function DocumentContent({
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`;
|
||||
anchor.download = `${pageTitle ?? "未命名页面"}-${new Date().toISOString()}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [disableDownload, history, title]);
|
||||
}, [disableDownload, history, pageTitle]);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-full overflow-hidden bg-wolai-bg",
|
||||
@@ -784,6 +882,16 @@ export function DocumentContent({
|
||||
})),
|
||||
[pageSubtree],
|
||||
);
|
||||
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
|
||||
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
|
||||
const getLatestPersistedMeta = useCallback(
|
||||
() => ({
|
||||
workspaceId,
|
||||
revision: contentRevision,
|
||||
conflictDetectionKey,
|
||||
}),
|
||||
[conflictDetectionKey, contentRevision, workspaceId],
|
||||
);
|
||||
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
@@ -861,6 +969,68 @@ export function DocumentContent({
|
||||
editorBridge.replaceWithSnapshot(pendingSnapshot.blocks);
|
||||
setHistoryOpen(false);
|
||||
}, [editorBridge, isEditing]);
|
||||
|
||||
const handleHostEvent = useCallback((event: EditorHostEvent) => {
|
||||
setHostEventAt(event.at);
|
||||
if (event.kind === "status_changed") {
|
||||
setHostStatus(event.status);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "runtime_load_failed") {
|
||||
setHostRuntimeLoadFailure(event.message);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "host_init_failed") {
|
||||
setHostInitFailure(event.message);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "command_failed") {
|
||||
setHostCommandFailure(event.message);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "save_failed") {
|
||||
setHostSaveFailure(event.message);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hostObservability = useMemo(
|
||||
() => ({
|
||||
requestedHostKind,
|
||||
activeHostKind,
|
||||
status: hostStatus,
|
||||
runtimeLoadFailed: hostRuntimeLoadFailure,
|
||||
hostInitFailed: hostInitFailure,
|
||||
commandFailed: hostCommandFailure,
|
||||
saveFailed: hostSaveFailure,
|
||||
fallbackCount: hostFallbackCount,
|
||||
lastFallbackReason,
|
||||
lastFallbackAt,
|
||||
lastEventAt: hostEventAt,
|
||||
fallbackTimestamps: fallbackTriggerHistoryRef.current,
|
||||
}),
|
||||
[
|
||||
activeHostKind,
|
||||
hostEventAt,
|
||||
hostFallbackCount,
|
||||
hostInitFailure,
|
||||
hostCommandFailure,
|
||||
hostRuntimeLoadFailure,
|
||||
hostSaveFailure,
|
||||
hostStatus,
|
||||
lastFallbackAt,
|
||||
lastFallbackReason,
|
||||
requestedHostKind,
|
||||
],
|
||||
);
|
||||
const activeHostFailureMessage =
|
||||
hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure;
|
||||
const showFallbackBanner =
|
||||
requestedHostKind !== "blocknote" && activeHostKind === "blocknote" && lastFallbackReason != null;
|
||||
const showFailureBanner =
|
||||
requestedHostKind !== "blocknote" &&
|
||||
activeHostKind !== "blocknote" &&
|
||||
activeHostFailureMessage != null;
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className={pageRootClass} ref={pageRootRef}>
|
||||
@@ -935,15 +1105,55 @@ export function DocumentContent({
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{showFallbackBanner ? (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
默认 island 主编辑器已自动回退到 BlockNote
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-amber-700">
|
||||
原因:{lastFallbackReason}
|
||||
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setActiveHostKind(requestedHostKind);
|
||||
resetHostObservability(requestedHostKind);
|
||||
}}
|
||||
>
|
||||
重试 island 主链
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{showFailureBanner ? (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div>
|
||||
<div className="font-medium">island 主链出现错误</div>
|
||||
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => requestFallbackToBlockNote("explicit_fallback", activeHostFailureMessage)}
|
||||
>
|
||||
切回 BlockNote
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{keepEditorMounted && (
|
||||
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
|
||||
{shouldUseExperimentalHost ? (
|
||||
{activeHostKind !== "blocknote" ? (
|
||||
<EditorHost
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
title={title}
|
||||
hostKind={editorHostKind}
|
||||
title={pageTitle}
|
||||
hostKind={activeHostKind}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
@@ -955,13 +1165,17 @@ export function DocumentContent({
|
||||
setContentRevision(meta.revision);
|
||||
setConflictDetectionKey(meta.conflictDetectionKey);
|
||||
}}
|
||||
onHostEvent={handleHostEvent}
|
||||
onRequestFallback={(payload) => {
|
||||
requestFallbackToBlockNote(payload.reason, payload.error);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
title={title}
|
||||
title={pageTitle}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
@@ -977,6 +1191,21 @@ export function DocumentContent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="sr-only"
|
||||
data-editor-host-observability={JSON.stringify(hostObservability)}
|
||||
data-editor-host-active={hostObservability.activeHostKind}
|
||||
data-editor-host-requested={hostObservability.requestedHostKind}
|
||||
data-editor-host-status={hostObservability.status}
|
||||
data-editor-host-runtime-load-failed={
|
||||
hostObservability.runtimeLoadFailed ? "1" : "0"
|
||||
}
|
||||
data-editor-host-init-failed={hostObservability.hostInitFailed ? "1" : "0"}
|
||||
data-editor-host-command-failed={hostObservability.commandFailed ? "1" : "0"}
|
||||
data-editor-host-save-failed={hostObservability.saveFailed ? "1" : "0"}
|
||||
data-editor-host-fallback-count={String(hostObservability.fallbackCount)}
|
||||
data-editor-host-last-fallback-reason={hostObservability.lastFallbackReason ?? ""}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<div className="relative" ref={readViewRootRef}>
|
||||
<DocumentReadView
|
||||
@@ -1037,8 +1266,14 @@ export function DocumentContent({
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel
|
||||
documentId={documentId}
|
||||
getLatestBlocks={() => latestBlocksRef.current}
|
||||
getLatestPageSubtree={() => pageSubtree}
|
||||
getLatestBlocks={getLatestBlocks}
|
||||
getLatestPageSubtree={getLatestPageSubtree}
|
||||
getLatestPersistedMeta={getLatestPersistedMeta}
|
||||
onPersistedMetaChange={(meta) => {
|
||||
setContentRevision(meta.revision);
|
||||
setConflictDetectionKey(meta.conflictDetectionKey);
|
||||
}}
|
||||
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
||||
/>
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { DocumentContentProps } from "@/components/editor/document-content";
|
||||
import { DocumentContent } from "@/components/editor/document-content";
|
||||
|
||||
export function DocumentShell(props: DocumentContentProps) {
|
||||
export interface DocumentShellProps extends DocumentContentProps {}
|
||||
|
||||
export function DocumentShell(props: DocumentShellProps) {
|
||||
return <DocumentContent {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
normalizeEditorHostKind,
|
||||
resolveEditorHostKind,
|
||||
} from "@/components/editor/editor-host-config";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("editor-host-config", () => {
|
||||
it("保持 island 为默认正式 host", () => {
|
||||
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
|
||||
});
|
||||
|
||||
it("将 inline 和旧 runtime 兼容别名收敛到正式 island host", () => {
|
||||
expect(normalizeEditorHostKind("leptos_tiptap_inline")).toBe("leptos_tiptap_island");
|
||||
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
|
||||
expect(normalizeEditorHostKind("leptos_tiptap")).toBe("leptos_tiptap_island");
|
||||
});
|
||||
|
||||
it("保留 iframe debug 作为显式调试 host", () => {
|
||||
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe(
|
||||
"leptos_tiptap_iframe_debug",
|
||||
);
|
||||
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_iframe_debug");
|
||||
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe(
|
||||
"leptos_tiptap_iframe_debug",
|
||||
);
|
||||
});
|
||||
|
||||
it("优先尊重 query override 的 debug host 选择", () => {
|
||||
expect(
|
||||
resolveEditorHostKind({
|
||||
override: "leptos_tiptap_iframe_debug",
|
||||
runtimeDefault: "leptos_tiptap_island",
|
||||
}),
|
||||
).toBe("leptos_tiptap_iframe_debug");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,59 @@
|
||||
export type EditorHostKind =
|
||||
| "blocknote"
|
||||
| "leptos_tiptap_inline"
|
||||
| "leptos_tiptap_island"
|
||||
| "leptos_tiptap_iframe_debug";
|
||||
|
||||
export interface EditorHostConfig {
|
||||
kind: EditorHostKind;
|
||||
}
|
||||
|
||||
const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "blocknote";
|
||||
export const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "leptos_tiptap_island";
|
||||
|
||||
export function normalizeEditorHostKind(value: unknown): EditorHostKind {
|
||||
export function normalizeEditorHostKind(
|
||||
value: unknown,
|
||||
fallback: EditorHostKind = DEFAULT_EDITOR_HOST_KIND,
|
||||
): EditorHostKind {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (normalized === "leptos_tiptap_inline") {
|
||||
return "leptos_tiptap_inline";
|
||||
if (normalized === "blocknote") {
|
||||
return "blocknote";
|
||||
}
|
||||
if (normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap") {
|
||||
if (
|
||||
normalized === "leptos_tiptap_runtime" ||
|
||||
normalized === "leptos_tiptap_inline" ||
|
||||
normalized === "leptos_tiptap"
|
||||
) {
|
||||
return "leptos_tiptap_island";
|
||||
}
|
||||
// 说明:iframe 只保留给显式调试桥,不再混入正式 runtime 主链。
|
||||
if (
|
||||
normalized === "leptos_tiptap_iframe_debug" ||
|
||||
normalized === "leptos_tiptap_debug" ||
|
||||
normalized === "iframe_debug"
|
||||
) {
|
||||
return "leptos_tiptap_iframe_debug";
|
||||
}
|
||||
return DEFAULT_EDITOR_HOST_KIND;
|
||||
// 说明:保留历史 query 值兼容;未显式声明 debug 时,一律回到正式 runtime host。
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveEditorHostKind(input: {
|
||||
override?: unknown;
|
||||
runtimeDefault?: unknown;
|
||||
}): EditorHostKind {
|
||||
const runtimeDefault = normalizeEditorHostKind(
|
||||
input.runtimeDefault,
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
);
|
||||
const hasOverride =
|
||||
typeof input.override === "string" && input.override.trim().length > 0;
|
||||
if (hasOverride) {
|
||||
return normalizeEditorHostKind(input.override, runtimeDefault);
|
||||
}
|
||||
return runtimeDefault;
|
||||
}
|
||||
|
||||
export function isLeptosTiptapHostKind(kind: EditorHostKind): boolean {
|
||||
return kind !== "blocknote";
|
||||
}
|
||||
|
||||
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
|
||||
|
||||
@@ -22,10 +22,44 @@ export interface DocumentEditorHostProps {
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
}) => void;
|
||||
onHostEvent?: (event: EditorHostEvent) => void;
|
||||
onRequestFallback?: (payload: EditorHostFallbackRequest) => void;
|
||||
}
|
||||
|
||||
export type BlockNoteEditorProps = DocumentEditorHostProps;
|
||||
|
||||
export type EditorHostFallbackReason =
|
||||
| "runtime_load_failed"
|
||||
| "host_init_failed"
|
||||
| "command_failed"
|
||||
| "save_failed"
|
||||
| "explicit_fallback";
|
||||
|
||||
export type EditorHostFallbackRequest = {
|
||||
reason: EditorHostFallbackReason;
|
||||
error?: string | null;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type EditorHostEvent =
|
||||
| {
|
||||
kind: "status_changed";
|
||||
status: string;
|
||||
at: string;
|
||||
message?: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "runtime_load_failed" | "host_init_failed" | "command_failed" | "save_failed";
|
||||
at: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: "fallback_triggered";
|
||||
at: string;
|
||||
reason: EditorHostFallbackReason;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export type LeptosTiptapHostBridgeState = {
|
||||
ready: boolean;
|
||||
runtimeName: string;
|
||||
@@ -52,3 +86,8 @@ export type LeptosTiptapHostBridgeEventDetail = {
|
||||
error?: string | null;
|
||||
at?: string;
|
||||
};
|
||||
|
||||
export type LeptosTiptapRuntimePageOptions = Pick<
|
||||
PageOptionsState,
|
||||
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_EDITOR_HOST_KIND,
|
||||
normalizeEditorHostKind,
|
||||
} from "@/components/editor/editor-host-config";
|
||||
|
||||
describe("editor-host", () => {
|
||||
it("默认正式主链是 island host", () => {
|
||||
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
|
||||
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,19 @@ import dynamic from "next/dynamic";
|
||||
import type { ComponentType } from "react";
|
||||
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
|
||||
|
||||
const LeptosTiptapIslandEditor = dynamic(
|
||||
() =>
|
||||
import("@/components/editor/leptos-tiptap-island-editor-host").then(
|
||||
(mod) => mod.LeptosTiptapIslandEditorHost,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
|
||||
),
|
||||
},
|
||||
) as ComponentType<DocumentEditorHostProps>;
|
||||
|
||||
const LeptosTiptapIframeDebugEditor = dynamic(
|
||||
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
|
||||
{
|
||||
@@ -14,22 +27,9 @@ const LeptosTiptapIframeDebugEditor = dynamic(
|
||||
},
|
||||
) as ComponentType<DocumentEditorHostProps>;
|
||||
|
||||
const LeptosTiptapInlineEditor = dynamic(
|
||||
() =>
|
||||
import("@/components/editor/leptos-tiptap-inline-editor-host").then(
|
||||
(mod) => mod.LeptosTiptapInlineEditorHost,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
|
||||
),
|
||||
},
|
||||
) as ComponentType<DocumentEditorHostProps>;
|
||||
|
||||
export function EditorHost(props: DocumentEditorHostProps) {
|
||||
if (props.hostKind === "leptos_tiptap_inline") {
|
||||
return <LeptosTiptapInlineEditor {...props} />;
|
||||
if (props.hostKind === "leptos_tiptap_iframe_debug") {
|
||||
return <LeptosTiptapIframeDebugEditor {...props} />;
|
||||
}
|
||||
return <LeptosTiptapIframeDebugEditor {...props} />;
|
||||
return <LeptosTiptapIslandEditor {...props} />;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
|
||||
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
|
||||
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
|
||||
const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null);
|
||||
const runtimeDocRef = useRef<Json>(
|
||||
normalizeRuntimeDoc(tiptapDocFromBlocks(props.initialContent as Json) as Json) as Json,
|
||||
);
|
||||
const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT);
|
||||
@@ -222,6 +225,10 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
|
||||
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
|
||||
}, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeDocRef.current = runtimeDoc;
|
||||
}, [runtimeDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json;
|
||||
setRuntimeDoc(nextRuntimeDoc);
|
||||
@@ -246,15 +253,6 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
|
||||
}));
|
||||
}, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow;
|
||||
if (!iframeWindow) return;
|
||||
const payload = bootstrapPayloadRef.current;
|
||||
if (!payload) return;
|
||||
postBridgeMessage(iframeWindow, REPLACE_DOCUMENT_EVENT, payload);
|
||||
setBridgeState((prev) => ({ ...prev, status: prev.ready ? "reloading" : prev.status }));
|
||||
}, [runtimeDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge: EditorReferenceBridge = {
|
||||
insertInlineReference: () => ({ blockId: null }),
|
||||
@@ -304,7 +302,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
|
||||
const publishSnapshot = (payload: HostDocumentPayload) => {
|
||||
const blocks = blocksFromTiptapDoc(payload.content) as Json;
|
||||
const stats = buildStats(blocks);
|
||||
setRuntimeDoc(normalizeRuntimeDoc(payload.content as Json) as Json);
|
||||
const nextRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
|
||||
runtimeDocRef.current = nextRuntimeDoc;
|
||||
setRuntimeDoc(nextRuntimeDoc);
|
||||
onSnapshotRef.current?.({ blocks, stats });
|
||||
onStatsChangeRef.current?.(stats);
|
||||
return { blocks, stats };
|
||||
@@ -423,14 +423,14 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
|
||||
data-editor-host-kind="leptos_tiptap_iframe_debug"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800">
|
||||
<span>调试桥:`iframe + postMessage` prototype</span>
|
||||
<span>主编辑器:`leptos-tiptap` iframe debug host</span>
|
||||
<span>{bridgeState.status}</span>
|
||||
</div>
|
||||
<iframe
|
||||
key={`${props.documentId}:${reloadKey}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
title="Leptos Tiptap Editor Debug Bridge"
|
||||
title="Leptos Tiptap Main Editor Runtime"
|
||||
className="w-full border-0"
|
||||
style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }}
|
||||
data-bridge-status={bridgeState.status}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
import { act, useState } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { LeptosTiptapIslandEditorHost } from "@/components/editor/leptos-tiptap-island-editor-host";
|
||||
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
|
||||
|
||||
const runtimeModuleUrl = `data:text/javascript;base64,${Buffer.from(`
|
||||
const mounts = new Map();
|
||||
export default async function init() {
|
||||
globalThis.__MNOTE_ISLAND_TEST_STATE__.initCalls += 1;
|
||||
}
|
||||
export function mount(container, options) {
|
||||
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
|
||||
const mountId = state.nextMountId++;
|
||||
const surface = document.createElement("div");
|
||||
surface.className = "editor-surface";
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "ProseMirror";
|
||||
editor.setAttribute("contenteditable", "true");
|
||||
editor.textContent = options?.title ?? "untitled";
|
||||
surface.appendChild(editor);
|
||||
container.replaceChildren(surface);
|
||||
container.addEventListener("mnote:leptos-tiptap-spike:command", (event) => {
|
||||
const detail = event?.detail ?? {};
|
||||
state.commandCalls.push({
|
||||
command: detail?.payload?.command ?? null,
|
||||
payload: detail?.payload ?? null,
|
||||
});
|
||||
});
|
||||
mounts.set(mountId, container);
|
||||
state.mountCalls.push({
|
||||
mountId,
|
||||
title: options?.title ?? null,
|
||||
pageOptions: options?.pageOptions ?? null,
|
||||
});
|
||||
return mountId;
|
||||
}
|
||||
export function unmount(mountId) {
|
||||
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
|
||||
const container = mounts.get(mountId);
|
||||
if (container) {
|
||||
container.replaceChildren();
|
||||
mounts.delete(mountId);
|
||||
}
|
||||
state.unmountCalls.push(mountId);
|
||||
}
|
||||
`).toString("base64")}`;
|
||||
|
||||
vi.mock("@/components/editor/leptos-tiptap-island-loader", () => ({
|
||||
loadLeptosTiptapIslandAssets: vi.fn(async () => ({
|
||||
manifest: null,
|
||||
entryAssetUrl: runtimeModuleUrl,
|
||||
wasmAssetUrl: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
type RuntimeTestState = {
|
||||
initCalls: number;
|
||||
nextMountId: number;
|
||||
mountCalls: Array<{ mountId: number; title: string | null; pageOptions?: unknown }>;
|
||||
unmountCalls: number[];
|
||||
commandCalls: Array<{ command: string | null; payload?: unknown }>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MNOTE_ISLAND_TEST_STATE__?: RuntimeTestState;
|
||||
}
|
||||
}
|
||||
|
||||
function flushEffects() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function buildProps(overrides: Partial<DocumentEditorHostProps> = {}): DocumentEditorHostProps {
|
||||
return {
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
initialContent: [],
|
||||
title: "标题 A",
|
||||
hostKind: "leptos_tiptap_island",
|
||||
initialRevision: 1,
|
||||
initialConflictDetectionKey: "doc-1:1",
|
||||
pageOptions: {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
readOnly: false,
|
||||
onStatsChange: vi.fn(),
|
||||
onSnapshot: vi.fn(),
|
||||
onCloseToc: vi.fn(),
|
||||
onPersistedMetaChange: vi.fn(),
|
||||
onHostEvent: vi.fn(),
|
||||
onRequestFallback: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function HostEchoHarness({
|
||||
initialContent,
|
||||
onSnapshotSpy,
|
||||
}: {
|
||||
initialContent: DocumentEditorHostProps["initialContent"];
|
||||
onSnapshotSpy?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
|
||||
return (
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
initialContent: content,
|
||||
onSnapshot: (payload) => {
|
||||
onSnapshotSpy?.(payload);
|
||||
setContent(payload.blocks);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("leptos-tiptap-island-editor-host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
window.__MNOTE_ISLAND_TEST_STATE__ = {
|
||||
initCalls: 0,
|
||||
nextMountId: 1,
|
||||
mountCalls: [],
|
||||
unmountCalls: [],
|
||||
commandCalls: [],
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushEffects();
|
||||
});
|
||||
container.remove();
|
||||
delete window.__MNOTE_ISLAND_TEST_STATE__;
|
||||
});
|
||||
|
||||
it("同一文档仅标题变化时不应重复卸载并重挂 island", async () => {
|
||||
const initialProps = buildProps();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
|
||||
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
title: "标题 B",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
|
||||
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("页面内 island 宿主不应再额外施加横向 padding", async () => {
|
||||
await act(async () => {
|
||||
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const host = container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(host).not.toBeNull();
|
||||
expect(host?.className).toContain("min-h-[720px]");
|
||||
expect(host?.className).toContain("py-6");
|
||||
expect(host?.className).not.toContain("px-8");
|
||||
});
|
||||
|
||||
it("mount 时应把 pageOptions 传给 island runtime", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
pageOptions: {
|
||||
...buildProps().pageOptions,
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
layoutDensity: "compact",
|
||||
showHeadingNumbers: false,
|
||||
embedDefaultBlockId: "block-anchor-1",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls[0]?.pageOptions).toEqual({
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
layoutDensity: "compact",
|
||||
showHeadingNumbers: false,
|
||||
embedDefaultBlockId: "block-anchor-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("pageOptions 变化时应向 runtime 发送 setPageOptions 命令", async () => {
|
||||
await act(async () => {
|
||||
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
pageOptions: {
|
||||
...buildProps().pageOptions,
|
||||
wideLayout: true,
|
||||
layoutDensity: "spacious",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const latestPageOptionsCommand = [...(window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "setPageOptions");
|
||||
|
||||
expect(latestPageOptionsCommand?.payload).toMatchObject({
|
||||
command: "setPageOptions",
|
||||
pageOptions: {
|
||||
wideLayout: true,
|
||||
smallText: false,
|
||||
layoutDensity: "spacious",
|
||||
showHeadingNumbers: true,
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("runtime 快照回流为同一内容时不应再次发送 replaceContent", async () => {
|
||||
const onSnapshot = vi.fn();
|
||||
const initialProps = buildProps({
|
||||
initialContent: [
|
||||
{
|
||||
id: "block-1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello island" }],
|
||||
props: {},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
onSnapshot,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const host = container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(host).not.toBeNull();
|
||||
const initialReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
await act(async () => {
|
||||
host?.dispatchEvent(
|
||||
new CustomEvent("mnote:leptos-tiptap-spike:change", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
protocol: "mnote.leptos_tiptap.bridge.v1",
|
||||
source: "mnote:leptos-tiptap-spike",
|
||||
payload: {
|
||||
title: "标题 A",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello island" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
readOnly: false,
|
||||
revision: 1,
|
||||
conflictDetectionKey: "doc-1:1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const nextReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
expect(onSnapshot).toHaveBeenCalled();
|
||||
expect(nextReplaceCount).toBe(initialReplaceCount);
|
||||
});
|
||||
|
||||
it("空文档初始化后收到等价空段落快照时不应再次发送 replaceContent", async () => {
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
initialContent: [],
|
||||
onSnapshot,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const host = container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
const initialReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
await act(async () => {
|
||||
host?.dispatchEvent(
|
||||
new CustomEvent("mnote:leptos-tiptap-spike:change", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
protocol: "mnote.leptos_tiptap.bridge.v1",
|
||||
source: "mnote:leptos-tiptap-spike",
|
||||
payload: {
|
||||
title: "标题 A",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
readOnly: false,
|
||||
revision: 1,
|
||||
conflictDetectionKey: "doc-1:1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const nextReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
expect(onSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(nextReplaceCount).toBe(initialReplaceCount);
|
||||
});
|
||||
|
||||
it("父组件回灌 runtime 等价 blocks 时不应再次发送 replaceContent", async () => {
|
||||
const onSnapshotSpy = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HostEchoHarness
|
||||
initialContent={[
|
||||
{
|
||||
id: "block-1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello island" }],
|
||||
props: {},
|
||||
children: [],
|
||||
},
|
||||
]}
|
||||
onSnapshotSpy={onSnapshotSpy}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const host = container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
const initialReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
await act(async () => {
|
||||
host?.dispatchEvent(
|
||||
new CustomEvent("mnote:leptos-tiptap-spike:change", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
protocol: "mnote.leptos_tiptap.bridge.v1",
|
||||
source: "mnote:leptos-tiptap-spike",
|
||||
payload: {
|
||||
title: "标题 A",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello island" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
readOnly: false,
|
||||
revision: 1,
|
||||
conflictDetectionKey: "doc-1:1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const nextReplaceCount =
|
||||
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
|
||||
(entry) => entry.command === "replaceContent",
|
||||
).length ?? 0;
|
||||
|
||||
expect(onSnapshotSpy).toHaveBeenCalledTimes(1);
|
||||
expect(nextReplaceCount).toBe(initialReplaceCount);
|
||||
});
|
||||
|
||||
it("应兼容 runtime 把 tiptap 文档作为 Map 回传", async () => {
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LeptosTiptapIslandEditorHost
|
||||
{...buildProps({
|
||||
initialContent: [],
|
||||
onSnapshot,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const host = container.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
host?.dispatchEvent(
|
||||
new CustomEvent("mnote:leptos-tiptap-spike:change", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
protocol: "mnote.leptos_tiptap.bridge.v1",
|
||||
source: "mnote:leptos-tiptap-spike",
|
||||
payload: {
|
||||
title: "标题 A",
|
||||
content: new Map([
|
||||
["type", "doc"],
|
||||
[
|
||||
"content",
|
||||
[
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "hello from map" }],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
meta: {
|
||||
readOnly: false,
|
||||
revision: 1,
|
||||
conflictDetectionKey: "doc-1:1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(onSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(onSnapshot.mock.calls[0]?.[0]?.blocks).toEqual([
|
||||
{
|
||||
id: "block-1",
|
||||
type: "paragraph",
|
||||
content: "hello from map",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,832 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
DocumentEditorHostProps,
|
||||
EditorHostFallbackReason,
|
||||
} from "@/components/editor/editor-host-types";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import type { EditorReferenceBridge } from "@/store/editor-bridge";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import {
|
||||
blocksFromTiptapDoc,
|
||||
editorBlockDocumentFromTiptapDoc,
|
||||
tiptapDocFromBlocks,
|
||||
} from "@/lib/documents/tiptap-content-converter";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import {
|
||||
loadLeptosTiptapIslandAssets,
|
||||
} from "@/components/editor/leptos-tiptap-island-loader";
|
||||
|
||||
const EVENT_PREFIX = "mnote:leptos-tiptap-spike";
|
||||
const PROTOCOL = "mnote.leptos_tiptap.bridge.v1";
|
||||
const READY_EVENT = `${EVENT_PREFIX}:ready`;
|
||||
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
|
||||
const STATE_EVENT = `${EVENT_PREFIX}:state`;
|
||||
const STATUS_EVENT = `${EVENT_PREFIX}:status`;
|
||||
const SELECTION_EVENT = `${EVENT_PREFIX}:selection`;
|
||||
const HEIGHT_EVENT = `${EVENT_PREFIX}:height`;
|
||||
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
||||
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
||||
const FALLBACK_MIN_HEIGHT = 720;
|
||||
const SAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
type IslandRuntimeModule = {
|
||||
default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise<unknown>;
|
||||
mount: (container: Element, options: unknown) => number;
|
||||
unmount: (mountId: number) => void;
|
||||
};
|
||||
|
||||
type RuntimeEnvelope<T = unknown> = {
|
||||
protocol?: string;
|
||||
runtime?: string;
|
||||
version?: string;
|
||||
source?: string;
|
||||
event?: string;
|
||||
payload?: T;
|
||||
};
|
||||
|
||||
type ChangePayload = {
|
||||
documentId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
title?: string;
|
||||
content?: unknown;
|
||||
meta?: {
|
||||
dirtyCount?: number;
|
||||
editorFocused?: boolean;
|
||||
slashOpen?: boolean;
|
||||
toolbarOpen?: boolean;
|
||||
selectedBlockIndex?: number | null;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type StatePayload = {
|
||||
title?: string;
|
||||
dirtyCount?: number;
|
||||
selectedBlockIndex?: number | null;
|
||||
editorFocused?: boolean;
|
||||
slashOpen?: boolean;
|
||||
toolbarOpen?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type StatusPayload = {
|
||||
currentBlockId?: string | null;
|
||||
selectedBlockIndex?: number | null;
|
||||
};
|
||||
|
||||
type SelectionPayload = {
|
||||
currentBlockId?: string | null;
|
||||
currentBlockIndex?: number | null;
|
||||
};
|
||||
|
||||
type HeightPayload = {
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type ErrorPayload = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type RuntimeBridgeState = {
|
||||
ready: boolean;
|
||||
runtimeName: string;
|
||||
runtimeVersion: string;
|
||||
runtimeUrl: string;
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
status: string;
|
||||
lastChangeAt?: string | null;
|
||||
lastSaveRequestAt?: string | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
type IslandBootstrapPayload = {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title: string | null;
|
||||
content: unknown;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
readOnly: boolean;
|
||||
pageOptions: RuntimePageOptionsPayload;
|
||||
};
|
||||
|
||||
type RuntimePageOptionsPayload = Pick<
|
||||
PageOptionsState,
|
||||
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
|
||||
>;
|
||||
|
||||
function buildRuntimePageOptions(pageOptions: PageOptionsState): RuntimePageOptionsPayload {
|
||||
return {
|
||||
wideLayout: pageOptions.wideLayout,
|
||||
smallText: pageOptions.smallText,
|
||||
layoutDensity: pageOptions.layoutDensity,
|
||||
showHeadingNumbers: pageOptions.showHeadingNumbers,
|
||||
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function flattenText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(flattenText).join("");
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as { text?: unknown; content?: unknown };
|
||||
return `${flattenText(record.text)}${flattenText(record.content)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function serializeHostSyncValue(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeValue(value: unknown): unknown {
|
||||
if (value instanceof Map) {
|
||||
return Object.fromEntries(
|
||||
Array.from(value.entries()).map(([key, nestedValue]) => [
|
||||
key,
|
||||
normalizeRuntimeValue(nestedValue),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeRuntimeValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
|
||||
key,
|
||||
normalizeRuntimeValue(nestedValue),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildHostSyncKey(input: {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title: string | null;
|
||||
content: unknown;
|
||||
readOnly: boolean;
|
||||
}): string {
|
||||
return serializeHostSyncValue({
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
readOnly: input.readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalizeTiptapDoc(value: unknown): Json {
|
||||
const normalizedValue = normalizeRuntimeValue(value);
|
||||
if (
|
||||
normalizedValue &&
|
||||
typeof normalizedValue === "object" &&
|
||||
(normalizedValue as { type?: unknown }).type === "doc"
|
||||
) {
|
||||
return tiptapDocFromBlocks(blocksFromTiptapDoc(normalizedValue) as Json) as Json;
|
||||
}
|
||||
return tiptapDocFromBlocks(normalizedValue as Json) as Json;
|
||||
}
|
||||
|
||||
function buildStats(blocks: Json): DocumentStats {
|
||||
const items = Array.isArray(blocks) ? blocks : [];
|
||||
const text = items
|
||||
.map((item) =>
|
||||
item && typeof item === "object"
|
||||
? flattenText((item as { content?: unknown }).content)
|
||||
: "",
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const todoItems = items.filter(
|
||||
(item) => item && typeof item === "object" && (item as { type?: unknown }).type === "todo",
|
||||
);
|
||||
const todoDone = todoItems.filter(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
Boolean((item as { props?: { checked?: unknown } }).props?.checked),
|
||||
).length;
|
||||
return {
|
||||
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
|
||||
characterCount: text.length,
|
||||
blockCount: items.length,
|
||||
todoTotal: todoItems.length,
|
||||
todoDone,
|
||||
};
|
||||
}
|
||||
|
||||
function isRuntimeEnvelope(value: unknown): value is RuntimeEnvelope {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const maybe = value as RuntimeEnvelope;
|
||||
return maybe.protocol === PROTOCOL && maybe.source === EVENT_PREFIX;
|
||||
}
|
||||
|
||||
async function loadIslandRuntime(): Promise<{
|
||||
runtimeModule: IslandRuntimeModule;
|
||||
entryAssetUrl: string;
|
||||
wasmAssetUrl: string | null;
|
||||
}> {
|
||||
const { entryAssetUrl, wasmAssetUrl } = await loadLeptosTiptapIslandAssets();
|
||||
if (!entryAssetUrl) {
|
||||
throw new Error("island manifest 缺少 entryAssetPath");
|
||||
}
|
||||
const runtimeModule = (await import(
|
||||
/* webpackIgnore: true */ entryAssetUrl
|
||||
)) as IslandRuntimeModule;
|
||||
if (typeof runtimeModule.default !== "function") {
|
||||
throw new Error("island entry 缺少默认 wasm 初始化函数");
|
||||
}
|
||||
if (typeof runtimeModule.mount !== "function") {
|
||||
throw new Error("island entry 缺少 mount 导出");
|
||||
}
|
||||
if (typeof runtimeModule.unmount !== "function") {
|
||||
throw new Error("island entry 缺少 unmount 导出");
|
||||
}
|
||||
await runtimeModule.default(wasmAssetUrl ?? undefined);
|
||||
return {
|
||||
runtimeModule,
|
||||
entryAssetUrl,
|
||||
wasmAssetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchRuntimeCommand(target: EventTarget, payload: unknown) {
|
||||
const envelope: RuntimeEnvelope = {
|
||||
protocol: PROTOCOL,
|
||||
runtime: "leptos-tiptap-island-host",
|
||||
version: "1.0.0",
|
||||
source: EVENT_PREFIX,
|
||||
event: COMMAND_EVENT,
|
||||
payload,
|
||||
};
|
||||
const event = new CustomEvent(COMMAND_EVENT, {
|
||||
bubbles: true,
|
||||
detail: envelope,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const mountIdRef = useRef<number | null>(null);
|
||||
const runtimeModuleRef = useRef<IslandRuntimeModule | null>(null);
|
||||
const runtimeTargetRef = useRef<EventTarget | null>(null);
|
||||
const latestDocRef = useRef<Json>(tiptapDocFromBlocks(props.initialContent as Json) as Json);
|
||||
const latestBlocksRef = useRef<Json>(props.initialContent as Json);
|
||||
const currentBlockIdRef = useRef<string | null>(null);
|
||||
const hostIdentityRef = useRef({
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
});
|
||||
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
|
||||
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
|
||||
const lastHostSyncKeyRef = useRef<string | null>(null);
|
||||
const onSnapshotRef = useRef(props.onSnapshot);
|
||||
const onStatsChangeRef = useRef(props.onStatsChange);
|
||||
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
|
||||
const onHostEventRef = useRef(props.onHostEvent);
|
||||
const onRequestFallbackRef = useRef(props.onRequestFallback);
|
||||
const bootstrapPayloadRef = useRef<IslandBootstrapPayload>({
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
|
||||
revision: props.initialRevision ?? null,
|
||||
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
pageOptions: buildRuntimePageOptions(props.pageOptions),
|
||||
});
|
||||
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
|
||||
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
|
||||
ready: false,
|
||||
runtimeName: "leptos-tiptap-island",
|
||||
runtimeVersion: "1.0.0",
|
||||
runtimeUrl: "",
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
status: "booting",
|
||||
lastChangeAt: null,
|
||||
lastSaveRequestAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
const debouncedPersistRef = useRef<ReturnType<typeof useDebouncedCallback> | null>(null);
|
||||
|
||||
const mountIdentity = useMemo(
|
||||
() => `${props.workspaceId}:${props.documentId}`,
|
||||
[props.documentId, props.workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSnapshotRef.current = props.onSnapshot;
|
||||
onStatsChangeRef.current = props.onStatsChange;
|
||||
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
|
||||
onHostEventRef.current = props.onHostEvent;
|
||||
onRequestFallbackRef.current = props.onRequestFallback;
|
||||
hostIdentityRef.current = {
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
};
|
||||
revisionRef.current = props.initialRevision ?? null;
|
||||
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
|
||||
}, [
|
||||
props.documentId,
|
||||
props.initialConflictDetectionKey,
|
||||
props.initialRevision,
|
||||
props.onHostEvent,
|
||||
props.onPersistedMetaChange,
|
||||
props.onRequestFallback,
|
||||
props.onSnapshot,
|
||||
props.onStatsChange,
|
||||
props.readOnly,
|
||||
props.title,
|
||||
props.workspaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
bootstrapPayloadRef.current = {
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
|
||||
revision: props.initialRevision ?? null,
|
||||
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
pageOptions: buildRuntimePageOptions(props.pageOptions),
|
||||
};
|
||||
}, [
|
||||
mountIdentity,
|
||||
props.documentId,
|
||||
props.initialConflictDetectionKey,
|
||||
props.initialContent,
|
||||
props.initialRevision,
|
||||
props.pageOptions,
|
||||
props.readOnly,
|
||||
props.title,
|
||||
props.workspaceId,
|
||||
]);
|
||||
|
||||
const requestFallback = useCallback(
|
||||
(reason: EditorHostFallbackReason, error: string) => {
|
||||
const at = toIsoNow();
|
||||
if (reason !== "explicit_fallback") {
|
||||
onHostEventRef.current?.({
|
||||
kind:
|
||||
reason === "save_failed"
|
||||
? "save_failed"
|
||||
: reason === "runtime_load_failed"
|
||||
? "runtime_load_failed"
|
||||
: reason === "command_failed"
|
||||
? "command_failed"
|
||||
: "host_init_failed",
|
||||
at,
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
onRequestFallbackRef.current?.({
|
||||
reason,
|
||||
error,
|
||||
at,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const persistDocument = useCallback(async () => {
|
||||
const normalizedDoc = latestDocRef.current;
|
||||
const normalizedBlocks = blocksFromTiptapDoc(normalizedDoc) as Json;
|
||||
const response = await fetch("/api/documents/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
buildDocumentSavePayload({
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
revision: revisionRef.current,
|
||||
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
|
||||
content: normalizedBlocks,
|
||||
tiptapDocument: normalizedDoc,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
snapshotCapturedAt: toIsoNow(),
|
||||
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload?.error === "string" ? payload.error : `保存失败(${response.status})`,
|
||||
);
|
||||
}
|
||||
revisionRef.current =
|
||||
typeof payload?.revision === "number" ? payload.revision : revisionRef.current;
|
||||
conflictDetectionKeyRef.current =
|
||||
typeof payload?.conflictDetectionKey === "string"
|
||||
? payload.conflictDetectionKey
|
||||
: conflictDetectionKeyRef.current;
|
||||
onPersistedMetaChangeRef.current?.({
|
||||
revision: revisionRef.current,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
});
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
ready: true,
|
||||
status: "saved",
|
||||
lastSaveRequestAt: toIsoNow(),
|
||||
lastError: null,
|
||||
}));
|
||||
}, [props.documentId, props.workspaceId]);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback(() => {
|
||||
void persistDocument().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "保存失败";
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
lastError: message,
|
||||
}));
|
||||
requestFallback("save_failed", message);
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedPersistRef.current = debouncedPersist;
|
||||
}, [debouncedPersist]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const bootstrapPayload = bootstrapPayloadRef.current;
|
||||
|
||||
let disposed = false;
|
||||
let removeListeners: Array<() => void> = [];
|
||||
|
||||
const attachEvent = <T,>(eventName: string, handler: (payload: T) => void) => {
|
||||
const listener = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<RuntimeEnvelope<T>>;
|
||||
if (!isRuntimeEnvelope(customEvent.detail)) {
|
||||
return;
|
||||
}
|
||||
handler((customEvent.detail.payload ?? {}) as T);
|
||||
};
|
||||
container.addEventListener(eventName, listener);
|
||||
removeListeners.push(() => container.removeEventListener(eventName, listener));
|
||||
};
|
||||
|
||||
void loadIslandRuntime()
|
||||
.then(({ runtimeModule, entryAssetUrl }) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
runtimeModuleRef.current = runtimeModule;
|
||||
runtimeTargetRef.current = container;
|
||||
container.dataset.editorHostKind = "leptos_tiptap_island";
|
||||
container.dataset.mnoteRuntime = "leptos_tiptap_island";
|
||||
container.dataset.mnoteRuntimeBridge = "island";
|
||||
container.dataset.mnoteRuntimeUrl = entryAssetUrl;
|
||||
|
||||
attachEvent<ChangePayload>(CHANGE_EVENT, (payload) => {
|
||||
const hostIdentity = hostIdentityRef.current;
|
||||
const nextDoc = canonicalizeTiptapDoc(payload.content ?? latestDocRef.current);
|
||||
const nextBlocks = blocksFromTiptapDoc(nextDoc) as Json;
|
||||
const nextStats = buildStats(nextBlocks);
|
||||
const nextReadOnly = payload.meta?.readOnly ?? hostIdentity.readOnly;
|
||||
const nextTitle = payload.title ?? hostIdentity.title;
|
||||
latestDocRef.current = nextDoc;
|
||||
latestBlocksRef.current = nextBlocks;
|
||||
lastHostSyncKeyRef.current = buildHostSyncKey({
|
||||
documentId: hostIdentity.documentId,
|
||||
workspaceId: hostIdentity.workspaceId,
|
||||
title: nextTitle,
|
||||
content: nextDoc,
|
||||
readOnly: nextReadOnly,
|
||||
});
|
||||
onSnapshotRef.current?.({ blocks: nextBlocks, stats: nextStats });
|
||||
onStatsChangeRef.current?.(nextStats);
|
||||
if (payload.meta) {
|
||||
revisionRef.current =
|
||||
typeof payload.meta.revision === "number"
|
||||
? payload.meta.revision
|
||||
: revisionRef.current;
|
||||
conflictDetectionKeyRef.current =
|
||||
typeof payload.meta.conflictDetectionKey === "string"
|
||||
? payload.meta.conflictDetectionKey
|
||||
: conflictDetectionKeyRef.current;
|
||||
}
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
ready: true,
|
||||
status: "dirty",
|
||||
lastChangeAt: toIsoNow(),
|
||||
lastError: null,
|
||||
}));
|
||||
debouncedPersistRef.current?.();
|
||||
});
|
||||
|
||||
attachEvent<StatePayload>(STATE_EVENT, (payload) => {
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
ready: true,
|
||||
status:
|
||||
payload.readOnly === true
|
||||
? "read_only"
|
||||
: payload.slashOpen || payload.toolbarOpen
|
||||
? "interacting"
|
||||
: "ready",
|
||||
lastError: null,
|
||||
}));
|
||||
onHostEventRef.current?.({
|
||||
kind: "status_changed",
|
||||
status:
|
||||
payload.readOnly === true
|
||||
? "read_only"
|
||||
: payload.slashOpen || payload.toolbarOpen
|
||||
? "interacting"
|
||||
: "ready",
|
||||
at: toIsoNow(),
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
attachEvent<StatusPayload>(STATUS_EVENT, (payload) => {
|
||||
currentBlockIdRef.current =
|
||||
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
|
||||
});
|
||||
|
||||
attachEvent<SelectionPayload>(SELECTION_EVENT, (payload) => {
|
||||
currentBlockIdRef.current =
|
||||
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
|
||||
});
|
||||
|
||||
attachEvent<HeightPayload>(HEIGHT_EVENT, (payload) => {
|
||||
const nextHeight = Number(payload.height);
|
||||
if (Number.isFinite(nextHeight)) {
|
||||
setEditorHeight(Math.max(FALLBACK_MIN_HEIGHT, Math.ceil(nextHeight)));
|
||||
}
|
||||
});
|
||||
|
||||
attachEvent<ErrorPayload>(ERROR_EVENT, (payload) => {
|
||||
const message =
|
||||
typeof payload.message === "string" ? payload.message : "leptos-tiptap island 初始化失败";
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
lastError: message,
|
||||
}));
|
||||
requestFallback("host_init_failed", message);
|
||||
});
|
||||
|
||||
attachEvent(READY_EVENT, () => {
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
ready: true,
|
||||
status: "ready",
|
||||
lastError: null,
|
||||
}));
|
||||
});
|
||||
|
||||
latestDocRef.current = bootstrapPayload.content as Json;
|
||||
latestBlocksRef.current = blocksFromTiptapDoc(bootstrapPayload.content) as Json;
|
||||
lastHostSyncKeyRef.current = buildHostSyncKey({
|
||||
documentId: bootstrapPayload.documentId,
|
||||
workspaceId: bootstrapPayload.workspaceId,
|
||||
title: bootstrapPayload.title,
|
||||
content: bootstrapPayload.content,
|
||||
readOnly: bootstrapPayload.readOnly,
|
||||
});
|
||||
mountIdRef.current = runtimeModule.mount(container, {
|
||||
documentId: bootstrapPayload.documentId,
|
||||
workspaceId: bootstrapPayload.workspaceId,
|
||||
title: bootstrapPayload.title,
|
||||
content: bootstrapPayload.content,
|
||||
readOnly: bootstrapPayload.readOnly,
|
||||
revision: bootstrapPayload.revision,
|
||||
conflictDetectionKey: bootstrapPayload.conflictDetectionKey,
|
||||
pageOptions: bootstrapPayload.pageOptions,
|
||||
editable: !bootstrapPayload.readOnly,
|
||||
});
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
runtimeUrl: entryAssetUrl,
|
||||
status: "mounting",
|
||||
lastError: null,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "加载 leptos-tiptap island 失败";
|
||||
setBridgeState((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
lastError: message,
|
||||
}));
|
||||
requestFallback("runtime_load_failed", message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
debouncedPersistRef.current?.cancel();
|
||||
removeListeners.forEach((dispose) => dispose());
|
||||
removeListeners = [];
|
||||
if (mountIdRef.current != null && runtimeModuleRef.current) {
|
||||
try {
|
||||
runtimeModuleRef.current.unmount(mountIdRef.current);
|
||||
} catch {
|
||||
// 说明:页面卸载时不再追加错误提示,避免离场噪音。
|
||||
}
|
||||
}
|
||||
mountIdRef.current = null;
|
||||
runtimeTargetRef.current = null;
|
||||
runtimeModuleRef.current = null;
|
||||
useEditorBridgeStore.getState().registerBridge(null);
|
||||
};
|
||||
}, [
|
||||
mountIdentity,
|
||||
requestFallback,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = runtimeTargetRef.current;
|
||||
if (!target || mountIdRef.current == null) {
|
||||
return;
|
||||
}
|
||||
const nextDoc = tiptapDocFromBlocks(props.initialContent as Json);
|
||||
const nextHostSyncKey = buildHostSyncKey({
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
content: nextDoc,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
});
|
||||
if (lastHostSyncKeyRef.current === nextHostSyncKey) {
|
||||
return;
|
||||
}
|
||||
lastHostSyncKeyRef.current = nextHostSyncKey;
|
||||
dispatchRuntimeCommand(target, {
|
||||
command: "replaceContent",
|
||||
documentId: props.documentId,
|
||||
workspaceId: props.workspaceId,
|
||||
title: props.title ?? null,
|
||||
content: nextDoc,
|
||||
revision: props.initialRevision ?? null,
|
||||
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
|
||||
readOnly: Boolean(props.readOnly),
|
||||
editable: !props.readOnly,
|
||||
});
|
||||
}, [
|
||||
props.documentId,
|
||||
props.initialConflictDetectionKey,
|
||||
props.initialContent,
|
||||
props.initialRevision,
|
||||
props.readOnly,
|
||||
props.title,
|
||||
props.workspaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = runtimeTargetRef.current;
|
||||
if (!target || mountIdRef.current == null) {
|
||||
return;
|
||||
}
|
||||
dispatchRuntimeCommand(target, {
|
||||
command: "setPageOptions",
|
||||
pageOptions: buildRuntimePageOptions(props.pageOptions),
|
||||
});
|
||||
}, [props.pageOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = runtimeTargetRef.current;
|
||||
if (!target || mountIdRef.current == null) {
|
||||
return;
|
||||
}
|
||||
const editorBridge: EditorReferenceBridge = {
|
||||
insertInlineReference: (targetDocument, aliasText) => {
|
||||
try {
|
||||
dispatchRuntimeCommand(target, {
|
||||
command: "insertInlineReference",
|
||||
referenceDocumentId: targetDocument.id,
|
||||
text: aliasText?.trim() || targetDocument.title || "无标题",
|
||||
});
|
||||
} catch (error) {
|
||||
requestFallback(
|
||||
"command_failed",
|
||||
error instanceof Error ? error.message : "插入行内引用失败",
|
||||
);
|
||||
}
|
||||
return { blockId: currentBlockIdRef.current };
|
||||
},
|
||||
insertEmbedReference: (targetDocument) => {
|
||||
try {
|
||||
dispatchRuntimeCommand(target, {
|
||||
command: "insertEmbedReference",
|
||||
referenceDocumentId: targetDocument.id,
|
||||
text: targetDocument.title || "无标题",
|
||||
});
|
||||
} catch (error) {
|
||||
requestFallback(
|
||||
"command_failed",
|
||||
error instanceof Error ? error.message : "插入嵌入引用失败",
|
||||
);
|
||||
}
|
||||
return { blockId: currentBlockIdRef.current };
|
||||
},
|
||||
undo: () => {
|
||||
try {
|
||||
dispatchRuntimeCommand(target, { command: "undo" });
|
||||
} catch (error) {
|
||||
requestFallback("command_failed", error instanceof Error ? error.message : "撤销失败");
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
dispatchRuntimeCommand(target, { command: "redo" });
|
||||
} catch (error) {
|
||||
requestFallback("command_failed", error instanceof Error ? error.message : "重做失败");
|
||||
}
|
||||
},
|
||||
getCursorBlockId: () => currentBlockIdRef.current,
|
||||
replaceWithSnapshot: (blocks: Json) => {
|
||||
const hostIdentity = hostIdentityRef.current;
|
||||
const nextDoc = tiptapDocFromBlocks(blocks) as Json;
|
||||
latestDocRef.current = nextDoc;
|
||||
latestBlocksRef.current = Array.isArray(blocks) ? blocks : blocksFromTiptapDoc(nextDoc);
|
||||
lastHostSyncKeyRef.current = buildHostSyncKey({
|
||||
documentId: hostIdentity.documentId,
|
||||
workspaceId: hostIdentity.workspaceId,
|
||||
title: hostIdentity.title,
|
||||
content: nextDoc,
|
||||
readOnly: hostIdentity.readOnly,
|
||||
});
|
||||
try {
|
||||
dispatchRuntimeCommand(target, {
|
||||
command: "replaceContent",
|
||||
documentId: hostIdentity.documentId,
|
||||
workspaceId: hostIdentity.workspaceId,
|
||||
title: hostIdentity.title,
|
||||
content: nextDoc,
|
||||
revision: revisionRef.current,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
readOnly: hostIdentity.readOnly,
|
||||
editable: !hostIdentity.readOnly,
|
||||
});
|
||||
} catch (error) {
|
||||
requestFallback(
|
||||
"command_failed",
|
||||
error instanceof Error ? error.message : "替换编辑器快照失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
requestFallbackToBlockNote: () => {
|
||||
requestFallback("explicit_fallback", "通过页面壳显式切回 BlockNote");
|
||||
},
|
||||
};
|
||||
useEditorBridgeStore.getState().registerBridge(editorBridge);
|
||||
return () => {
|
||||
useEditorBridgeStore.getState().registerBridge(null);
|
||||
};
|
||||
}, [
|
||||
props.documentId,
|
||||
props.readOnly,
|
||||
props.title,
|
||||
props.workspaceId,
|
||||
requestFallback,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountRef}
|
||||
className="min-h-[720px] py-6"
|
||||
data-editor-host-kind="leptos_tiptap_island"
|
||||
data-runtime-editor-status={bridgeState.status}
|
||||
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||
style={{ minHeight: `${editorHeight}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
const ISLAND_MANIFEST_URL = "/api/leptos-tiptap-runtime/manifest.json";
|
||||
|
||||
export type LeptosTiptapIslandManifest = {
|
||||
entryAssetPath: string | null;
|
||||
wasmAssetPath: string | null;
|
||||
assetPaths: string[];
|
||||
generatedRootPath: string | null;
|
||||
};
|
||||
|
||||
export type LeptosTiptapIslandAssetUrls = {
|
||||
manifest: LeptosTiptapIslandManifest | null;
|
||||
entryAssetUrl: string | null;
|
||||
wasmAssetUrl: string | null;
|
||||
};
|
||||
|
||||
export function buildLeptosTiptapIslandAssetUrl(assetPath: string | null): string | null {
|
||||
if (!assetPath) {
|
||||
return null;
|
||||
}
|
||||
return `/api/leptos-tiptap-runtime/${assetPath}`;
|
||||
}
|
||||
|
||||
export async function loadLeptosTiptapIslandAssets(): Promise<LeptosTiptapIslandAssetUrls> {
|
||||
const response = await fetch(ISLAND_MANIFEST_URL, { cache: "no-store" }).catch(() => null);
|
||||
if (!response || !response.ok) {
|
||||
return {
|
||||
manifest: null,
|
||||
entryAssetUrl: null,
|
||||
wasmAssetUrl: null,
|
||||
};
|
||||
}
|
||||
const manifest = (await response.json().catch(() => null)) as LeptosTiptapIslandManifest | null;
|
||||
return {
|
||||
manifest,
|
||||
entryAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.entryAssetPath ?? null),
|
||||
wasmAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.wasmAssetPath ?? null),
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,11 @@ export type LoadedLeptosTiptapRuntime = {
|
||||
|
||||
let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null;
|
||||
|
||||
function formatRuntimeLoaderError(stage: string, error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : "未知错误";
|
||||
return new Error(`[leptos_tiptap_runtime_loader:${stage}] ${message}`);
|
||||
}
|
||||
|
||||
function toRuntimeAssetUrl(relativePath: string): string {
|
||||
return `/api/leptos-tiptap-runtime/${relativePath}`;
|
||||
}
|
||||
@@ -65,26 +70,46 @@ function extractRegisterFunction(module: Record<string, unknown>): (() => void)
|
||||
}
|
||||
|
||||
async function fetchRuntimeManifest(): Promise<RuntimeManifest> {
|
||||
const response = await fetch(MANIFEST_URL, { cache: "no-store" });
|
||||
const response = await fetch(MANIFEST_URL, { cache: "no-store" }).catch((error) => {
|
||||
throw formatRuntimeLoaderError("manifest_fetch", error);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 leptos-tiptap runtime manifest 失败(${response.status})`);
|
||||
throw new Error(
|
||||
`[leptos_tiptap_runtime_loader:manifest_fetch] 读取 leptos-tiptap runtime manifest 失败(${response.status})`,
|
||||
);
|
||||
}
|
||||
const manifest = (await response.json()) as RuntimeManifest;
|
||||
const manifest = (await response.json().catch((error) => {
|
||||
throw formatRuntimeLoaderError("manifest_parse", error);
|
||||
})) as RuntimeManifest;
|
||||
if (!manifest.bridgeRuntimePath) {
|
||||
throw new Error("runtime manifest 缺少 bridgeRuntimePath");
|
||||
throw new Error("[leptos_tiptap_runtime_loader:manifest_validate] runtime manifest 缺少 bridgeRuntimePath");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> {
|
||||
const manifest = await fetchRuntimeManifest();
|
||||
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!);
|
||||
bridge.init_bridge_runtime();
|
||||
const manifest = await fetchRuntimeManifest().catch((error) => {
|
||||
throw formatRuntimeLoaderError("manifest", error);
|
||||
});
|
||||
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!).catch((error) => {
|
||||
throw formatRuntimeLoaderError("bridge_import", error);
|
||||
});
|
||||
try {
|
||||
bridge.init_bridge_runtime();
|
||||
} catch (error) {
|
||||
throw formatRuntimeLoaderError("bridge_init", error);
|
||||
}
|
||||
|
||||
for (const modulePath of manifest.extensionModulePaths) {
|
||||
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath);
|
||||
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath).catch((error) => {
|
||||
throw formatRuntimeLoaderError(`extension_import:${modulePath}`, error);
|
||||
});
|
||||
const register = extractRegisterFunction(extensionModule);
|
||||
register?.();
|
||||
try {
|
||||
register?.();
|
||||
} catch (error) {
|
||||
throw formatRuntimeLoaderError(`extension_register:${modulePath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { manifest, bridge };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PageOptionsSidebar } from "./page-options-sidebar";
|
||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
|
||||
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
|
||||
return {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStats(): DocumentStats {
|
||||
return {
|
||||
wordCount: 3,
|
||||
characterCount: 12,
|
||||
blockCount: 1,
|
||||
todoTotal: 0,
|
||||
todoDone: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PageOptionsSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("对已保存但未完成编辑器语义的设置应显示降级说明", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<PageOptionsSidebar
|
||||
documentId="doc-1"
|
||||
options={buildOptions({ showHeadingNumbers: true, embedDefaultBlockId: "block-1" })}
|
||||
stats={buildStats()}
|
||||
onToggle={() => undefined}
|
||||
onExport={() => undefined}
|
||||
onOpenHistory={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("标题编号");
|
||||
expect(container.textContent).toContain("已保存字段");
|
||||
expect(container.textContent).toContain("编辑器语义暂未正式接通");
|
||||
|
||||
const customTab = container.querySelectorAll("button")[1];
|
||||
expect(customTab).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
customTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("嵌入默认位置");
|
||||
expect(container.textContent).toContain("这是已保存字段");
|
||||
expect(container.textContent).toContain("当前编辑器语义暂未正式接通");
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const OPTION_META: Record<
|
||||
},
|
||||
showHeadingNumbers: {
|
||||
label: "标题编号",
|
||||
description: "自动为标题添加编号",
|
||||
description: "自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)",
|
||||
icon: ListOrdered,
|
||||
},
|
||||
showToc: {
|
||||
@@ -289,6 +289,9 @@ export function PageOptionsSidebar({
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
这是已保存字段,但当前编辑器语义暂未正式接通。
|
||||
</p>
|
||||
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
|
||||
当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { usePageHeadTitle } from "./use-page-head-title";
|
||||
|
||||
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题 A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
||||
return buildSidebarInitialData({
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents,
|
||||
trashedDocuments: [],
|
||||
mindmaps: [],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
}
|
||||
|
||||
function HookProbe(props: { documentId: string; fallbackTitle: string }) {
|
||||
const state = usePageHeadTitle(props);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="page-head-title"
|
||||
data-display-title={state.displayTitle}
|
||||
data-committed-title={state.committedTitle}
|
||||
data-has-draft={state.hasDraft ? "1" : "0"}
|
||||
/>
|
||||
<button type="button" data-testid="set-draft" onClick={() => state.setDraftTitle(" 新标题 ")}>
|
||||
设草稿
|
||||
</button>
|
||||
<button type="button" data-testid="commit-persisted" onClick={() => state.commitPersistedTitle("新标题")}>
|
||||
提交持久化
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("usePageHeadTitle", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("应优先使用 live sidebar snapshot 中的标题作为 committed title", async () => {
|
||||
const snapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "树标题",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={snapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("树标题");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("树标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("0");
|
||||
});
|
||||
|
||||
it("应只把本地输入保留为短暂 draft,并在 live 标题追平后自动清空", async () => {
|
||||
const initialSnapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "旧标题",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={initialSnapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const setDraftButton = container.querySelector<HTMLButtonElement>("[data-testid='set-draft']");
|
||||
act(() => {
|
||||
setDraftButton?.click();
|
||||
});
|
||||
|
||||
let probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe(" 新标题 ");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("旧标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("1");
|
||||
|
||||
const commitPersistedButton = container.querySelector<HTMLButtonElement>("[data-testid='commit-persisted']");
|
||||
act(() => {
|
||||
commitPersistedButton?.click();
|
||||
});
|
||||
|
||||
probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("1");
|
||||
|
||||
const syncedSnapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "新标题",
|
||||
updated_at: "2026-04-21T00:00:02.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={syncedSnapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
|
||||
function normalizePageHeadTitle(title: string | null | undefined): string {
|
||||
const normalized = String(title ?? "").trim();
|
||||
return normalized || "无标题";
|
||||
}
|
||||
|
||||
export function usePageHeadTitle(input: { documentId: string; fallbackTitle: string }) {
|
||||
const liveSidebarTitle = usePreferredSidebarDocumentTitle(input.documentId);
|
||||
const committedTitle = useMemo(
|
||||
() => normalizePageHeadTitle(liveSidebarTitle ?? input.fallbackTitle),
|
||||
[input.fallbackTitle, liveSidebarTitle],
|
||||
);
|
||||
const [draftState, setDraftState] = useState<{
|
||||
documentId: string;
|
||||
title: string | null;
|
||||
}>({
|
||||
documentId: input.documentId,
|
||||
title: null,
|
||||
});
|
||||
const draftTitle = draftState.documentId === input.documentId ? draftState.title : null;
|
||||
const hasDraft = draftTitle != null && normalizePageHeadTitle(draftTitle) !== committedTitle;
|
||||
|
||||
return {
|
||||
displayTitle: hasDraft ? draftTitle ?? committedTitle : committedTitle,
|
||||
committedTitle,
|
||||
hasDraft,
|
||||
setDraftTitle: (title: string) => {
|
||||
setDraftState({
|
||||
documentId: input.documentId,
|
||||
title,
|
||||
});
|
||||
},
|
||||
commitPersistedTitle: (title: string) => {
|
||||
setDraftState({
|
||||
documentId: input.documentId,
|
||||
title: normalizePageHeadTitle(title),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
|
||||
const PreferredSidebarSnapshotContext = createContext<SidebarInitialData | null>(null);
|
||||
|
||||
interface PreferredSidebarSnapshotProviderProps {
|
||||
data: SidebarInitialData;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PreferredSidebarSnapshotProvider({
|
||||
data,
|
||||
children,
|
||||
}: PreferredSidebarSnapshotProviderProps) {
|
||||
return (
|
||||
<PreferredSidebarSnapshotContext.Provider value={data}>
|
||||
{children}
|
||||
</PreferredSidebarSnapshotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOptionalPreferredSidebarSnapshotData() {
|
||||
return useContext(PreferredSidebarSnapshotContext);
|
||||
}
|
||||
|
||||
export function usePreferredSidebarSnapshotData() {
|
||||
const context = useOptionalPreferredSidebarSnapshotData();
|
||||
if (!context) {
|
||||
throw new Error("usePreferredSidebarSnapshotData 必须在 PreferredSidebarSnapshotProvider 中使用");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function usePreferredSidebarDocument(documentId: string): DocumentRecord | null {
|
||||
const snapshot = useOptionalPreferredSidebarSnapshotData();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.documents.find((item) => item.id === documentId) ?? null;
|
||||
}, [documentId, snapshot]);
|
||||
}
|
||||
|
||||
export function usePreferredSidebarDocumentTitle(documentId: string): string | null {
|
||||
const document = usePreferredSidebarDocument(documentId);
|
||||
const title = document?.title?.trim();
|
||||
return title ? title : null;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
|
||||
import { bindSidebarRefreshEvents } from "./sidebar-events";
|
||||
|
||||
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
|
||||
return {
|
||||
id: "asset-1",
|
||||
workspace_id: "ws-1",
|
||||
document_id: "doc-1",
|
||||
asset_type: "image",
|
||||
file_url: "/files/a.png",
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: "uploads/a.png",
|
||||
file_name: "a.png",
|
||||
file_size: 1,
|
||||
mime_type: "image/png",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bindSidebarRefreshEvents", () => {
|
||||
it("收到 documents-changed 时应触发 sidebar 与共享摘要刷新", () => {
|
||||
const target = new EventTarget();
|
||||
const onAsset = vi.fn();
|
||||
const sidebarRefetch = vi.fn();
|
||||
const refreshShareSummary = vi.fn();
|
||||
const refreshGroupPublicSummary = vi.fn();
|
||||
|
||||
const dispose = bindSidebarRefreshEvents({
|
||||
target,
|
||||
onAsset,
|
||||
sidebarRefetch,
|
||||
refreshShareSummary,
|
||||
refreshGroupPublicSummary,
|
||||
});
|
||||
|
||||
target.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId: "doc-1" } }));
|
||||
|
||||
expect(onAsset).not.toHaveBeenCalled();
|
||||
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
|
||||
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
|
||||
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("收到 assets-changed 且带 asset 时应先透传 asset 再刷新", () => {
|
||||
const target = new EventTarget();
|
||||
const onAsset = vi.fn();
|
||||
const sidebarRefetch = vi.fn();
|
||||
const refreshShareSummary = vi.fn();
|
||||
const refreshGroupPublicSummary = vi.fn();
|
||||
const asset = buildAsset({ asset_type: "mindmap" });
|
||||
|
||||
const dispose = bindSidebarRefreshEvents({
|
||||
target,
|
||||
onAsset,
|
||||
sidebarRefetch,
|
||||
refreshShareSummary,
|
||||
refreshGroupPublicSummary,
|
||||
});
|
||||
|
||||
target.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail: { docId: "doc-1", asset } }));
|
||||
|
||||
expect(onAsset).toHaveBeenCalledWith(asset);
|
||||
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
|
||||
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
|
||||
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
type SidebarRefreshEventDetail = {
|
||||
docId?: string;
|
||||
asset?: MediaAsset;
|
||||
};
|
||||
|
||||
export function bindSidebarRefreshEvents(input: {
|
||||
target?: EventTarget;
|
||||
onAsset?: (asset?: MediaAsset) => void;
|
||||
sidebarRefetch: () => void | Promise<unknown>;
|
||||
refreshShareSummary: () => void | Promise<unknown>;
|
||||
refreshGroupPublicSummary: () => void | Promise<unknown>;
|
||||
}): () => void {
|
||||
const target = input.target ?? window;
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<SidebarRefreshEventDetail>;
|
||||
if (custom.detail?.asset) {
|
||||
input.onAsset?.(custom.detail.asset);
|
||||
}
|
||||
void input.sidebarRefetch();
|
||||
void input.refreshShareSummary();
|
||||
void input.refreshGroupPublicSummary();
|
||||
};
|
||||
|
||||
target.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
target.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
target.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSidebarTreeSyncKey, buildMediaAssetListSyncKey } from "./sidebar-sync";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
function buildNode(overrides: Partial<SidebarTreeNode> = {}): SidebarTreeNode {
|
||||
return {
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
access_scope: "private",
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
|
||||
return {
|
||||
id: "asset-1",
|
||||
workspace_id: "ws-1",
|
||||
document_id: "doc-1",
|
||||
asset_type: "image",
|
||||
file_url: "/files/a.png",
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: "uploads/a.png",
|
||||
file_name: "a.png",
|
||||
file_size: 1,
|
||||
mime_type: "image/png",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar-sync", () => {
|
||||
it("相同侧边栏树内容应产生相同 sync key", () => {
|
||||
const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
|
||||
const right = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
|
||||
|
||||
expect(buildSidebarTreeSyncKey(left)).toBe(buildSidebarTreeSyncKey(right));
|
||||
});
|
||||
|
||||
it("侧边栏树语义变化时应产生不同 sync key", () => {
|
||||
const left = [buildNode({ title: "标题 A" })];
|
||||
const right = [buildNode({ title: "标题 B" })];
|
||||
|
||||
expect(buildSidebarTreeSyncKey(left)).not.toBe(buildSidebarTreeSyncKey(right));
|
||||
});
|
||||
|
||||
it("相同附件列表内容应产生相同 sync key", () => {
|
||||
const left = [buildAsset()];
|
||||
const right = [buildAsset()];
|
||||
|
||||
expect(buildMediaAssetListSyncKey(left)).toBe(buildMediaAssetListSyncKey(right));
|
||||
});
|
||||
|
||||
it("附件列表语义变化时应产生不同 sync key", () => {
|
||||
const left = [buildAsset({ file_name: "a.png" })];
|
||||
const right = [buildAsset({ file_name: "b.png" })];
|
||||
|
||||
expect(buildMediaAssetListSyncKey(left)).not.toBe(buildMediaAssetListSyncKey(right));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
type SidebarTreeSnapshot = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parentId: string | null;
|
||||
sortOrder: number | null;
|
||||
updatedAt: string | null;
|
||||
children: SidebarTreeSnapshot[];
|
||||
};
|
||||
|
||||
function toSidebarTreeSnapshot(nodes: SidebarTreeNode[]): SidebarTreeSnapshot[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title ?? null,
|
||||
parentId: node.parent_id ?? null,
|
||||
sortOrder: node.sort_order ?? null,
|
||||
updatedAt: node.updated_at ?? null,
|
||||
children: toSidebarTreeSnapshot(node.children ?? []),
|
||||
}));
|
||||
}
|
||||
|
||||
function toMediaAssetSnapshot(assets: MediaAsset[]) {
|
||||
return assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
assetType: asset.asset_type,
|
||||
documentId: asset.document_id,
|
||||
fileName: asset.file_name ?? null,
|
||||
updatedAt: asset.updated_at ?? null,
|
||||
storagePath: asset.storage_path ?? null,
|
||||
fileUrl: asset.file_url ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function toMillis(value: string | null | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
|
||||
return JSON.stringify(toSidebarTreeSnapshot(nodes));
|
||||
}
|
||||
|
||||
export function buildMediaAssetListSyncKey(assets: MediaAsset[]): string {
|
||||
return JSON.stringify(toMediaAssetSnapshot(assets));
|
||||
}
|
||||
|
||||
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||
return JSON.stringify({
|
||||
activeWorkspaceId: data.activeWorkspaceId,
|
||||
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
||||
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
|
||||
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
|
||||
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
|
||||
trashedDocuments: (data.trashedDocuments ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title ?? null,
|
||||
parentId: item.parent_id ?? null,
|
||||
deletedAt: item.deleted_at,
|
||||
accessScope: item.access_scope,
|
||||
})),
|
||||
trashedMediaAssets: toMediaAssetSnapshot(data.trashedMediaAssets ?? []),
|
||||
trashedMindmapAssets: toMediaAssetSnapshot(data.trashedMindmapAssets ?? []),
|
||||
trashedTableAssets: toMediaAssetSnapshot(data.trashedTableAssets ?? []),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||
const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||
const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||
const assetTimes = [
|
||||
...(data.mediaAssets ?? []),
|
||||
...(data.mindmapAssets ?? []),
|
||||
...(data.tableAssets ?? []),
|
||||
...(data.trashedMediaAssets ?? []),
|
||||
...(data.trashedMindmapAssets ?? []),
|
||||
...(data.trashedTableAssets ?? []),
|
||||
].map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||
const trashedDocumentTimes = (data.trashedDocuments ?? []).map((item) => toMillis(item.deleted_at));
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
...documentTimes,
|
||||
...treeTimes,
|
||||
...assetTimes,
|
||||
...trashedDocumentTimes,
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,12 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
|
||||
import {
|
||||
buildMediaAssetListSyncKey,
|
||||
buildSidebarTreeSyncKey,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
@@ -65,7 +71,7 @@ import {
|
||||
} from "@/components/sidebar/tree-pane-bindings";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
|
||||
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -139,6 +145,9 @@ const extractMindmapIdFromStoragePath = (
|
||||
|
||||
interface SidebarProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarData?: SidebarInitialData;
|
||||
sidebarQuery?: SidebarDataResult;
|
||||
treeStream?: ReturnType<typeof useSidebarTreeStream>;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -147,7 +156,17 @@ interface ContextMenuState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function Sidebar({ initialData }: SidebarProps) {
|
||||
export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) {
|
||||
if (sidebarQuery && treeStream) {
|
||||
return (
|
||||
<SidebarContent
|
||||
initialData={initialData}
|
||||
sidebarData={sidebarData}
|
||||
sidebarQuery={sidebarQuery}
|
||||
treeStream={treeStream}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SidebarConvex initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -161,11 +180,12 @@ function SidebarConvex({ initialData }: SidebarProps) {
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
interface SidebarContentProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarData?: SidebarInitialData;
|
||||
sidebarQuery: SidebarDataResult;
|
||||
treeStream: ReturnType<typeof useSidebarTreeStream>;
|
||||
}
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
|
||||
function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebarQuery, treeStream }: SidebarContentProps) {
|
||||
const convex = useConvex();
|
||||
useConvexAuth();
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
@@ -175,10 +195,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
return treeStream.data ?? sidebarQuery.data ?? initialData;
|
||||
}, [treeStream.data, sidebarQuery, initialData]);
|
||||
const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
|
||||
initialData,
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
});
|
||||
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
||||
|
||||
const sidebarHydrate = useSidebarStore.persist?.rehydrate;
|
||||
const sidebarRefetch = sidebarQuery.refetch;
|
||||
// isLoading 判断
|
||||
const isLoading = sidebarQuery.isLoading;
|
||||
const segments = useSelectedLayoutSegments();
|
||||
@@ -250,6 +275,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
anchorRowId: null,
|
||||
focusedRowId: null,
|
||||
}));
|
||||
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
||||
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||
|
||||
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
@@ -257,19 +287,36 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const nextTree = sidebarData.kernelSidebarTree;
|
||||
const nextSyncKey = buildSidebarTreeSyncKey(nextTree);
|
||||
if (treeSyncKeyRef.current === nextSyncKey) {
|
||||
return;
|
||||
}
|
||||
treeSyncKeyRef.current = nextSyncKey;
|
||||
setTree(() => {
|
||||
const nextTree = sidebarData.kernelSidebarTree;
|
||||
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
||||
return nextTree;
|
||||
});
|
||||
}, [sidebarData.kernelSidebarTree]);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaAssets(sidebarData.mediaAssets ?? []);
|
||||
const nextAssets = sidebarData.mediaAssets ?? [];
|
||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||
if (mediaAssetsSyncKeyRef.current === nextSyncKey) {
|
||||
return;
|
||||
}
|
||||
mediaAssetsSyncKeyRef.current = nextSyncKey;
|
||||
setMediaAssets(nextAssets);
|
||||
}, [sidebarData.mediaAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
||||
const nextAssets = sidebarData.mindmapAssets ?? [];
|
||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||
if (mindmapAssetsSyncKeyRef.current === nextSyncKey) {
|
||||
return;
|
||||
}
|
||||
mindmapAssetsSyncKeyRef.current = nextSyncKey;
|
||||
setMindmapAssets(nextAssets);
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -280,13 +327,35 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
hidden.delete(id);
|
||||
}
|
||||
});
|
||||
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
|
||||
const nextAssets = (sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id));
|
||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||
if (tableAssetsSyncKeyRef.current === nextSyncKey) {
|
||||
return;
|
||||
}
|
||||
tableAssetsSyncKeyRef.current = nextSyncKey;
|
||||
setTableAssets(nextAssets);
|
||||
}, [sidebarData.tableAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [activeId, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarHydrated) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await sidebarHydrate?.();
|
||||
if (!cancelled) {
|
||||
setSidebarHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sidebarHydrate, sidebarHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
@@ -294,7 +363,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
if (tableId) {
|
||||
hiddenTableIdsRef.current.delete(tableId);
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
void sidebarRefetch();
|
||||
};
|
||||
const onDeleted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
@@ -303,7 +372,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
hiddenTableIdsRef.current.set(tableId, Date.now());
|
||||
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
void sidebarRefetch();
|
||||
};
|
||||
window.addEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
@@ -311,11 +380,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
window.removeEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.removeEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
}, [sidebarRefetch]);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
await sidebarRefetch();
|
||||
}, [sidebarRefetch]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
try {
|
||||
@@ -379,9 +448,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
}, [refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
const dispose = bindSidebarRefreshEvents({
|
||||
onAsset: (asset) => {
|
||||
if (asset?.id) {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
@@ -400,18 +468,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
},
|
||||
sidebarRefetch,
|
||||
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
|
||||
void refreshShareSummary();
|
||||
void refreshGroupPublicSummary();
|
||||
};
|
||||
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
refreshShareSummary,
|
||||
refreshGroupPublicSummary,
|
||||
});
|
||||
return dispose;
|
||||
}, [sidebarRefetch, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state,
|
||||
@@ -1450,11 +1514,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
await refreshTree();
|
||||
const query = new URLSearchParams();
|
||||
query.set("edit", "1");
|
||||
if (nextNode.workspace_id) {
|
||||
query.set("workspaceId", nextNode.workspace_id);
|
||||
}
|
||||
router.push(`/documents/${nextNode.id}?${query.toString()}`);
|
||||
const nextQuery = query.toString();
|
||||
router.push(
|
||||
nextQuery
|
||||
? `/documents/${nextNode.id}?${nextQuery}`
|
||||
: `/documents/${nextNode.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
|
||||
} finally {
|
||||
@@ -2603,6 +2671,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
return (
|
||||
<>
|
||||
{!sidebarHydrated ? null : null}
|
||||
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
|
||||
{sidebarBody}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { usePreferredSidebarSnapshot } from "./use-preferred-sidebar-snapshot";
|
||||
|
||||
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题 A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
||||
return buildSidebarInitialData({
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents,
|
||||
trashedDocuments: [],
|
||||
mindmaps: [],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
}
|
||||
|
||||
function Harness(props: {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQueryData: SidebarInitialData;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
||||
}) {
|
||||
const state = usePreferredSidebarSnapshot(props);
|
||||
|
||||
useEffect(() => {
|
||||
props.onState(state);
|
||||
}, [props, state]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("usePreferredSidebarSnapshot", () => {
|
||||
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;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
onState.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("tree stream 落后于 query refetch 时应优先使用更新后的 query 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const caughtUpTreeStream = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={initialData}
|
||||
treeStreamData={staleTreeStream}
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={staleTreeStream}
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "query",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={caughtUpTreeStream}
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import {
|
||||
buildSidebarDataSyncKey,
|
||||
getSidebarDataFreshness,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
|
||||
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
||||
|
||||
export function usePreferredSidebarSnapshot(input: {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
}) {
|
||||
const querySyncKey = useMemo(
|
||||
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
||||
[input.sidebarQueryData],
|
||||
);
|
||||
const treeStreamSyncKey = useMemo(
|
||||
() => (input.treeStreamData ? buildSidebarDataSyncKey(input.treeStreamData) : null),
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||
const queryFreshness = useMemo(
|
||||
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : Number.NEGATIVE_INFINITY),
|
||||
[input.sidebarQueryData],
|
||||
);
|
||||
const treeStreamFreshness = useMemo(
|
||||
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : Number.NEGATIVE_INFINITY),
|
||||
[input.treeStreamData],
|
||||
);
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (input.treeStreamData && input.sidebarQueryData) {
|
||||
if (treeStreamSyncKey === querySyncKey) {
|
||||
return "tree_stream";
|
||||
}
|
||||
return queryFreshness > treeStreamFreshness ? "query" : "tree_stream";
|
||||
}
|
||||
if (input.treeStreamData) {
|
||||
return "tree_stream";
|
||||
}
|
||||
if (input.sidebarQueryData) {
|
||||
return "query";
|
||||
}
|
||||
return "initial";
|
||||
}, [
|
||||
input.sidebarQueryData,
|
||||
input.treeStreamData,
|
||||
queryFreshness,
|
||||
querySyncKey,
|
||||
treeStreamFreshness,
|
||||
treeStreamSyncKey,
|
||||
]);
|
||||
|
||||
const data =
|
||||
source === "tree_stream" && input.treeStreamData
|
||||
? input.treeStreamData
|
||||
: source === "query" && input.sidebarQueryData
|
||||
? input.sidebarQueryData
|
||||
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
|
||||
const syncKey =
|
||||
source === "tree_stream"
|
||||
? treeStreamSyncKey ?? initialSyncKey
|
||||
: source === "query"
|
||||
? querySyncKey ?? initialSyncKey
|
||||
: initialSyncKey;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data,
|
||||
source,
|
||||
syncKey,
|
||||
}),
|
||||
[data, source, syncKey],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { act, useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useSidebarData } from "./use-sidebar-data";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
|
||||
const mockUseConvexSidebarData = vi.fn();
|
||||
const mockUseQuery = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/use-convex-sidebar-data", () => ({
|
||||
useConvexSidebarData: (...args: unknown[]) => mockUseConvexSidebarData(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (...args: unknown[]) => mockUseQuery(...args),
|
||||
}));
|
||||
|
||||
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({
|
||||
initialData,
|
||||
onState,
|
||||
}: {
|
||||
initialData: SidebarInitialData;
|
||||
onState: (state: ReturnType<typeof useSidebarData>) => void;
|
||||
}) {
|
||||
const state = useSidebarData(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [onState, state]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useSidebarData", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const onState = vi.fn();
|
||||
const stableRefetch = vi.fn(async () => undefined);
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
onState.mockClear();
|
||||
mockUseConvexSidebarData.mockImplementation(() => ({
|
||||
data: buildInitialData(),
|
||||
isLoading: false,
|
||||
isAuthLoading: false,
|
||||
isAuthenticated: true,
|
||||
hasLiveSubscription: true,
|
||||
canUseHttpFallback: false,
|
||||
error: null,
|
||||
refetch: stableRefetch,
|
||||
}));
|
||||
mockUseQuery.mockImplementation(() => ({
|
||||
data: buildInitialData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: stableRefetch,
|
||||
}));
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("底层 hook 语义未变时应保持返回对象稳定", async () => {
|
||||
const initialData = buildInitialData();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||
});
|
||||
const firstState = onState.mock.lastCall?.[0];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||
});
|
||||
const secondState = onState.mock.lastCall?.[0];
|
||||
|
||||
expect(firstState).toBeDefined();
|
||||
expect(secondState).toBe(firstState);
|
||||
});
|
||||
|
||||
it("Convex live 模式下手动 refetch 应强制刷新一份 HTTP sidebar snapshot", async () => {
|
||||
const initialData = buildInitialData();
|
||||
const refreshedData: SidebarInitialData = {
|
||||
...buildInitialData(),
|
||||
documents: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws_1",
|
||||
title: "新标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
},
|
||||
],
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
id: "doc-1",
|
||||
workspace_id: "ws_1",
|
||||
title: "新标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
access_scope: "private",
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => refreshedData,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||
});
|
||||
|
||||
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||
await act(async () => {
|
||||
await state.refetch();
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||
expect(refreshedState.data.documents[0]?.title).toBe("新标题");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
buildSidebarDataSyncKey,
|
||||
getSidebarDataFreshness,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
|
||||
@@ -11,6 +15,24 @@ export interface SidebarDataResult {
|
||||
source: "convex-live" | "http-fallback" | "initial";
|
||||
}
|
||||
|
||||
const STABLE_SIDEBAR_CACHE_LIMIT = 12;
|
||||
const stableSidebarDataCache = new Map<string, SidebarInitialData>();
|
||||
|
||||
function getStableSidebarData(syncKey: string, data: SidebarInitialData): SidebarInitialData {
|
||||
const cached = stableSidebarDataCache.get(syncKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
stableSidebarDataCache.set(syncKey, data);
|
||||
if (stableSidebarDataCache.size > STABLE_SIDEBAR_CACHE_LIMIT) {
|
||||
const oldestKey = stableSidebarDataCache.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
stableSidebarDataCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
|
||||
method: "GET",
|
||||
@@ -31,6 +53,13 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
||||
const convexSidebar = useConvexSidebarData(workspaceId);
|
||||
const shouldUseHttpFallback =
|
||||
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
||||
const [manualSnapshotState, setManualSnapshotState] = useState<{
|
||||
workspaceId: string;
|
||||
data: SidebarInitialData | null;
|
||||
}>({
|
||||
workspaceId,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const httpQuery = useQuery({
|
||||
queryKey: ["sidebar", workspaceId],
|
||||
@@ -40,41 +69,115 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
||||
enabled: shouldUseHttpFallback,
|
||||
});
|
||||
|
||||
return useMemo<SidebarDataResult>(() => {
|
||||
const liveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
||||
const isLoading =
|
||||
convexSidebar.hasLiveSubscription
|
||||
? convexSidebar.isLoading
|
||||
: shouldUseHttpFallback
|
||||
? httpQuery.isLoading
|
||||
: false;
|
||||
const source: SidebarDataResult["source"] = convexSidebar.data
|
||||
? "convex-live"
|
||||
: shouldUseHttpFallback && httpQuery.data
|
||||
? "http-fallback"
|
||||
: "initial";
|
||||
const refetch = async () => {
|
||||
if (convexSidebar.hasLiveSubscription) {
|
||||
await convexSidebar.refetch();
|
||||
return liveData;
|
||||
}
|
||||
if (shouldUseHttpFallback) {
|
||||
return httpQuery.refetch();
|
||||
}
|
||||
return liveData;
|
||||
};
|
||||
const manualSnapshot =
|
||||
manualSnapshotState.workspaceId === workspaceId
|
||||
? manualSnapshotState.data
|
||||
: null;
|
||||
|
||||
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
||||
const baseLiveDataSyncKey = useMemo(
|
||||
() => buildSidebarDataSyncKey(baseLiveData),
|
||||
[baseLiveData],
|
||||
);
|
||||
const baseLiveDataFreshness = useMemo(
|
||||
() => getSidebarDataFreshness(baseLiveData),
|
||||
[baseLiveData],
|
||||
);
|
||||
const manualSnapshotSyncKey = useMemo(
|
||||
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
|
||||
[manualSnapshot],
|
||||
);
|
||||
const manualSnapshotFreshness = useMemo(
|
||||
() => (manualSnapshot ? getSidebarDataFreshness(manualSnapshot) : Number.NEGATIVE_INFINITY),
|
||||
[manualSnapshot],
|
||||
);
|
||||
const liveData = useMemo(() => {
|
||||
if (!manualSnapshot) {
|
||||
return baseLiveData;
|
||||
}
|
||||
if (manualSnapshotSyncKey === baseLiveDataSyncKey) {
|
||||
return baseLiveData;
|
||||
}
|
||||
return manualSnapshotFreshness >= baseLiveDataFreshness
|
||||
? manualSnapshot
|
||||
: baseLiveData;
|
||||
}, [
|
||||
baseLiveData,
|
||||
baseLiveDataFreshness,
|
||||
baseLiveDataSyncKey,
|
||||
manualSnapshot,
|
||||
manualSnapshotFreshness,
|
||||
manualSnapshotSyncKey,
|
||||
]);
|
||||
const isLoading =
|
||||
convexSidebar.hasLiveSubscription
|
||||
? convexSidebar.isLoading
|
||||
: shouldUseHttpFallback
|
||||
? httpQuery.isLoading
|
||||
: false;
|
||||
const source: SidebarDataResult["source"] = convexSidebar.data
|
||||
? "convex-live"
|
||||
: shouldUseHttpFallback && httpQuery.data
|
||||
? "http-fallback"
|
||||
: "initial";
|
||||
const error = convexSidebar.error ?? httpQuery.error ?? null;
|
||||
const liveDataRef = useRef(liveData);
|
||||
const convexRefetchRef = useRef(convexSidebar.refetch);
|
||||
const httpRefetchRef = useRef(httpQuery.refetch);
|
||||
const liveDataSyncKey = useMemo(() => buildSidebarDataSyncKey(liveData), [liveData]);
|
||||
|
||||
useEffect(() => {
|
||||
liveDataRef.current = liveData;
|
||||
}, [liveData]);
|
||||
|
||||
useEffect(() => {
|
||||
convexRefetchRef.current = convexSidebar.refetch;
|
||||
}, [convexSidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
httpRefetchRef.current = httpQuery.refetch;
|
||||
}, [httpQuery]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
if (workspaceId) {
|
||||
try {
|
||||
const refreshedSnapshot = await requestSidebarData(workspaceId);
|
||||
setManualSnapshotState({
|
||||
workspaceId,
|
||||
data: refreshedSnapshot,
|
||||
});
|
||||
return refreshedSnapshot;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (convexSidebar.hasLiveSubscription) {
|
||||
await convexRefetchRef.current();
|
||||
return liveDataRef.current;
|
||||
}
|
||||
if (shouldUseHttpFallback) {
|
||||
return httpRefetchRef.current();
|
||||
}
|
||||
return liveDataRef.current;
|
||||
}, [
|
||||
convexSidebar.hasLiveSubscription,
|
||||
shouldUseHttpFallback,
|
||||
workspaceId,
|
||||
]);
|
||||
const stableLiveData = getStableSidebarData(liveDataSyncKey, liveData);
|
||||
|
||||
return useMemo<SidebarDataResult>(() => {
|
||||
return {
|
||||
data: liveData,
|
||||
data: stableLiveData,
|
||||
isLoading,
|
||||
error: convexSidebar.error ?? httpQuery.error ?? null,
|
||||
error,
|
||||
refetch,
|
||||
source,
|
||||
};
|
||||
}, [
|
||||
convexSidebar,
|
||||
httpQuery,
|
||||
initialData,
|
||||
shouldUseHttpFallback,
|
||||
error,
|
||||
isLoading,
|
||||
refetch,
|
||||
stableLiveData,
|
||||
source,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface EditorReferenceBridge {
|
||||
insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
|
||||
replaceWithSnapshot: (blocks: Json) => void;
|
||||
openTableFullScreen?: (tableId: string) => void;
|
||||
requestFallbackToBlockNote?: () => void;
|
||||
}
|
||||
|
||||
interface EditorBridgeState {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
|
||||
describe("useSidebarStore hydration", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("模块初始化时应保持默认首帧状态,避免先读持久化状态", () => {
|
||||
const state = useSidebarStore.getState();
|
||||
|
||||
expect(state.open).toBe(false);
|
||||
expect(state.width).toBe(280);
|
||||
expect(state.viewMode).toBe("section");
|
||||
});
|
||||
|
||||
it("手动 hydrate 后才应用持久化状态", () => {
|
||||
localStorage.setItem(
|
||||
"sidebar-ui",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
open: true,
|
||||
width: 360,
|
||||
collapsedSections: {
|
||||
starred: false,
|
||||
public: false,
|
||||
shared: false,
|
||||
private: true,
|
||||
templates: false,
|
||||
},
|
||||
trashConfirm: false,
|
||||
viewMode: "filesystem",
|
||||
},
|
||||
version: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useSidebarStore;
|
||||
expect(store.persist.hasHydrated()).toBe(false);
|
||||
void store.persist.rehydrate();
|
||||
|
||||
expect(store.getState().open).toBe(true);
|
||||
expect(store.getState().width).toBe(360);
|
||||
expect(store.getState().viewMode).toBe("filesystem");
|
||||
expect(store.persist.hasHydrated()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
{
|
||||
name: "sidebar-ui",
|
||||
version: 4,
|
||||
skipHydration: true,
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as
|
||||
| Partial<
|
||||
|
||||
Reference in New Issue
Block a user