feat: wire page options through aggregate AI flow
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Json } from "@/types/supabase";
|
||||
import {
|
||||
DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT,
|
||||
applyDocWriteToolResultToPageBody,
|
||||
extractCurrentPageOptionsPatchFromToolResult,
|
||||
buildAiAgentSessionsStoragePayload,
|
||||
extractCurrentPageTitleFromSlashToolResult,
|
||||
shouldSyncActiveSessionSnapshot,
|
||||
@@ -262,6 +263,87 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCurrentPageOptionsPatchFromToolResult", () => {
|
||||
it("结构化页面设置写回结果命中当前页时应返回 patch", () => {
|
||||
expect(
|
||||
extractCurrentPageOptionsPatchFromToolResult({
|
||||
tool: "doc_replace_range",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
action: "update_page_options",
|
||||
documentId: "doc-1",
|
||||
pageOptionsPatch: {
|
||||
showToc: true,
|
||||
pageFont: "song",
|
||||
layoutDensity: "compact",
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
showToc: true,
|
||||
pageFont: "song",
|
||||
layoutDensity: "compact",
|
||||
embedDefaultBlockId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("非当前页或无有效 patch 时应忽略", () => {
|
||||
expect(
|
||||
extractCurrentPageOptionsPatchFromToolResult({
|
||||
tool: "doc_replace_range",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
action: "update_page_options",
|
||||
documentId: "doc-2",
|
||||
pageOptionsPatch: {
|
||||
showToc: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
extractCurrentPageOptionsPatchFromToolResult({
|
||||
tool: "doc_replace_range",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
action: "update_page_options",
|
||||
documentId: "doc-1",
|
||||
pageOptionsPatch: {
|
||||
showToc: "yes",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("planned 或 ui_only 选项不应进入 AI 页面设置正式写回 patch", () => {
|
||||
expect(
|
||||
extractCurrentPageOptionsPatchFromToolResult({
|
||||
tool: "doc_replace_range",
|
||||
ok: true,
|
||||
documentId: "doc-1",
|
||||
result: {
|
||||
action: "update_page_options",
|
||||
documentId: "doc-1",
|
||||
pageOptionsPatch: {
|
||||
showToc: true,
|
||||
protectEditing: true,
|
||||
showStructure: true,
|
||||
showBlockRefCount: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
showToc: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("DocumentAiAgentPanel.runtime island contract", () => {
|
||||
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X } from "lucide-react";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { PageLayoutDensity, PageOptionsState, PageFont } from "@/types/page-options";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type PageBodyPersistedMeta,
|
||||
} from "@/lib/documents/page-command-client";
|
||||
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
|
||||
import { PAGE_OPTION_SEMANTICS } from "@/lib/documents/page-option-semantics";
|
||||
import type { PageAggregateAiSnapshot } from "@/components/editor/DocumentAiAgentPanel";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
@@ -89,6 +91,73 @@ const DEFAULT_TOOLS: ToolName[] = [
|
||||
"search_web",
|
||||
];
|
||||
|
||||
const AI_WRITABLE_BOOLEAN_PAGE_OPTIONS = [
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"showWordCount",
|
||||
"collapseBacklinks",
|
||||
"hideChildPages",
|
||||
] as const satisfies ReadonlyArray<keyof PageOptionsState>;
|
||||
|
||||
const AI_WRITABLE_STRING_PAGE_OPTIONS = {
|
||||
pageFont: ["default", "song", "kai"],
|
||||
layoutDensity: ["compact", "normal", "spacious"],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeAiWritablePageOptionsPatch(
|
||||
value: unknown,
|
||||
): Partial<PageOptionsState> | null {
|
||||
if (!isPlainRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patch: Partial<PageOptionsState> = {};
|
||||
for (const key of AI_WRITABLE_BOOLEAN_PAGE_OPTIONS) {
|
||||
if (PAGE_OPTION_SEMANTICS[key].runtimeSupport !== "wired") {
|
||||
continue;
|
||||
}
|
||||
const nextValue = value[key];
|
||||
if (typeof nextValue === "boolean") {
|
||||
patch[key] = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
const rawPageFont = value.pageFont;
|
||||
if (
|
||||
PAGE_OPTION_SEMANTICS.pageFont.runtimeSupport === "wired" &&
|
||||
typeof rawPageFont === "string" &&
|
||||
(AI_WRITABLE_STRING_PAGE_OPTIONS.pageFont as readonly string[]).includes(rawPageFont)
|
||||
) {
|
||||
patch.pageFont = rawPageFont as PageFont;
|
||||
}
|
||||
|
||||
const rawLayoutDensity = value.layoutDensity;
|
||||
if (
|
||||
PAGE_OPTION_SEMANTICS.layoutDensity.runtimeSupport === "wired" &&
|
||||
typeof rawLayoutDensity === "string" &&
|
||||
(AI_WRITABLE_STRING_PAGE_OPTIONS.layoutDensity as readonly string[]).includes(rawLayoutDensity)
|
||||
) {
|
||||
patch.layoutDensity = rawLayoutDensity as PageLayoutDensity;
|
||||
}
|
||||
|
||||
const rawEmbedDefaultBlockId = value.embedDefaultBlockId;
|
||||
if (PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport === "wired") {
|
||||
if (rawEmbedDefaultBlockId === null) {
|
||||
patch.embedDefaultBlockId = null;
|
||||
} else if (typeof rawEmbedDefaultBlockId === "string" && rawEmbedDefaultBlockId.trim()) {
|
||||
patch.embedDefaultBlockId = rawEmbedDefaultBlockId.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
@@ -231,6 +300,28 @@ export function extractCurrentPageTitleFromSlashToolResult(input: {
|
||||
return title || null;
|
||||
}
|
||||
|
||||
export function extractCurrentPageOptionsPatchFromToolResult(input: {
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
result: unknown;
|
||||
documentId: string;
|
||||
}): Partial<PageOptionsState> | null {
|
||||
if (!input.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultRecord = isPlainRecord(input.result) ? input.result : null;
|
||||
const payload = isPlainRecord(resultRecord?.data) ? resultRecord.data : resultRecord;
|
||||
if (!payload || String(payload.action ?? "") !== "update_page_options") {
|
||||
return null;
|
||||
}
|
||||
if (String(payload.documentId ?? "") !== input.documentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAiWritablePageOptionsPatch(payload.pageOptionsPatch);
|
||||
}
|
||||
|
||||
const safeJsonStringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
@@ -244,11 +335,13 @@ export function DocumentAiAgentPanelRuntime({
|
||||
getLatestPageAggregateSnapshot,
|
||||
onPersistedMetaChange,
|
||||
onPageHeadTitleChange,
|
||||
onPageOptionsChange,
|
||||
}: {
|
||||
documentId: string;
|
||||
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
onPageHeadTitleChange?: (title: string) => void;
|
||||
onPageOptionsChange?: (patch: Partial<PageOptionsState>) => void;
|
||||
}) {
|
||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||
|
||||
@@ -806,6 +899,16 @@ export function DocumentAiAgentPanelRuntime({
|
||||
onPageHeadTitleChange?.(nextPageTitle);
|
||||
}
|
||||
|
||||
const nextPageOptionsPatch = extractCurrentPageOptionsPatchFromToolResult({
|
||||
tool,
|
||||
ok: Boolean(obj.ok),
|
||||
result,
|
||||
documentId,
|
||||
});
|
||||
if (nextPageOptionsPatch) {
|
||||
onPageOptionsChange?.(nextPageOptionsPatch);
|
||||
}
|
||||
|
||||
void applyDocWriteToolResultToPageBody({
|
||||
tool,
|
||||
ok: Boolean(obj.ok),
|
||||
@@ -1003,7 +1106,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
if (v === "online" || v === "local" || v === "ollama") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
@@ -1011,7 +1114,6 @@ export function DocumentAiAgentPanelRuntime({
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<input
|
||||
className="h-8 w-[180px] rounded border px-2 text-xs"
|
||||
@@ -1293,7 +1395,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
if (v === "online" || v === "local" || v === "ollama") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
@@ -1301,7 +1403,6 @@ export function DocumentAiAgentPanelRuntime({
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
|
||||
{aiProvider === "codex" ? (
|
||||
@@ -1676,7 +1777,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
value={aiProvider}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
if (v === "online" || v === "local" || v === "ollama") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
@@ -1684,7 +1785,6 @@ export function DocumentAiAgentPanelRuntime({
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
|
||||
{aiProvider === "codex" ? (
|
||||
|
||||
@@ -24,6 +24,7 @@ type DocumentAiAgentPanelProps = {
|
||||
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
onPageHeadTitleChange?: (title: string) => void;
|
||||
onPageOptionsChange?: (patch: Partial<PageOptionsState>) => void;
|
||||
};
|
||||
|
||||
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
|
||||
|
||||
@@ -52,23 +52,14 @@ import type {
|
||||
EditorHostFallbackReason,
|
||||
} 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,
|
||||
selectPageAggregateClientTitleState,
|
||||
} from "@/components/editor/page-aggregate-client-state";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
|
||||
const PageOptionsSidebar = dynamic(
|
||||
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
|
||||
@@ -141,7 +132,6 @@ export function DocumentContent({
|
||||
}: 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;
|
||||
@@ -166,15 +156,7 @@ export function DocumentContent({
|
||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const {
|
||||
displayTitle: pageTitle,
|
||||
committedTitle: committedPageTitle,
|
||||
setDraftTitle: setPageTitleDraft,
|
||||
commitPersistedTitle,
|
||||
} = usePageHeadTitle({
|
||||
documentId,
|
||||
fallbackTitle: initialTitle,
|
||||
});
|
||||
const liveSidebarTitle = usePreferredSidebarDocumentTitle(documentId);
|
||||
const content = pageClientState.content;
|
||||
const contentRevision = pageClientState.contentRevision;
|
||||
const conflictDetectionKey = pageClientState.conflictDetectionKey;
|
||||
@@ -182,11 +164,9 @@ export function DocumentContent({
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||
const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind);
|
||||
const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote";
|
||||
const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() =>
|
||||
requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind,
|
||||
);
|
||||
const requestedHostKind = isLeptosTiptapHostKind(editorHostKind)
|
||||
? editorHostKind
|
||||
: DEFAULT_EDITOR_HOST_KIND;
|
||||
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
|
||||
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
|
||||
const [hostCommandFailure, setHostCommandFailure] = useState<string | null>(null);
|
||||
@@ -196,6 +176,7 @@ export function DocumentContent({
|
||||
const [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
|
||||
const [hostStatus, setHostStatus] = useState<string>("idle");
|
||||
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
|
||||
const [hostReloadKey, setHostReloadKey] = useState(0);
|
||||
const fallbackTriggerHistoryRef = useRef<string[]>([]);
|
||||
const shouldStartEditing = canEditDocument;
|
||||
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
|
||||
@@ -210,8 +191,8 @@ export function DocumentContent({
|
||||
const lastCopyBlockedAtRef = useRef<number>(0);
|
||||
const hasRequestedFallbackRef = useRef(false);
|
||||
|
||||
const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => {
|
||||
setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting");
|
||||
const resetHostObservability = useCallback(() => {
|
||||
setHostStatus("booting");
|
||||
setHostEventAt(new Date().toISOString());
|
||||
setHostRuntimeLoadFailure(null);
|
||||
setHostInitFailure(null);
|
||||
@@ -224,18 +205,17 @@ export function DocumentContent({
|
||||
hasRequestedFallbackRef.current = false;
|
||||
}, []);
|
||||
|
||||
const requestFallbackToBlockNote = useCallback(
|
||||
const recordHostFailure = 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");
|
||||
setHostStatus("host_error");
|
||||
setHostEventAt(now);
|
||||
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
|
||||
0,
|
||||
@@ -327,8 +307,8 @@ export function DocumentContent({
|
||||
}, [disableCopy]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
||||
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
||||
resetHostObservability();
|
||||
setHostReloadKey((prev) => prev + 1);
|
||||
}, [requestedHostKind, resetHostObservability]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -351,13 +331,6 @@ export function DocumentContent({
|
||||
}
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
title: committedPageTitle,
|
||||
});
|
||||
}, [committedPageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchPageClientState({
|
||||
type: "hydrate_from_page",
|
||||
@@ -435,7 +408,7 @@ export function DocumentContent({
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
@@ -447,9 +420,9 @@ export function DocumentContent({
|
||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
page?: PageAggregateProjection;
|
||||
result?: PageAggregateProjection;
|
||||
};
|
||||
const reloadedPage = payload.page ?? null;
|
||||
const reloadedPage = payload.result ?? null;
|
||||
const reloadedBody = reloadedPage?.body ?? null;
|
||||
if (canceled) return;
|
||||
if (reloadedPage) {
|
||||
@@ -511,30 +484,31 @@ export function DocumentContent({
|
||||
persistTitleCommand: executePageHeadCommand,
|
||||
notifyDocumentsChanged: emitDocumentsChanged,
|
||||
});
|
||||
commitPersistedTitle(payload);
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
type: "commit_persisted_page_title",
|
||||
title: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新页面标题失败", error);
|
||||
}
|
||||
},
|
||||
[commitPersistedTitle, documentId, readOnly, workspaceId],
|
||||
[documentId, readOnly, workspaceId],
|
||||
);
|
||||
|
||||
const handleAiPageHeadTitleChange = useCallback(
|
||||
(nextTitle: string) => {
|
||||
setPageTitleDraft(nextTitle);
|
||||
commitPersistedTitle(nextTitle);
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
type: "set_draft_page_title",
|
||||
title: nextTitle,
|
||||
});
|
||||
dispatchPageClientState({
|
||||
type: "commit_persisted_page_title",
|
||||
title: nextTitle,
|
||||
});
|
||||
emitDocumentsChanged(documentId);
|
||||
void persistTitle(nextTitle);
|
||||
},
|
||||
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
|
||||
[documentId, persistTitle],
|
||||
);
|
||||
|
||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||
@@ -544,7 +518,10 @@ export function DocumentContent({
|
||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canEditDocument) return;
|
||||
const value = event.target.value;
|
||||
setPageTitleDraft(value);
|
||||
dispatchPageClientState({
|
||||
type: "set_draft_page_title",
|
||||
title: value,
|
||||
});
|
||||
debouncedPersistTitle(value);
|
||||
};
|
||||
|
||||
@@ -601,6 +578,13 @@ export function DocumentContent({
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const handleAiPageOptionsChange = useCallback(
|
||||
(patch: Partial<PageOptionsState>) => {
|
||||
setOptionPatch(patch);
|
||||
},
|
||||
[setOptionPatch],
|
||||
);
|
||||
|
||||
const closeToc = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
if (!options.showToc) return;
|
||||
@@ -853,9 +837,20 @@ export function DocumentContent({
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
const titleState = useMemo(
|
||||
() =>
|
||||
selectPageAggregateClientTitleState(pageClientState, {
|
||||
liveSidebarTitle,
|
||||
}),
|
||||
[liveSidebarTitle, pageClientState],
|
||||
);
|
||||
const pageTitle = titleState.displayTitle;
|
||||
const pageSubtree = useMemo(
|
||||
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
|
||||
[pageClientState, pageTitle],
|
||||
() =>
|
||||
selectPageAggregateClientPageSubtree(pageClientState, {
|
||||
liveSidebarTitle,
|
||||
}),
|
||||
[liveSidebarTitle, pageClientState],
|
||||
);
|
||||
const readViewTocEntries = useMemo(
|
||||
() =>
|
||||
@@ -873,9 +868,9 @@ export function DocumentContent({
|
||||
() =>
|
||||
selectPageAggregateClientAiSnapshot(pageClientState, {
|
||||
workspaceId,
|
||||
pageTitle,
|
||||
liveSidebarTitle,
|
||||
}),
|
||||
[pageClientState, pageTitle, workspaceId],
|
||||
[liveSidebarTitle, pageClientState, workspaceId],
|
||||
);
|
||||
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
|
||||
dispatchPageClientState({
|
||||
@@ -991,7 +986,7 @@ export function DocumentContent({
|
||||
const hostObservability = useMemo(
|
||||
() => ({
|
||||
requestedHostKind,
|
||||
activeHostKind,
|
||||
activeHostKind: requestedHostKind,
|
||||
status: hostStatus,
|
||||
runtimeLoadFailed: hostRuntimeLoadFailure,
|
||||
hostInitFailed: hostInitFailure,
|
||||
@@ -1004,7 +999,6 @@ export function DocumentContent({
|
||||
fallbackTimestamps: fallbackTriggerHistoryRef.current,
|
||||
}),
|
||||
[
|
||||
activeHostKind,
|
||||
hostEventAt,
|
||||
hostFallbackCount,
|
||||
hostInitFailure,
|
||||
@@ -1019,11 +1013,7 @@ export function DocumentContent({
|
||||
);
|
||||
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}>
|
||||
@@ -1099,14 +1089,13 @@ 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">
|
||||
{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 主编辑器已自动回退到 BlockNote
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-amber-700">
|
||||
原因:{lastFallbackReason}
|
||||
<div className="font-medium">island 主链出现错误</div>
|
||||
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
|
||||
<div className="mt-1 text-xs text-red-600">
|
||||
原因:{lastFallbackReason ?? "unknown"}
|
||||
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1115,72 +1104,38 @@ export function DocumentContent({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setActiveHostKind(requestedHostKind);
|
||||
resetHostObservability(requestedHostKind);
|
||||
resetHostObservability();
|
||||
setHostReloadKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
重试 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}>
|
||||
{activeHostKind !== "blocknote" ? (
|
||||
<EditorHost
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
title={pageTitle}
|
||||
hostKind={activeHostKind}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||
handlePersistedMetaChange(meta);
|
||||
}}
|
||||
onHostEvent={handleHostEvent}
|
||||
onRequestFallback={(payload) => {
|
||||
requestFallbackToBlockNote(payload.reason, payload.error);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
title={pageTitle}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||
handlePersistedMetaChange(meta);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<EditorHost
|
||||
key={`${requestedHostKind}:${hostReloadKey}`}
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
title={pageTitle}
|
||||
hostKind={requestedHostKind}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||
handlePersistedMetaChange(meta);
|
||||
}}
|
||||
onHostEvent={handleHostEvent}
|
||||
onRequestFallback={(payload) => {
|
||||
recordHostFailure(payload.reason, payload.error);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -1261,6 +1216,7 @@ export function DocumentContent({
|
||||
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
|
||||
onPersistedMetaChange={handlePersistedMetaChange}
|
||||
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
||||
onPageOptionsChange={handleAiPageOptionsChange}
|
||||
/>
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
|
||||
@@ -52,12 +52,17 @@ describe("PageOptionsSidebar", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("对已保存但未完成编辑器语义的设置应显示降级说明", () => {
|
||||
it("已正式接通的设置不应再显示未接通降级文案,planned 设置应明确标记为待接线", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<PageOptionsSidebar
|
||||
documentId="doc-1"
|
||||
options={buildOptions({ showHeadingNumbers: true, embedDefaultBlockId: "block-1" })}
|
||||
options={buildOptions({
|
||||
showHeadingNumbers: true,
|
||||
protectEditing: true,
|
||||
showBlockRefCount: true,
|
||||
embedDefaultBlockId: "block-1",
|
||||
})}
|
||||
stats={buildStats()}
|
||||
onToggle={() => undefined}
|
||||
onExport={() => undefined}
|
||||
@@ -67,8 +72,9 @@ describe("PageOptionsSidebar", () => {
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("标题编号");
|
||||
expect(container.textContent).toContain("已保存字段");
|
||||
expect(container.textContent).toContain("编辑器语义暂未正式接通");
|
||||
expect(container.textContent).not.toContain("标题编号自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)");
|
||||
expect(container.textContent).toContain("编辑保护");
|
||||
expect(container.textContent).toContain("当前为降级展示");
|
||||
|
||||
const customTab = container.querySelectorAll("button")[1];
|
||||
expect(customTab).not.toBeNull();
|
||||
@@ -78,7 +84,45 @@ describe("PageOptionsSidebar", () => {
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("嵌入默认位置");
|
||||
expect(container.textContent).toContain("这是已保存字段");
|
||||
expect(container.textContent).toContain("当前编辑器语义暂未正式接通");
|
||||
expect(container.textContent).toContain("当前:block-1");
|
||||
expect(container.textContent).not.toContain("这是已保存字段,但当前编辑器语义暂未正式接通。");
|
||||
expect(container.textContent).toContain("显示块引用数字");
|
||||
expect(container.textContent).toContain("当前为占位能力");
|
||||
});
|
||||
|
||||
it("纯占位设置不应继续制造“已开启/已关闭但没有实际效果”的交互假象", () => {
|
||||
const toggled: string[] = [];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<PageOptionsSidebar
|
||||
documentId="doc-1"
|
||||
options={buildOptions({ showBlockRefCount: true })}
|
||||
stats={buildStats()}
|
||||
onToggle={(key) => {
|
||||
toggled.push(key);
|
||||
}}
|
||||
onExport={() => undefined}
|
||||
onOpenHistory={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const customTab = container.querySelectorAll("button")[1];
|
||||
expect(customTab).not.toBeNull();
|
||||
act(() => {
|
||||
customTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const placeholderToggle = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("显示块引用数字"),
|
||||
);
|
||||
expect(placeholderToggle?.textContent).toContain("待接线");
|
||||
|
||||
act(() => {
|
||||
placeholderToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(toggled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +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";
|
||||
import { PAGE_OPTION_PANEL_GROUPS, PAGE_OPTION_PANEL_POLICY } from "@/lib/documents/page-option-semantics";
|
||||
|
||||
type TabId = "page" | "custom" | "global";
|
||||
|
||||
@@ -33,7 +33,7 @@ const OPTION_META: Record<
|
||||
},
|
||||
showHeadingNumbers: {
|
||||
label: "标题编号",
|
||||
description: "自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)",
|
||||
description: "自动为标题添加编号(已接通:阅读态与主编辑区同步生效)",
|
||||
icon: ListOrdered,
|
||||
},
|
||||
showToc: {
|
||||
@@ -43,7 +43,7 @@ const OPTION_META: Record<
|
||||
},
|
||||
protectEditing: {
|
||||
label: "编辑保护",
|
||||
description: "保护内容避免误触修改",
|
||||
description: "当前为降级展示:页面可记住该值,但还不是正式稳定能力",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
showWordCount: {
|
||||
@@ -63,11 +63,13 @@ const OPTION_META: Record<
|
||||
},
|
||||
showBlockRefCount: {
|
||||
label: "显示块引用数字",
|
||||
description: "显示块被引用次数(当前为占位,后续补齐)",
|
||||
description: "当前为占位能力:已保留设置位,但还未接通正式引用计数",
|
||||
icon: Focus,
|
||||
},
|
||||
};
|
||||
|
||||
const NON_INTERACTIVE_PLACEHOLDER_OPTIONS = new Set<BooleanPageOptionKey>(["showBlockRefCount"]);
|
||||
|
||||
interface PageOptionsSidebarProps {
|
||||
documentId: string;
|
||||
options: PageOptionsState;
|
||||
@@ -289,9 +291,7 @@ export function PageOptionsSidebar({
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
这是已保存字段,但当前编辑器语义暂未正式接通。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-emerald-600">已接通:嵌入默认位置会进入当前页面的正式运行时语义。</p>
|
||||
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
|
||||
当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
|
||||
</div>
|
||||
@@ -411,12 +411,20 @@ function OptionToggle({
|
||||
const meta = OPTION_META[optionKey];
|
||||
const Icon = meta.icon;
|
||||
const active = options[optionKey];
|
||||
const panelPolicy = PAGE_OPTION_PANEL_POLICY[optionKey];
|
||||
const isInteractive = !NON_INTERACTIVE_PLACEHOLDER_OPTIONS.has(optionKey);
|
||||
const statusText =
|
||||
panelPolicy === "downgrade" ? "待接线" : active ? "已开启" : "已关闭";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl border border-transparent bg-[#f9fafc] px-3 py-2 text-left shadow-sm transition hover:border-[#dbe7ff]"
|
||||
onClick={() => onToggle(optionKey)}
|
||||
onClick={() => {
|
||||
if (!isInteractive) return;
|
||||
onToggle(optionKey);
|
||||
}}
|
||||
disabled={!isInteractive}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -432,8 +440,13 @@ function OptionToggle({
|
||||
<div className="text-xs text-gray-400">{meta.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("text-xs font-semibold", active ? "text-[#2563eb]" : "text-gray-400")}>
|
||||
{active ? "已开启" : "已关闭"}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
panelPolicy === "downgrade" ? "text-amber-600" : active ? "text-[#2563eb]" : "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user