From b7ceb7afaec0686d19c4beab4868e77abfe08714 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sun, 10 May 2026 08:40:16 +0800 Subject: [PATCH] feat: wire page options through aggregate AI flow --- ...6-page-aggregate-alignment-checklist-v1.md | 51 ++- ...sk164-page-options-visible-effect-smoke.js | 181 +++++++++ .../DocumentAiAgentPanel.runtime.test.tsx | 82 ++++ .../editor/DocumentAiAgentPanel.runtime.tsx | 112 +++++- .../editor/DocumentAiAgentPanel.tsx | 1 + .../components/editor/document-content.tsx | 210 ++++------ .../editor/page-options-sidebar.test.tsx | 56 ++- .../editor/page-options-sidebar.tsx | 33 +- .../documents/page-option-semantics.test.ts | 9 + .../lib/documents/page-option-semantics.ts | 20 + .../lib/server/mnote-cli-agent-host.test.ts | 129 ++++++- .../src/lib/server/mnote-cli-agent-host.ts | 362 +++++++++++++++++- 12 files changed, 1085 insertions(+), 161 deletions(-) create mode 100644 scripts/task164-page-options-visible-effect-smoke.js diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md index 613fe7a3..ac64bedf 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md @@ -30,7 +30,7 @@ ### 2.1 已经成立的事实 - [x] `leptos-tiptap` 已经成为页面内正式主编辑区,不再是 iframe bridge。 -- [x] `/api/documents/page` 已优先消费 Rust `mnote.page_aggregate.v1` snapshot,TS builder 退为 fallback。 +- [x] 文档页正式读链已固定消费 Rust `mnote.page_aggregate.v1` snapshot,TS builder 已退出 runtime 主链。 - [x] 正文保存已经能按 `workspaceId/documentId` 正确落到对应页面。 - [x] 当前主编辑区已具备可继续推进的基础交互能力。 - [x] 当前问题已经不再是“能不能接入主编辑器”,而是“接入后如何收口为单一真源”。 @@ -79,7 +79,7 @@ - [x] 不再继续给 `DocumentPageProps`、`DocumentContentProps` 零散加字段来扩页面真相。 - [x] 文档页加载入口能明确区分“聚合读取结果”和“局部 UI 临时态”。 -补充:当前已新增 `page-aggregate-builder.ts`、`page-aggregate-loader.ts` 与 `/api/documents/page`,文档页 SSR 入口和 `DocumentContent` 的内容重试补拉都已改为消费同一份 `PageAggregateProjection`,不再由 `page.tsx` 手工拼 `meta + content`。Rust 侧当前已直接暴露 `/api/page-aggregate/:id` 与 `mnote.page_aggregate.v1` snapshot;`page-aggregate-loader.ts` 会先校验并消费这条 Rust 读链,只有 snapshot 不可用或不可信时才回退到 TS builder。与此同时,`storage-convex-bridge` 与 `bridge-runtime` 已开始接受 `page.head.updateTitle / page.layout.updateOptions / page.body.save` 这组 page command family 的命名口径,因此这里按“读取契约已进入 Rust-first,写入命令面已开始收口”勾选完成。 +补充:当前 `page-aggregate-loader.ts`、文档页 SSR 入口与 `DocumentContent` 的内容重试补拉都已经直接消费 Rust `/api/page-aggregate/:id` 返回的 `PageAggregateProjection`,不再由 `page.tsx` 手工拼 `meta + content`。Next `/api/documents/page` 已降级为显式 `410` 的 compat 边界;`page-aggregate-loader.ts` 现在只校验并消费这条 Rust 正式读链,不再在 runtime 中回退到 TS builder。与此同时,`storage-convex-bridge` 与 `bridge-runtime` 已开始接受 `page.head.updateTitle / page.layout.updateOptions / page.body.save` 这组 page command family 的命名口径,因此这里按“读取契约已进入 Rust-first,写入命令面已开始收口”勾选完成。 ### 4.3 退出标准 @@ -111,9 +111,9 @@ - [x] 页面加载时不再能明显看出“这是几份子结果拼出来的页面”。 - [ ] 后续页面新增字段时,不再需要继续向外层 props 链同时塞多种局部真相。 -补充:当前首屏 SSR 与客户端内容重试补拉都已经走 `/api/documents/page -> page aggregate loader -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。 +补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。 -补充:本轮已新增 `page-aggregate-client-state.ts`,并让 `DocumentContent` 把原先分散维护的 `options / content / serverContentSnapshot / serverPageSubtreeSnapshot / serverPageSubtreeTitle / contentRevision / conflictDetectionKey` 开始收口为同一份 client aggregate state reducer。这里代表“页面本地 `body/layout/tree` 真相已经开始统一”,不等于标题链、页面设置运行时分类、AI 对页面设置面的正式入口都已闭环,因此本阶段不提前宣称聚合完成。对应最小回归测试为 `page-aggregate-client-state.test.ts`。 +补充:本轮已新增 `page-aggregate-client-state.ts`,并让 `DocumentContent` 把原先分散维护的 `title / options / content / serverContentSnapshot / serverPageSubtreeSnapshot / contentRevision / conflictDetectionKey` 继续收口为同一份 client aggregate state reducer;标题的 draft / committed / persisted 语义也已并入这份 reducer,不再额外维护独立 `usePageHeadTitle` hook 或 `serverPageSubtreeTitle` 双轨状态。这里代表“页面本地 `head/body/layout/tree` 真相已经开始统一”,不等于页面设置运行时分类、AI 对页面设置面的正式入口都已闭环,因此本阶段不提前宣称聚合完成。对应最小回归测试为 `page-aggregate-client-state.test.ts`、`document-content.test.ts`。 --- @@ -138,6 +138,14 @@ 对应最小回归测试为 `page-option-semantics.test.ts`、`leptos-tiptap-island-editor-host.test.tsx`、`page-options-sidebar.test.tsx`。第二项继续保留未完成,因为“哪些项应降级/隐藏/保留”的产品面纠偏还没完全落到 inspector 与 AI tool surface。 +补充:当前统一口径已经开始落到代码级 `PAGE_OPTION_PANEL_POLICY`,最小分类如下: + +- 保留在页面设置面板且继续作为正式项:`wideLayout / smallText / showHeadingNumbers / showToc / showWordCount / collapseBacklinks / pageFont / layoutDensity / hideChildPages / embedDefaultBlockId` +- 保留但降级展示、不再冒充正式完成项:`protectEditing / showBlockRefCount` +- 不再作为页面级正式项,继续留在全局偏好:`showStructure` + +这里的“保留 / 降级 / 全局”是当前 Inspector、AI 页面设置写回白名单与后续 smoke 验收都必须共用的同一份产品口径,不允许再由单个 UI 文件私自重写说明。 + ### 6.2 最小接入优先级 - [x] `wideLayout` 真正进入 island 布局语义,而不是只改外层壳宽度。 @@ -150,12 +158,23 @@ - [x] 没有真正接入 island 的编辑器语义项,不再继续以“已开启/已关闭”假装正式完成。 - [x] 对未支持项给出显式降级说明,而不是仅保存字段。 -- [ ] 让“页面设置有值但编辑器内部没变化”这类状态在产品上消失。 +- [x] 让“页面设置有值但编辑器内部没变化”这类状态在产品上消失。 + +补充:本轮已把 AI 页面设置结构化结果接入 `DocumentContent` 现有的 `patch_page_options + page.layout.updateOptions` 正式链路,页面设置不再只能靠人类点击 inspector 才能进入同一条页面命令面。同时,AI 正式写回白名单已限制为 `page-option-semantics.ts` 中 `runtimeSupport === "wired"` 的字段,`protectEditing / showStructure / showBlockRefCount` 继续排除在正式写回外,避免把 planned / ui_only 选项误描述为稳定能力。`mnote-cli host` 侧也已新增最小服务端分支:命中页面设置 patch 时,直接执行 `page.layout.updateOptions` 并产出结构化 `tool_result(action=update_page_options)`,不再只把整段结果退化成 assistant 文本。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`mnote-cli-agent-host.test.ts`。 +补充:本轮继续把 Inspector 产品态与这份分类对齐:`showHeadingNumbers / embedDefaultBlockId` 已按“正式接通项”更新文案,不再继续显示“已保存字段但未接通”;`showBlockRefCount` 作为纯占位项也不再继续暴露可点击的假开关,而是明确显示 `待接线`。对应最小回归测试为 `page-options-sidebar.test.tsx`、`page-option-semantics.test.ts`。 ### 6.4 退出标准 -- [ ] inspector 中至少最小优先级项修改后,主编辑区可见结果真实变化。 -- [ ] 用户不再需要猜“这个设置到底有没有真正作用到编辑器”。 +- [x] inspector 中至少最小优先级项修改后,主编辑区可见结果真实变化。 +- [x] 用户不再需要猜“这个设置到底有没有真正作用到编辑器”。 + +补充:本轮已新增 `scripts/task164-page-options-visible-effect-smoke.js`,并在 `http://127.0.0.1:3000` 实跑确认: + +- `wideLayout` 从 `false -> true` 后,页面根属性 `data-page-wide-layout` 立即切到 `true`,主内容列最大宽度从默认态切到 `980px` +- `smallText` 从 `false -> true` 后,编辑器字号从 `16px` 变为 `15px` +- `layoutDensity` 从 `normal -> compact` 后,段落底部间距从 `8px` 变为 `4px` + +同时,Inspector 文案与状态也已经按“正式接通 / 待接线 / 全局项”统一,不再让用户靠猜测判断设置是否真正生效。 --- @@ -185,7 +204,7 @@ 补充:已新增 `AppLayoutShell`,把 layout 顶栏 `Breadcrumb` 从 SSR 注入的静态 `documents` 挪到与 `Sidebar` 共享的同一条 live sidebar snapshot 管线;并且 layout shell 会把“已选中的 preferred snapshot”同一对象同时透传给 `Sidebar` 与 `Breadcrumb`,不再各自独立选择。对应回归测试为 `app-layout-shell.test.tsx`。这意味着 breadcrumb / sidebar 现在至少共享同一份工作区树 canonical snapshot,不再是 layout 一条静态链、sidebar 一条 live 链并行。页头 `page.head.title` 与这条工作区树链之间的最终统一验收仍待补齐,因此本阶段继续不提前打满。 -补充:已新增 `PreferredSidebarSnapshotProvider` 与 `usePageHeadTitle`,把文档页头标题从 `DocumentContent` 内部长期持有的 `pageTitle` 本地真相,改为“同一份 preferred sidebar snapshot 的 committed title + 短暂 draft”。同时修正 `useSidebarData.refetch()`,在 Convex live 模式下收到 `documents-changed` 也会主动拉取一份新的 `/api/sidebar` snapshot,再与 tree stream 做 freshness 选择,避免“页头草稿是新的,但 breadcrumb / sidebar / page tree / file tree 还卡在旧快照”。对应单测为 `use-page-head-title.test.tsx`、`use-sidebar-data.test.tsx`。浏览器烟测 `scripts/task110-page-title-single-truth-smoke.js` 已验证:页头重命名后,breadcrumb、默认 sidebar、page tree、file tree、切页往返、刷新均保持一致,因此本阶段与“标题来自同一份更新后的 projection”相关的勾选正式保留。 +补充:已新增 `PreferredSidebarSnapshotProvider`,并把文档页头标题的本地 draft / committed / persisted 语义直接并入 `page-aggregate-client-state`;`DocumentContent` 现在只从同一份 preferred sidebar snapshot 读取 live committed title,再与聚合 reducer 内的短暂 draft / persisted title 做合成,不再额外维护独立标题 hook。与此同时,`useSidebarData.refetch()` 在 Convex live 模式下收到 `documents-changed` 也会主动拉取一份新的 `/api/sidebar` snapshot,再与 tree stream 做 freshness 选择,避免“页头草稿是新的,但 breadcrumb / sidebar / page tree / file tree 还卡在旧快照”。对应单测为 `page-aggregate-client-state.test.ts`、`use-sidebar-data.test.tsx`。浏览器烟测 `scripts/task110-page-title-single-truth-smoke.js` 已验证:页头重命名后,breadcrumb、默认 sidebar、page tree、file tree、切页往返、刷新均保持一致,因此本阶段与“标题来自同一份更新后的 projection”相关的勾选正式保留。 ### 7.3 退出标准 @@ -219,6 +238,18 @@ - [x] AI 改写结果能通过主编辑区 island 正式回显。 - [ ] AI 改写后树标题 / 页面头部 / 页面设置不再走各自独立副作用链。 +补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理: + +- `/api/ai-agent/run` 当前默认主路仍是 `mnote-cli host` +- `mnote-cli host` 目前没有真正的模型/tool 执行环,而是 `cargo ... tool run --tool-name doc_get --mode explain-plan` 的说明型入口 +- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“主路内的最小结构化兼容分支”,不是完整正式 tool surface + +这意味着 `8.2` 后续完成标准必须至少包含: + +1. 页面设置进入真正的 agent/tool 执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别 +2. 页面设置结构化结果由正式 tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result` +3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证 + ### 8.3 退出标准 - [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。 @@ -227,7 +258,9 @@ - `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。 - `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。 -- 当前 `pageOptions` 仍没有进入 Hermes 正式 tool surface,因此“页面设置类 AI 命令”尚未收口;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 +- 当前 AI 面板已经能消费结构化 `update_page_options` 结果,并把 `pageOptionsPatch` 回接到当前页 `DocumentContent` 的同一条 `patch_page_options + page.layout.updateOptions` 提交链;同时只允许 `runtimeSupport === "wired"` 的字段进入正式写回,避免 planned / ui_only 页面设置混入主链。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`document-content.test.ts`。 +- `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 +- 但 `pageOptions` 仍没有进入 Hermes 正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整模型工具编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 --- diff --git a/scripts/task164-page-options-visible-effect-smoke.js b/scripts/task164-page-options-visible-effect-smoke.js new file mode 100644 index 00000000..fe95e2bc --- /dev/null +++ b/scripts/task164-page-options-visible-effect-smoke.js @@ -0,0 +1,181 @@ +"use strict"; + +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +async function saveDocumentContent(requestContext, workspaceId, documentId) { + await requestJson(requestContext, "/api/documents/save", { + method: "POST", + data: { + workspaceId, + documentId, + content: [ + { + id: "h1", + type: "heading", + props: { level: 1 }, + content: [{ type: "text", text: "Page Option Smoke Heading" }], + }, + { + id: "p1", + type: "paragraph", + content: [{ type: "text", text: "Body for page option smoke." }], + }, + ], + blockCount: 2, + snapshotCapturedAt: new Date().toISOString(), + }, + }); +} + +async function openPageSettingsDialog(page) { + const trigger = page.getByTestId("wolai-page-settings-trigger"); + await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trigger.click({ timeout: UI_TIMEOUT_MS }); + await page.getByRole("tablist", { name: "页面设置分组" }).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function readPageOptionRuntimeState(page) { + return page.evaluate(() => { + const editor = document.querySelector(".ProseMirror"); + const firstParagraph = document.querySelector(".ProseMirror p"); + const heading = document.querySelector(".ProseMirror h1"); + const shell = + document.querySelector('[data-shell-mode="document"]') || + document.querySelector(".document-shell") || + document.querySelector("article main"); + + return { + htmlWide: document.documentElement.getAttribute("data-page-wide-layout"), + htmlSmall: document.documentElement.getAttribute("data-page-small-text"), + htmlDensity: document.documentElement.getAttribute("data-layout-density"), + shellMaxWidth: + shell instanceof HTMLElement ? shell.style.maxWidth || window.getComputedStyle(shell).maxWidth : null, + editorFontSize: + editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null, + paragraphMarginBottom: + firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null, + headingBefore: + heading instanceof HTMLElement ? window.getComputedStyle(heading, "::before").content : null, + }; + }); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + + let caughtError = null; + let fixture = null; + + try { + await ensureAuthenticated(page, context.request); + const created = await createTempDocument(context.request, null); + fixture = { + workspaceId: created.workspaceId, + createdIds: [created.documentId], + documentId: created.documentId, + }; + + await saveDocumentContent(context.request, fixture.workspaceId, fixture.documentId); + await openDocument(page, fixture.workspaceId, fixture.documentId); + await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + await openPageSettingsDialog(page); + + const initial = await readPageOptionRuntimeState(page); + assert(initial.htmlWide === "false", `初始 wideLayout 应为 false,实际 ${initial.htmlWide}`); + assert(initial.htmlSmall === "false", `初始 smallText 应为 false,实际 ${initial.htmlSmall}`); + assert(initial.htmlDensity === "normal", `初始 layoutDensity 应为 normal,实际 ${initial.htmlDensity}`); + + await page.getByRole("checkbox", { name: /自适应宽度/ }).click({ timeout: UI_TIMEOUT_MS }); + const afterWide = await readPageOptionRuntimeState(page); + assert(afterWide.htmlWide === "true", `开启宽版后 htmlWide 应为 true,实际 ${afterWide.htmlWide}`); + assert(afterWide.shellMaxWidth === "980px", `开启宽版后主列宽应为 980px,实际 ${afterWide.shellMaxWidth}`); + + await page.getByRole("checkbox", { name: /小字体/ }).click({ timeout: UI_TIMEOUT_MS }); + const afterSmall = await readPageOptionRuntimeState(page); + assert(afterSmall.htmlSmall === "true", `开启小字体后 htmlSmall 应为 true,实际 ${afterSmall.htmlSmall}`); + assert( + typeof initial.editorFontSize === "string" && + typeof afterSmall.editorFontSize === "string" && + parseFloat(afterSmall.editorFontSize) < parseFloat(initial.editorFontSize), + `开启小字体后编辑器字号应变小,初始 ${initial.editorFontSize},实际 ${afterSmall.editorFontSize}`, + ); + + await page.getByRole("tab", { name: "自定义页面" }).click({ timeout: UI_TIMEOUT_MS }); + await page.getByLabel("layoutDensity").selectOption("紧凑"); + const afterDensity = await readPageOptionRuntimeState(page); + assert(afterDensity.htmlDensity === "compact", `切换紧凑后 htmlDensity 应为 compact,实际 ${afterDensity.htmlDensity}`); + assert( + typeof initial.paragraphMarginBottom === "string" && + typeof afterDensity.paragraphMarginBottom === "string" && + parseFloat(afterDensity.paragraphMarginBottom) <= parseFloat(initial.paragraphMarginBottom), + `切换紧凑后段落底部间距应更紧,初始 ${initial.paragraphMarginBottom},实际 ${afterDensity.paragraphMarginBottom}`, + ); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + workspaceId: fixture.workspaceId, + documentId: fixture.documentId, + initial, + afterWide, + afterSmall, + afterDensity, + }, + null, + 2, + ), + ); + } catch (error) { + caughtError = error; + } finally { + if (fixture) { + try { + await cleanupDocuments(context.request, fixture.createdIds); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${ + cleanupError instanceof Error + ? cleanupError.stack || cleanupError.message + : String(cleanupError) + }`, + ); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx index 51843cd3..df43cec7 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx @@ -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 主路径", () => { diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx index c1ea3234..cad42d5f 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx @@ -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; + +const AI_WRITABLE_STRING_PAGE_OPTIONS = { + pageFont: ["default", "song", "kai"], + layoutDensity: ["compact", "normal", "spacious"], +} as const satisfies Record; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeAiWritablePageOptionsPatch( + value: unknown, +): Partial | null { + if (!isPlainRecord(value)) { + return null; + } + + const patch: Partial = {}; + 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 } | { 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 | 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) => 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({ - { 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({ - {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({ - {aiProvider === "codex" ? ( diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx index b6a7f802..0e5fc7c6 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx @@ -24,6 +24,7 @@ type DocumentAiAgentPanelProps = { getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot; onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void; onPageHeadTitleChange?: (title: string) => void; + onPageOptionsChange?: (patch: Partial) => void; }; const DocumentAiAgentPanelRuntime = dynamic( diff --git a/wolai-frontend/src/components/editor/document-content.tsx b/wolai-frontend/src/components/editor/document-content.tsx index 9ca99745..88fc0861 100644 --- a/wolai-frontend/src/components/editor/document-content.tsx +++ b/wolai-frontend/src/components/editor/document-content.tsx @@ -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: () => ( -
编辑器加载中...
- ), - }, -); +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(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(null); const [hostInitFailure, setHostInitFailure] = useState(null); const [hostCommandFailure, setHostCommandFailure] = useState(null); @@ -196,6 +176,7 @@ export function DocumentContent({ const [lastFallbackAt, setLastFallbackAt] = useState(null); const [hostStatus, setHostStatus] = useState("idle"); const [hostEventAt, setHostEventAt] = useState(null); + const [hostReloadKey, setHostReloadKey] = useState(0); const fallbackTriggerHistoryRef = useRef([]); const shouldStartEditing = canEditDocument; const [isEditing, setIsEditing] = useState(() => shouldStartEditing); @@ -210,8 +191,8 @@ export function DocumentContent({ const lastCopyBlockedAtRef = useRef(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) => { 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) => { + 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 ( @@ -1099,14 +1089,13 @@ export function DocumentContent({ ) : (
- {showFallbackBanner ? ( -
+ {showFailureBanner ? ( +
-
- 默认 island 主编辑器已自动回退到 BlockNote -
-
- 原因:{lastFallbackReason} +
island 主链出现错误
+
{activeHostFailureMessage}
+
+ 原因:{lastFallbackReason ?? "unknown"} {lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
@@ -1115,72 +1104,38 @@ export function DocumentContent({ size="sm" variant="outline" onClick={() => { - setActiveHostKind(requestedHostKind); - resetHostObservability(requestedHostKind); + resetHostObservability(); + setHostReloadKey((prev) => prev + 1); }} > - 重试 island 主链 - -
- ) : null} - {showFailureBanner ? ( -
-
-
island 主链出现错误
-
{activeHostFailureMessage}
-
-
) : null} {keepEditorMounted && (
- {activeHostKind !== "blocknote" ? ( - { - handlePersistedMetaChange(meta); - }} - onHostEvent={handleHostEvent} - onRequestFallback={(payload) => { - requestFallbackToBlockNote(payload.reason, payload.error); - }} - /> - ) : ( - { - handlePersistedMetaChange(meta); - }} - /> - )} + { + handlePersistedMetaChange(meta); + }} + onHostEvent={handleHostEvent} + onRequestFallback={(payload) => { + recordHostFailure(payload.reason, payload.error); + }} + />
)}
); diff --git a/wolai-frontend/src/components/editor/page-options-sidebar.test.tsx b/wolai-frontend/src/components/editor/page-options-sidebar.test.tsx index 58fd394c..b630932a 100644 --- a/wolai-frontend/src/components/editor/page-options-sidebar.test.tsx +++ b/wolai-frontend/src/components/editor/page-options-sidebar.test.tsx @@ -52,12 +52,17 @@ describe("PageOptionsSidebar", () => { container.remove(); }); - it("对已保存但未完成编辑器语义的设置应显示降级说明", () => { + it("已正式接通的设置不应再显示未接通降级文案,planned 设置应明确标记为待接线", () => { act(() => { root.render( 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( + { + 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([]); }); }); diff --git a/wolai-frontend/src/components/editor/page-options-sidebar.tsx b/wolai-frontend/src/components/editor/page-options-sidebar.tsx index 7e60cb29..c9774dee 100644 --- a/wolai-frontend/src/components/editor/page-options-sidebar.tsx +++ b/wolai-frontend/src/components/editor/page-options-sidebar.tsx @@ -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(["showBlockRefCount"]); + interface PageOptionsSidebarProps { documentId: string; options: PageOptionsState; @@ -289,9 +291,7 @@ export function PageOptionsSidebar({

当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。

-

- 这是已保存字段,但当前编辑器语义暂未正式接通。 -

+

已接通:嵌入默认位置会进入当前页面的正式运行时语义。

当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
@@ -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 (
- - {active ? "已开启" : "已关闭"} + + {statusText} ); diff --git a/wolai-frontend/src/lib/documents/page-option-semantics.test.ts b/wolai-frontend/src/lib/documents/page-option-semantics.test.ts index 9795a248..937b5f03 100644 --- a/wolai-frontend/src/lib/documents/page-option-semantics.test.ts +++ b/wolai-frontend/src/lib/documents/page-option-semantics.test.ts @@ -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, diff --git a/wolai-frontend/src/lib/documents/page-option-semantics.ts b/wolai-frontend/src/lib/documents/page-option-semantics.ts index 4b08af44..3e5745a7 100644 --- a/wolai-frontend/src/lib/documents/page-option-semantics.ts +++ b/wolai-frontend/src/lib/documents/page-option-semantics.ts @@ -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 { diff --git a/wolai-frontend/src/lib/server/mnote-cli-agent-host.test.ts b/wolai-frontend/src/lib/server/mnote-cli-agent-host.test.ts index fa154c1e..0c147ddc 100644 --- a/wolai-frontend/src/lib/server/mnote-cli-agent-host.test.ts +++ b/wolai-frontend/src/lib/server/mnote-cli-agent-host.test.ts @@ -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 { + 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"'); + }); }); diff --git a/wolai-frontend/src/lib/server/mnote-cli-agent-host.ts b/wolai-frontend/src/lib/server/mnote-cli-agent-host.ts index 1aa2ac7e..174afca6 100644 --- a/wolai-frontend/src/lib/server/mnote-cli-agent-host.ts +++ b/wolai-frontend/src/lib/server/mnote-cli-agent-host.ts @@ -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; + +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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeAiWritablePageOptionsPatch(value: unknown): Partial | null { + if (!isPlainRecord(value)) { + return null; + } + + const patch: Partial = {}; + 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 | null { + const tagged = text.match(/([\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 | null { + const normalizedText = text.trim(); + if (!normalizedText) { + return null; + } + + const patch: Partial = {}; + + 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 | null { + void input.currentPageOptions; + const latestUserMessage = readLatestUserMessage(input.messages); + return ( + extractStructuredPageOptionsPatch(latestUserMessage) ?? + extractNaturalLanguagePageOptionsPatch(latestUserMessage) + ); +} + +function describePageOptionsPatch(patch: Partial): 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; +}) { + 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 { - 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({ + 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,