feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
@@ -87,10 +87,29 @@ describe("applyDocWriteToolResultToPageBody", () => {
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
},
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: [{ id: "block_0", type: "paragraph", content: "旧正文" }] as Json,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
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,
},
}),
applyEditorSnapshot,
onPersistedMetaChange,
@@ -121,10 +140,76 @@ describe("applyDocWriteToolResultToPageBody", () => {
ok: true,
result: { data: [] },
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
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,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
applyPageBodyCommandImpl,
}),
).resolves.toBe(false);
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
});
it("doc 写工具返回非 legacy blocks 数组时应直接忽略", async () => {
const applyPageBodyCommandImpl = vi.fn();
await expect(
applyDocWriteToolResultToPageBody({
tool: "doc_replace_range",
ok: true,
result: {
data: {
documentId: "doc-1",
blocks: [],
},
},
documentId: "doc-1",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
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,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
@@ -14,13 +14,13 @@ import { Textarea } from "@/components/ui/textarea";
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,
executePageBodyCommand,
type ExecutePageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-body-command";
} from "@/lib/documents/page-command-client";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageAggregateAiSnapshot } from "@/components/editor/DocumentAiAgentPanel";
type AgentMessage = { role: "user" | "assistant"; content: string };
type CodexMode = "chat" | "test" | "dev";
@@ -164,10 +164,10 @@ export async function applyDocWriteToolResultToPageBody(input: {
ok: boolean;
result: unknown;
documentId: string;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
applyPageBodyCommandImpl?: (input: ExecutePageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
}): Promise<boolean> {
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
return false;
@@ -175,12 +175,12 @@ export async function applyDocWriteToolResultToPageBody(input: {
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) {
if (!Array.isArray(dataNode)) {
return false;
}
const latestPersistedMeta = input.getLatestPersistedMeta();
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
const latestPersistedMeta = input.getLatestPageAggregateSnapshot().persistedMeta;
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? executePageBodyCommand;
const persistedMeta = await applyPageBodyCommandImpl({
documentId: input.documentId,
workspaceId: latestPersistedMeta.workspaceId,
@@ -233,16 +233,12 @@ const safeJsonStringify = (value: unknown) => {
export function DocumentAiAgentPanelRuntime({
documentId,
getLatestBlocks,
getLatestPageSubtree,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
onPersistedMetaChange,
onPageHeadTitleChange,
}: {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}) {
@@ -260,7 +256,12 @@ export function DocumentAiAgentPanelRuntime({
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [aiModelKey, setAiModelKey] = useState<string>("");
const [aiProfileId, setAiProfileId] = useState<string>("");
const [page, setPage] = useState<PanelPage>("chat");
const [onlineConfig, setOnlineConfig] = useState<DocumentAiCapabilityConfig | null>(null);
const [onlineConfigLoading, setOnlineConfigLoading] = useState(false);
const [onlineConfigError, setOnlineConfigError] = useState<string>("");
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
@@ -283,25 +284,77 @@ export function DocumentAiAgentPanelRuntime({
}, [flightMode]);
useEffect(() => {
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", modelKey: "", profileId: "", maxSteps: 10 });
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setAiModelKey(prefs.modelKey || "");
setAiProfileId(prefs.profileId || "");
}, []);
useEffect(() => {
writeAiPanelPrefs("doc_ai", {
provider: aiProvider,
model: aiModel,
modelKey: aiModelKey,
profileId: aiProfileId,
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
});
}, [aiModel, aiProvider, maxSteps]);
}, [aiModel, aiModelKey, aiProfileId, aiProvider, maxSteps]);
useEffect(() => {
// 关闭面板时,回到对话页,避免下次打开还停留在设置/历史等子页
if (!open) setPage("chat");
}, [open]);
useEffect(() => {
if (aiProvider !== "online") return;
let cancelled = false;
setOnlineConfigLoading(true);
setOnlineConfigError("");
void fetch("/api/ai-agent/document/config", {
method: "GET",
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
throw new Error(String(payload?.error ?? `读取页面 AI 配置失败:${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
})
.then((payload) => {
if (cancelled) return;
setOnlineConfig(payload);
setAiModelKey((prev) => {
const picked = prev.trim();
if (picked && payload.models.some((item) => item.key === picked)) {
return picked;
}
return payload.defaultModelKey || picked;
});
setAiProfileId((prev) => {
const picked = prev.trim();
if (picked && payload.profiles.some((item) => item.id === picked)) {
return picked;
}
return payload.defaultProfileId || picked;
});
})
.catch((error) => {
if (cancelled) return;
setOnlineConfigError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
if (!cancelled) {
setOnlineConfigLoading(false);
}
});
return () => {
cancelled = true;
};
}, [aiProvider]);
// 会话/历史:按 documentId 隔离持久化
useEffect(() => {
try {
@@ -414,6 +467,18 @@ export function DocumentAiAgentPanelRuntime({
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const selectedOnlineModel = useMemo(
() => onlineConfig?.models.find((item) => item.key === aiModelKey) ?? null,
[aiModelKey, onlineConfig],
);
const selectedOnlineProfile = useMemo(
() => onlineConfig?.profiles.find((item) => item.id === aiProfileId) ?? null,
[aiProfileId, onlineConfig],
);
const onlineSessionId = useMemo(() => {
if (aiProvider !== "online" || !activeSessionId || !documentId) return null;
return `doc_ai:${documentId}:${activeSessionId}`;
}, [activeSessionId, aiProvider, documentId]);
const [codexSessionDraft, setCodexSessionDraft] = useState("");
useEffect(() => {
@@ -578,10 +643,11 @@ export function DocumentAiAgentPanelRuntime({
const payloadMessagesForRequest = payloadMessages;
const blocks = getLatestBlocks();
const pageAggregateSnapshot = getLatestPageAggregateSnapshot();
const blocks = pageAggregateSnapshot.blocks;
const blocksJson = blocks ? safeJsonStringify(blocks) : "";
const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000;
const pageSubtree = getLatestPageSubtree();
const pageSubtree = pageAggregateSnapshot.pageSubtree;
const contextNode = pageSubtree?.rootNode ?? null;
const contextSubtree = pageSubtree
? {
@@ -596,6 +662,16 @@ export function DocumentAiAgentPanelRuntime({
const codexSessionIdForRequest =
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
const onlineModelKeyForRequest =
aiProvider === "online"
? String(aiModelKey || onlineConfig?.defaultModelKey || "")
.trim() || null
: null;
const onlineProfileIdForRequest =
aiProvider === "online"
? String(aiProfileId || onlineConfig?.defaultProfileId || "")
.trim() || null
: null;
if (aiProvider === "codex" && activeSessionId) {
setSessions((prev) =>
@@ -638,13 +714,17 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree,
outline: contextOutline,
evidence: contextEvidence,
pageOptions: pageAggregateSnapshot.pageOptions,
},
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
...(aiProvider === "online" && onlineSessionId ? { sessionId: onlineSessionId } : {}),
...(aiProvider === "online" && onlineModelKeyForRequest ? { modelKey: onlineModelKeyForRequest } : {}),
...(aiProvider === "online" && onlineProfileIdForRequest ? { profileId: onlineProfileIdForRequest } : {}),
...(aiProvider !== "codex" && aiProvider !== "online" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
@@ -723,7 +803,7 @@ export function DocumentAiAgentPanelRuntime({
ok: Boolean(obj.ok),
result,
documentId,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
? (blocks) => {
editorBridge.replaceWithSnapshot(blocks);
@@ -1054,47 +1134,76 @@ export function DocumentAiAgentPanelRuntime({
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
{aiProvider === "online" ? (
<>
<div className="text-xs text-muted-foreground">
online mindmap
</div>
</div>
<div className="space-y-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{tool.title}</div>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{tool.name}</code>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.mode}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.scope}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.status}</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">{tool.description}</div>
</div>
))}
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
)}
</>
)}
</div>
</ScrollArea>
@@ -1254,6 +1363,22 @@ export function DocumentAiAgentPanelRuntime({
VSCode Codex CLI <code className="rounded bg-muted px-1 py-0.5">#dev</code> SessionId
</div>
</div>
) : aiProvider === "online" ? (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
<select
className="h-9 min-w-[260px] rounded border bg-white px-2 text-sm"
value={aiModelKey}
onChange={(e) => setAiModelKey(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.models.map((item) => (
<option key={item.key} value={item.key}>
{item.title} · {item.key}
</option>
))}
</select>
</>
) : (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
@@ -1277,12 +1402,35 @@ export function DocumentAiAgentPanelRuntime({
disabled={loading}
/>
)}
<datalist id="doc-ai-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</>
)}
</div>
{aiProvider === "online" ? (
<div className="space-y-2 rounded border bg-muted/30 p-3 text-xs text-muted-foreground">
<div>
线 provider OmniRouteBase URL
<code className="ml-1 rounded bg-muted px-1 py-0.5">{onlineConfig?.baseUrl || "http://localhost:20128/v1"}</code>
</div>
<div>
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.key || "未选择"}</code>
{" · "}
Combo
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.resolvedCombo || "由网关运行时解析"}</code>
</div>
<div>
Runtime Model
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{selectedOnlineModel?.resolvedRuntimeModel || "由网关运行时解析"}
</code>
</div>
<div>
Runtime Model AI OmniRoute runtime model provider
</div>
{onlineConfigLoading ? <div> AI </div> : null}
{onlineConfigError ? <div className="text-red-600">{onlineConfigError}</div> : null}
</div>
) : null}
<div className="text-xs text-muted-foreground">
线/ BaseURL Key / provider model Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
</div>
@@ -1323,6 +1471,62 @@ export function DocumentAiAgentPanelRuntime({
/>
</label>
</div>
{aiProvider === "online" ? (
<div className="space-y-3 rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground">Soul / Profile</label>
<select
className="h-9 min-w-[240px] rounded border bg-white px-2 text-sm"
value={aiProfileId}
onChange={(e) => setAiProfileId(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title}
</option>
))}
</select>
</div>
<div className="text-xs text-muted-foreground">
{selectedOnlineProfile?.description || "当前未读取到 profile 描述。"}
</div>
<div className="text-xs text-muted-foreground">
Profile registry
</div>
<div className="text-xs text-muted-foreground">
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{onlineConfig?.sessionEnabled ? `已启用 · ${onlineSessionId || "等待会话初始化"}` : "未启用"}
</code>
</div>
<div className="space-y-2 rounded border bg-muted/20 p-3">
<div className="text-xs font-medium text-foreground"> scope </div>
<div className="text-xs text-muted-foreground">
online tool registry mindmap
</div>
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
<div className="flex flex-wrap gap-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white px-2 py-1 text-xs">
<div className="font-medium">{tool.title}</div>
<div className="text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{tool.name}</code>
{" · "}
{tool.mode}
{" / "}
{tool.scope}
</div>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="text-xs text-muted-foreground"></div>
</div>
</ScrollArea>
@@ -2,16 +2,26 @@
import dynamic from "next/dynamic";
import { useEffect } from "react";
import type { PageBodyPersistedMeta } from "@/lib/documents/page-body-command";
import type { PageOptionsState } from "@/types/page-options";
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";
export type PageAggregateAiSnapshot = {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
};
type DocumentAiAgentPanelProps = {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
};
@@ -35,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { BlockNoteEditorProps } from "@/components/editor/editor-host-types";
@@ -306,48 +307,19 @@ export function BlockNoteEditor({
try {
setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
});
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? null;
conflictDetectionKeyRef.current = persistedMeta.conflictDetectionKey ?? null;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
@@ -21,14 +30,17 @@ 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 { extractPageBlocks } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageBodyPersistedMeta,
} from "@/lib/documents/page-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import {
DEFAULT_EDITOR_HOST_KIND,
@@ -41,6 +53,12 @@ import type {
} from "@/components/editor/editor-host-types";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -95,21 +113,6 @@ export interface DocumentContentProps {
editorHostKind?: EditorHostKind;
}
const defaultOptions: PageOptionsState = {
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,
};
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;
@@ -143,16 +146,17 @@ export function DocumentContent({
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;
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [pageClientState, dispatchPageClientState] = useReducer(
pageAggregateClientStateReducer,
page,
createPageAggregateClientState,
);
const options = pageClientState.options;
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -171,14 +175,9 @@ export function DocumentContent({
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>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const content = pageClientState.content;
const contentRevision = pageClientState.contentRevision;
const conflictDetectionKey = pageClientState.conflictDetectionKey;
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
@@ -353,33 +352,23 @@ export function DocumentContent({
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setServerPageSubtreeTitle(committedPageTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: committedPageTitle,
});
}, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
}, [page]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
useEffect(() => {
const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
@@ -424,7 +413,10 @@ export function DocumentContent({
const load = async () => {
setContentError(null);
setContentLoading(initialContent == null);
setContent(initialContent);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
setShowContentLoadingIndicator(false);
if (initialContent != null) {
@@ -443,7 +435,7 @@ export function DocumentContent({
try {
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: "GET",
credentials: "include",
@@ -455,31 +447,31 @@ export function DocumentContent({
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
page?: PageAggregateProjection;
};
const reloadedPage = payload.page ?? null;
const reloadedBody = reloadedPage?.body ?? null;
if (canceled) return;
setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: committedPageTitle,
);
if (reloadedPage) {
dispatchPageClientState({
type: "hydrate_from_page",
page: {
...reloadedPage,
body: {
...reloadedBody,
content: reloadedBody?.content ?? null,
revision:
typeof reloadedBody?.revision === "number" && Number.isInteger(reloadedBody.revision)
? reloadedBody.revision
: 0,
conflictDetectionKey:
typeof reloadedBody?.conflictDetectionKey === "string" && reloadedBody.conflictDetectionKey.trim()
? reloadedBody.conflictDetectionKey
: `${documentId}:0`,
},
},
});
}
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
@@ -506,7 +498,7 @@ export function DocumentContent({
contentLoadingTimerRef.current = null;
}
};
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
}, [contentReloadKey, documentId, initialContent, page, workspaceId]);
const persistTitle = useCallback(
async (nextTitle: string) => {
@@ -516,11 +508,14 @@ export function DocumentContent({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
persistTitleCommand: executePageHeadCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: payload,
});
} catch (error) {
console.error("更新页面标题失败", error);
}
@@ -532,10 +527,14 @@ export function DocumentContent({
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: nextTitle,
});
emitDocumentsChanged(documentId);
void persistTitle(nextTitle);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
@@ -565,7 +564,7 @@ export function DocumentContent({
async (patch: Partial<PageOptionsState>) => {
if (readOnly) return;
try {
await updatePageOptionsCommand({
await executePageLayoutCommand({
documentId,
workspaceId,
pageOptions: patch,
@@ -580,37 +579,37 @@ export function DocumentContent({
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
const nextValue = !options[key];
dispatchPageClientState({
type: "patch_page_options",
patch: { [key]: nextValue } as Partial<PageOptionsState>,
});
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
},
[persistOptions, readOnly],
[options, persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
dispatchPageClientState({
type: "patch_page_options",
patch,
});
void persistOptions(patch);
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
if (readOnly) return;
setOptions((prev) => {
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
return next;
if (!options.showToc) return;
dispatchPageClientState({
type: "patch_page_options",
patch: { showToc: false },
});
}, [persistOptions, readOnly]);
void persistOptions({ showToc: false });
}, [options.showToc, persistOptions, readOnly]);
const handleSetPageFont = useCallback(
(font: PageFont) => {
@@ -854,22 +853,10 @@ export function DocumentContent({
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const pageSubtree = useMemo(() => {
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
}
return null;
}, [
content,
pageTitle,
serverContentSnapshot,
serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
const pageSubtree = useMemo(
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
[pageClientState, pageTitle],
);
const readViewTocEntries = useMemo(
() =>
(pageSubtree?.outline ?? [])
@@ -882,16 +869,20 @@ 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 getLatestPageAggregateSnapshot = useCallback(
() =>
selectPageAggregateClientAiSnapshot(pageClientState, {
workspaceId,
pageTitle,
}),
[pageClientState, pageTitle, workspaceId],
);
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
dispatchPageClientState({
type: "apply_persisted_body_meta",
meta,
});
}, []);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
@@ -903,7 +894,10 @@ export function DocumentContent({
}, [isEditing]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setContent(payload.blocks);
dispatchPageClientState({
type: "apply_local_content_snapshot",
content: payload.blocks,
});
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
const now = Date.now();
@@ -1162,8 +1156,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
@@ -1184,8 +1177,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
/>
)}
@@ -1266,13 +1258,8 @@ export function DocumentContent({
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
onPersistedMetaChange={handlePersistedMetaChange}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/>
</ImagePickerProvider>
@@ -4,6 +4,7 @@ import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import type { ReferenceTarget } from "@/types/search";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
import type { LeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export interface DocumentEditorHostProps {
documentId: string;
@@ -87,7 +88,4 @@ export type LeptosTiptapHostBridgeEventDetail = {
at?: string;
};
export type LeptosTiptapRuntimePageOptions = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
export type { LeptosTiptapRuntimePageOptions };
@@ -11,11 +11,11 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
const RUNTIME_PORT = 8123;
const RUNTIME_NAME = "8123-leptos-tiptap-runtime";
@@ -72,19 +72,6 @@ function resolveRuntimeUrl() {
if (typeof window === "undefined") {
return `http://localhost:${RUNTIME_PORT}/`;
}
const cfg = getMnoteRuntimeConfig();
if (cfg.mnoteWebBaseUrl) {
try {
const url = new URL(cfg.mnoteWebBaseUrl);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
// ignore
}
}
try {
const url = new URL(window.location.href);
url.port = String(RUNTIME_PORT);
@@ -313,37 +300,26 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const saveSnapshot = async (payload: HostDocumentPayload) => {
const normalizedRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
const { blocks } = publishSnapshot(payload);
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: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
),
});
const nextMeta = await response.json().catch(() => null);
if (!response.ok) {
const errorMessage = typeof nextMeta?.error === "string" ? nextMeta.error : `保存失败(${response.status}`;
throw new Error(errorMessage);
}
const nextRevision = typeof nextMeta?.revision === "number" ? nextMeta.revision : payload.meta?.revision ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
);
const nextRevision = persistedMeta.revision ?? payload.meta?.revision ?? null;
const nextConflictDetectionKey =
typeof nextMeta?.conflictDetectionKey === "string"
? nextMeta.conflictDetectionKey
: payload.meta?.conflict_detection_key ?? null;
persistedMeta.conflictDetectionKey ?? payload.meta?.conflict_detection_key ?? null;
revisionRef.current = nextRevision;
conflictDetectionKeyRef.current = nextConflictDetectionKey;
onPersistedMetaChangeRef.current?.({
@@ -17,6 +17,7 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import { loadLeptosTiptapRuntime } from "@/components/editor/leptos-tiptap-runtime-loader";
type RuntimeSelectionState = Record<string, boolean | number | null | undefined> & {
@@ -601,45 +602,25 @@ export function LeptosTiptapRuntimeEditorHost(props: DocumentEditorHostProps) {
const persistSnapshot = async () => {
emitStatus(INLINE_RUNTIME_STATUS.saving);
const snapshot = latestSnapshotRef.current ?? (await readCurrentSnapshot());
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(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
),
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
revision?: number | null;
conflictDetectionKey?: string | null;
}
| null;
if (!response.ok) {
throw new Error(payload?.error ?? `保存失败(${response.status}`);
}
revisionRef.current =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string" &&
payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -9,13 +9,15 @@ 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 type { DocumentStats } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import {
loadLeptosTiptapIslandAssets,
} from "@/components/editor/leptos-tiptap-island-loader";
@@ -116,21 +118,7 @@ type IslandBootstrapPayload = {
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,
};
}
type RuntimePageOptionsPayload = ReturnType<typeof pickLeptosTiptapRuntimePageOptions>;
function toIsoNow(): string {
return new Date().toISOString();
@@ -316,7 +304,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
@@ -375,7 +363,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
};
}, [
mountIdentity,
@@ -418,35 +406,22 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
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;
const persistedMeta = await executePageBodySavePayload(
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,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string"
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -717,7 +692,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
}
dispatchRuntimeCommand(target, {
command: "setPageOptions",
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
}, [props.pageOptions]);
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import type { Json } from "@/types/supabase";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
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,
};
function createPageSubtree(title: string): PageSubtreeProjection {
return {
projectionId: "projection:page_tree:doc_1",
projection: "page_tree",
rootNodeId: "doc_1",
rootNode: {
id: "doc_1",
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title,
textSnippet: null,
blockType: null,
headingLevel: null,
numbering: null,
childCount: 0,
order: 0,
path: ["doc_1"],
},
},
subtree: {
rootNodeId: "doc_1",
nodes: [],
},
outline: [],
evidence: [],
stats: {
blockCount: 0,
headingCount: 0,
evidenceCount: 0,
maxDepth: 0,
},
};
}
function createPageAggregate(input?: {
title?: string;
content?: unknown;
pageSubtree?: PageSubtreeProjection | null;
pageOptionsPatch?: Partial<PageOptionsState>;
revision?: number | null;
conflictDetectionKey?: string | null;
}) {
const content =
input?.content ??
[
{
id: "block_1",
type: "paragraph",
content: [{ type: "text", text: "正文" }],
},
];
return buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: input?.title ?? "页面标题",
pageOptions: {
...pageOptions,
...input?.pageOptionsPatch,
},
content,
revision: input?.revision ?? 3,
conflictDetectionKey: input?.conflictDetectionKey ?? "doc_1:3",
pageSubtree: input?.pageSubtree ?? createPageSubtree("页面标题"),
});
}
describe("page-aggregate-client-state", () => {
it("应从 page aggregate 初始化 body/layout/tree 本地真相", () => {
const page = createPageAggregate();
const state = createPageAggregateClientState(page);
expect(state.options).toEqual(page.layout.pageOptions);
expect(state.content).toBe(page.body.content);
expect(state.serverContentSnapshot).toBe(page.body.content);
expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
expect(state.serverPageSubtreeTitle).toBe("页面标题");
expect(state.contentRevision).toBe(3);
expect(state.conflictDetectionKey).toBe("doc_1:3");
});
it("路由 reload 后应整体替换 body/layout/tree 的服务端快照", () => {
const initialPage = createPageAggregate();
const reloadedContent = [
{
id: "block_2",
type: "paragraph",
content: [{ type: "text", text: "刷新后的正文" }],
},
];
const reloadedPage = createPageAggregate({
title: "刷新后的标题",
content: reloadedContent,
pageSubtree: createPageSubtree("刷新后的标题"),
pageOptionsPatch: { wideLayout: true, showToc: true },
revision: 9,
conflictDetectionKey: "doc_1:9",
});
const next = pageAggregateClientStateReducer(createPageAggregateClientState(initialPage), {
type: "hydrate_from_page",
page: reloadedPage,
});
expect(next.options.wideLayout).toBe(true);
expect(next.options.showToc).toBe(true);
expect(next.content).toBe(reloadedContent);
expect(next.serverContentSnapshot).toBe(reloadedContent);
expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree);
expect(next.serverPageSubtreeTitle).toBe("刷新后的标题");
expect(next.contentRevision).toBe(9);
expect(next.conflictDetectionKey).toBe("doc_1:9");
});
it("正文保存元信息回写时只更新 persisted meta,不覆盖本地内容快照", () => {
const page = createPageAggregate();
const localContent = [
{
id: "block_local",
type: "paragraph",
content: [{ type: "text", text: "本地正文" }],
},
];
const withLocalSnapshot = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "apply_local_content_snapshot",
content: localContent,
});
const next = pageAggregateClientStateReducer(withLocalSnapshot, {
type: "apply_persisted_body_meta",
meta: {
revision: 10,
conflictDetectionKey: "doc_1:10",
},
});
expect(next.content).toBe(localContent);
expect(next.serverContentSnapshot).toBe(page.body.content);
expect(next.contentRevision).toBe(10);
expect(next.conflictDetectionKey).toBe("doc_1:10");
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }],
});
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title",
title: "持久化后的标题",
});
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull();
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree);
});
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
const page = createPageAggregate();
const next = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "patch_page_options",
patch: {
showToc: true,
layoutDensity: "compact",
},
});
expect(next.options).toEqual({
...page.layout.pageOptions,
showToc: true,
layoutDensity: "compact",
});
expect(next.content).toBe(page.body.content);
expect(next.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
});
it("应能从统一 client state 导出 AI 所需的页面聚合快照", () => {
const page = createPageAggregate({
pageOptionsPatch: {
wideLayout: true,
smallText: true,
},
});
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1",
pageTitle: "页面标题",
});
expect(snapshot).toEqual({
blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree,
persistedMeta: {
workspaceId: "ws_1",
revision: 3,
conflictDetectionKey: "doc_1:3",
},
pageOptions: {
...pageOptions,
wideLayout: true,
smallText: true,
},
});
});
});
@@ -0,0 +1,144 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientState = {
options: PageOptionsState;
content: unknown;
serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null;
conflictDetectionKey: string | null;
};
export type PageAggregateClientStateAction =
| {
type: "hydrate_from_page";
page: PageAggregateProjection;
}
| {
type: "patch_page_options";
patch: Partial<PageOptionsState>;
}
| {
type: "apply_local_content_snapshot";
content: unknown;
}
| {
type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
};
function normalizePageTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string {
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title;
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) {
return subtreeTitle.trim();
}
return normalizePageTitle(page.head.title);
}
export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
options: page.layout.pageOptions,
content: page.body.content,
serverContentSnapshot: page.body.content,
serverPageSubtreeSnapshot: page.tree.pageSubtree,
serverPageSubtreeTitle: resolveServerPageSubtreeTitle(page),
contentRevision: page.body.revision,
conflictDetectionKey: page.body.conflictDetectionKey,
};
}
export function pageAggregateClientStateReducer(
state: PageAggregateClientState,
action: PageAggregateClientStateAction,
): PageAggregateClientState {
switch (action.type) {
case "hydrate_from_page":
return createPageAggregateClientState(action.page);
case "patch_page_options":
return {
...state,
options: {
...state.options,
...action.patch,
},
};
case "apply_local_content_snapshot":
return {
...state,
content: action.content,
};
case "apply_persisted_body_meta":
return {
...state,
contentRevision: action.meta.revision,
conflictDetectionKey: action.meta.conflictDetectionKey,
};
case "update_server_page_subtree_title":
return {
...state,
serverPageSubtreeTitle: normalizePageTitle(action.title),
};
default:
return state;
}
}
export function selectPageAggregateClientPageSubtree(
state: PageAggregateClientState,
pageTitle: string,
): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return state.serverPageSubtreeSnapshot;
}
return null;
}
export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState,
input: {
workspaceId: string | null;
pageTitle: string;
},
): {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
} {
const blocks = state.content as Json | null;
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle),
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
conflictDetectionKey: state.conflictDetectionKey,
},
pageOptions: state.options,
};
}
@@ -7,6 +7,7 @@ import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity,
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
import { PAGE_OPTION_PANEL_GROUPS } from "@/lib/documents/page-option-semantics";
type TabId = "page" | "custom" | "global";
@@ -67,17 +68,6 @@ const OPTION_META: Record<
},
};
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
];
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
@@ -163,7 +153,12 @@ export function PageOptionsSidebar({
</div>
</section>
)}
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="页面选项"
optionKeys={PAGE_OPTION_PANEL_GROUPS.page}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
@@ -282,7 +277,12 @@ export function PageOptionsSidebar({
</div>
</section>
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="反向链接"
optionKeys={PAGE_OPTION_PANEL_GROUPS.custom}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>