feat: wire page options through aggregate AI flow

This commit is contained in:
lix-2026
2026-05-10 08:40:16 +08:00
parent 3adc2b5e85
commit b7ceb7afae
12 changed files with 1085 additions and 161 deletions
@@ -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>
);
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { PageOptionsState } from "@/types/page-options";
import {
PAGE_OPTION_PANEL_GROUPS,
PAGE_OPTION_PANEL_POLICY,
PAGE_OPTION_SEMANTICS,
pickLeptosTiptapRuntimePageOptions,
} from "@/lib/documents/page-option-semantics";
@@ -50,6 +51,14 @@ describe("page-option-semantics", () => {
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
});
it("应固定 inspector 的保留/降级/全局口径,而不是让状态说明散落在多个 UI 文件里", () => {
expect(PAGE_OPTION_PANEL_POLICY.showHeadingNumbers).toBe("keep");
expect(PAGE_OPTION_PANEL_POLICY.embedDefaultBlockId).toBe("keep");
expect(PAGE_OPTION_PANEL_POLICY.protectEditing).toBe("downgrade");
expect(PAGE_OPTION_PANEL_POLICY.showBlockRefCount).toBe("downgrade");
expect(PAGE_OPTION_PANEL_POLICY.showStructure).toBe("global_only");
});
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
wideLayout: true,
@@ -11,6 +11,7 @@ export type PageOptionSurface =
| "inspector_only";
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
export type PageOptionPanelPolicy = "keep" | "downgrade" | "global_only";
export type PageOptionSemanticDescriptor = {
surfaces: PageOptionSurface[];
@@ -98,6 +99,25 @@ export type LeptosTiptapRuntimePageOptions = {
embedDefaultBlockId: string | null;
};
export const PAGE_OPTION_PANEL_POLICY: Record<
BooleanPageOptionKey | "pageFont" | "layoutDensity" | "showStructure" | "embedDefaultBlockId",
PageOptionPanelPolicy
> = {
wideLayout: "keep",
smallText: "keep",
showHeadingNumbers: "keep",
showToc: "keep",
showStructure: "global_only",
protectEditing: "downgrade",
showWordCount: "keep",
collapseBacklinks: "keep",
pageFont: "keep",
layoutDensity: "keep",
hideChildPages: "keep",
showBlockRefCount: "downgrade",
embedDefaultBlockId: "keep",
};
export function pickLeptosTiptapRuntimePageOptions(
pageOptions: PageOptionsState,
): LeptosTiptapRuntimePageOptions {
@@ -1,9 +1,18 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PageOptionsState } from "@/types/page-options";
const { mockSpawn } = vi.hoisted(() => ({
const {
mockSpawn,
mockBuildDocumentBridgeContext,
mockBuildDocumentCommandEnvelope,
mockExecutePageWriteBridgeCommand,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockBuildDocumentBridgeContext: vi.fn(),
mockBuildDocumentCommandEnvelope: vi.fn(),
mockExecutePageWriteBridgeCommand: vi.fn(),
}));
vi.mock("node:child_process", () => ({
@@ -27,7 +36,38 @@ vi.mock("next/server", () => ({
},
}));
import { startMnoteCliAgentHostRun } from "./mnote-cli-agent-host";
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
}));
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
executePageWriteBridgeCommand: mockExecutePageWriteBridgeCommand,
}));
import {
extractPageOptionsPatchFromAiMessage,
startMnoteCliAgentHostRun,
} from "./mnote-cli-agent-host";
function buildPageOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: false,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
function createMockChild(stdoutText: string) {
const child = new EventEmitter() as EventEmitter & {
@@ -49,6 +89,9 @@ function createMockChild(stdoutText: string) {
describe("startMnoteCliAgentHostRun", () => {
beforeEach(() => {
mockSpawn.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockExecutePageWriteBridgeCommand.mockReset();
delete process.env.DEV_USER_ID;
delete process.env.DEV_USER_EMAIL;
delete process.env.DEV_USER_NAME;
@@ -116,4 +159,86 @@ describe("startMnoteCliAgentHostRun", () => {
workspaceId: "ws_req_1778035004501_15",
});
});
it("应从自然语言页面设置请求中提取 wired patch", () => {
expect(
extractPageOptionsPatchFromAiMessage({
messages: [{ role: "user", content: "请显示目录,改成紧凑排版,并切换成宋体" }],
currentPageOptions: buildPageOptions(),
}),
).toEqual({
showToc: true,
layoutDensity: "compact",
pageFont: "song",
});
});
it("命中页面设置 patch 时应直接发结构化 tool_result,而不是启动 cargo", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
workspaceId: "ws-1",
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
deploymentId: null,
projectId: null,
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input) => ({
...input,
commandId: "cmd-1",
idempotencyKey: null,
actor: input.context.actor,
source: input.context.source,
target: input.target ?? null,
preflightData: null,
reason: input.reason ?? null,
refs: input.refs ?? [],
dryRun: input.context.dryRun,
validateOnly: input.context.validateOnly,
}));
mockExecutePageWriteBridgeCommand.mockResolvedValue({
requestId: "req-1",
traceId: "trace-1",
commandId: "cmd-1",
commandName: "page.layout.updateOptions",
revision: null,
conflictDetectionKey: null,
});
const response = await startMnoteCliAgentHostRun({
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
userId: "user-1",
payload: {
stream: true,
messages: [{ role: "user", content: "请显示目录,并切换成紧凑排版" }],
context: {
documentId: "doc-1",
workspaceId: "ws-1",
pageOptions: buildPageOptions(),
},
},
});
const text = await response.text();
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockExecutePageWriteBridgeCommand).toHaveBeenCalledTimes(1);
expect(text).toContain("event: tool_call");
expect(text).toContain('"tool":"page_options_patch"');
expect(text).toContain('"action":"update_page_options"');
expect(text).toContain('"showToc":true');
expect(text).toContain('"layoutDensity":"compact"');
});
});
@@ -4,6 +4,14 @@ import path from "node:path";
import { NextResponse } from "next/server";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import { PAGE_OPTION_SEMANTICS } from "@/lib/documents/page-option-semantics";
import {
assertOptionsPatch,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
type AgentMessage = { role: "user" | "assistant"; content: string };
@@ -41,12 +49,216 @@ type CliRunResult = {
};
const CLI_HOST_TIMEOUT_MS = 30_000;
const PAGE_OPTIONS_TOOL_NAME = "page_options_patch";
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const AI_WRITABLE_BOOLEAN_PAGE_OPTIONS = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"showWordCount",
"collapseBacklinks",
"hideChildPages",
] as const satisfies ReadonlyArray<keyof PageOptionsState>;
const AI_WRITABLE_PAGE_FONT_VALUES = ["default", "song", "kai"] as const;
const AI_WRITABLE_LAYOUT_DENSITY_VALUES = ["compact", "normal", "spacious"] as const;
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_PAGE_FONT_VALUES as readonly string[]).includes(rawPageFont)
) {
patch.pageFont = rawPageFont;
}
const rawLayoutDensity = value.layoutDensity;
if (
PAGE_OPTION_SEMANTICS.layoutDensity.runtimeSupport === "wired" &&
typeof rawLayoutDensity === "string" &&
(AI_WRITABLE_LAYOUT_DENSITY_VALUES as readonly string[]).includes(rawLayoutDensity)
) {
patch.layoutDensity = rawLayoutDensity;
}
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();
}
}
if (Object.keys(patch).length === 0) {
return null;
}
assertOptionsPatch(patch);
return patch;
}
function readLatestUserMessage(messages: AgentMessage[]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role === "user" && String(message.content ?? "").trim()) {
return String(message.content);
}
}
return "";
}
function extractStructuredPageOptionsPatch(text: string): Partial<PageOptionsState> | null {
const tagged = text.match(/<update_page_options>([\s\S]*?)<\/update_page_options>/i);
const candidates = [tagged?.[1] ?? "", text];
for (const candidate of candidates) {
const trimmed = candidate.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!isPlainRecord(parsed)) {
continue;
}
const payload =
String(parsed.action ?? "") === "update_page_options" && isPlainRecord(parsed.pageOptionsPatch)
? parsed.pageOptionsPatch
: parsed;
const normalized = normalizeAiWritablePageOptionsPatch(payload);
if (normalized) {
return normalized;
}
} catch {
// ignore
}
}
return null;
}
function extractNaturalLanguagePageOptionsPatch(text: string): Partial<PageOptionsState> | null {
const normalizedText = text.trim();
if (!normalizedText) {
return null;
}
const patch: Partial<PageOptionsState> = {};
if (/(显示|打开|开启).*(目录|标题目录)|显示标题目录/.test(normalizedText)) {
patch.showToc = true;
} else if (/(隐藏|关闭).*(目录|标题目录)|关闭标题目录/.test(normalizedText)) {
patch.showToc = false;
}
if (/(开启|打开|启用|改成|切换成).*(宽版|宽布局|自适应宽度)|使用宽版/.test(normalizedText)) {
patch.wideLayout = true;
} else if (/(关闭|取消|恢复).*(宽版|宽布局|自适应宽度)|恢复标准宽度/.test(normalizedText)) {
patch.wideLayout = false;
}
if (/(开启|打开|启用|改成|切换成).*(小字体)|使用小字体/.test(normalizedText)) {
patch.smallText = true;
} else if (/(关闭|取消|恢复).*(小字体)|恢复正常字体大小/.test(normalizedText)) {
patch.smallText = false;
}
if (/(显示|打开|开启).*(标题编号|标题自动编号)/.test(normalizedText)) {
patch.showHeadingNumbers = true;
} else if (/(隐藏|关闭).*(标题编号|标题自动编号)/.test(normalizedText)) {
patch.showHeadingNumbers = false;
}
if (/宋体/.test(normalizedText)) {
patch.pageFont = "song";
} else if (/楷体/.test(normalizedText)) {
patch.pageFont = "kai";
} else if (/(默认字体|恢复默认字体)/.test(normalizedText)) {
patch.pageFont = "default";
}
if (/紧凑/.test(normalizedText)) {
patch.layoutDensity = "compact";
} else if (/(宽松|疏朗)/.test(normalizedText)) {
patch.layoutDensity = "spacious";
} else if (/(默认间距|标准间距|正常间距)/.test(normalizedText)) {
patch.layoutDensity = "normal";
}
return Object.keys(patch).length > 0 ? patch : null;
}
export function extractPageOptionsPatchFromAiMessage(input: {
messages: AgentMessage[];
currentPageOptions?: PageOptionsState | null;
}): Partial<PageOptionsState> | null {
void input.currentPageOptions;
const latestUserMessage = readLatestUserMessage(input.messages);
return (
extractStructuredPageOptionsPatch(latestUserMessage) ??
extractNaturalLanguagePageOptionsPatch(latestUserMessage)
);
}
function describePageOptionsPatch(patch: Partial<PageOptionsState>): string {
const labels: string[] = [];
if ("showToc" in patch) labels.push(patch.showToc ? "显示目录" : "隐藏目录");
if ("wideLayout" in patch) labels.push(patch.wideLayout ? "启用宽版" : "关闭宽版");
if ("smallText" in patch) labels.push(patch.smallText ? "启用小字体" : "关闭小字体");
if ("showHeadingNumbers" in patch) {
labels.push(patch.showHeadingNumbers ? "显示标题编号" : "隐藏标题编号");
}
if ("pageFont" in patch) {
labels.push(
patch.pageFont === "song" ? "切换为宋体" : patch.pageFont === "kai" ? "切换为楷体" : "恢复默认字体",
);
}
if ("layoutDensity" in patch) {
labels.push(
patch.layoutDensity === "compact"
? "切换为紧凑排版"
: patch.layoutDensity === "spacious"
? "切换为宽松排版"
: "恢复标准排版",
);
}
if ("embedDefaultBlockId" in patch) {
labels.push(patch.embedDefaultBlockId ? "更新嵌入默认位置" : "清除嵌入默认位置");
}
if ("showWordCount" in patch) labels.push(patch.showWordCount ? "显示字数统计" : "隐藏字数统计");
if ("collapseBacklinks" in patch) {
labels.push(patch.collapseBacklinks ? "折叠反向引用" : "展开反向引用");
}
if ("hideChildPages" in patch) labels.push(patch.hideChildPages ? "隐藏子页面" : "显示子页面");
return labels.length > 0 ? `已更新页面设置:${labels.join("")}` : "已更新页面设置。";
}
async function pathExists(targetPath: string) {
try {
await access(targetPath);
@@ -175,6 +387,49 @@ async function runMnoteCli(input: {
});
}
async function executeAiPageOptionsPatch(input: {
request: Request;
payload: MnoteCliAgentRunPayload;
patch: Partial<PageOptionsState>;
}) {
const documentId = String(input.payload.context?.documentId ?? "").trim();
const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: PAGE_COMMAND_NAMES.updateLayout,
payload: {
documentId,
workspaceId,
options: input.patch,
},
context: bridgeContext,
target: {
workspaceId,
pageId: documentId,
},
reason: "ai-agent-run:mnote-cli-host:update-page-options",
refs: ["mnote-cli-host", "page_options_patch"],
});
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
return {
documentId,
patch: input.patch,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
assistantText: describePageOptionsPatch(input.patch),
};
}
export async function startMnoteCliAgentHostRun(input: {
request: Request;
userId: string;
@@ -182,7 +437,112 @@ export async function startMnoteCliAgentHostRun(input: {
userName?: string;
payload: MnoteCliAgentRunPayload;
}): Promise<Response> {
void input.request;
const pageOptionsPatch = extractPageOptionsPatchFromAiMessage({
messages: input.payload.messages,
currentPageOptions: input.payload.context?.pageOptions ?? null,
});
const shouldHandlePageOptionsPatch =
pageOptionsPatch &&
String(input.payload.context?.documentId ?? "").trim() &&
String(input.payload.context?.workspaceId ?? "").trim();
if (shouldHandlePageOptionsPatch) {
const toolCallId = `${PAGE_OPTIONS_TOOL_NAME}_${Date.now()}`;
const toolArgs = {
documentId: String(input.payload.context?.documentId ?? "").trim(),
pageOptionsPatch,
};
if (!input.payload.stream) {
const result = await executeAiPageOptionsPatch({
request: input.request,
payload: input.payload,
patch: pageOptionsPatch,
});
return NextResponse.json({
ok: true,
bridgeOwner: "mnote-cli",
text: result.assistantText,
toolResult: {
action: "update_page_options",
documentId: result.documentId,
pageOptionsPatch: result.patch,
meta: result.meta,
},
});
}
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
controller.enqueue(
encoder.encode(
toSseFrame("tool_call", {
id: toolCallId,
tool: PAGE_OPTIONS_TOOL_NAME,
args: toolArgs,
}),
),
);
const startedAt = Date.now();
try {
const result = await executeAiPageOptionsPatch({
request: input.request,
payload: input.payload,
patch: pageOptionsPatch,
});
const toolResult = {
action: "update_page_options",
documentId: result.documentId,
pageOptionsPatch: result.patch,
meta: result.meta,
};
controller.enqueue(
encoder.encode(
toSseFrame("tool_result", {
id: toolCallId,
tool: PAGE_OPTIONS_TOOL_NAME,
ok: true,
ms: Math.max(0, Date.now() - startedAt),
result: toolResult,
}),
),
);
controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text: result.assistantText })));
controller.enqueue(
encoder.encode(toSseFrame("completion", { ok: true, text: result.assistantText, steps: 1 })),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
controller.enqueue(
encoder.encode(
toSseFrame("tool_result", {
id: toolCallId,
tool: PAGE_OPTIONS_TOOL_NAME,
ok: false,
ms: Math.max(0, Date.now() - startedAt),
result: { error: message },
}),
),
);
controller.enqueue(encoder.encode(toSseFrame("error", { ok: false, message })));
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "mnote-cli",
},
});
}
const run = runMnoteCli({
userId: input.userId,
userEmail: input.userEmail,