feat: wire page options through aggregate AI flow
This commit is contained in:
@@ -30,7 +30,7 @@
|
|||||||
### 2.1 已经成立的事实
|
### 2.1 已经成立的事实
|
||||||
|
|
||||||
- [x] `leptos-tiptap` 已经成为页面内正式主编辑区,不再是 iframe bridge。
|
- [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] 正文保存已经能按 `workspaceId/documentId` 正确落到对应页面。
|
||||||
- [x] 当前主编辑区已具备可继续推进的基础交互能力。
|
- [x] 当前主编辑区已具备可继续推进的基础交互能力。
|
||||||
- [x] 当前问题已经不再是“能不能接入主编辑器”,而是“接入后如何收口为单一真源”。
|
- [x] 当前问题已经不再是“能不能接入主编辑器”,而是“接入后如何收口为单一真源”。
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- [x] 不再继续给 `DocumentPageProps`、`DocumentContentProps` 零散加字段来扩页面真相。
|
- [x] 不再继续给 `DocumentPageProps`、`DocumentContentProps` 零散加字段来扩页面真相。
|
||||||
- [x] 文档页加载入口能明确区分“聚合读取结果”和“局部 UI 临时态”。
|
- [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 退出标准
|
### 4.3 退出标准
|
||||||
|
|
||||||
@@ -111,9 +111,9 @@
|
|||||||
- [x] 页面加载时不再能明显看出“这是几份子结果拼出来的页面”。
|
- [x] 页面加载时不再能明显看出“这是几份子结果拼出来的页面”。
|
||||||
- [ ] 后续页面新增字段时,不再需要继续向外层 props 链同时塞多种局部真相。
|
- [ ] 后续页面新增字段时,不再需要继续向外层 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-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 最小接入优先级
|
### 6.2 最小接入优先级
|
||||||
|
|
||||||
- [x] `wideLayout` 真正进入 island 布局语义,而不是只改外层壳宽度。
|
- [x] `wideLayout` 真正进入 island 布局语义,而不是只改外层壳宽度。
|
||||||
@@ -150,12 +158,23 @@
|
|||||||
|
|
||||||
- [x] 没有真正接入 island 的编辑器语义项,不再继续以“已开启/已关闭”假装正式完成。
|
- [x] 没有真正接入 island 的编辑器语义项,不再继续以“已开启/已关闭”假装正式完成。
|
||||||
- [x] 对未支持项给出显式降级说明,而不是仅保存字段。
|
- [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 退出标准
|
### 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` 与这条工作区树链之间的最终统一验收仍待补齐,因此本阶段继续不提前打满。
|
补充:已新增 `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 退出标准
|
### 7.3 退出标准
|
||||||
|
|
||||||
@@ -219,6 +238,18 @@
|
|||||||
- [x] AI 改写结果能通过主编辑区 island 正式回显。
|
- [x] AI 改写结果能通过主编辑区 island 正式回显。
|
||||||
- [ ] AI 改写后树标题 / 页面头部 / 页面设置不再走各自独立副作用链。
|
- [ ] 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 退出标准
|
### 8.3 退出标准
|
||||||
|
|
||||||
- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。
|
- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。
|
||||||
@@ -227,7 +258,9 @@
|
|||||||
|
|
||||||
- `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。
|
- `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。
|
||||||
- `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。
|
- `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` 的最后一项继续保留未完成,避免误判为整条线已经闭环。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { Json } from "@/types/supabase";
|
|||||||
import {
|
import {
|
||||||
DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT,
|
DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT,
|
||||||
applyDocWriteToolResultToPageBody,
|
applyDocWriteToolResultToPageBody,
|
||||||
|
extractCurrentPageOptionsPatchFromToolResult,
|
||||||
buildAiAgentSessionsStoragePayload,
|
buildAiAgentSessionsStoragePayload,
|
||||||
extractCurrentPageTitleFromSlashToolResult,
|
extractCurrentPageTitleFromSlashToolResult,
|
||||||
shouldSyncActiveSessionSnapshot,
|
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", () => {
|
describe("DocumentAiAgentPanel.runtime island contract", () => {
|
||||||
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
|
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X } from "lucide-react";
|
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X } from "lucide-react";
|
||||||
import type { Json } from "@/types/supabase";
|
import type { Json } from "@/types/supabase";
|
||||||
|
import type { PageLayoutDensity, PageOptionsState, PageFont } from "@/types/page-options";
|
||||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type PageBodyPersistedMeta,
|
type PageBodyPersistedMeta,
|
||||||
} from "@/lib/documents/page-command-client";
|
} from "@/lib/documents/page-command-client";
|
||||||
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
|
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";
|
import type { PageAggregateAiSnapshot } from "@/components/editor/DocumentAiAgentPanel";
|
||||||
|
|
||||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
@@ -89,6 +91,73 @@ const DEFAULT_TOOLS: ToolName[] = [
|
|||||||
"search_web",
|
"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 ToolLog =
|
||||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: 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;
|
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) => {
|
const safeJsonStringify = (value: unknown) => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
@@ -244,11 +335,13 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
getLatestPageAggregateSnapshot,
|
getLatestPageAggregateSnapshot,
|
||||||
onPersistedMetaChange,
|
onPersistedMetaChange,
|
||||||
onPageHeadTitleChange,
|
onPageHeadTitleChange,
|
||||||
|
onPageOptionsChange,
|
||||||
}: {
|
}: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
||||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||||
onPageHeadTitleChange?: (title: string) => void;
|
onPageHeadTitleChange?: (title: string) => void;
|
||||||
|
onPageOptionsChange?: (patch: Partial<PageOptionsState>) => void;
|
||||||
}) {
|
}) {
|
||||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||||
|
|
||||||
@@ -806,6 +899,16 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
onPageHeadTitleChange?.(nextPageTitle);
|
onPageHeadTitleChange?.(nextPageTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextPageOptionsPatch = extractCurrentPageOptionsPatchFromToolResult({
|
||||||
|
tool,
|
||||||
|
ok: Boolean(obj.ok),
|
||||||
|
result,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
if (nextPageOptionsPatch) {
|
||||||
|
onPageOptionsChange?.(nextPageOptionsPatch);
|
||||||
|
}
|
||||||
|
|
||||||
void applyDocWriteToolResultToPageBody({
|
void applyDocWriteToolResultToPageBody({
|
||||||
tool,
|
tool,
|
||||||
ok: Boolean(obj.ok),
|
ok: Boolean(obj.ok),
|
||||||
@@ -1003,7 +1106,7 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
value={aiProvider}
|
value={aiProvider}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = String(e.target.value || "").trim();
|
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");
|
else setAiProvider("online");
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -1011,7 +1114,6 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
<option value="online">在线</option>
|
<option value="online">在线</option>
|
||||||
<option value="local">本地</option>
|
<option value="local">本地</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
className="h-8 w-[180px] rounded border px-2 text-xs"
|
className="h-8 w-[180px] rounded border px-2 text-xs"
|
||||||
@@ -1293,7 +1395,7 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
value={aiProvider}
|
value={aiProvider}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = String(e.target.value || "").trim();
|
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");
|
else setAiProvider("online");
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -1301,7 +1403,6 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
<option value="online">在线</option>
|
<option value="online">在线</option>
|
||||||
<option value="local">本地</option>
|
<option value="local">本地</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{aiProvider === "codex" ? (
|
{aiProvider === "codex" ? (
|
||||||
@@ -1676,7 +1777,7 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
value={aiProvider}
|
value={aiProvider}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = String(e.target.value || "").trim();
|
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");
|
else setAiProvider("online");
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -1684,7 +1785,6 @@ export function DocumentAiAgentPanelRuntime({
|
|||||||
<option value="online">在线</option>
|
<option value="online">在线</option>
|
||||||
<option value="local">本地</option>
|
<option value="local">本地</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{aiProvider === "codex" ? (
|
{aiProvider === "codex" ? (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type DocumentAiAgentPanelProps = {
|
|||||||
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
|
||||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||||
onPageHeadTitleChange?: (title: string) => void;
|
onPageHeadTitleChange?: (title: string) => void;
|
||||||
|
onPageOptionsChange?: (patch: Partial<PageOptionsState>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
|
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
|
||||||
|
|||||||
@@ -52,23 +52,14 @@ import type {
|
|||||||
EditorHostFallbackReason,
|
EditorHostFallbackReason,
|
||||||
} from "@/components/editor/editor-host-types";
|
} from "@/components/editor/editor-host-types";
|
||||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||||
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
|
|
||||||
import {
|
import {
|
||||||
createPageAggregateClientState,
|
createPageAggregateClientState,
|
||||||
pageAggregateClientStateReducer,
|
pageAggregateClientStateReducer,
|
||||||
selectPageAggregateClientAiSnapshot,
|
selectPageAggregateClientAiSnapshot,
|
||||||
selectPageAggregateClientPageSubtree,
|
selectPageAggregateClientPageSubtree,
|
||||||
|
selectPageAggregateClientTitleState,
|
||||||
} from "@/components/editor/page-aggregate-client-state";
|
} from "@/components/editor/page-aggregate-client-state";
|
||||||
|
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const PageOptionsSidebar = dynamic(
|
const PageOptionsSidebar = dynamic(
|
||||||
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
|
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
|
||||||
@@ -141,7 +132,6 @@ export function DocumentContent({
|
|||||||
}: DocumentContentProps) {
|
}: DocumentContentProps) {
|
||||||
const documentId = page.identity.documentId;
|
const documentId = page.identity.documentId;
|
||||||
const workspaceId = page.identity.workspaceId;
|
const workspaceId = page.identity.workspaceId;
|
||||||
const initialTitle = page.head.title;
|
|
||||||
const updatedAt = page.head.updatedAt;
|
const updatedAt = page.head.updatedAt;
|
||||||
const readOnly = page.head.permissions.readOnly;
|
const readOnly = page.head.permissions.readOnly;
|
||||||
const disableDownload = page.head.permissions.disableDownload;
|
const disableDownload = page.head.permissions.disableDownload;
|
||||||
@@ -166,15 +156,7 @@ export function DocumentContent({
|
|||||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||||
const {
|
const liveSidebarTitle = usePreferredSidebarDocumentTitle(documentId);
|
||||||
displayTitle: pageTitle,
|
|
||||||
committedTitle: committedPageTitle,
|
|
||||||
setDraftTitle: setPageTitleDraft,
|
|
||||||
commitPersistedTitle,
|
|
||||||
} = usePageHeadTitle({
|
|
||||||
documentId,
|
|
||||||
fallbackTitle: initialTitle,
|
|
||||||
});
|
|
||||||
const content = pageClientState.content;
|
const content = pageClientState.content;
|
||||||
const contentRevision = pageClientState.contentRevision;
|
const contentRevision = pageClientState.contentRevision;
|
||||||
const conflictDetectionKey = pageClientState.conflictDetectionKey;
|
const conflictDetectionKey = pageClientState.conflictDetectionKey;
|
||||||
@@ -182,11 +164,9 @@ export function DocumentContent({
|
|||||||
const [contentError, setContentError] = useState<string | null>(null);
|
const [contentError, setContentError] = useState<string | null>(null);
|
||||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||||
const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind);
|
const requestedHostKind = isLeptosTiptapHostKind(editorHostKind)
|
||||||
const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote";
|
? editorHostKind
|
||||||
const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() =>
|
: DEFAULT_EDITOR_HOST_KIND;
|
||||||
requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind,
|
|
||||||
);
|
|
||||||
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
|
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
|
||||||
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
|
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
|
||||||
const [hostCommandFailure, setHostCommandFailure] = 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 [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
|
||||||
const [hostStatus, setHostStatus] = useState<string>("idle");
|
const [hostStatus, setHostStatus] = useState<string>("idle");
|
||||||
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
|
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
|
||||||
|
const [hostReloadKey, setHostReloadKey] = useState(0);
|
||||||
const fallbackTriggerHistoryRef = useRef<string[]>([]);
|
const fallbackTriggerHistoryRef = useRef<string[]>([]);
|
||||||
const shouldStartEditing = canEditDocument;
|
const shouldStartEditing = canEditDocument;
|
||||||
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
|
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
|
||||||
@@ -210,8 +191,8 @@ export function DocumentContent({
|
|||||||
const lastCopyBlockedAtRef = useRef<number>(0);
|
const lastCopyBlockedAtRef = useRef<number>(0);
|
||||||
const hasRequestedFallbackRef = useRef(false);
|
const hasRequestedFallbackRef = useRef(false);
|
||||||
|
|
||||||
const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => {
|
const resetHostObservability = useCallback(() => {
|
||||||
setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting");
|
setHostStatus("booting");
|
||||||
setHostEventAt(new Date().toISOString());
|
setHostEventAt(new Date().toISOString());
|
||||||
setHostRuntimeLoadFailure(null);
|
setHostRuntimeLoadFailure(null);
|
||||||
setHostInitFailure(null);
|
setHostInitFailure(null);
|
||||||
@@ -224,18 +205,17 @@ export function DocumentContent({
|
|||||||
hasRequestedFallbackRef.current = false;
|
hasRequestedFallbackRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestFallbackToBlockNote = useCallback(
|
const recordHostFailure = useCallback(
|
||||||
(reason: EditorHostFallbackReason, error?: string | null) => {
|
(reason: EditorHostFallbackReason, error?: string | null) => {
|
||||||
if (hasRequestedFallbackRef.current) {
|
if (hasRequestedFallbackRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasRequestedFallbackRef.current = true;
|
hasRequestedFallbackRef.current = true;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
setActiveHostKind("blocknote");
|
|
||||||
setHostFallbackCount((prev) => prev + 1);
|
setHostFallbackCount((prev) => prev + 1);
|
||||||
setLastFallbackReason(reason);
|
setLastFallbackReason(reason);
|
||||||
setLastFallbackAt(now);
|
setLastFallbackAt(now);
|
||||||
setHostStatus("blocknote_fallback");
|
setHostStatus("host_error");
|
||||||
setHostEventAt(now);
|
setHostEventAt(now);
|
||||||
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
|
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
|
||||||
0,
|
0,
|
||||||
@@ -327,8 +307,8 @@ export function DocumentContent({
|
|||||||
}, [disableCopy]);
|
}, [disableCopy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
resetHostObservability();
|
||||||
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
|
setHostReloadKey((prev) => prev + 1);
|
||||||
}, [requestedHostKind, resetHostObservability]);
|
}, [requestedHostKind, resetHostObservability]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -351,13 +331,6 @@ export function DocumentContent({
|
|||||||
}
|
}
|
||||||
}, [documentId, editorBridge, openTableId, router]);
|
}, [documentId, editorBridge, openTableId, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatchPageClientState({
|
|
||||||
type: "update_server_page_subtree_title",
|
|
||||||
title: committedPageTitle,
|
|
||||||
});
|
|
||||||
}, [committedPageTitle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatchPageClientState({
|
dispatchPageClientState({
|
||||||
type: "hydrate_from_page",
|
type: "hydrate_from_page",
|
||||||
@@ -435,7 +408,7 @@ export function DocumentContent({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -447,9 +420,9 @@ export function DocumentContent({
|
|||||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as {
|
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;
|
const reloadedBody = reloadedPage?.body ?? null;
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
if (reloadedPage) {
|
if (reloadedPage) {
|
||||||
@@ -511,30 +484,31 @@ export function DocumentContent({
|
|||||||
persistTitleCommand: executePageHeadCommand,
|
persistTitleCommand: executePageHeadCommand,
|
||||||
notifyDocumentsChanged: emitDocumentsChanged,
|
notifyDocumentsChanged: emitDocumentsChanged,
|
||||||
});
|
});
|
||||||
commitPersistedTitle(payload);
|
|
||||||
dispatchPageClientState({
|
dispatchPageClientState({
|
||||||
type: "update_server_page_subtree_title",
|
type: "commit_persisted_page_title",
|
||||||
title: payload,
|
title: payload,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新页面标题失败", error);
|
console.error("更新页面标题失败", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[commitPersistedTitle, documentId, readOnly, workspaceId],
|
[documentId, readOnly, workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAiPageHeadTitleChange = useCallback(
|
const handleAiPageHeadTitleChange = useCallback(
|
||||||
(nextTitle: string) => {
|
(nextTitle: string) => {
|
||||||
setPageTitleDraft(nextTitle);
|
|
||||||
commitPersistedTitle(nextTitle);
|
|
||||||
dispatchPageClientState({
|
dispatchPageClientState({
|
||||||
type: "update_server_page_subtree_title",
|
type: "set_draft_page_title",
|
||||||
|
title: nextTitle,
|
||||||
|
});
|
||||||
|
dispatchPageClientState({
|
||||||
|
type: "commit_persisted_page_title",
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
});
|
});
|
||||||
emitDocumentsChanged(documentId);
|
emitDocumentsChanged(documentId);
|
||||||
void persistTitle(nextTitle);
|
void persistTitle(nextTitle);
|
||||||
},
|
},
|
||||||
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
|
[documentId, persistTitle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||||
@@ -544,7 +518,10 @@ export function DocumentContent({
|
|||||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!canEditDocument) return;
|
if (!canEditDocument) return;
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
setPageTitleDraft(value);
|
dispatchPageClientState({
|
||||||
|
type: "set_draft_page_title",
|
||||||
|
title: value,
|
||||||
|
});
|
||||||
debouncedPersistTitle(value);
|
debouncedPersistTitle(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -601,6 +578,13 @@ export function DocumentContent({
|
|||||||
[persistOptions, readOnly],
|
[persistOptions, readOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAiPageOptionsChange = useCallback(
|
||||||
|
(patch: Partial<PageOptionsState>) => {
|
||||||
|
setOptionPatch(patch);
|
||||||
|
},
|
||||||
|
[setOptionPatch],
|
||||||
|
);
|
||||||
|
|
||||||
const closeToc = useCallback(() => {
|
const closeToc = useCallback(() => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
if (!options.showToc) return;
|
if (!options.showToc) return;
|
||||||
@@ -853,9 +837,20 @@ export function DocumentContent({
|
|||||||
options.smallText && "wolai-small-text",
|
options.smallText && "wolai-small-text",
|
||||||
options.hideChildPages && "wolai-hide-child-pages",
|
options.hideChildPages && "wolai-hide-child-pages",
|
||||||
);
|
);
|
||||||
|
const titleState = useMemo(
|
||||||
|
() =>
|
||||||
|
selectPageAggregateClientTitleState(pageClientState, {
|
||||||
|
liveSidebarTitle,
|
||||||
|
}),
|
||||||
|
[liveSidebarTitle, pageClientState],
|
||||||
|
);
|
||||||
|
const pageTitle = titleState.displayTitle;
|
||||||
const pageSubtree = useMemo(
|
const pageSubtree = useMemo(
|
||||||
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
|
() =>
|
||||||
[pageClientState, pageTitle],
|
selectPageAggregateClientPageSubtree(pageClientState, {
|
||||||
|
liveSidebarTitle,
|
||||||
|
}),
|
||||||
|
[liveSidebarTitle, pageClientState],
|
||||||
);
|
);
|
||||||
const readViewTocEntries = useMemo(
|
const readViewTocEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -873,9 +868,9 @@ export function DocumentContent({
|
|||||||
() =>
|
() =>
|
||||||
selectPageAggregateClientAiSnapshot(pageClientState, {
|
selectPageAggregateClientAiSnapshot(pageClientState, {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
pageTitle,
|
liveSidebarTitle,
|
||||||
}),
|
}),
|
||||||
[pageClientState, pageTitle, workspaceId],
|
[liveSidebarTitle, pageClientState, workspaceId],
|
||||||
);
|
);
|
||||||
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
|
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
|
||||||
dispatchPageClientState({
|
dispatchPageClientState({
|
||||||
@@ -991,7 +986,7 @@ export function DocumentContent({
|
|||||||
const hostObservability = useMemo(
|
const hostObservability = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
requestedHostKind,
|
requestedHostKind,
|
||||||
activeHostKind,
|
activeHostKind: requestedHostKind,
|
||||||
status: hostStatus,
|
status: hostStatus,
|
||||||
runtimeLoadFailed: hostRuntimeLoadFailure,
|
runtimeLoadFailed: hostRuntimeLoadFailure,
|
||||||
hostInitFailed: hostInitFailure,
|
hostInitFailed: hostInitFailure,
|
||||||
@@ -1004,7 +999,6 @@ export function DocumentContent({
|
|||||||
fallbackTimestamps: fallbackTriggerHistoryRef.current,
|
fallbackTimestamps: fallbackTriggerHistoryRef.current,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeHostKind,
|
|
||||||
hostEventAt,
|
hostEventAt,
|
||||||
hostFallbackCount,
|
hostFallbackCount,
|
||||||
hostInitFailure,
|
hostInitFailure,
|
||||||
@@ -1019,11 +1013,7 @@ export function DocumentContent({
|
|||||||
);
|
);
|
||||||
const activeHostFailureMessage =
|
const activeHostFailureMessage =
|
||||||
hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure;
|
hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure;
|
||||||
const showFallbackBanner =
|
|
||||||
requestedHostKind !== "blocknote" && activeHostKind === "blocknote" && lastFallbackReason != null;
|
|
||||||
const showFailureBanner =
|
const showFailureBanner =
|
||||||
requestedHostKind !== "blocknote" &&
|
|
||||||
activeHostKind !== "blocknote" &&
|
|
||||||
activeHostFailureMessage != null;
|
activeHostFailureMessage != null;
|
||||||
return (
|
return (
|
||||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||||
@@ -1099,14 +1089,13 @@ export function DocumentContent({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showFallbackBanner ? (
|
{showFailureBanner ? (
|
||||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div 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>
|
||||||
<div className="font-medium">
|
<div className="font-medium">island 主链出现错误</div>
|
||||||
默认 island 主编辑器已自动回退到 BlockNote
|
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
|
||||||
</div>
|
<div className="mt-1 text-xs text-red-600">
|
||||||
<div className="mt-1 text-xs text-amber-700">
|
原因:{lastFallbackReason ?? "unknown"}
|
||||||
原因:{lastFallbackReason}
|
|
||||||
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
|
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,72 +1104,38 @@ export function DocumentContent({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveHostKind(requestedHostKind);
|
resetHostObservability();
|
||||||
resetHostObservability(requestedHostKind);
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{keepEditorMounted && (
|
{keepEditorMounted && (
|
||||||
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
|
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
|
||||||
{activeHostKind !== "blocknote" ? (
|
<EditorHost
|
||||||
<EditorHost
|
key={`${requestedHostKind}:${hostReloadKey}`}
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
initialContent={content}
|
initialContent={content}
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
hostKind={activeHostKind}
|
hostKind={requestedHostKind}
|
||||||
initialRevision={contentRevision}
|
initialRevision={contentRevision}
|
||||||
initialConflictDetectionKey={conflictDetectionKey}
|
initialConflictDetectionKey={conflictDetectionKey}
|
||||||
pageOptions={options}
|
pageOptions={options}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onStatsChange={handleStatsChange}
|
onStatsChange={handleStatsChange}
|
||||||
onSnapshot={handleSnapshot}
|
onSnapshot={handleSnapshot}
|
||||||
onCloseToc={closeToc}
|
onCloseToc={closeToc}
|
||||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||||
handlePersistedMetaChange(meta);
|
handlePersistedMetaChange(meta);
|
||||||
}}
|
}}
|
||||||
onHostEvent={handleHostEvent}
|
onHostEvent={handleHostEvent}
|
||||||
onRequestFallback={(payload) => {
|
onRequestFallback={(payload) => {
|
||||||
requestFallbackToBlockNote(payload.reason, payload.error);
|
recordHostFailure(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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -1261,6 +1216,7 @@ export function DocumentContent({
|
|||||||
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
|
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
|
||||||
onPersistedMetaChange={handlePersistedMetaChange}
|
onPersistedMetaChange={handlePersistedMetaChange}
|
||||||
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
||||||
|
onPageOptionsChange={handleAiPageOptionsChange}
|
||||||
/>
|
/>
|
||||||
</ImagePickerProvider>
|
</ImagePickerProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,12 +52,17 @@ describe("PageOptionsSidebar", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("对已保存但未完成编辑器语义的设置应显示降级说明", () => {
|
it("已正式接通的设置不应再显示未接通降级文案,planned 设置应明确标记为待接线", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<PageOptionsSidebar
|
<PageOptionsSidebar
|
||||||
documentId="doc-1"
|
documentId="doc-1"
|
||||||
options={buildOptions({ showHeadingNumbers: true, embedDefaultBlockId: "block-1" })}
|
options={buildOptions({
|
||||||
|
showHeadingNumbers: true,
|
||||||
|
protectEditing: true,
|
||||||
|
showBlockRefCount: true,
|
||||||
|
embedDefaultBlockId: "block-1",
|
||||||
|
})}
|
||||||
stats={buildStats()}
|
stats={buildStats()}
|
||||||
onToggle={() => undefined}
|
onToggle={() => undefined}
|
||||||
onExport={() => 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("编辑保护");
|
||||||
|
expect(container.textContent).toContain("当前为降级展示");
|
||||||
|
|
||||||
const customTab = container.querySelectorAll("button")[1];
|
const customTab = container.querySelectorAll("button")[1];
|
||||||
expect(customTab).not.toBeNull();
|
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).toContain("当前编辑器语义暂未正式接通");
|
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 { DocumentTaskPanel } from "@/components/document-task-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
|
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";
|
type TabId = "page" | "custom" | "global";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ const OPTION_META: Record<
|
|||||||
},
|
},
|
||||||
showHeadingNumbers: {
|
showHeadingNumbers: {
|
||||||
label: "标题编号",
|
label: "标题编号",
|
||||||
description: "自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)",
|
description: "自动为标题添加编号(已接通:阅读态与主编辑区同步生效)",
|
||||||
icon: ListOrdered,
|
icon: ListOrdered,
|
||||||
},
|
},
|
||||||
showToc: {
|
showToc: {
|
||||||
@@ -43,7 +43,7 @@ const OPTION_META: Record<
|
|||||||
},
|
},
|
||||||
protectEditing: {
|
protectEditing: {
|
||||||
label: "编辑保护",
|
label: "编辑保护",
|
||||||
description: "保护内容避免误触修改",
|
description: "当前为降级展示:页面可记住该值,但还不是正式稳定能力",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
showWordCount: {
|
showWordCount: {
|
||||||
@@ -63,11 +63,13 @@ const OPTION_META: Record<
|
|||||||
},
|
},
|
||||||
showBlockRefCount: {
|
showBlockRefCount: {
|
||||||
label: "显示块引用数字",
|
label: "显示块引用数字",
|
||||||
description: "显示块被引用次数(当前为占位,后续补齐)",
|
description: "当前为占位能力:已保留设置位,但还未接通正式引用计数",
|
||||||
icon: Focus,
|
icon: Focus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NON_INTERACTIVE_PLACEHOLDER_OPTIONS = new Set<BooleanPageOptionKey>(["showBlockRefCount"]);
|
||||||
|
|
||||||
interface PageOptionsSidebarProps {
|
interface PageOptionsSidebarProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
options: PageOptionsState;
|
options: PageOptionsState;
|
||||||
@@ -289,9 +291,7 @@ export function PageOptionsSidebar({
|
|||||||
<p className="mt-1 text-xs text-gray-400">
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。
|
当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-amber-600">
|
<p className="mt-1 text-xs text-emerald-600">已接通:嵌入默认位置会进入当前页面的正式运行时语义。</p>
|
||||||
这是已保存字段,但当前编辑器语义暂未正式接通。
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
|
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
|
||||||
当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
|
当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
|
||||||
</div>
|
</div>
|
||||||
@@ -411,12 +411,20 @@ function OptionToggle({
|
|||||||
const meta = OPTION_META[optionKey];
|
const meta = OPTION_META[optionKey];
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const active = options[optionKey];
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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]"
|
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 className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -432,8 +440,13 @@ function OptionToggle({
|
|||||||
<div className="text-xs text-gray-400">{meta.description}</div>
|
<div className="text-xs text-gray-400">{meta.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-xs font-semibold", active ? "text-[#2563eb]" : "text-gray-400")}>
|
<span
|
||||||
{active ? "已开启" : "已关闭"}
|
className={cn(
|
||||||
|
"text-xs font-semibold",
|
||||||
|
panelPolicy === "downgrade" ? "text-amber-600" : active ? "text-[#2563eb]" : "text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { PageOptionsState } from "@/types/page-options";
|
import type { PageOptionsState } from "@/types/page-options";
|
||||||
import {
|
import {
|
||||||
PAGE_OPTION_PANEL_GROUPS,
|
PAGE_OPTION_PANEL_GROUPS,
|
||||||
|
PAGE_OPTION_PANEL_POLICY,
|
||||||
PAGE_OPTION_SEMANTICS,
|
PAGE_OPTION_SEMANTICS,
|
||||||
pickLeptosTiptapRuntimePageOptions,
|
pickLeptosTiptapRuntimePageOptions,
|
||||||
} from "@/lib/documents/page-option-semantics";
|
} from "@/lib/documents/page-option-semantics";
|
||||||
@@ -50,6 +51,14 @@ describe("page-option-semantics", () => {
|
|||||||
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
|
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", () => {
|
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
|
||||||
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
|
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
|
||||||
wideLayout: true,
|
wideLayout: true,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type PageOptionSurface =
|
|||||||
| "inspector_only";
|
| "inspector_only";
|
||||||
|
|
||||||
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
|
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
|
||||||
|
export type PageOptionPanelPolicy = "keep" | "downgrade" | "global_only";
|
||||||
|
|
||||||
export type PageOptionSemanticDescriptor = {
|
export type PageOptionSemanticDescriptor = {
|
||||||
surfaces: PageOptionSurface[];
|
surfaces: PageOptionSurface[];
|
||||||
@@ -98,6 +99,25 @@ export type LeptosTiptapRuntimePageOptions = {
|
|||||||
embedDefaultBlockId: string | null;
|
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(
|
export function pickLeptosTiptapRuntimePageOptions(
|
||||||
pageOptions: PageOptionsState,
|
pageOptions: PageOptionsState,
|
||||||
): LeptosTiptapRuntimePageOptions {
|
): LeptosTiptapRuntimePageOptions {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(),
|
mockSpawn: vi.fn(),
|
||||||
|
mockBuildDocumentBridgeContext: vi.fn(),
|
||||||
|
mockBuildDocumentCommandEnvelope: vi.fn(),
|
||||||
|
mockExecutePageWriteBridgeCommand: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("node:child_process", () => ({
|
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) {
|
function createMockChild(stdoutText: string) {
|
||||||
const child = new EventEmitter() as EventEmitter & {
|
const child = new EventEmitter() as EventEmitter & {
|
||||||
@@ -49,6 +89,9 @@ function createMockChild(stdoutText: string) {
|
|||||||
describe("startMnoteCliAgentHostRun", () => {
|
describe("startMnoteCliAgentHostRun", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSpawn.mockReset();
|
mockSpawn.mockReset();
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockExecutePageWriteBridgeCommand.mockReset();
|
||||||
delete process.env.DEV_USER_ID;
|
delete process.env.DEV_USER_ID;
|
||||||
delete process.env.DEV_USER_EMAIL;
|
delete process.env.DEV_USER_EMAIL;
|
||||||
delete process.env.DEV_USER_NAME;
|
delete process.env.DEV_USER_NAME;
|
||||||
@@ -116,4 +159,86 @@ describe("startMnoteCliAgentHostRun", () => {
|
|||||||
workspaceId: "ws_req_1778035004501_15",
|
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 { NextResponse } from "next/server";
|
||||||
import type { PageOptionsState } from "@/types/page-options";
|
import type { PageOptionsState } from "@/types/page-options";
|
||||||
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
|
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 };
|
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
@@ -41,12 +49,216 @@ type CliRunResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CLI_HOST_TIMEOUT_MS = 30_000;
|
const CLI_HOST_TIMEOUT_MS = 30_000;
|
||||||
|
const PAGE_OPTIONS_TOOL_NAME = "page_options_patch";
|
||||||
|
|
||||||
const toSseFrame = (event: string, data: unknown) => {
|
const toSseFrame = (event: string, data: unknown) => {
|
||||||
const json = JSON.stringify(data ?? null);
|
const json = JSON.stringify(data ?? null);
|
||||||
return `event: ${event}\ndata: ${json}\n\n`;
|
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) {
|
async function pathExists(targetPath: string) {
|
||||||
try {
|
try {
|
||||||
await access(targetPath);
|
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: {
|
export async function startMnoteCliAgentHostRun(input: {
|
||||||
request: Request;
|
request: Request;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -182,7 +437,112 @@ export async function startMnoteCliAgentHostRun(input: {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
payload: MnoteCliAgentRunPayload;
|
payload: MnoteCliAgentRunPayload;
|
||||||
}): Promise<Response> {
|
}): 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({
|
const run = runMnoteCli({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
userEmail: input.userEmail,
|
userEmail: input.userEmail,
|
||||||
|
|||||||
Reference in New Issue
Block a user