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

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

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

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -0,0 +1,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);
});
}
+15 -11
View File
@@ -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 switchruntime/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>
+14 -16
View File
@@ -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);
}
}
+18 -6
View File
@@ -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 -7
View File
@@ -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,
);
}
+100 -31
View File
@@ -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("新标题");
});
});
+133 -30
View File
@@ -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",
+66 -2
View File
@@ -34,12 +34,17 @@ export type MnoteRuntimeConfig = {
mnoteWebTreeShellEnabled?: boolean;
/**
* host
* host BlockNote
* leptos_tiptap_island
*/
documentEditorHost?:
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
/**
* BlockNote 退kill switch
* query host 退 blocknote
*/
documentEditorBlocknoteKillSwitch?: boolean;
/**
* Electron
*/
@@ -81,6 +86,37 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
return undefined;
};
const parseDocumentEditorHost = (
value: unknown,
): MnoteRuntimeConfig["documentEditorHost"] | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "blocknote") {
return "blocknote";
}
if (
normalized === "leptos_tiptap_island" ||
normalized === "leptos_tiptap_runtime" ||
normalized === "leptos_tiptap_inline" ||
normalized === "leptos_tiptap"
) {
return "leptos_tiptap_island";
}
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return undefined;
};
function getServerNodeBuiltin<T>(moduleName: string): T | null {
if (typeof window !== "undefined") {
return null;
@@ -135,6 +171,28 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
),
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
) !== undefined
? {
documentEditorBlocknoteKillSwitch: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
),
}
: {}),
...(parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
) !== undefined
? {
documentEditorHost: parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
),
}
: {}),
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -223,12 +281,18 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl,
onlyofficeStorageHostOverride,
onlyofficeProxyOrigin,
@@ -0,0 +1,79 @@
import { buildHermesRuntimeToolResultRequest } from "@/lib/ai-agent/hermes/tool-result-recovery";
import { buildMnoteWebForwardHeaders, getMnoteWebBaseUrl } from "@/lib/server/mnote-web";
type PlainObject = Record<string, unknown>;
function readErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") {
return payload.error;
}
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export async function fetchHermesStructuredToolResultFromMnoteWeb(input: {
request?: Request;
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): Promise<unknown> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const headers = await buildMnoteWebForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const response = await fetch(new URL("/api/hermes/bridge", `${baseUrl}/`).toString(), {
method: "POST",
headers,
body: JSON.stringify(
buildHermesRuntimeToolResultRequest({
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data: input.data,
requestId: input.requestId,
traceId: input.traceId,
workspaceId: input.workspaceId,
target: input.target,
reason: input.reason,
refs: input.refs,
}),
),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
ok?: boolean;
result?: unknown;
error?: string;
message?: string;
}
| null;
if (!response.ok) {
throw new Error(readErrorMessage(payload, "mnote-web Hermes runtime 请求失败"));
}
if (!payload || !("result" in payload)) {
throw new Error("mnote-web Hermes runtime 未返回 result");
}
return payload.result;
}
+21 -2
View File
@@ -20,7 +20,7 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
}
}
async function buildForwardHeaders(request?: Request): Promise<Headers> {
export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
@@ -89,7 +89,7 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildForwardHeaders(input.request);
const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
@@ -122,3 +122,22 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
},
};
}
export function buildMnoteWebStreamUrl(input: {
workspaceId: string;
cursor?: string | null;
}): URL {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/stream/events", `${baseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
url.searchParams.set("workspaceId", input.workspaceId.trim());
if (typeof input.cursor === "string" && input.cursor.trim()) {
url.searchParams.set("cursor", input.cursor.trim());
}
return url;
}
+14 -3
View File
@@ -69,9 +69,20 @@ export function buildWorkspaceTreeStreamUrl(
cursor?: string | null,
): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
const shouldUseSameOriginProxy =
normalizedBaseUrl.length > 0 &&
typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
@@ -6,14 +6,38 @@ import {
} from "./protocol";
describe("tree-stream/protocol", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("构造 workspace sidebar stream url", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9",
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
it("解析 snapshot / delta / resync 协议消息", () => {
expect(
parseTreeStreamMessage({
@@ -0,0 +1,184 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockRuntimeConfig = vi.hoisted(() => ({
getMnoteRuntimeConfig: vi.fn(),
}));
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
vi.mock("@/lib/mnote-web-auth", () => ({
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
}));
type MockEventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
static instances: MockEventSource[] = [];
readonly url: string;
readonly withCredentials: boolean;
readonly listeners = new Map<string, Set<MockEventListener>>();
onmessage: MockEventListener | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string, options?: EventSourceInit) {
this.url = url;
this.withCredentials = Boolean(options?.withCredentials);
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
const next = this.listeners.get(type) ?? new Set<MockEventListener>();
next.add(typedListener);
this.listeners.set(type, next);
}
removeEventListener(type: string, listener: EventListener) {
const typedListener = listener as unknown as MockEventListener;
this.listeners.get(type)?.delete(typedListener);
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
const event = {
type,
data: JSON.stringify(payload),
} as MessageEvent<string>;
this.listeners.get(type)?.forEach((listener) => listener(event));
if (type === "message" && this.onmessage) {
this.onmessage(event);
}
}
}
declare global {
// eslint-disable-next-line no-var
var EventSource: typeof MockEventSource;
}
function flush() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function buildInitialData(): SidebarInitialData {
return {
activeWorkspaceId: "ws_1",
workspaces: [],
documents: [],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelSidebarTree: [],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
trashedTableAssets: [],
tableAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
mediaAssets: [],
};
}
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
const state = useSidebarTreeStream(buildInitialData());
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
}
describe("useSidebarTreeStream", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
mnoteWebBaseUrl: "http://127.0.0.1:3104",
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("收到带 cursor 的 snapshot 后不应重建 EventSource", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.emit("snapshot", {
stream: "workspace",
workspaceId: "ws_1",
cursor: "evt_2",
projection: "sidebar_tree",
data: {
active_workspace_id: "ws_1",
workspaces: [],
documents: [],
kernel_sidebar_projection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
},
});
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
cursor: "evt_2",
status: "live",
}),
);
});
});
@@ -41,6 +41,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
data: null,
@@ -50,8 +51,13 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
});
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
cursorRef.current = state.cursor;
}, [state.cursor]);
useEffect(() => {
if (!streamEnabled) {
cursorRef.current = null;
setState({
data: null,
status: "idle",
@@ -134,7 +140,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
return;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
@@ -164,7 +170,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, state.cursor, streamEnabled, workspaceId]);
}, [baseUrl, streamEnabled, workspaceId]);
return state;
}
@@ -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 {
+47
View File
@@ -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);
});
});
+1
View File
@@ -56,6 +56,7 @@ export const useSidebarStore = create<SidebarState>()(
{
name: "sidebar-ui",
version: 4,
skipHydration: true,
migrate: (persistedState, version) => {
const state = persistedState as
| Partial<