diff --git a/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md b/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md new file mode 100644 index 00000000..966d9e70 --- /dev/null +++ b/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md @@ -0,0 +1,356 @@ +# 2-1 [process] 页面块存储与投影对齐方案 v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`PROCESS`。 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> +> 代码依据: +> - `/mnt/Data1T/mnote/wolai-frontend/convex/schema.ts` +> - `/mnt/Data1T/mnote/wolai-frontend/convex/documents.ts` +> - `/mnt/Data1T/mnote/wolai-frontend/convex/blocks.ts` +> - `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs` +> - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` + +--- + +## 1. 结论 + +页面块 AI 工具要可读、可写、可移动,必须先把三层对齐: + +```text +Rust canonical block model + <-> Page Aggregate block projection + <-> Convex persisted page body snapshot +``` + +当前真实持久化主链仍是: + +```text +documents.content +documents.content_revision +documents.content_conflict_key +``` + +`blocks` 表存在,但当前 schema 没有 `order_key` 字段,mutation 也没有维护父子排序和正文 snapshot 的一致性;因此第一阶段不能把 `blocks` 表描述为页面正文事实源。 + +第一阶段正确策略是: + +> **Rust 先持有块语义和命令计划,Convex 仍保存 canonical snapshot 到 `documents.content`;Page Aggregate 从 snapshot 投影出稳定 block view。等块投影、revision、刷新链路稳定后,再评估是否把 `blocks` 表升级为写主链。** + +--- + +## 2. 当前存储状态 + +### 2.1 `documents` 表 + +`wolai-frontend/convex/schema.ts` 中 `documents` 保存: + +- `content: v.any()` +- `content_revision: v.optional(v.number())` +- `content_conflict_key: v.optional(v.union(v.string(), v.null()))` +- `raw_text` +- 页面标题、父级、排序、页面设置、统计等字段 + +`documents.ts` 的 `updateContent` / `updateDocumentContentRecord` 已经支持: + +- `expectedRevision` +- `conflictDetectionKey` +- revision 自增 +- conflict key 更新为 `${documentId}:${nextRevision}` +- raw text 回写 +- ingest job debounce + +这说明当前页面正文冲突检测已经有页面级版本基础。 + +### 2.2 `blocks` 表 + +`blocks` 表当前字段: + +- `id` +- `workspace_id` +- `page_id` +- `parent_block_id` +- `type` +- `props` +- `content` +- `child_block_ids` +- `created_by` +- `created_at` +- `updated_at` + +当前问题: + +- schema 没有 `order_key`,但 `blocks.ts` 的 append/insertAfter 入参包含 `orderKey` 却未持久化。 +- `insertAfter` 只插入新 block,不维护 sibling order。 +- `replace/remove` 操作的是 `blocks` 表,不会同步 `documents.content`。 +- storage mapping 中旧 `insert_block/update_block/move_block/delete_block` 指向 blocks 类命令,但当前长期页面正文保存仍映射到 `documents:updateContent`。 + +因此 `blocks` 表当前只能视为历史/辅助/实验 substrate,不是页面正文单一真源。 + +### 2.3 Rust command 到 Convex 的映射 + +`storage-convex-bridge` 当前将: + +- `documents.save` +- `page.body.save` +- `blocks.patch` +- `tree.node.embed` + +映射到 `documents:updateContent`。 + +这说明当前正式页面正文写入边界仍是 `page.body.save -> documents:updateContent`。 + +--- + +## 3. 对齐目标 + +### 3.1 短期目标 + +短期固定为: + +```text +AI tool / editor command + -> Rust EditorCommand + -> canonical EditorBlockDocument + -> legacy content snapshot + -> page.body.save + -> documents:updateContent + -> Page Aggregate reload +``` + +允许: + +- 底层整份 snapshot 回写。 +- `documents.content` 继续是 persisted body。 +- `blocks` 表保持只读参考或历史兼容。 + +不允许: + +- AI 直接写 `documents.content` 私有数组。 +- AI 直接写 `blocks` 表并期待页面正文自动变化。 +- 前端临时 Tiptap JSON 成为存储事实源。 + +### 3.2 中期目标 + +中期应让 `PageAggregate.page_body` 输出: + +- `content` +- `revision` +- `conflictDetectionKey` +- `blockDocument` +- `blockProjectionVersion` +- `projectionSource` + +并保证: + +- `blockDocument` 可由 `documents.content` 可靠生成。 +- `blockDocument` 可生成 canonical `documents.content`。 +- 保存后 `/api/page-aggregate/:id` 与 `mnote.doc.fetch` 看到相同 block ids。 +- editor session 能基于 revision 刷新。 + +### 3.3 长期目标 + +长期如果升级 `blocks` 表为写主链,必须先满足: + +- `blocks` 表补齐 `order_key` 或等价排序字段。 +- `blocks` 表与 `documents.content` 的关系明确:谁是事实源,谁是 materialized snapshot。 +- 每个 block write 都能产出 page body revision。 +- 页面读取、搜索、引用、评论、历史、实时事件都消费同一投影。 +- 有迁移、回填、校验和回滚方案。 + +在这些完成前,不把 `blocks` 表提升为页面正文事实源。 + +--- + +## 4. Revision 与 Conflict Key + +### 4.1 页面级 revision + +页面正文当前使用: + +```text +content_revision: number +content_conflict_key: `${documentId}:${revision}` +``` + +AI 写工具必须携带: + +- `expectedRevision` +- `conflictDetectionKey` +- `idempotencyKey` +- `dryRun` + +缺少 `expectedRevision` 或 `conflictDetectionKey` 时: + +- 只允许 dry-run。 +- 不允许真实写入。 + +### 4.2 块级 revisionRef + +块级工具需要额外返回 `revisionRef`: + +```text +pageRev:{revision}:block:{blockId}:hash:{hash} +``` + +用途: + +- 防止 AI 基于旧块内容写入。 +- 防止移动时 anchor 已改变。 +- 帮助 dry-run 返回精确 warning。 + +说明: + +- `hash` 可以用 canonical block JSON 或文本/结构摘要计算。 +- Tiptap AI Toolkit `_hash` 可作为参考,但不能替代 mnote `blockId`。 +- 第一阶段 `revisionRef` 不需要单独持久化,可以由 Page Aggregate projection 生成。 + +### 4.3 冲突粒度 + +第一阶段冲突粒度: + +- 写入仍以页面级 revision 为硬门槛。 +- 块级 `revisionRef` 作为额外预检和 warning 来源。 + +后续如果引入块级持久化: + +- 可以把块级 revision 升级为硬冲突键。 +- 但仍必须推进 page body revision,保证页面快照和实时事件一致。 + +--- + +## 5. Projection 对齐 + +### 5.1 Page Aggregate 输出 + +`PageAggregate.page_body` 应从当前: + +```json +{ + "content": [], + "revision": 12, + "conflictDetectionKey": "doc_1:12" +} +``` + +扩展为: + +```json +{ + "content": [], + "revision": 12, + "conflictDetectionKey": "doc_1:12", + "blockDocument": { + "documentId": "doc_1", + "rootBlockIds": [], + "blocks": [] + }, + "blockProjectionVersion": 1, + "projectionSource": "documents.content" +} +``` + +### 5.2 投影来源 + +投影来源必须显式标记: + +- `documents.content`:从当前 persisted snapshot 生成。 +- `editorDocument`:从 canonical editor document 直接生成。 +- `tiptapDocument`:从 Tiptap JSON bridge 生成。 +- `blocksTable`:仅在未来 blocks 表成为受控投影或主链后使用。 + +### 5.3 Page Aggregate 与 AI 工具一致性 + +`mnote.doc.fetch`、`mnote.block.fetch`、`mnote.block.replace`、`mnote.block.insert_after`、`mnote.block.move_after` 都必须以 Page Aggregate block projection 为读来源。 + +写入完成后必须验证三处一致: + +- `/api/page-aggregate/:id` +- 当前 editor session 可见内容 +- 再次 `mnote.doc.fetch` / `mnote.block.fetch` + +--- + +## 6. Editor Session Refresh + +块写工具成功后,必须解决“后端已写、前端编辑器还停在旧文档”的问题。 + +第一阶段可选策略: + +1. 触发 page aggregate reload。 +2. 向当前页面 stream/event 发 `page.body.saved` 或等价 body refresh event。 +3. editor island 收到 revision 变化后 reload canonical content。 + +最低要求: + +- 写工具返回新 revision。 +- 前端能检测当前 editor session revision 已落后。 +- smoke 中必须证明刷新页面后内容仍在。 + +不得把“Convex 已保存”当成用户可见编辑闭环。 + +--- + +## 7. 实施顺序 + +### Phase 1:snapshot canonicalization + +- [ ] 从 `documents.content` 稳定生成 `EditorBlockDocument`。 +- [ ] 从 `EditorBlockDocument` 稳定生成 canonical `documents.content`。 +- [ ] 为每个可编辑块生成 `blockId/type/text/attrs/children/order/path/revisionRef`。 +- [ ] Page Aggregate 输出 block projection。 + +### Phase 2:read projection + +- [ ] `mnote.doc.fetch scope=block/full/outline/keyword` 消费 Page Aggregate。 +- [ ] `mnote.block.fetch` 消费 Page Aggregate。 +- [ ] 返回 page revision、conflict key、block revision refs。 + +### Phase 3:write through Rust command + +- [ ] `mnote.block.replace` 生成 `EditorCommand::ReplaceBlock`。 +- [ ] `mnote.block.insert_after` 生成 `EditorCommand::InsertBlockAfter`。 +- [ ] Rust 将命令应用到 canonical `EditorBlockDocument`。 +- [ ] 生成 canonical content 后走 `page.body.save -> documents:updateContent`。 + +### Phase 4:move gate + +- [ ] `mnote.block.move_after` 先只做同父级叶子块 dry-run。 +- [ ] 同父级移动真实写入通过后再考虑标题子树、列表、复杂块。 + +### Phase 5:blocks 表评估 + +- [ ] 补 `order_key` 设计。 +- [ ] 明确 `blocks` 表是主链还是 materialized projection。 +- [ ] 设计迁移和双写校验。 +- [ ] 通过后再移动长期写主链。 + +--- + +## 8. 不做清单 + +- [ ] 不让 AI 直接调用 `documents:updateContent`。 +- [ ] 不让 AI 直接调用 `blocks:*` mutation。 +- [ ] 不把 `blocks` 表描述为当前页面正文事实源。 +- [ ] 不把 Tiptap `UniqueID` 或 `_hash` 当作业务 block id。 +- [ ] 不在没有 page revision 的情况下真实写入。 +- [ ] 不在 editor session refresh 未闭环时宣布块写工具完成。 + +--- + +## 9. 完成定义 + +本设计不能标记 `DONE`,直到: + +- [ ] Page Aggregate 输出稳定 block projection。 +- [ ] `documents.content -> EditorBlockDocument -> documents.content` 有测试覆盖。 +- [ ] `mnote.block.fetch` 能读取 projection 中任意可编辑块。 +- [ ] `mnote.block.replace` 和 `mnote.block.insert_after` 通过真实页面 smoke。 +- [ ] 写入后 revision/conflict key 更新,刷新页面和 AI 回读一致。 +- [ ] `blocks` 表当前定位在代码和设计中不再被误称为正文主链。 diff --git a/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md b/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md new file mode 100644 index 00000000..e9837dcc --- /dev/null +++ b/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md @@ -0,0 +1,402 @@ +# 5-13 [process] 页面块身份与命令合同 v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`PROCESS`。 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> +> 代码依据: +> - `/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/model.rs` +> - `/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/command.rs` +> - `/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/tiptap.rs` +> - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` +> +> 外部参考: +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs/src/content/content-ai/capabilities/ai-toolkit/agents/tools/index.mdx` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs/src/content/content-ai/capabilities/ai-toolkit/api-reference/read-the-document.mdx` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs/src/content/content-ai/capabilities/ai-toolkit/api-reference/execute-tool.mdx` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-main/packages/extension-unique-id/src/unique-id.ts` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-main/packages/server-ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts` + +--- + +## 1. 结论 + +页面块 AI 工具不能建立在“整页 `content` 字符串/数组 patch”之上。它必须先有一个稳定的页面块合同: + +> **Rust `EditorBlockDocument` 是页面正文的 canonical block view;`EditorBlock.block_id` 是 mnote 页面内块身份真相;Tiptap JSON、ProseMirror position、`UniqueID`、AI Toolkit `_hash` 都只能是 runtime adapter 或定位辅助。** + +第一阶段允许底层仍回写整份 `documents.content`,但命令语义必须先落到 Rust block command: + +- `editor.block.replace` +- `editor.block.insert_after` +- `editor.block.delete` +- `editor.block.move_after` + +也就是说,持久化可以暂时是 snapshot save,工具合同不能退化成 snapshot patch。 + +--- + +## 2. 当前真实状态 + +### 2.1 已经存在的基础 + +`core-protocol` 已经有正式块模型: + +- `EditorBlockDocument.document_id` +- `EditorBlockDocument.root_block_ids` +- `EditorBlockDocument.blocks` +- `EditorBlock.block_id` +- `EditorBlock.block_type` +- `EditorBlock.props` +- `EditorBlock.content_nodes` +- `EditorBlock.child_block_ids` + +`core-protocol` 也已经有编辑命令形态: + +- `EditorReplaceBlock` +- `EditorInsertBlockAfter` +- `EditorDeleteBlock` +- `EditorMoveBlock` +- `EditorSplitBlock` +- `EditorMergeWithPrevious` +- `EditorIndentBlock` +- `EditorOutdentBlock` + +`core-protocol/src/editor/tiptap.rs` 已经提供 `EditorBlockDocumentTiptapBridge`,能在 `EditorBlockDocument` 与 Tiptap JSON 之间转换,并保留段落、标题、列表、任务、引用、代码、分隔线、图片、表格、目录、mindmap 等类型的阶段性映射。 + +### 2.2 仍然缺失的合同 + +现有模型还不足以直接开放成熟 AI 块工具,缺口是: + +- `PageAggregate.page_body` 只定义了 `content/revision/conflictDetectionKey`,没有定义 canonical block projection。 +- `EditorBlockDocument` 还没有在 Page Aggregate 输出中成为稳定字段。 +- `EditorBlock` 没有显式 `parentBlockId/order/path/revisionRef/editable` 投影。 +- `doc_insert_blocks/doc_replace_range` 仍是工具雏形,能够生成部分 editor commands,但仍主要围绕 legacy block array 回写。 +- `EditorMoveBlock` 有协议形态,但 `mnote.block.move_after` 的父子、排序、复杂块规则尚未冻结。 + +因此必须先补本合同,再实现 AI 块读写移动。 + +--- + +## 3. 块身份规则 + +### 3.1 正式身份 + +正式块身份字段固定为: + +```json +{ + "blockId": "block_abc", + "documentId": "doc_1", + "workspaceId": "ws_1" +} +``` + +规则: + +- `blockId` 在同一 `documentId` 内唯一。 +- AI、前端、Tiptap runtime 都不得自行把临时 DOM id 当作 `blockId`。 +- 新块 id 必须由 Rust runtime 分配,或者由 Rust runtime 校验后接受。 +- 保存后再次读取,原块 `blockId` 必须保持不变。 + +### 3.2 复制规则 + +复制块时: + +- 原块 `blockId` 不复用。 +- 新块获得新 `blockId`。 +- 若复制子树,子块全部获得新 `blockId`。 +- 块引用、页面引用、mindmap/resource 等外部关联需要单独定义重定向或保留规则,未定义前不得向 AI 开放复制子树工具。 + +### 3.3 移动规则 + +移动块时: + +- 被移动块 `blockId` 保持不变。 +- 同父级移动只改变 sibling order。 +- 跨父级移动同时改变 `parentBlockId` 与 order。 +- 第一阶段只开放同父级叶子块移动。 +- 标题带子块、列表项、表格、mindmap、resource、page reference、block reference 必须在单独规则中定义后再开放。 + +### 3.4 Tiptap `UniqueID` 与 `_hash` + +Tiptap 参考结论: + +- `UniqueID` 可以给 Tiptap 节点补 id,并处理协作、粘贴、拖拽场景下的去重。 +- Server AI Toolkit 的 `_hash` 是 AI 编辑辅助属性,可帮助定位变化。 +- 这两者都不能替代 mnote 的 `EditorBlock.block_id`。 + +mnote 规则: + +- Tiptap node attrs 中可以携带 `block_id` / `blockId`,但来源必须是 Rust canonical block projection。 +- `_hash` 可以进入 `revisionRef` 的辅助计算,但不能成为业务主键。 +- 如果 Tiptap 文档缺少 block id,导入链必须进入“补 id + 记录 warning”的兼容路径,而不是让 AI 直接编辑无身份块。 + +--- + +## 4. Canonical Block Projection + +`PageAggregate.page_body` 需要新增稳定块投影,建议字段为: + +```json +{ + "content": [], + "revision": 12, + "conflictDetectionKey": "doc_1:12", + "blockDocument": { + "documentId": "doc_1", + "rootBlockIds": ["b1", "b2"], + "blocks": [ + { + "blockId": "b1", + "type": "paragraph", + "text": "正文", + "attrs": {}, + "contentNodes": [], + "children": [], + "parentBlockId": null, + "order": "00010000", + "path": [0], + "depth": 0, + "revisionRef": "pageRev:12:block:b1:hash:abc", + "editable": true, + "unsupportedReason": null + } + ] + } +} +``` + +字段说明: + +- `blockDocument` 是 Page Aggregate 给 editor/AI/read view 的 canonical block view。 +- `content` 保留当前兼容 snapshot,不作为 AI 精确编辑的唯一输入。 +- `text` 是 AI 读取用摘要,不替代 `contentNodes`。 +- `attrs` 是稳定属性投影,不暴露 Tiptap 私有字段;确需保留 Tiptap snapshot 时放入内部 adapter,不进入 AI 默认读取。 +- `order` 是同父级排序投影,第一阶段可由数组位置计算,后续可升级为持久 order key。 +- `path` 是读取定位辅助,不作为写入主键。 +- `revisionRef` 是块级冲突辅助,必须包含 page revision 和块内容/结构摘要。 + +--- + +## 5. EditorBlockDocument 与 Tiptap JSON 边界 + +### 5.1 正式边界 + +正式边界固定为: + +```text +AI / Hermes tool + -> mnote.doc.* / mnote.block.* + -> Rust canonical tool adapter + -> EditorBlockDocument + EditorCommand + -> Tiptap bridge / legacy content bridge / Convex save adapter +``` + +不得变成: + +```text +AI + -> Tiptap JSON 私有 shape + -> Convex documents.content +``` + +### 5.2 Tiptap JSON 的职责 + +Tiptap JSON 可以用于: + +- 浏览器 editor runtime。 +- ProseMirror command 执行。 +- 兼容导入/导出。 +- 测试 bridge round trip。 + +Tiptap JSON 不可以用于: + +- AI 长期工具入参合同。 +- mnote 块身份事实源。 +- Convex 长期块结构事实源。 + +### 5.3 PageMarkdown / PageXML 的职责 + +AI 对外格式固定为: + +- `PageMarkdown`:适合文本、标题、列表、简单块。 +- `PageXML`:适合携带 `block-id`、块属性、结构化块。 +- `json`:只能是 mnote canonical block projection,不是裸 Tiptap JSON。 + +--- + +## 6. 块命令合同 + +### 6.1 `editor.block.replace` + +语义: + +> 替换目标块的类型、属性或内容,不改变目标块 `blockId`、父级、同级顺序。 + +输入: + +```json +{ + "documentId": "doc_1", + "blockId": "b1", + "blockType": "paragraph", + "props": {}, + "contentNodes": [ + { "type": "text", "text": "替换后的正文", "marks": [] } + ], + "expectedRevision": 12, + "blockRevisionRef": "pageRev:12:block:b1:hash:abc" +} +``` + +阻断: + +- `blockId` 不存在。 +- `expectedRevision` 不匹配。 +- `blockRevisionRef` 明显不匹配。 +- 目标块 `editable=false`。 +- 目标块是未定义 replace 规则的复杂块。 + +### 6.2 `editor.block.insert_after` + +语义: + +> 在 anchor 块同父级后插入一个或多个新块。 + +输入: + +```json +{ + "documentId": "doc_1", + "anchorBlockId": "b1", + "blocks": [ + { + "type": "todo", + "props": { "checked": false }, + "contentNodes": [ + { "type": "text", "text": "新增待办", "marks": [] } + ] + } + ], + "expectedRevision": 12, + "anchorRevisionRef": "pageRev:12:block:b1:hash:abc" +} +``` + +规则: + +- 新块 id 由 runtime 生成。 +- 新块默认进入 anchor 同父级。 +- 插入多个块时保持输入顺序。 +- 第一阶段不允许把复杂子树作为单次 insert payload。 + +### 6.3 `editor.block.delete` + +语义: + +> 删除目标块;是否保留、提升或级联删除子块由命令参数明确指定。 + +第一阶段不直接开放给 AI 正式写入,只允许 dry-run。 + +必须先冻结: + +- `preserveChildren=true` 时子块提升到哪里。 +- `preserveChildren=false` 时删除范围如何展示和审计。 +- block reference / comment thread / resource attachment 如何处理。 + +### 6.4 `editor.block.move_after` + +语义: + +> 将目标块移动到 anchor 块之后。 + +第一阶段输入: + +```json +{ + "documentId": "doc_1", + "blockId": "b3", + "anchorBlockId": "b1", + "expectedRevision": 12, + "blockRevisionRef": "pageRev:12:block:b3:hash:aaa", + "anchorRevisionRef": "pageRev:12:block:b1:hash:bbb" +} +``` + +第一阶段只允许: + +- `blockId` 与 `anchorBlockId` 同父级。 +- 目标块是叶子块。 +- 类型为 `paragraph`、普通 `heading`、普通 `todo`。 + +第一阶段阻断: + +- 跨父级移动。 +- 移动到自己的子树内。 +- 标题带子块整体移动。 +- 列表项层级移动。 +- 表格、mindmap、resource、page reference、block reference。 +- 跨页面移动。 + +--- + +## 7. 读工具对命令合同的要求 + +写工具开放前,读工具必须先能返回: + +- `blockId` +- `type` +- `text` +- `attrs` +- `children` +- `parentBlockId` +- `order` +- `path` +- `depth` +- `revisionRef` +- `editable` +- `unsupportedReason` + +`mnote.block.fetch` 必须能返回同父级前后文,供 AI 在 `replace/insert_after/move_after` 前做二次确认。 + +--- + +## 8. 与 Tiptap AI Toolkit 的关系 + +Tiptap AI Toolkit 对 mnote 的启发是: + +- `tiptapRead` 证明 AI 读工具需要高效、可定位的文档读格式。 +- `tiptapEdit` 证明 AI 写工具应该是 operations,而不是整页替换。 +- `tiptapReadSelection` 证明 selection/range 需要冻结,不能依赖用户思考期间不断变化的浏览器选择。 +- `executeTool/streamTool` 证明工具执行面需要返回 `docChanged`、错误、review/preview 结果。 +- schema awareness 证明 AI 需要明确 editor context,而不是猜块类型。 + +mnote 不直接采用 Tiptap operations 作为外部合同,原因是: + +- 公开文档没有完整 operations schema。 +- Tiptap operations 面向 ProseMirror 文档层,不覆盖 mnote 的 Rust kernel、Page Aggregate、Convex revision、Hermes audit。 +- mnote 需要稳定到跨编辑器、跨存储演进的块合同。 + +因此 Tiptap 作为参考模型,不作为 mnote 外部工具合同。 + +--- + +## 9. 完成定义 + +本设计不能标记 `DONE`,直到: + +- [ ] Page Aggregate 输出 canonical `blockDocument` 或等价稳定 block projection。 +- [ ] `EditorBlockDocument` 与 Tiptap JSON bridge 通过 paragraph/heading/list/todo/code/table/image/mindmap 的 round trip 测试。 +- [ ] `editor.block.replace` 能生成 canonical content 并回写当前 `documents.content`。 +- [ ] `editor.block.insert_after` 能生成新 block id、正确插入并回读。 +- [ ] `editor.block.move_after` 至少通过同父级叶子块 dry-run。 +- [ ] AI 工具不再把裸 Tiptap JSON 或 Convex `documents.content` 私有结构当长期合同。 diff --git a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md index d6b70abb..7601cefc 100644 --- a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md +++ b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md @@ -5,6 +5,8 @@ > 上位依据:`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` > > Hermes Web UI 参考:`packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` +> +> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系以 `design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` 为后续规划依据。 ## 1. 总边界 diff --git a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md new file mode 100644 index 00000000..367e5d94 --- /dev/null +++ b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md @@ -0,0 +1,352 @@ +# 7-10 [process] 页面块 AI 工具执行 checklist v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`PROCESS`。 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` + +--- + +## 1. 目的 + +本 checklist 把 `7-9` 的路线图拆成可验收执行链,避免页面块 AI 工具停留在“工具名已设计、真实页面不可回读”的状态。 + +执行顺序固定为: + +```text +fetch/find + -> block.fetch + -> plan_update + -> block.replace + -> block.insert_after + -> block.move_after +``` + +每一步都必须满足: + +- Rust command 或 projection 有测试。 +- Hermes tool 有结构化返回。 +- 真实页面 smoke 通过。 +- 写入后能通过 Page Aggregate 与 AI fetch 回读。 + +--- + +## 2. Phase 0:设计与基线冻结 + +- [ ] `5-13` 已冻结 block identity、command、Tiptap boundary。 +- [ ] `2-1` 已冻结 `documents.content`、`blocks` 表、Page Aggregate、revision/conflict key 的关系。 +- [ ] `7-9` 已更新 Tiptap AI Toolkit 对照,不再写成“Tiptap 没有官方 AI 文档工具”。 +- [ ] `7-9` 明确 `mnote.page.save` 是粗粒度兜底,不是精确块工具。 +- [ ] 当前 reference code 已在 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs` 与 `tiptap-main` 可读。 + +验收: + +- [ ] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/process design/02-convex-rust-long-term-architecture/process design/07-ai/process` 能找到对应设计。 + +--- + +## 3. Phase 1:Page Aggregate Block Projection + +目标: + +> 每个可编辑块都能从 Page Aggregate 读到稳定身份和冲突辅助信息。 + +任务: + +- [ ] `PageBody` 增加 `blockDocument` 或等价稳定字段。 +- [ ] 从 `documents.content` 生成 `EditorBlockDocument`。 +- [ ] 从 Tiptap JSON 生成 `EditorBlockDocument` 的 bridge 测试覆盖当前主类型。 +- [ ] 每个块输出 `blockId/type/text/attrs/children/parentBlockId/order/path/depth/revisionRef/editable`。 +- [ ] 对无 id legacy block 生成稳定迁移策略或 warning。 +- [ ] 复杂块输出 `editable=false` 或受限能力。 + +验证命令: + +```bash +cargo test -p core-protocol editor_tiptap_bridge +cargo test -p bridge-runtime editor_document +``` + +真实页面 smoke: + +- [ ] 登录 `http://localhost:3000/auth` 测试账号。 +- [ ] 新建页面 `TEST-AI-BLOCK-PROJECTION-`。 +- [ ] 输入段落、标题、todo、列表、mindmap/resource 占位至少各一个。 +- [ ] 调 `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester//page-aggregate.json`。 +- [ ] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。 + +通过标准: + +- [ ] 页面刷新后 block ids 不变化。 +- [ ] projection 中 `blockCount` 与页面可编辑块数量大体一致;复杂块允许受限但必须有 warning。 + +--- + +## 4. Phase 2:`mnote.doc.fetch` / `mnote.doc.find` + +目标: + +> AI 能先读取和定位,不需要猜整页 `content` shape。 + +任务: + +- [ ] tool manifest 增加 `mnote.doc.fetch`。 +- [ ] tool manifest 增加 `mnote.doc.find`。 +- [ ] `doc.fetch` 支持 `scope=full/outline/keyword/block/selection`。 +- [ ] `doc.fetch` 支持 `detail=simple/with_ids/full`。 +- [ ] `doc.find` 支持按 text/type/blockId 查找。 +- [ ] 返回 page `revision/conflictDetectionKey`。 +- [ ] 返回可直接传入 `block.fetch/replace/insert_after` 的 `blockId`。 + +验证命令: + +```bash +cargo test -p mnote-web hermes_tools +cargo test -p bridge-runtime doc_find +``` + +真实页面 smoke: + +- [ ] 使用 `TEST-AI-BLOCK-FETCH-` 页面。 +- [ ] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。 +- [ ] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。 +- [ ] `mnote.doc.find query=<唯一前缀>` 定位目标段落。 +- [ ] 保存工具返回到 `tmp/hermes-tester//doc-fetch-find.json`。 + +通过标准: + +- [ ] 不读取浏览器 DOM。 +- [ ] `doc.find` 返回的 block id 能被 `block.fetch` 读取。 +- [ ] 大页面默认分块或限制输出,不默认返回无限正文。 + +--- + +## 5. Phase 3:`mnote.block.fetch` + +目标: + +> AI 能读取单块、子块和同父级上下文,形成写入前确认。 + +任务: + +- [ ] tool manifest 增加 `mnote.block.fetch`。 +- [ ] 支持 `includeChildren`。 +- [ ] 支持 `contextBefore/contextAfter`。 +- [ ] 支持 `format=json/markdown/page_xml/text`。 +- [ ] 返回 `revisionRef`、`editable`、`unsupportedReason`。 +- [ ] 不存在 block 返回 `mnote_block_not_found`。 + +验证命令: + +```bash +cargo test -p mnote-web block_fetch +``` + +真实页面 smoke: + +- [ ] 从 `doc.find` 结果选择一个段落 block。 +- [ ] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。 +- [ ] 断言 before/after 只来自同父级。 + +通过标准: + +- [ ] block 文本与页面显示一致。 +- [ ] `revisionRef` 可被后续 dry-run 使用。 +- [ ] 复杂块不会伪装成完全可编辑。 + +--- + +## 6. Phase 4:`mnote.doc.plan_update` + +目标: + +> 所有写入先 dry-run,返回 diff、warnings、risk,不改变页面。 + +任务: + +- [ ] tool manifest 增加 `mnote.doc.plan_update`。 +- [ ] 支持 `command=block_replace`。 +- [ ] 支持 `command=block_insert_after`。 +- [ ] 支持 `command=block_move_after` dry-run。 +- [ ] 支持 `command=str_replace` 且多重匹配阻断。 +- [ ] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。 +- [ ] 返回 `planId`、`diff`、`warnings`、`risk`、`blocked`。 + +验证命令: + +```bash +cargo test -p mnote-web plan_update +cargo test -p bridge-runtime doc_replace_range +cargo test -p bridge-runtime doc_insert_blocks +``` + +真实页面 smoke: + +- [ ] 对目标段落执行 `block_replace dryRun=true`。 +- [ ] 对目标段落执行 `block_insert_after dryRun=true`。 +- [ ] 对两个同父级普通块执行 `block_move_after dryRun=true`。 +- [ ] dry-run 前后分别保存 `/api/page-aggregate/:id`,确认内容 hash 不变。 + +通过标准: + +- [ ] dry-run 不写 Convex。 +- [ ] plan 能解释 before/after。 +- [ ] 不支持场景返回 `blocked=true`。 + +--- + +## 7. Phase 5:`mnote.block.replace` + +目标: + +> 第一条最小精确块写入闭环。 + +任务: + +- [ ] tool manifest 增加 `mnote.block.replace`。 +- [ ] 输入必须包含 `blockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 +- [ ] `dryRun=true` 只返回 plan。 +- [ ] `dryRun=false` 生成 `EditorCommand::ReplaceBlock`。 +- [ ] Rust 应用命令生成 canonical content。 +- [ ] 通过 `page.body.save -> documents:updateContent` 持久化。 +- [ ] 返回新 revision、changedBlocks、audit。 + +验证命令: + +```bash +cargo test -p mnote-web block_replace +cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime +``` + +真实页面 smoke: + +- [ ] 新建 `TEST-AI-BLOCK-REPLACE-` 页面。 +- [ ] 写入两段不同文本。 +- [ ] `doc.find` 找到第二段 block id。 +- [ ] `block.replace dryRun=true` 查看 plan。 +- [ ] `block.replace dryRun=false` 替换第二段。 +- [ ] 页面截图证明只替换第二段。 +- [ ] `/api/page-aggregate/:id` 回读证明持久化。 +- [ ] `mnote.doc.fetch` 再次证明 AI 可读回。 + +通过标准: + +- [ ] 相邻块不变化。 +- [ ] 目标 block id 保持不变。 +- [ ] 旧 revision 写入返回 conflict。 + +--- + +## 8. Phase 6:`mnote.block.insert_after` + +目标: + +> AI 能在指定块后插入新块,并拿到新 block id。 + +任务: + +- [ ] tool manifest 增加 `mnote.block.insert_after`。 +- [ ] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 +- [ ] 新块 id 由 Rust runtime 分配。 +- [ ] 支持单块和最多 20 个普通块插入。 +- [ ] 第一阶段支持 paragraph/heading/todo。 +- [ ] 返回 inserted block ids 和新 revision。 + +验证命令: + +```bash +cargo test -p mnote-web block_insert_after +cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands +``` + +真实页面 smoke: + +- [ ] 新建 `TEST-AI-BLOCK-INSERT-` 页面。 +- [ ] 在第一段后插入 todo。 +- [ ] 截图证明位置准确。 +- [ ] 刷新页面后再次确认。 +- [ ] `mnote.block.fetch` 能读取新 block id。 + +通过标准: + +- [ ] 插入位置准确。 +- [ ] 新 block id 不是 AI 自造未校验 id。 +- [ ] 重复同一 `idempotencyKey` 不重复插入。 + +--- + +## 9. Phase 7:`mnote.block.move_after` + +目标: + +> 第一阶段只开放同父级普通叶子块移动。 + +任务: + +- [ ] tool manifest 增加 `mnote.block.move_after`。 +- [ ] `dryRun=true` 支持同父级叶子块 diff。 +- [ ] `dryRun=false` 前先继续阻断所有复杂块。 +- [ ] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。 +- [ ] 阻断移动到自身、移动到子树、跨页面移动。 +- [ ] 返回 from/to parent/order。 + +验证命令: + +```bash +cargo test -p mnote-web block_move_after +cargo test -p mnote-editor-core command_executor +``` + +真实页面 smoke: + +- [ ] 新建 `TEST-AI-BLOCK-MOVE-` 页面,包含三段普通段落。 +- [ ] dry-run 移动第三段到第一段后。 +- [ ] 正式执行同父级移动。 +- [ ] 截图证明顺序为第一段、第三段、第二段。 +- [ ] `mnote.doc.fetch` 回读顺序一致。 +- [ ] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。 + +通过标准: + +- [ ] moving block id 保持不变。 +- [ ] 同父级顺序正确。 +- [ ] 复杂块不被误移动。 + +--- + +## 10. Phase 8:UI 与 Review Mode + +目标: + +> AI 写入可解释、可确认,不把 preview 当持久审阅事实。 + +任务: + +- [ ] Hermes tool event UI 展示 `plan/diff/warnings/risk`。 +- [ ] `page.save` 标记为粗粒度高风险兜底。 +- [ ] `block.replace/insert_after/move_after` 展示 changedBlocks。 +- [ ] 本地 preview/suggestion 与持久 comment/tracked-change 分开。 +- [ ] 协作可见审阅必须另走正式 comment/history/tracked-change 设计。 + +通过标准: + +- [ ] 用户能看到 AI 将改哪个 block。 +- [ ] `blocked=true` 的工具调用不会出现写入按钮。 +- [ ] preview 不写入正式 comment/history。 + +--- + +## 11. DONE 条件 + +本 checklist 不能移动到 `done/`,直到: + +- [ ] Phase 1 到 Phase 6 全部完成。 +- [ ] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。 +- [ ] 每个写工具都有真实页面 smoke 证据。 +- [ ] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。 +- [ ] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。 diff --git a/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md b/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md new file mode 100644 index 00000000..aed1c880 --- /dev/null +++ b/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md @@ -0,0 +1,971 @@ +# 7-9 [process] 页面/块 AI 工具体系规划 v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`PROCESS`。 +> +> 本稿承接 `7-6` 的 mnote Hermes plugin tool 合同、`7-8` 的 Hermes Runtime BFF 方向,以及近期页面 AI 工具实测中暴露的问题:当前 `mnote.page.get/save/update_title/update_options` 已能完成页面级读写,但工具粒度仍偏粗,不能长期代表“AI 能精确编辑页面/块”。 +> +> 核心参考: +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/ai-toolkit-demos` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-main` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-apcore` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-ai-autocomplete` +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` + +--- + +## 1. 结论 + +不要把问题理解成二选一: + +- 不是“等 Rust kernel / Page Aggregate / editor module 完全稳定后,才开始设计工具”。 +- 也不是“现在立刻扩一批块级写工具,让 Hermes 直接调用当前 Convex / 前端私有 shape”。 + +正确路径是: + +> **现在继续写工具体系设计,冻结 AI 面向的稳定工具合同;实现上分阶段推进,先做只读、定位、dry-run、plan 和最小单块写入。复杂块移动、块嵌入、多块批量改写、媒体块编排,要等 Rust block model、Page Aggregate command、Convex 持久化和 editor session 刷新链路达到硬门槛后再开放。** + +当前 `mnote.page.save` 可以继续作为页面级兜底工具,但不能继续被描述为长期块编辑方案。长期工具体系必须基于稳定的 `Page Aggregate / EditorBlockDocument / Rust runtime command` 中间合同,而不是让 AI 直接操作前端 Tiptap JSON、Convex `documents.content` 私有结构或历史 `blocks` 表。 + +--- + +## 2. 为什么参考飞书 CLI + +`cli-main` 的价值不在于“飞书有很多命令,所以 mnote 也应该堆很多工具”,而在于它把文档工具做成了三层: + +1. **Shortcut 层**:面向人和 Agent 的高层命令,例如 `docs +fetch`、`docs +update`、`docs +media-insert`。 +2. **稳定文档操作协议层**:用 `doc-format`、`scope`、`detail`、`command`、`block_id`、`revision_id`、`dry-run` 表达文档读写。 +3. **底层 API adapter 层**:把稳定协议翻译成真实平台 API,例如 `docs_ai/v1/documents` 或 MCP tool call。 + +这正好对应 mnote 当前问题: + +- Rust kernel、Page Aggregate、Convex、leptos-tiptap 还在收口。 +- 如果现在让 Hermes 直接调用底层 shape,后续底层一变,skill/tool 就会失效。 +- 如果先冻结一个 AI 面向的稳定文档操作协议,底层变动可以收口在 adapter。 + +因此 mnote 应参考的是飞书的“稳定投影 DSL + 少量高层命令 + dry-run 诊断层”,而不是照搬 Go CLI 或飞书 API。 + +--- + +## 3. `cli-main` 具体参考位置 + +参考根目录: + +```text +/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main +``` + +优先参考下表,不需要整仓搬运: + +| mnote 主题 | 参考文件 | 搜索点 | 可吸收内容 | 不采用内容 | +| --- | --- | --- | --- | --- | +| 文档工具注册 | `shortcuts/doc/shortcuts.go` | `Shortcuts()`、`docs +fetch`、`docs +update` | 工具分组、命令命名、文档域工具入口 | 不照搬 CLI 交互层 | +| 文档读取 v2 | `shortcuts/doc/docs_fetch_v2.go` | `executeFetchV2`、`buildReadOption`、`--detail`、`--scope` | `simple/with-ids/full`、`outline/range/keyword/section`、局部读取 | 不使用飞书 token/API | +| 文档更新 v2 | `shortcuts/doc/docs_update_v2.go` | `validCommandsV2`、`buildUpdateBody`、`revision-id`、`dry-run` | `str_replace/block_insert_after/block_replace/block_delete/block_move_after/append/overwrite` | 不直接把飞书 command 当 mnote 内部命令名 | +| v1 兼容更新 | `shortcuts/doc/docs_update.go` | `CallMCPTool`、`update-doc` | 兼容层与主线层并存时的隔离方式 | 不保留多套长期真相 | +| 更新前诊断 | `shortcuts/doc/docs_update_check.go` | `CheckDocsUpdateArgs`、`warning` | 工具调用前给 Agent 的静态语义警告 | 不只靠 LLM 自觉避免危险编辑 | +| 媒体插入编排 | `shortcuts/doc/doc_media_insert.go` | `dry-run`、`steps`、`batch_update` | 多步工具先 dry-run 展示计划,再执行 | 第一阶段不做完整媒体工具 | +| 文档 XML DSL | `skills/lark-doc/references/lark-doc-xml.md` | ``、`<callout>`、`<grid>`、`<img>`、`<cite>` | 用 PageXML/PageMarkdown 屏蔽底层块结构 | 不采用飞书专有块类型作为 mnote 类型 | +| 文档更新说明 | `skills/lark-doc/references/lark-doc-update.md` | `str_replace`、`block_insert_after`、`revision`、`warnings` | 面向 Agent 的工具使用说明、返回结构 | 不把说明当实现 | +| 文档读取说明 | `skills/lark-doc/references/lark-doc-fetch.md` | `detail`、`scope`、`with-ids` | 读取前先定位、再编辑的 workflow | 不让 AI 默认整页读取超大正文 | +| 工具抽象 | `shortcuts/common/types.go` | `Shortcut`、`Validate`、`Execute`、`Risk` | 元信息、权限、风险、dry-run、validate 一体化 | 不复制 Go 框架 | +| dry-run 通用能力 | `internal/cmdutil/dryrun.go` | `DryRun` | 所有写工具都能返回计划和风险 | 不做纯 CLI 文本输出 | +| 风险提示 | `internal/cmdutil/risk.go` | `Risk` | 工具风险等级进入确认和审计 | 不用 CLI prompt 作为 Web 确认机制 | + +快速定位命令: + +```bash +cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main +rg -n "validCommandsV2|buildReadOption|buildUpdateBody|revision|dry-run|CheckDocsUpdateArgs|Shortcut|Risk|CallMCPTool|scope|detail" shortcuts internal skills/lark-doc +``` + +--- + +## 4. 官方 Tiptap AI Toolkit 与补充参考的吸收边界 + +Tiptap AI Toolkit 的核心价值是把编辑器能力拆成 AI 可调用工具,而不是让外部系统直接暴露编辑器内部实现。Context7 与本地 `tiptap-docs` 公开文档能确认的主线能力包括: + +- `toolDefinitions()` 向 AI SDK 暴露工具定义。 +- `tiptapRead` 用高效格式读取文档,支持 range/chunk 类读取模型。 +- `tiptapEdit` 用 operations 列表编辑文档。 +- `tiptapReadSelection` 读取当前选区。 +- `executeTool` / `streamTool` 把 AI 生成的 tool call 应用到 editor,并返回 `docChanged`、错误和工具结果。 +- review options 支持 `disabled/review/preview/trackedChanges`,但 preview/suggestions 与持久 tracked changes 是两类语义。 + +需要修正之前口径: + +- 不能再说 “Tiptap 没有官方 AI 文档工具”。它有官方 AI Toolkit 工具层。 +- 也不能说 “Tiptap AI Toolkit 可以直接解决 mnote 块工具”。公开文档没有给出完整 `tiptapEdit.operations` schema,且它面向 Tiptap/ProseMirror 文档层,不覆盖 mnote 的 Rust kernel、Page Aggregate、Convex revision/conflict key、Hermes audit。 +- 当前 npm registry 无法直接获取 `@tiptap-pro/ai-toolkit*` 完整实现源码;本仓可参考的是公开 docs 与 `tiptap-main/packages/server-ai-toolkit`、`extension-unique-id`、`extension-drag-handle`、`extension-node-range` 等开源代码。 + +这些能力应映射为 mnote 自己的工具层: + +| Tiptap AI Toolkit | mnote 对应 | 吸收内容 | 不吸收内容 | +| --- | --- | --- | --- | +| `tiptapRead` | `mnote.doc.fetch` / `mnote.block.fetch` | 先读、带范围、返回适合 AI 的文档表示 | 不把 Tiptap JSON 作为长期工具格式 | +| `tiptapEdit` | `mnote.doc.plan_update` + `mnote.block.*` | 操作列表、reviewable edit、meta justification | 不让浏览器 editor command 成为事实源 | +| `tiptapReadSelection` | `mnote.doc.fetch scope=selection` | selection-aware workflow | 不直接持久化浏览器 selection range | +| `toolDefinitions()` | Rust Hermes manifest | schema、description、capability、annotation | 不依赖私有 npm 包作为运行时硬依赖 | + +`tiptap-apcore` 的补充价值更偏工具基础设施。它已经把 Tiptap command 分成 query、format、content、destructive、selection、history,并为工具提供: + +- `inputSchema` / `outputSchema` +- `readonly` / `destructive` / `idempotent` / `requiresApproval` +- ACL role:`readonly`、`editor`、`admin` +- `selectionEffect` +- executor 前置检查、ACL、query/command 分发 + +mnote 应吸收这些元数据,但命名和执行面必须改成 Rust-owned: + +```json +{ + "name": "mnote.block.replace", + "capabilityScope": ["page.write", "block.write"], + "annotations": { + "readonly": false, + "destructive": false, + "idempotent": false, + "requiresApproval": true, + "selectionEffect": "destroy" + }, + "runtimeOwner": "mnote-web", + "writeOwner": "rust-runtime-kernel" +} +``` + +`tiptap-ai-autocomplete` 的价值限于交互层: + +- ghost text 定位。 +- 选区 bubble menu。 +- streaming preview。 +- accept/reject 后再写入。 + +它不应进入 Hermes tool contract 的核心,只能作为页面 AI 面板、选区 AI 菜单和 preview UI 的参考。 + +`tiptap-main` 开源代码给 mnote 的补充约束: + +- `extension-unique-id` 可作为 Tiptap runtime 节点 id 辅助,但不能替代 Rust `EditorBlock.block_id`。 +- `server-ai-toolkit` 的 `_hash` 是 AI 编辑定位/变化检测辅助,不能替代业务 `blockId`;它可以参与 mnote `revisionRef` 的 hash 部分。 +- `extension-drag-handle` 与 `extension-node-range` 说明块选择、拖拽、selection toolbar AI 应共享一套 node range 计算,而不是每个入口重新解析 DOM。 +- schema awareness / editor context 应进入 `mnote.doc.fetch` 或工具 manifest 的上下文生成,避免 AI 猜测当前可用块类型。 + +--- + +## 5. 当前 mnote 稳定性判断 + +### 5.1 已经可以承载工具设计的边界 + +- Page Aggregate 读合同已经稳定为 `mnote.page_aggregate.v1`。 +- 页面主读链已经可以通过 Rust `/api/page-aggregate/:id` 返回 meta/content/options/body snapshot。 +- Hermes 页面 AI 已有 `mnote.*` 工具注册、dispatch、trace/audit、idempotency、dryRun 基础。 +- 页面级工具 `mnote.page.get/save/update_title/update_options` 已能完成最小闭环。 +- Rust `core-protocol` 已有工具规格方向,`bridge-runtime` 已出现 `doc_insert_blocks`、`doc_replace_range` 等工具/测试雏形。 + +这些足以支撑“工具合同设计”和“只读/定位/dry-run 工具实现”。 + +### 5.2 还不适合大规模开放块级写工具的边界 + +- 当前保存主链仍是 `documents.content` legacy JSON snapshot,不是 `EditorBlockDocument` 原生落库,也不是 Tiptap JSON 原生落库。 +- 历史 `blocks` 表仍存在,但没有维护正文主链所需的 order、父子顺序和 `documents.content` 同步闭环,不能描述为当前正文事实源。 +- Page Aggregate 当前 `body.content` 仍主要是 `documents:getContent` 的 projection 包装,不是强类型 block protocol。 +- Page Aggregate 当前仍有 compat join 痕迹,不是所有页面正文语义都已经 kernel-native。 +- 部分 Rust block/editor command 已有 runtime 映射,但 Convex 执行面、editor session 刷新和真实页面可见性还需要逐条验收。 +- 块移动、块复制、块嵌入、块引用维护需要稳定块 id、revision、父子关系、排序、权限和冲突处理;当前不应直接给 Hermes 开生产写入口。 +- `mnote.page.save` 当前是整页/追加/前置级写入,适合作为过渡和兜底,不适合承诺“精确块编辑”。 +- `mnote.page.save` 当前要求 `dryRun/idempotencyKey`,但页面级 revision/conflict key 还不是强制写入门槛;块级写工具开放前必须补上强制 CAS 或等价冲突阻断。 + +### 5.3 设计上的硬边界 + +- AI 工具不得绕过 Rust runtime 直接写 Convex。 +- AI 工具不得把前端 Tiptap JSON 当长期外部合同。 +- AI 工具不得依赖浏览器临时 DOM id 或 runtime-only selection。 +- 写工具必须有 `dryRun`、`idempotencyKey`、`revision` 或等价冲突键。 +- 写工具必须返回 `warnings`,并能阻止明显错误的编辑假设。 +- 写工具成功后必须能通过真实页面、`/api/page-aggregate` 和再次 `mnote.*.fetch/get` 三处验收。 + +--- + +## 6. 工具体系分层 + +### L0:内部 kernel / adapter 命令 + +只给 Rust runtime、Page Aggregate adapter、Convex bridge 使用,不直接暴露给 Hermes。 + +示例: + +- `page.aggregate.get` +- `page.body.save` +- `page.body.apply_patch` +- `editor.block.insert_after` +- `editor.block.replace` +- `editor.block.delete` +- `editor.block.move_after` +- `tree.node.create` +- `tree.subtree.move` + +要求: + +- 可以随内核演进调整。 +- 必须有测试覆盖。 +- 由 L1 canonical tools 翻译调用。 + +### L1:AI 可调用 canonical tools + +Hermes skill/plugin 对外暴露的稳定工具层。第一阶段只做少量、可解释、可验收工具。 + +建议命名: + +- `mnote.doc.fetch` +- `mnote.doc.find` +- `mnote.doc.plan_update` +- `mnote.doc.apply_update` +- `mnote.block.fetch` +- `mnote.block.insert_after` +- `mnote.block.replace` +- `mnote.block.delete` +- `mnote.block.move_after` +- `mnote.page.append` +- `mnote.page.overwrite` + +其中: + +- `mnote.doc.fetch/find/plan_update` 可先做。 +- `mnote.block.insert_after/replace` 是最小块写入切片。 +- `mnote.block.delete/move_after` 等架构稳定后再做。 +- `mnote.page.overwrite` 是高风险兜底,必须强确认和 dry-run。 + +### L2:workflow tools + +面向用户任务的编排工具,不应第一阶段优先做。 + +示例: + +- `mnote.workflow.write_weekly_report` +- `mnote.workflow.rewrite_section` +- `mnote.workflow.extract_action_items` +- `mnote.workflow.create_meeting_note` +- `mnote.workflow.generate_project_plan` + +要求: + +- L2 必须调用 L1,不直接写 L0。 +- L2 的产物先走 `plan_update` / `dryRun`,用户确认后再执行。 +- 不用 L2 掩盖 L1 工具合同不稳定的问题。 + +--- + +## 7. 稳定文档表示 + +### 7.1 PageMarkdown + +适合纯文本、标题、列表、引用、代码、简单表格等常见 AI 输出。 + +用途: + +- AI 生成大纲、摘要、会议纪要。 +- `append/prepend/overwrite`。 +- `str_replace` 简单替换。 + +限制: + +- 不表达复杂属性、块引用、嵌入、资源块、页面块关系。 +- 不作为唯一长期格式。 + +### 7.2 PageXML + +参考飞书 DocxXML,但定义 mnote 自己的 PageXML。用于需要稳定 block id、属性、资源引用和结构化块的场景。 + +最小形态示例: + +```xml +<page title="项目计划"> + <heading level="2" block-id="heading_1">目标</heading> + <paragraph block-id="p_1">第一段文字</paragraph> + <todo block-id="todo_1" checked="false">确认方案</todo> + <callout tone="info">重要说明</callout> +</page> +``` + +原则: + +- `block-id` 只引用 mnote 已存在或本次 dry-run 分配的新 id。 +- PageXML 是 AI 外部合同,内部可翻译为 `EditorBlockDocument`。 +- 不暴露 Tiptap 节点私有字段。 +- 不复制飞书专有块;只取 DSL 思想。 + +### 7.3 EditorBlockDocument + +Rust 内部结构化文档模型,是 PageXML/PageMarkdown 到 Page Aggregate / editor session 的中间形态。 + +要求: + +- 持有稳定 `block_id`。 +- 能表达 block type、text、attrs、children、parent、order、path、revisionRef。 +- 能生成 diff plan。 +- 能映射到当前 Convex-backed 保存链路。 +- 能在 Tiptap JSON、legacy `documents.content` 与 Page Aggregate block projection 之间做受控转换。 + +--- + +## 8. Canonical tool schema 草案 + +### 8.1 `mnote.doc.fetch` + +用途:读取当前文档或局部文档,返回可供 AI 定位和编辑的稳定投影。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "format": "markdown", + "detail": "with_ids", + "scope": "full", + "startBlockId": null, + "endBlockId": null, + "keyword": null, + "sectionTitle": null, + "contextBefore": 2, + "contextAfter": 2, + "maxDepth": 6 +} +``` + +字段约束: + +- `format`: `markdown | page_xml | text | json` +- `detail`: `simple | with_ids | full` +- `scope`: `full | outline | range | keyword | section | selection | block` + +返回: + +```json +{ + "ok": true, + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "format": "markdown", + "detail": "with_ids", + "scope": "section", + "content": "## 目标 <!-- block:heading_1 -->\n正文 <!-- block:p_1 -->", + "blocks": [ + { "blockId": "heading_1", "type": "heading", "text": "目标", "depth": 0, "revisionRef": "pageRev:rev_12:block:heading_1:hash:aaa" }, + { "blockId": "p_1", "type": "paragraph", "text": "正文", "depth": 1, "revisionRef": "pageRev:rev_12:block:p_1:hash:bbb" } + ], + "warnings": [] +} +``` + +验收标准: + +- [ ] `scope=full` 能返回当前页面正文,且 `with_ids` 包含稳定 `blockId`。 +- [ ] `scope=outline` 只返回标题/层级和必要 block id。 +- [ ] `scope=keyword` 返回命中块和前后上下文。 +- [ ] 返回内容与 `/api/page-aggregate/:id` 的 block snapshot 一致。 +- [ ] 不依赖浏览器 DOM。 + +### 8.2 `mnote.doc.find` + +用途:在当前文档中查找关键词、块类型、标题或引用目标,帮助 AI 先定位再编辑。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "query": "待办", + "match": "text", + "limit": 20 +} +``` + +返回: + +```json +{ + "ok": true, + "matches": [ + { + "blockId": "todo_1", + "type": "todo", + "text": "确认待办", + "path": ["项目计划", "本周"], + "score": 0.92 + } + ] +} +``` + +验收标准: + +- [ ] 可以按文本查找。 +- [ ] 可以按 block type 查找。 +- [ ] 返回结果能直接作为 `block.replace/insert_after` 的 anchor。 + +### 8.3 `mnote.doc.plan_update` + +用途:只生成变更计划和诊断,不实际写入。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "command": "block_replace", + "format": "markdown", + "blockId": "p_1", + "content": "替换后的段落", + "dryRun": true +} +``` + +支持命令: + +- `str_replace` +- `block_insert_after` +- `block_replace` +- `block_delete` +- `block_move_after` +- `append` +- `overwrite` + +返回: + +```json +{ + "ok": true, + "dryRun": true, + "revision": "rev_12", + "plan": [ + { + "op": "replace", + "targetBlockId": "p_1", + "before": "旧段落", + "after": "替换后的段落" + } + ], + "warnings": [], + "requiresConfirmation": true, + "risk": "medium" +} +``` + +诊断规则参考 `docs_update_check.go`: + +- 替换目标匹配多个位置时,提示先用 `blockId` 精确定位。 +- `str_replace` 找不到唯一匹配时,不执行。 +- 跨多个块的自然语言替换必须转成 `block_delete + block_insert_after` 或 `block_replace`。 +- `overwrite` 必须标高风险。 +- 缺少 `revision` 时只允许 dry-run,不允许真实写入。 + +验收标准: + +- [ ] 所有写命令都能先 dry-run。 +- [ ] dry-run 不改变 Convex 内容、不触发 editor 内容变化。 +- [ ] 返回的 `plan` 可被 UI 折叠展示。 +- [ ] 明显不安全的编辑返回 `warnings` 或 `blocked=true`。 + +### 8.4 `mnote.doc.apply_update` + +用途:执行已经 dry-run 过的文档变更计划。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "idempotencyKey": "idem_1", + "planId": "plan_1", + "command": "block_replace", + "format": "markdown", + "blockId": "p_1", + "content": "替换后的段落", + "dryRun": false +} +``` + +执行要求: + +- 必须校验 `revision`。 +- 必须校验 `idempotencyKey`。 +- 必须落 Rust runtime / Page Aggregate command。 +- 成功后必须返回新 revision 和受影响 block id。 + +返回: + +```json +{ + "ok": true, + "revision": "rev_13", + "changedBlocks": [ + { "blockId": "p_1", "op": "replace" } + ], + "audit": { + "effect": "write", + "commandName": "page.body.apply_update", + "commandId": "cmd_1" + } +} +``` + +验收标准: + +- [ ] 执行后 `/api/page-aggregate/:id` 能读到更新。 +- [ ] 当前打开页面能自动刷新或通过 editor session reload 看到更新。 +- [ ] 再次 `mnote.doc.fetch` 能读到更新。 +- [ ] 重复同一 `idempotencyKey` 不造成重复写入。 +- [ ] revision 冲突返回 `mnote_tool_conflict`。 + +### 8.5 `mnote.block.fetch` + +用途:读取单个块及可选上下文,作为 `tiptapReadSelection` / selection-aware editing 的稳定服务端版本。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "blockId": "p_1", + "revision": "rev_12", + "includeChildren": true, + "contextBefore": 1, + "contextAfter": 1, + "format": "json" +} +``` + +字段约束: + +- `blockId` 必须来自 `mnote.doc.fetch/find` 或 Page Aggregate block projection。 +- `format`: `json | markdown | page_xml | text` +- `includeChildren` 默认 `true`,但复杂块可返回 `unsupportedReason`。 +- `contextBefore/contextAfter` 只返回同父级上下文。 + +返回: + +```json +{ + "ok": true, + "revision": "rev_12", + "block": { + "blockId": "p_1", + "type": "paragraph", + "text": "正文", + "attrs": {}, + "path": [2], + "parentBlockId": null, + "order": "00020000", + "revisionRef": "pageRev:rev_12:block:p_1:hash:abc", + "editable": true + }, + "context": { + "before": [], + "after": [ + { "blockId": "p_2", "type": "paragraph", "text": "下一段" } + ] + }, + "warnings": [] +} +``` + +验收标准: + +- [ ] `block.fetch` 能读取 `doc.find` 返回的 block id。 +- [ ] 返回 `revisionRef`,可被后续写工具用于冲突检测。 +- [ ] 不存在 block 返回 `mnote_block_not_found`。 +- [ ] 不可编辑块返回 `editable=false` 与 `unsupportedReason`。 + +### 8.6 `mnote.block.replace` + +用途:最小精确块写工具,作为第一批块级写入候选。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "blockId": "p_1", + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "blockRevisionRef": "pageRev:rev_12:block:p_1:hash:abc", + "format": "markdown", + "content": "替换后的块内容", + "idempotencyKey": "idem_1", + "dryRun": true +} +``` + +验收标准: + +- [ ] 只替换目标 block,不影响相邻 block。 +- [ ] block id 稳定,刷新后仍可定位。 +- [ ] 支持 `dryRun=true`。 +- [ ] 支持真实页面 smoke。 + +### 8.7 `mnote.block.insert_after` + +用途:在指定块后插入一个或多个块。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "anchorBlockId": "p_1", + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "anchorRevisionRef": "pageRev:rev_12:block:p_1:hash:abc", + "format": "markdown", + "content": "- 新待办", + "idempotencyKey": "idem_2", + "dryRun": true +} +``` + +验收标准: + +- [ ] 插入位置准确。 +- [ ] 新 block id 由 Rust/runtime 分配或确认,不由 AI 自造。 +- [ ] 当前页面能看到新块。 +- [ ] 再次 fetch 能拿到新 block id。 + +### 8.8 `mnote.block.move_after` + +用途:移动一个块到同父级 anchor 块后。第一阶段仅作为受限结构性写工具开放。 + +入参: + +```json +{ + "workspaceId": "ws_1", + "documentId": "doc_1", + "blockId": "p_3", + "anchorBlockId": "p_1", + "revision": "rev_12", + "conflictDetectionKey": "doc_1:12", + "blockRevisionRef": "pageRev:rev_12:block:p_3:hash:aaa", + "anchorRevisionRef": "pageRev:rev_12:block:p_1:hash:bbb", + "idempotencyKey": "idem_move_1", + "dryRun": true +} +``` + +第一阶段允许: + +- `blockId` 与 `anchorBlockId` 同父级。 +- 普通 paragraph。 +- 普通 heading 叶子块。 +- 普通 todo 叶子块。 + +第一阶段阻断: + +- 跨父级移动。 +- 标题带子块整体移动。 +- 列表项跨层级移动。 +- 表格、resource、mindmap、page reference。 +- 跨页面移动。 + +返回: + +```json +{ + "ok": true, + "dryRun": true, + "risk": "medium", + "diff": [ + { + "op": "move_after", + "blockId": "p_3", + "anchorBlockId": "p_1", + "from": { "parentBlockId": null, "order": "00030000" }, + "to": { "parentBlockId": null, "afterOrder": "00010000" } + } + ], + "warnings": [] +} +``` + +验收标准: + +- [ ] dry-run 不改变页面。 +- [ ] 正式写入后 moving block id 保持不变。 +- [ ] 同父级顺序正确。 +- [ ] 不支持场景返回 `blocked=true` 和明确 warning。 +- [ ] 再次 `mnote.doc.fetch` 能读回新顺序。 + +--- + +## 9. 实施阶段 + +### Phase A:只读和定位 + +目标: + +- 建立 `mnote.doc.fetch` 和 `mnote.doc.find`。 +- 支持 `format`、`detail`、`scope`。 +- 让 AI 能稳定读到 block id、标题路径、上下文。 + +可做: + +- [ ] 从 Page Aggregate snapshot 生成 PageMarkdown。 +- [ ] 从 Page Aggregate snapshot 生成 PageXML 最小子集。 +- [ ] 支持 `scope=full/outline/keyword/block`。 +- [ ] 支持 `detail=simple/with_ids/full`。 +- [ ] Hermes tool event UI 展示 fetch/find 摘要。 + +验收: + +- [ ] 真实登录页面中,Hermes 调用 `mnote.doc.fetch` 能读出当前页面。 +- [ ] `with_ids` 返回的 block id 与 DOM `data-block-id` / Page Aggregate 一致。 +- [ ] `keyword` 读取不会返回整页超大正文。 +- [ ] 失败时返回权限/不存在/空页面的结构化错误。 + +### Phase B:dry-run / plan + +目标: + +- 建立 `mnote.doc.plan_update`。 +- 写入前先给出可解释 diff、风险、warnings。 + +可做: + +- [ ] 支持 `str_replace` dry-run。 +- [ ] 支持 `block_replace` dry-run。 +- [ ] 支持 `block_insert_after` dry-run。 +- [ ] 支持 `append` dry-run。 +- [ ] 实现静态诊断规则。 + +验收: + +- [ ] dry-run 不改变页面内容。 +- [ ] UI 可折叠展示 plan。 +- [ ] 多重匹配、不存在 block、缺 revision 都返回明确 warning。 + +### Phase C:最小块级写入 + +目标: + +- 开放 `mnote.block.replace` 和 `mnote.block.insert_after` 的真实写入。 + +前置硬门槛: + +- [ ] Page Aggregate 中每个可编辑块都有稳定 `blockId`。 +- [ ] Rust runtime 能把 block replace/insert 变更映射到持久化结构。 +- [ ] Convex-backed 保存链能保存变更并刷新。 +- [ ] 当前 editor session 能 reload 或 live refresh。 +- [ ] smoke 覆盖 AI tool -> Rust -> Convex -> 页面可见 -> fetch 回读。 + +验收: + +- [ ] AI 替换单个段落后,页面只变这一段。 +- [ ] AI 在指定块后插入待办后,页面位置正确。 +- [ ] 刷新页面后内容仍在。 +- [ ] 版本冲突不会覆盖用户刚刚输入的内容。 + +### Phase D:多块和结构性写入 + +目标: + +- 开放 `block_delete`、`block_move_after`、`block_copy_insert_after`。 + +前置硬门槛: + +- [ ] 块父子关系、排序、缩进、折叠状态稳定。 +- [ ] 删除/移动能处理子树。 +- [ ] 冲突检测覆盖移动前后的邻居和父节点。 +- [ ] 有撤销或可审计回滚策略。 + +验收: + +- [ ] 移动标题块时,其子块处理规则明确且测试覆盖。 +- [ ] 删除块需要确认,并返回被删除范围。 +- [ ] 复制插入生成新 id,不复用旧 id。 + +### Phase E:workflow tools + +目标: + +- 基于 L1 工具做高层 AI 写作工作流。 + +可做: + +- [ ] 生成会议纪要。 +- [ ] 生成周报。 +- [ ] 重写某一节。 +- [ ] 提取待办并插入当前页。 + +验收: + +- [ ] workflow 只调用 L1 canonical tools。 +- [ ] 每个 workflow 都能先 plan,再执行。 +- [ ] 不出现直接整页覆盖用户内容的默认行为。 + +--- + +## 10. 架构优先级判断 + +当前应并行推进,但优先级要明确: + +1. **先稳定 Page Aggregate / block identity / editor save-refresh 链路**:这是块级写工具能否长期可靠的根。 +2. **同时设计并冻结 L1 canonical tools 合同**:避免继续把临时页面保存扩展成长期能力。 +3. **先实现只读和 dry-run**:这部分对底层写链依赖小,能立即改善 AI 可靠性。 +4. **等硬门槛满足后开放最小块写入**:先 `replace` / `insert_after`,不要一口气做完整块操作。 +5. **最后做 workflow**:工具层不稳定时,workflow 只会放大错误。 + +因此,回答“先优化稳定架构,还是继续写工具设计”: + +> **架构稳定是实现复杂写工具的前置;工具设计现在就应该继续,而且必须用于反向约束架构稳定的验收标准。** + +--- + +## 11. 近期不做清单 + +- [ ] 不让 Hermes 直接调用 Convex mutation。 +- [ ] 不把 Tiptap JSON 暴露成 AI 长期工具入参。 +- [ ] 不把 `mnote.page.save` 包装成所有块编辑的长期方案。 +- [ ] 不先做大量 `workflow.*` 工具。 +- [ ] 不做无 revision / 无 dry-run / 无 idempotency 的写工具。 +- [ ] 不在工具里读取浏览器 DOM 来决定写入位置。 +- [ ] 不用页面当前可见文本做唯一定位依据;必须支持 block id 或唯一匹配诊断。 + +--- + +## 12. 执行 checklist + +### 12.1 设计冻结 + +- [ ] 确认 `mnote.doc.fetch/find/plan_update/apply_update` 命名。 +- [ ] 确认 `PageMarkdown` 最小语法。 +- [ ] 确认 `PageXML` 最小语法。 +- [ ] 确认 `revision` 来源。 +- [ ] 确认 `blockId` 来源只来自 Page Aggregate / Rust runtime。 +- [ ] 确认 `warnings` / `risk` / `requiresConfirmation` 返回格式。 + +验收标准: + +- [ ] `design/07-ai/process` 中有稳定工具合同。 +- [ ] `7-6` 不再把 `mnote.page.save` 描述为长期块编辑合同。 +- [ ] `5-6` 的 Page Aggregate checklist 能引用本稿作为 AI 写入门槛。 + +### 12.2 只读工具实现 + +- [ ] Rust tool manifest 增加 `mnote.doc.fetch`。 +- [ ] Rust tool manifest 增加 `mnote.doc.find`。 +- [ ] Rust tool manifest 增加 `mnote.block.fetch`。 +- [ ] dispatch 调用 Page Aggregate snapshot,而不是 Convex 私有 shape。 +- [ ] 支持 `detail=simple/with_ids/full`。 +- [ ] 支持 `scope=full/outline/keyword/block`。 +- [ ] 工具结果进入 Hermes tool event UI。 + +验收标准: + +- [ ] `cargo test -p mnote-web hermes` 相关测试通过。 +- [ ] 真实网页登录后,AI 能通过 `mnote.doc.fetch` 读取当前页。 +- [ ] AI 能通过 `mnote.doc.find` 找到指定文本所在 block id。 +- [ ] `/api/page-aggregate/:id` 与 tool 返回 block id 一致。 + +### 12.3 dry-run 实现 + +- [ ] 增加 `mnote.doc.plan_update`。 +- [ ] 支持 `str_replace` 计划。 +- [ ] 支持 `block_replace` 计划。 +- [ ] 支持 `block_insert_after` 计划。 +- [ ] 返回 `plan/warnings/risk/requiresConfirmation`。 +- [ ] 静态诊断阻止多重匹配和缺 revision 的真实写入。 + +验收标准: + +- [ ] dry-run 不改变页面。 +- [ ] UI 能显示工具计划。 +- [ ] 多重匹配返回 warning。 +- [ ] 不存在 block 返回结构化错误。 + +### 12.4 最小块写入实现 + +- [ ] 打通 `mnote.block.replace`。 +- [ ] 打通 `mnote.block.insert_after`。 +- [ ] 写入统一走 Rust runtime / Page Aggregate command。 +- [ ] 成功后触发当前 editor session reload 或 live refresh。 +- [ ] 成功后返回新 revision 和 changedBlocks。 + +验收标准: + +- [ ] AI 替换单块,页面立即可见。 +- [ ] AI 插入新块,位置准确。 +- [ ] 刷新后内容仍在。 +- [ ] 再次 fetch 能读回变更。 +- [ ] revision 冲突被拦截。 + +### 12.5 真实网页 smoke + +- [ ] 新建测试页面,写入唯一前缀 `TEST-AI-TOOL-<timestamp>`。 +- [ ] `mnote.doc.fetch scope=full detail=with_ids` 读取页面。 +- [ ] `mnote.doc.find` 定位测试段落。 +- [ ] `mnote.doc.plan_update command=block_replace dryRun=true` 生成计划。 +- [ ] `mnote.block.replace dryRun=false` 替换段落。 +- [ ] 页面截图证明内容可见。 +- [ ] `/api/page-aggregate/:id` 证明内容持久化。 +- [ ] `mnote.doc.fetch` 再次证明 AI 可读回。 + +验收标准: + +- [ ] 证据目录写入 `tmp/hermes-tester/<run-id>/`。 +- [ ] 失败时记录到 `bugs/07-ai/process/` 或真正 owner 分类。 +- [ ] 通过后才能把对应 checklist 勾到 done。 + +--- + +## 13. 与当前页面 AI 的关系 + +当前已有工具继续保留: + +- `mnote.page.get` +- `mnote.page.save` +- `mnote.page.update_title` +- `mnote.page.update_options` +- `mnote.artifact.*` + +但口径调整为: + +- `mnote.page.get` 是页面级读取,不是长期精确块读取。 +- `mnote.page.save` 是页面级兜底写入,只适合 append/prepend/replace 等粗粒度操作。 +- 精确编辑应迁移到 `mnote.doc.*` / `mnote.block.*`。 +- 页面 AI 面板展示工具时,应把 `page.save` 标为高风险或粗粒度。 + +--- + +## 14. 迁移完成定义 + +本稿不能标记 `DONE`,直到满足: + +- [ ] `mnote.doc.fetch` / `mnote.doc.find` 已实现并通过真实页面 smoke。 +- [ ] `mnote.doc.plan_update` 已实现并能返回 warnings。 +- [ ] 至少一个最小块写工具 `mnote.block.replace` 或 `mnote.block.insert_after` 通过真实页面 smoke。 +- [ ] `mnote.page.save` 在 UI/manifest 中被标记为页面级兜底工具,不再作为默认精确编辑入口。 +- [ ] 相关工具设计被同步到 Hermes skill/plugin 描述,AI 能按“先 fetch/find,再 plan,再 apply”的顺序调用。 diff --git a/design/old/05-editor-mainline/done/tiptaplogin.md b/design/old/05-editor-mainline/done/tiptaplogin.md index 3d6d95e4..9e5835fe 100644 --- a/design/old/05-editor-mainline/done/tiptaplogin.md +++ b/design/old/05-editor-mainline/done/tiptaplogin.md @@ -2,3 +2,7 @@ email:liaibo@yeah.net key:Liaibo95540245 + +All Pro frontend extensions are published in the Tiptap private npm registry. Configure your package manager with the personal registry token shown below. You can access them during your trial or after subscribing. For a complete list of Pro extensions and setup instructions, see our extension list and guide. + +Registry token=Bu4vtkVPr0jEUpqzXOaOi7REUGTDVHHftcQMjSAxXpOgs2bg/ESbyoJedrVhmrG5 \ No newline at end of file diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs index b9857ccb..12bcfa00 100644 --- a/rust/crates/mnote-web/src/hermes_tools/manifest.rs +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -14,11 +14,11 @@ pub fn manifest() -> Value { }, "tools": [ page_get_tool(), - planned_tool("mnote.page.save", ["page.write"]), - planned_tool("mnote.page.update_title", ["page.write"]), - planned_tool("mnote.page.update_options", ["page.write"]), - planned_tool("mnote.artifact.create_summary", ["artifact.write"]), - planned_tool("mnote.artifact.create_ai_note", ["artifact.write"]) + page_save_tool(), + available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]), + available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]), + available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]), + available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"]) ] }) } @@ -47,12 +47,49 @@ fn page_get_tool() -> Value { }) } -fn planned_tool(name: &str, scope: impl IntoIterator<Item = &'static str>) -> Value { +fn page_save_tool() -> Value { json!({ - "name": name, - "description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel", + "name": "mnote.page.save", + "description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存", "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["page.write"], + "status": "available", + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "content", "dryRun", "idempotencyKey"], + "properties": { + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "sessionId": { "type": "string" }, + "runId": { "type": "string" }, + "toolCallId": { "type": "string" }, + "traceId": { "type": "string" }, + "content": { + "description": "要写入的正文块数组、{blocks:[...]}、TipTap content 数组或纯文本", + "type": ["array", "object", "string"] + }, + "mode": { + "type": "string", + "enum": ["replace", "append", "prepend"], + "default": "replace" + }, + "dryRun": { "type": "boolean" }, + "idempotencyKey": { "type": "string" } + } + } + }) +} + +fn available_tool( + name: &str, + description: &str, + scope: impl IntoIterator<Item = &'static str>, +) -> Value { + json!({ + "name": name, + "description": description, + "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": scope.into_iter().collect::<Vec<_>>(), - "status": "planned" + "status": "available" }) } diff --git a/rust/crates/mnote-web/src/hermes_tools/page.rs b/rust/crates/mnote-web/src/hermes_tools/page.rs index e58f79d9..8697e2bb 100644 --- a/rust/crates/mnote-web/src/hermes_tools/page.rs +++ b/rust/crates/mnote-web/src/hermes_tools/page.rs @@ -91,14 +91,20 @@ pub async fn page_save( WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content") .with_context(context) })?; + let mode = input + .arg_string("mode") + .unwrap_or_else(|| "replace".into()) + .trim() + .to_ascii_lowercase(); + let save_content = resolve_page_save_content(state, context, input, content, &mode).await?; page_command( state, context, input, "page.body.save", json!({ - "content": content, - "mode": input.arg_string("mode").unwrap_or_else(|| "replace".into()) + "content": save_content, + "mode": mode }), None, ) @@ -261,6 +267,98 @@ fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Valu payload } +async fn resolve_page_save_content( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, + content: Value, + mode: &str, +) -> Result<Value, WebError> { + match mode { + "replace" | "" => Ok(content), + "append" | "prepend" => { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let aggregate = build_page_aggregate_snapshot( + state, + context, + &document_id, + workspace_id.as_deref(), + None, + None, + ) + .await?; + let aggregate_value = serde_json::to_value(&aggregate) + .map_err(|error| WebError::internal(error.to_string()))?; + let current = aggregate_value + .pointer("/body/content") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(merge_page_content_for_mode(current, content, mode)) + } + other => Err(WebError::bad_request_code( + "mnote_tool_bad_request", + format!("mnote.page.save 不支持 mode={other}"), + ) + .with_context(context)), + } +} + +fn normalize_page_save_blocks(value: Value) -> Vec<Value> { + match value { + Value::Array(items) => items.into_iter().map(normalize_page_save_block).collect(), + Value::Object(mut map) => { + if let Some(Value::Array(items)) = map.remove("blocks") { + return items.into_iter().map(normalize_page_save_block).collect(); + } + if let Some(Value::Array(items)) = map.get("content").cloned() { + return items.into_iter().map(normalize_page_save_block).collect(); + } + vec![normalize_page_save_block(Value::Object(map))] + } + Value::String(text) => vec![json!({ + "type": "paragraph", + "content": text + })], + other => vec![other], + } +} + +fn normalize_page_save_block(value: Value) -> Value { + let Value::Object(mut map) = value else { + return value; + }; + if !map.contains_key("type") { + map.insert("type".into(), Value::String("paragraph".into())); + } + if !map.contains_key("content") { + if let Some(text) = map + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + { + map.insert("content".into(), Value::String(text.to_string())); + } + } + Value::Object(map) +} + +fn merge_page_content_for_mode(current: Value, next: Value, mode: &str) -> Value { + let mut current_blocks = normalize_page_save_blocks(current); + let mut next_blocks = normalize_page_save_blocks(next); + if mode == "prepend" { + next_blocks.extend(current_blocks); + Value::Array(next_blocks) + } else { + current_blocks.extend(next_blocks); + Value::Array(current_blocks) + } +} + fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>) { let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"]; let mut out = serde_json::Map::new(); @@ -339,3 +437,55 @@ fn collect_text(value: &Value) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_save_append_merges_current_and_next_blocks() { + let merged = merge_page_content_for_mode( + json!([ + {"id": "block_1", "type": "paragraph", "content": "原正文"} + ]), + json!([ + {"type": "paragraph", "content": [{"type": "text", "text": "AI 追加"}]} + ]), + "append", + ); + + assert_eq!(merged.as_array().map(Vec::len), Some(2)); + assert_eq!(merged[0]["id"], "block_1"); + assert_eq!(collect_text(&merged[1]), "AI 追加"); + } + + #[test] + fn page_save_prepend_accepts_plain_text() { + let merged = merge_page_content_for_mode( + json!([ + {"id": "block_1", "type": "paragraph", "content": "原正文"} + ]), + json!("AI 前置"), + "prepend", + ); + + assert_eq!(merged.as_array().map(Vec::len), Some(2)); + assert_eq!(collect_text(&merged[0]), "AI 前置"); + assert_eq!(merged[1]["id"], "block_1"); + } + + #[test] + fn page_save_accepts_text_field_blocks_from_ai() { + let merged = merge_page_content_for_mode( + json!([]), + json!([ + {"type": "paragraph", "text": "AI 使用 text 字段"} + ]), + "append", + ); + + assert_eq!(merged.as_array().map(Vec::len), Some(1)); + assert_eq!(merged[0]["content"], "AI 使用 text 字段"); + assert_eq!(collect_text(&merged[0]), "AI 使用 text 字段"); + } +} diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index 095b6c01..baeaa8b3 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -1,7 +1,9 @@ +use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; +use crate::transport::convex::execute_convex_query_by_name; use axum::body::Body; -use axum::extract::{Extension, Path, Query}; +use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; use axum::Json; @@ -56,6 +58,8 @@ struct HermesQueuedRun { profile: String, document_id: String, trace_id: String, + actor_id: String, + actor_type: String, input: String, context_summary: Value, queued_at: u128, @@ -450,10 +454,13 @@ async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> { } pub async fn create_run( + State(state): State<AppState>, Extension(context): Extension<RequestContext>, - Json(payload): Json<Value>, + Json(mut payload): Json<Value>, ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ensure_authenticated(&context)?; + let (actor_id, actor_type) = resolve_run_actor(&state, &context).await; + stamp_run_actor(&mut payload, &actor_id, &actor_type); let registration = run_registration_from_payload(&context, &payload); if session_has_active_run(®istration.session_id) { let queued = enqueue_run(&context, ®istration, &payload)?; @@ -1553,14 +1560,14 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V "workspaceId": workspace_id, "documentId": document_id, "profile": profile.unwrap_or("default"), - "actorId": context.auth.actor_id, - "actorType": context.auth.actor_type, + "actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id), + "actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type), "sessionId": session_id, "traceId": trace_id, - "toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。", + "toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。", "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null), - "pageContext": page_context + "pageContext": sanitize_run_page_context(page_context) }) .to_string(); @@ -1582,6 +1589,87 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V Ok(body) } +fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { + let actor_id = context.auth.actor_id.trim(); + if !actor_id.is_empty() && actor_id != "anonymous" { + return (actor_id.to_string(), context.auth.actor_type.clone()); + } + if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { + return (state.config().dev_user_id.clone(), "devFallback".into()); + } + ("anonymous".into(), "anonymous".into()) +} + +async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { + let actor_id = context.auth.actor_id.trim(); + if !actor_id.is_empty() && actor_id != "anonymous" { + return (actor_id.to_string(), context.auth.actor_type.clone()); + } + if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() { + return ("anonymous".into(), "anonymous".into()); + } + if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await { + return (user_id, "user".into()); + } + effective_run_actor(state, context) +} + +async fn resolve_current_convex_user_id( + state: &AppState, + context: &RequestContext, +) -> Result<Option<String>, WebError> { + let payload = execute_convex_query_by_name( + state.config(), + context, + "users:currentUser", + json!({}), + None, + "hermes_run_current_user", + ) + .await?; + Ok(payload + .get("_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)) +} + +fn stamp_run_actor(payload: &mut Value, actor_id: &str, actor_type: &str) { + let Value::Object(map) = payload else { + return; + }; + map.insert("actorId".into(), Value::String(actor_id.to_string())); + map.insert("actorType".into(), Value::String(actor_type.to_string())); +} + +fn sanitize_run_page_context(page_context: Value) -> Value { + let Some(source) = page_context.as_object() else { + return Value::Null; + }; + let mut sanitized = serde_json::Map::new(); + for key in [ + "contextScope", + "node", + "pageSubtreeSource", + "evidence", + "pageOptions", + "contentAccess", + ] { + if let Some(value) = source.get(key) { + sanitized.insert(key.to_string(), value.clone()); + } + } + sanitized.insert( + "contentAccess".to_string(), + sanitized + .get("contentAccess") + .cloned() + .unwrap_or_else(|| Value::String("mnote.page.get".into())), + ); + Value::Object(sanitized) +} + fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2029,6 +2117,16 @@ fn enqueue_run( profile: registration.profile.clone(), document_id: registration.document_id.clone(), trace_id: registration.trace_id.clone(), + actor_id: payload + .get("actorId") + .and_then(Value::as_str) + .unwrap_or("anonymous") + .to_string(), + actor_type: payload + .get("actorType") + .and_then(Value::as_str) + .unwrap_or("anonymous") + .to_string(), input, context_summary: queue_context_summary(payload), queued_at: now_ms(), @@ -2092,6 +2190,8 @@ fn queued_run_payload(queued: &HermesQueuedRun) -> Value { "profile": queued.profile, "message": queued.input, "traceId": queued.trace_id, + "actorId": queued.actor_id, + "actorType": queued.actor_type, "contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null) }) } @@ -2330,6 +2430,28 @@ mod tests { })) } + fn test_state() -> AppState { + AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }) + } + #[tokio::test] async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() { let _env_guard = env_lock().lock().expect("env lock"); @@ -3048,7 +3170,7 @@ mod tests { } #[test] - fn hermes_client_run_body_carries_page_context_into_run_input() { + fn hermes_client_run_body_carries_minimal_page_context_into_run_input() { let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), @@ -3061,7 +3183,14 @@ mod tests { "documentId": "doc_1", "sessionId": "sess_1", "message": "概括当前页面", - "pageContext": {"title": "页面标题"}, + "pageContext": { + "contextScope": "page", + "node": {"documentId": "doc_1", "title": "页面标题"}, + "documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}], + "subtree": {"children": [{"title": "不应进入 Hermes instructions"}]}, + "outline": [{"title": "不应进入 Hermes instructions"}], + "contentAccess": "mnote.page.get" + }, "selectedBlockId": "block_1", "selectedText": "选中文本", "traceId": "trace_1" @@ -3075,6 +3204,33 @@ mod tests { assert!(instructions.contains("\"documentId\":\"doc_1\"")); assert!(instructions.contains("\"title\":\"页面标题\"")); assert!(instructions.contains("\"selectedBlockId\":\"block_1\"")); + assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\"")); + assert!(!instructions.contains("不应进入 Hermes instructions")); + assert!(!instructions.contains("\"documentBlocks\"")); + assert!(!instructions.contains("\"subtree\"")); + assert!(!instructions.contains("\"outline\"")); + } + + #[test] + fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() { + let state = test_state(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", "mnote_web_convex_token=token_1".parse().unwrap()); + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &headers, + ); + let (actor_id, actor_type) = effective_run_actor(&state, &context); + assert_eq!(actor_id, "dev-user"); + assert_eq!(actor_type, "devFallback"); + let mut payload = json!({"message": "读取当前页面"}); + stamp_run_actor(&mut payload, &actor_id, &actor_type); + let body = build_run_upstream_body(&context, payload).expect("body"); + let instructions = body["instructions"].as_str().expect("instructions"); + assert!(instructions.contains("\"actorId\":\"dev-user\"")); + assert!(instructions.contains("\"actorType\":\"devFallback\"")); + assert!(!instructions.contains("\"actorId\":\"anonymous\"")); } #[test] diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index 3aef07e0..80a6c20a 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -65,7 +65,7 @@ pub async fn mnote_call( Extension(context): Extension<RequestContext>, Json(input): Json<ToolCallInput>, ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { - ensure_authenticated(&context)?; + let context = authenticated_tool_context(&context, &input)?; let trace_id = input .effective_trace_id(&context.trace.trace_id) .to_string(); @@ -373,6 +373,71 @@ fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")) } +fn authenticated_tool_context( + context: &RequestContext, + input: &ToolCallInput, +) -> Result<RequestContext, WebError> { + if ensure_authenticated(context).is_ok() { + return Ok(context.clone()); + } + let actor_id = input + .actor_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "anonymous") + .ok_or_else(|| { + WebError::new( + StatusCode::UNAUTHORIZED, + "mnote_tool_unauthorized", + "mnote Hermes tool 需要登录后访问", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools") + })?; + let has_run_identity = input + .session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + && input + .run_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + && input + .tool_call_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + && input + .trace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some(); + if !has_run_identity { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "mnote_tool_unauthorized", + "mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")); + } + let mut next = context.clone(); + next.auth.actor_id = actor_id.to_string(); + next.auth.actor_type = "user".into(); + if next.auth.session_id.is_none() { + next.auth.session_id = input.session_id.clone(); + } + Ok(next) +} + fn ensure_workspace_context( context: &RequestContext, input_workspace_id: Option<&str>, @@ -493,6 +558,11 @@ mod tests { let tools = payload["manifest"]["tools"].as_array().expect("tools"); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save")); + let page_save = tools + .iter() + .find(|tool| tool["name"] == "mnote.page.save") + .expect("page save tool"); + assert_eq!(page_save["status"], "available"); assert_eq!( payload["manifest"]["schemaVersion"], "mnote.hermes_tool_manifest.v1" @@ -517,6 +587,41 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "toolName": "mnote.page.get", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "actorId": "user_hermes", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_1", + "traceId": "trace_1", + "capabilityScope": ["page.read"] + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["audit"]["actorId"], "user_hermes"); + assert_eq!(payload["result"]["title"], "服务端页面"); + } + #[tokio::test] async fn hermes_tools_write_tools_require_auth() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 044b3e30..7b9b5e5c 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1607,12 +1607,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = toTiptapDocument(nextBody.content); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; - return; + if (!contentChanged) return; } - if (nextConflictKey === session.lastExternalConflictDetectionKey) return; + if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { markSessionExternalConflict(session, externalConflictMessage); return; @@ -1620,8 +1623,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; session.title = nextAggregate?.head?.title || session.title; - session.currentTiptapDocument = toTiptapDocument(nextBody.content); - session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; session.lastPersistedSerialized = session.currentSerialized; session.revision = nextRevision; session.conflictDetectionKey = nextConflictKey; @@ -1840,8 +1843,38 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }); }; + const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => { + if (!session) return false; + const doc = String(documentId || '').trim(); + const workspace = String(workspaceId || '').trim(); + if (doc && session.documentId !== doc) return false; + if (workspace && session.workspaceId && session.workspaceId !== workspace) return false; + return true; + }; + + const refreshDocumentSessionsFromExternalWrite = (detail, source) => { + const documentId = String(detail?.documentId || '').trim(); + const workspaceId = String(detail?.workspaceId || '').trim(); + let scheduled = 0; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; + session.lastExternalChangeSignalAt = Date.now(); + session.externalChangePending = true; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, treeExternalConflictMessage); + return; + } + scheduled += 1; + scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write'); + }); + return scheduled; + }; + window.addEventListener('tree:delta', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange); + window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { + refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); + }); const createDocumentSession = (runtimeDescriptor) => { const pageBody = runtimeDescriptor.aggregate.body || {}; @@ -2407,6 +2440,23 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { closeSecondaryPane(url instanceof URL ? url : currentUrl()); return true; }, + refreshDocument: async ({ documentId, workspaceId, source } = {}) => { + const targets = Array.from(documentSessionRegistry.values()).filter((session) => ( + sessionMatchesDocumentWorkspace(session, documentId, workspaceId) + )); + await Promise.all(targets.map((session) => refreshSessionFromExternalChange( + session, + source || 'mnote-web-programmatic-refresh', + ))); + return targets.length; + }, + refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => { + const primary = paneViewRegistry.get('primary'); + if (!primary?.session) return 0; + if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0; + await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh'); + return 1; + }, }; window.addEventListener('pagehide', () => { diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 2bca0bf3..ea193381 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -267,16 +267,17 @@ const SIDEBAR_TREE_JS: &str = r##" return { pageContext: { contextScope: scope, - documentBlocks: scope === 'page' ? (body.content || null) : null, + documentBlocks: null, node: { documentId: currentDocumentId(), title: title }, - subtree: scope === 'page' ? subtree : null, - outline: scope === 'page' ? outline : null, + subtree: null, + outline: null, pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, - pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null + pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, + contentAccess: 'mnote.page.get' }, selectedText: selectedText, selectedBlockId: null @@ -503,6 +504,29 @@ const SIDEBAR_TREE_JS: &str = r##" var value = (root.getAttribute('data-workspace-id') || '').trim(); if (value) return value; } + var documentId = currentDocumentId(); + if (documentId) { + var shell = document.querySelector('.document-shell[data-document-id="' + cssEscape(documentId) + '"][data-workspace-id]'); + if (shell instanceof HTMLElement) { + var shellWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim(); + if (shellWorkspaceId) return shellWorkspaceId; + } + } + var activePane = document.querySelector('[data-pane-visible="true"][data-pane-workspace-id]'); + if (activePane instanceof HTMLElement) { + var paneWorkspaceId = (activePane.getAttribute('data-pane-workspace-id') || '').trim(); + if (paneWorkspaceId) return paneWorkspaceId; + } + var anyDocumentShell = document.querySelector('.document-shell[data-workspace-id]'); + if (anyDocumentShell instanceof HTMLElement) { + var documentWorkspaceId = (anyDocumentShell.getAttribute('data-workspace-id') || '').trim(); + if (documentWorkspaceId) return documentWorkspaceId; + } + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var domWorkspaceId = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (domWorkspaceId) return domWorkspaceId; + } return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default'; } @@ -4124,6 +4148,69 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function pageAiNormalizeToolName(name) { + return String(name || '').trim().replace(/_/g, '.'); + } + + function pageAiToolEventDeepFindString(value, keys, depth) { + if (!value || typeof value !== 'object' || depth > 5) return ''; + for (var index = 0; index < keys.length; index += 1) { + var key = keys[index]; + if (Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === 'string' && value[key].trim()) { + return value[key].trim(); + } + } + if (Array.isArray(value)) { + for (var arrayIndex = 0; arrayIndex < value.length; arrayIndex += 1) { + var fromArray = pageAiToolEventDeepFindString(value[arrayIndex], keys, depth + 1); + if (fromArray) return fromArray; + } + return ''; + } + var preferred = ['audit', 'args', 'arguments', 'input', 'result', 'summary', 'output', 'upstream']; + for (var prefIndex = 0; prefIndex < preferred.length; prefIndex += 1) { + var child = value[preferred[prefIndex]]; + var fromPreferred = pageAiToolEventDeepFindString(child, keys, depth + 1); + if (fromPreferred) return fromPreferred; + } + var objectKeys = Object.keys(value); + for (var objectIndex = 0; objectIndex < objectKeys.length; objectIndex += 1) { + var fromObject = pageAiToolEventDeepFindString(value[objectKeys[objectIndex]], keys, depth + 1); + if (fromObject) return fromObject; + } + return ''; + } + + function pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId) { + var normalizedTool = pageAiNormalizeToolName(toolName); + var writesCurrentPage = [ + 'mnote.page.save', + 'mnote.page.update.title', + 'mnote.page.update.options' + ].indexOf(normalizedTool) >= 0 || [ + 'mnote.page.update_title', + 'mnote.page.update_options' + ].indexOf(String(toolName || '').trim()) >= 0; + if (!writesCurrentPage) return; + var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId(); + var workspaceId = pageAiToolEventDeepFindString(toolEvent, ['workspaceId', 'workspace_id'], 0) || resolveWorkspaceId(document.body); + try { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: toolName, + normalizedToolName: normalizedTool, + documentId: documentId, + workspaceId: workspaceId, + runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId || ''), + traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''), + toolCallId: String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || '') + } + })); + } catch (error) { + console.warn('mnote 页面 AI 写入刷新事件派发失败', error); + } + } + function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) { var toolEvent = null; try { @@ -4173,6 +4260,9 @@ const SIDEBAR_TREE_JS: &str = r##" existing.auditId = auditId || existing.auditId || ''; if (argsSummary) existing.argsSummary = argsSummary; if (resultSummary) existing.resultSummary = resultSummary; + if (status === 'completed') { + pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId); + } } async function pageAiCancelQueuedRun(queueId) { diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 7511dcfe..a6d720d0 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -111,25 +111,60 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S .with_header("x-upstream-service", "convex") })?; - let dev_user_id = config.dev_user_id.trim(); - if dev_user_id.is_empty() { + let Some(identity_user) = fallback_acting_identity_user(config, context) else { return Ok(format!("Convex {admin_key}")); - } + }; // 说明:Rust Web 直接调用 Convex HTTP API 时没有 Next/Convex Auth cookie。 - // 自托管开发态用 admin auth 携带 acting identity,让 @convex-dev/auth 的 - // getAuthUserId(ctx) 能得到 DEV_USER_ID,从而和 Next 开发态免登录语义一致。 + // 自托管开发态和 Hermes 委托 tool 调用都用 admin auth 携带 acting identity, + // 让 @convex-dev/auth 的 getAuthUserId(ctx) 得到实际执行用户。 let identity = json!({ - "subject": format!("{}|mnote-web-dev-session", dev_user_id), - "issuer": "mnote-web-dev", - "name": config.dev_user_name, - "email": config.dev_user_email, + "subject": format!("{}|{}", identity_user.user_id, identity_user.session_suffix), + "issuer": identity_user.issuer, + "name": identity_user.name, + "email": identity_user.email, }); let identity_encoded = base64::engine::general_purpose::STANDARD.encode(identity.to_string().as_bytes()); Ok(format!("Convex {admin_key}:{identity_encoded}")) } +struct ActingIdentityUser { + user_id: String, + session_suffix: &'static str, + issuer: &'static str, + name: String, + email: String, +} + +fn fallback_acting_identity_user( + config: &AppConfig, + context: &RequestContext, +) -> Option<ActingIdentityUser> { + let actor_id = context.auth.actor_id.trim(); + if !actor_id.is_empty() && actor_id != "anonymous" { + return Some(ActingIdentityUser { + user_id: actor_id.to_string(), + session_suffix: "mnote-web-delegated-session", + issuer: "mnote-web-delegated", + name: actor_id.to_string(), + email: String::new(), + }); + } + + let dev_user_id = config.dev_user_id.trim(); + if dev_user_id.is_empty() { + return None; + } + Some(ActingIdentityUser { + user_id: dev_user_id.to_string(), + session_suffix: "mnote-web-dev-session", + issuer: "mnote-web-dev", + name: config.dev_user_name.clone(), + email: config.dev_user_email.clone(), + }) +} + fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> { context .auth @@ -1175,6 +1210,31 @@ mod tests { assert_eq!(identity["email"], "dev@mnote.local"); } + #[test] + fn build_authorization_uses_delegated_actor_for_admin_identity() { + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", HeaderValue::from_static("user_real_1")); + headers.insert("x-mnote-actor-type", HeaderValue::from_static("user")); + + let authorization = + build_authorization(&config(), &request_context(headers)).expect("authorization"); + + assert!(authorization.starts_with("Convex admin-demo:")); + let encoded = authorization + .trim_start_matches("Convex admin-demo:") + .trim(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("identity base64"); + let identity: serde_json::Value = serde_json::from_slice(&decoded).expect("identity json"); + assert_eq!( + identity["subject"], + "user_real_1|mnote-web-delegated-session" + ); + assert_eq!(identity["issuer"], "mnote-web-delegated"); + assert_eq!(identity["name"], "user_real_1"); + } + #[test] fn build_authorization_reads_convex_token_from_cookie() { let mut headers = HeaderMap::new(); diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 4cce3677..41594f3d 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -33,12 +33,11 @@ import { register_text_style } from './snippets/leptos-tiptap-c355e6ec24c3df4c/s import { register_toc_node } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_toc_node.js'; import { register_underline } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js'; import { register_youtube } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js'; -import { find_mnote_block_anchor, post_mnote_document_save, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js'; +import { find_mnote_block_anchor, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js'; import * as import1 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js" import * as import2 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js" -import * as import3 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js" +import * as import3 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js" import * as import4 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js" -import * as import5 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js" export class IntoUnderlyingByteSource { @@ -370,6 +369,9 @@ function __wbg_get_imports() { const ret = arg0.children; return ret; }, + __wbg_click_bc40376705b1e04d: function(arg0) { + arg0.click(); + }, __wbg_clientHeight_01b31bebacb195f0: function(arg0) { const ret = arg0.clientHeight; return ret; @@ -561,13 +563,6 @@ function __wbg_get_imports() { const ret = arg0[arg1 >>> 0]; return ret; }, - __wbg_get_aa7ea1c497b45090: function() { return handleError(function (arg0, arg1, arg2, arg3) { - const ret = arg1.get(getStringFromWasm0(arg2, arg3)); - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }, arguments); }, __wbg_get_unchecked_17f53dad852b9588: function(arg0, arg1) { const ret = arg0[arg1 >>> 0]; return ret; @@ -583,10 +578,6 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, arguments); }, - __wbg_headers_6022deb4e576fb8e: function(arg0) { - const ret = arg0.headers; - return ret; - }, __wbg_height_cc0f4b9ec7073c11: function(arg0) { const ret = arg0.height; return ret; @@ -786,10 +777,6 @@ function __wbg_get_imports() { const ret = arg0.metaKey; return ret; }, - __wbg_new_0_4d657201ced14de3: function() { - const ret = new Date(); - return ret; - }, __wbg_new_0c7403db6e782f19: function(arg0) { const ret = new Uint8Array(arg0); return ret; @@ -878,18 +865,6 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, arguments); }, - __wbg_post_mnote_document_save_94a0f8ecea85d168: function() { return handleError(function (arg0, arg1) { - let deferred0_0; - let deferred0_1; - try { - deferred0_0 = arg0; - deferred0_1 = arg1; - const ret = post_mnote_document_save(getStringFromWasm0(arg0, arg1)); - return ret; - } finally { - wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); - } - }, arguments); }, __wbg_preventDefault_f55c01cb5fd2bcc0: function(arg0) { arg0.preventDefault(); }, @@ -1214,10 +1189,6 @@ function __wbg_get_imports() { const ret = arg0.target; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, - __wbg_text_595ef75535aa25c1: function() { return handleError(function (arg0) { - const ret = arg0.text(); - return ret; - }, arguments); }, __wbg_then_792e0c862b060889: function(arg0, arg1, arg2) { const ret = arg0.then(arg1, arg2); return ret; @@ -1226,14 +1197,6 @@ function __wbg_get_imports() { const ret = arg0.then(arg1); return ret; }, - __wbg_toISOString_07c00b3614e865a1: function(arg0) { - const ret = arg0.toISOString(); - return ret; - }, - __wbg_toString_306ed0b9f320c1ca: function(arg0) { - const ret = arg0.toString(); - return ret; - }, __wbg_top_158f7c4dd1427771: function(arg0) { const ret = arg0.top; return ret; @@ -1276,42 +1239,42 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1889, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1878, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2117, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2227, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2209, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2050, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2040, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2119, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2049, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2039, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2071, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2053, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2118, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); return ret; }, @@ -1350,9 +1313,8 @@ function __wbg_get_imports() { "./mnote-leptos-tiptap-spike-island_bg.js": import0, "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import1, "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import2, - "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import3, + "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import3, "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import4, - "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import5, }; } diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index d71e9e13..0291db7b 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 263f34ad..469c2a32 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -433,51 +433,6 @@ const SPIKE_STYLE: &str = r#" overflow: hidden; } -.ai-bridge-status { - position: absolute; - right: 18px; - top: 14px; - z-index: 8; - display: inline-flex; - align-items: center; - gap: 6px; - max-width: min(320px, 42vw); - padding: 5px 8px; - border: 1px solid rgba(17, 24, 39, 0.12); - border-radius: 6px; - background: rgba(255, 255, 255, 0.94); - color: #374151; - font-size: 12px; - line-height: 16px; - box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08); -} - -.ai-bridge-status[data-state="pending"] { - border-color: rgba(37, 99, 235, 0.28); - color: #1d4ed8; -} - -.ai-bridge-status[data-state="ready"] { - border-color: rgba(22, 163, 74, 0.28); - color: #15803d; -} - -.ai-bridge-status[data-state="error"] { - border-color: rgba(220, 38, 38, 0.28); - color: #b91c1c; -} - -.ai-bridge-status span { - font-weight: 700; -} - -.ai-bridge-status strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - .editor-topbar { display: flex; justify-content: space-between; @@ -7816,157 +7771,18 @@ fn apply_ok_feedback<E>( apply_feedback_result(setter, result.map(|_| ok_message)); } -fn selected_text_from_document(editor: TiptapEditorHandle) -> String { - window() - .and_then(|win| win.get_selection().ok().flatten()) - .map(|selection| String::from(selection.to_string())) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .or_else(|| { - editor - .get_json() - .ok() - .map(|document| collect_plain_text(&document)) - }) - .unwrap_or_default() -} - -fn ai_action_prompt(action: &str, selected_text: &str) -> String { - let instruction = match action { - "continue_writing" => "请基于当前块继续写作", - "summarize" => "请总结当前块内容", - "translate" => "请把当前块内容翻译为简体中文", - "improve" => "请改写并润色当前块内容", - _ => "请分析当前块并给出可用于编辑的建议", +fn open_hermes_page_ai_drawer() { + let Some(win) = window() else { + return; }; - if selected_text.trim().is_empty() { - instruction.to_string() - } else { - format!("{instruction}:\n\n{selected_text}") - } -} - -fn build_ai_bridge_payload( - editor: TiptapEditorHandle, - action: &str, - document_id: Option<String>, - workspace_id: Option<String>, - selected_block_index: Option<usize>, - selected_block_id: Option<String>, - selection_state: TiptapSelectionState, -) -> Value { - let tiptap_document = editor.get_json().unwrap_or_else( - |err| json!({ "type": "error", "message": format!("读取 Tiptap JSON 失败:{err}") }), - ); - let selection = serde_json::to_value(selection_state).unwrap_or(Value::Null); - let selected_text = selected_text_from_document(editor); - let selected_uids = selected_block_id - .as_ref() - .map(|block_id| vec![Value::String(block_id.clone())]) - .unwrap_or_default(); - json!({ - "stream": true, - "scope": "document", - "messages": [{ - "role": "user", - "content": ai_action_prompt(action, &selected_text), - }], - "maxSteps": 8, - "context": { - "source": "leptos-tiptap-island", - "action": action, - "documentId": document_id, - "workspaceId": workspace_id, - "selectedBlockIndex": selected_block_index, - "selectedBlockId": selected_block_id, - "selectedUids": selected_uids, - "selectedText": selected_text, - "selection": { - "currentBlockId": selected_block_id, - "state": selection, - }, - "tiptapDocument": tiptap_document, - "documentBlocks": tiptap_document, - }, - "options": { - "ai": { - "provider": "online", - }, - }, - }) -} - -async fn post_ai_bridge_request(payload: Value) -> Result<Value, String> { - let body_string = serde_json::to_string(&payload) - .map_err(|err| format!("序列化 AI bridge 请求失败:{err}"))?; - let request_init = RequestInit::new(); - request_init.set_method("POST"); - request_init.set_mode(RequestMode::SameOrigin); - request_init.set_body(&JsValue::from_str(&body_string)); - let headers = js_sys::Object::new(); - js_sys::Reflect::set( - &headers, - &JsValue::from_str("content-type"), - &JsValue::from_str("application/json"), - ) - .map_err(|_| "设置 AI bridge 请求头失败".to_string())?; - js_sys::Reflect::set( - request_init.as_ref(), - &JsValue::from_str("headers"), - &headers, - ) - .map_err(|_| "设置 AI bridge 请求头失败".to_string())?; - - let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?; - let response_value = - JsFuture::from(win.fetch_with_str_and_init("/api/ai-agent/run", &request_init)) - .await - .map_err(|err| format!("AI agent 请求失败:{err:?}"))?; - let response: Response = response_value - .dyn_into() - .map_err(|_| "AI agent 响应类型错误".to_string())?; - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .ok() - .flatten() - .unwrap_or_default(); - - if content_type.contains("text/event-stream") { - let text_value = JsFuture::from( - response - .text() - .map_err(|_| "读取 AI agent 事件流失败".to_string())?, - ) - .await - .map_err(|err| format!("解析 AI agent 事件流失败:{err:?}"))?; - let stream_text = text_value.as_string().unwrap_or_default(); - if !response.ok() { - return Err(format!("AI agent 返回失败:HTTP {status}; {stream_text}")); + let Some(document) = win.document() else { + return; + }; + if let Ok(Some(trigger)) = document.query_selector("[data-mnote-action=\"open-page-ai\"]") { + if let Some(button) = trigger.dyn_ref::<HtmlElement>() { + button.click(); } - return Ok(json!({ - "ok": true, - "stream": stream_text, - "contract": { - "schema": "mnote.ai_agent.run.v1", - "structuredWriteOwner": "openai-agents-python", - }, - })); } - - let json_value = JsFuture::from( - response - .json() - .map_err(|_| "读取 AI agent 响应失败".to_string())?, - ) - .await - .map_err(|err| format!("解析 AI agent 响应失败:{err:?}"))?; - let parsed = serde_wasm_bindgen::from_value::<Value>(json_value).unwrap_or(Value::Null); - if !response.ok() { - return Err(format!("AI agent 返回失败:HTTP {status}; {parsed}")); - } - Ok(parsed) } fn request_ai_edit_bridge( @@ -7986,261 +7802,26 @@ fn request_ai_edit_bridge( title: ReadSignal<String>, set_command_feedback: WriteSignal<String>, ) { - let persisted_identity = runtime_persisted_identity(document_id, workspace_id); - let payload = build_ai_bridge_payload( + let _ = ( editor, action, - document_id.get_untracked(), - workspace_id.get_untracked(), + document_id, + workspace_id, selected_block_index, selected_block_id, - selection_state.get_untracked(), + selection_state, + set_dirty_count, + set_html_output, + set_document_json, + set_json_output, + title, ); - set_ai_bridge_state.set("pending".to_string()); - set_ai_bridge_message.set("正在连接 mnote AI agent".to_string()); - set_command_feedback.set("AI 请求已发送到 /api/ai-agent/run".to_string()); - let _ = bump_mnote_e27_ai_bridge_request_count(); - spawn_local(async move { - match post_ai_bridge_request(payload).await { - Ok(response) => { - let owner = response - .get("contract") - .and_then(|contract| contract.get("structuredWriteOwner")) - .and_then(Value::as_str) - .unwrap_or("rust-web-hermes"); - match apply_ai_doc_write_response( - editor, - &response, - &persisted_identity, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - title, - set_command_feedback, - ) - .await - { - Ok(true) => { - set_ai_bridge_state.set("ready".to_string()); - set_ai_bridge_message.set(format!("AI agent 已写入:{owner}")); - } - Ok(false) => { - set_ai_bridge_state.set("ready".to_string()); - set_ai_bridge_message.set(format!("AI agent 主路径已就绪:{owner}")); - set_command_feedback - .set("AI agent 已返回,未收到可应用的 doc 写入结果".to_string()); - } - Err(err) => { - set_ai_bridge_state.set("error".to_string()); - set_ai_bridge_message.set(err.clone()); - set_command_feedback.set(err); - } - } - } - Err(err) => { - set_ai_bridge_state.set("error".to_string()); - set_ai_bridge_message.set(err.clone()); - set_command_feedback.set(err); - } - } - }); -} - -fn ai_legacy_block_text(block: &Value) -> String { - match block.get("content") { - Some(Value::String(text)) => text.clone(), - Some(Value::Array(children)) => children - .iter() - .map(|child| { - child - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| collect_plain_text(child)) - }) - .collect::<Vec<_>>() - .join(""), - Some(other) => collect_plain_text(other), - None => String::new(), - } -} - -fn ai_text_content(text: &str) -> Vec<Value> { - if text.is_empty() { - Vec::new() - } else { - vec![json!({ "type": "text", "text": text })] - } -} - -fn ai_block_attrs(block: &Value) -> Option<Map<String, Value>> { - let mut attrs = Map::new(); - let block_id = block - .get("id") - .or_else(|| block.get("blockId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - if let Some(block_id) = block_id { - attrs.insert("blockId".to_string(), Value::String(block_id.to_string())); - } - Some(attrs).filter(|attrs| !attrs.is_empty()) -} - -fn ai_heading_attrs(block: &Value) -> Option<Map<String, Value>> { - let mut attrs = ai_block_attrs(block).unwrap_or_default(); - let level = block - .get("level") - .or_else(|| block.get("props").and_then(|props| props.get("level"))) - .or_else(|| block.get("attrs").and_then(|attrs| attrs.get("level"))) - .and_then(Value::as_u64) - .unwrap_or(1) - .clamp(1, 6); - attrs.insert("level".to_string(), json!(level)); - Some(attrs) -} - -fn ai_legacy_block_to_tiptap_node(block: &Value) -> Value { - let block_type = block - .get("type") - .and_then(Value::as_str) - .unwrap_or("paragraph"); - let text = ai_legacy_block_text(block); - let inline = ai_text_content(&text); - match block_type { - "heading" | "header" => node_with_attrs("heading", ai_heading_attrs(block), inline), - "quote" | "blockquote" => node_with_attrs( - "blockquote", - ai_block_attrs(block), - vec![paragraph_node(inline)], - ), - "code" | "codeBlock" => node_with_attrs("codeBlock", ai_block_attrs(block), inline), - "divider" | "horizontalRule" => { - node_with_attrs("horizontalRule", ai_block_attrs(block), Vec::new()) - } - _ => node_with_attrs("paragraph", ai_block_attrs(block), inline), - } -} - -fn ai_legacy_blocks_to_tiptap_document(blocks: &[Value]) -> Value { - json!({ - "type": "doc", - "content": blocks.iter().map(ai_legacy_block_to_tiptap_node).collect::<Vec<_>>(), - }) -} - -fn ai_sse_tool_result_payloads(stream_text: &str) -> Vec<Value> { - let mut results = Vec::new(); - let mut current_event = String::new(); - let mut data_lines: Vec<String> = Vec::new(); - - let mut flush = |event: &mut String, lines: &mut Vec<String>| { - if event == "tool_result" && !lines.is_empty() { - let raw = lines.join("\n"); - if let Ok(value) = serde_json::from_str::<Value>(&raw) { - results.push(value); - } - } - event.clear(); - lines.clear(); - }; - - for line in stream_text.lines() { - if line.trim().is_empty() { - flush(&mut current_event, &mut data_lines); - continue; - } - if let Some(event) = line.strip_prefix("event:") { - current_event = event.trim().to_string(); - continue; - } - if let Some(data) = line.strip_prefix("data:") { - data_lines.push(data.trim_start().to_string()); - } - } - flush(&mut current_event, &mut data_lines); - results -} - -async fn persist_ai_doc_write_blocks( - persisted_identity: &PersistedDocumentIdentity, - blocks: &[Value], - tiptap_document: &Value, -) -> Result<(), String> { - let document_id = persisted_identity - .document_id - .clone() - .ok_or_else(|| "AI 写入缺少 documentId,无法保存".to_string())?; - let workspace_id = persisted_identity - .workspace_id - .clone() - .ok_or_else(|| "AI 写入缺少 workspaceId,无法保存".to_string())?; - let payload = json!({ - "documentId": document_id, - "workspaceId": workspace_id, - "content": blocks, - "tiptapDocument": tiptap_document, - "blockCount": blocks.len(), - "snapshotCapturedAt": js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default(), - }); - let body = - serde_json::to_string(&payload).map_err(|err| format!("序列化 AI 保存请求失败:{err}"))?; - post_mnote_document_save(body) - .await - .map_err(|err| format!("AI 写入保存失败:{err:?}"))?; - Ok(()) -} - -async fn apply_ai_doc_write_response( - editor: TiptapEditorHandle, - response: &Value, - persisted_identity: &PersistedDocumentIdentity, - set_dirty_count: WriteSignal<u32>, - set_html_output: WriteSignal<String>, - set_document_json: WriteSignal<Value>, - set_json_output: WriteSignal<String>, - title: ReadSignal<String>, - set_command_feedback: WriteSignal<String>, -) -> Result<bool, String> { - let Some(stream_text) = response.get("stream").and_then(Value::as_str) else { - return Ok(false); - }; - - for payload in ai_sse_tool_result_payloads(stream_text) { - let tool = payload - .get("tool") - .and_then(Value::as_str) - .unwrap_or_default(); - let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false); - if !ok || !(tool == "doc_insert_blocks" || tool == "doc_replace_range") { - continue; - } - let Some(blocks) = payload - .get("result") - .and_then(|result| result.get("data")) - .and_then(Value::as_array) - else { - continue; - }; - let next_document = ai_legacy_blocks_to_tiptap_document(blocks); - persist_ai_doc_write_blocks(persisted_identity, blocks, &next_document).await?; - apply_document_update( - editor, - persisted_identity, - next_document, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - title, - set_command_feedback, - "AI 已写入当前页面", - ); - return Ok(true); - } - - Ok(false) + let _ = ( + set_ai_bridge_state, + set_ai_bridge_message, + set_command_feedback, + ); + open_hermes_page_ai_drawer(); } fn normalize_layout_density(value: Option<String>) -> String { @@ -8366,8 +7947,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { let (block_menu_open, set_block_menu_open) = signal(false); let (block_turn_into_open, set_block_turn_into_open) = signal(false); let (block_folded_title_open, set_block_folded_title_open) = signal(false); - let (ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string()); - let (ai_bridge_message, set_ai_bridge_message) = signal("AI bridge 未连接".to_string()); + let (_ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string()); + let (_ai_bridge_message, set_ai_bridge_message) = signal("".to_string()); let (pending_drag, set_pending_drag) = signal(None::<PendingDragState>); let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>); let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>); @@ -9320,14 +8901,6 @@ fn App(mount_options: MountOptions) -> impl IntoView { }} <section class="editor-card"> - <div - class="ai-bridge-status" - data-testid="mnote-leptos-tiptap-ai-status" - data-state=move || ai_bridge_state.get() - > - <span>"AI"</span> - <strong>{move || ai_bridge_message.get()}</strong> - </div> {move || { if is_embedded { ().into_any() diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js index 17369a26..67afab97 100644 --- a/scripts/task-hermes-page-ai-smoke.js +++ b/scripts/task-hermes-page-ai-smoke.js @@ -159,7 +159,11 @@ async function main() { const runBody = JSON.parse(runRequest.body); assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId"); assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId"); - assert(runBody.pageContext, "run 请求必须携带 pageContext"); + assert(runBody.pageContext, "run 请求必须携带最小 pageContext"); + assert.equal(runBody.pageContext.documentBlocks, null, "run 请求不应直接携带页面正文 blocks"); + assert.equal(runBody.pageContext.subtree, null, "run 请求不应直接携带页面 subtree"); + assert.equal(runBody.pageContext.outline, null, "run 请求不应直接携带页面 outline"); + assert.equal(runBody.pageContext.contentAccess, "mnote.page.get", "正文必须通过 mnote.page.get tool 读取"); allowSessionRestore = true; await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });