chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
@@ -0,0 +1,73 @@
# 7-16 [done][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
> 关联代码:
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:895-956` — `search_replace`
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:920-936` — 归一化匹配后的切片计算
## 1. 问题定义
`mnote.doc.markdown_edit``search_replace` 在 Level 2 “忽略首尾空白和全角 / 半角差异”分支中,先对 `norm_line` 调用 `find(&norm_search)`,得到的是 UTF-8 byte offset
```rust
let start = norm_line.find(&norm_search).unwrap();
let end = start + norm_search.len();
```
随后代码把 `start / end` 当成字符序号传给 `line.char_indices().nth(...)`
```rust
&line[..line.char_indices().nth(start).map(|(i, _)| i).unwrap_or(0)]
&line[line.char_indices().nth(end).map(|(i, _)| i).unwrap_or(line.len())..]
```
这在 ASCII 文本里不明显,但中文、中文标点、全角字符都是多字节。byte offset 不等于字符序号,最终替换范围会偏移。
## 2. 影响
- 页面 AI 对中文正文执行 `mnote.doc.markdown_edit` 时,归一化匹配可能替换错位置。
- 本地 `.md` 文件和在线 Convex 文档共用该工具,因此两条 AI 编辑路径都会受影响。
- 如果替换结果继续写回,用户看到的正文可能被局部破坏,而不是简单失败。
## 3. 复现思路
构造一行中文正文,让精确匹配失败但归一化匹配命中,例如带首尾空白或全角 / 半角差异的 search:
```text
原文:第一段内容
search 一段
replace:二段
```
`find()` 得到的是 byte offset;当前代码按字符序号切片后,替换边界会落到错误字符位置。
## 4. 根因
`str::find` 返回 byte index`char_indices().nth(n)``n` 是第几个字符。当前实现把两种索引体系混用。
## 5. 建议修复
- 在归一化时保留原文字符到归一化字符的 offset map。
- 或者只在同一字符串上使用 byte index,并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
- 增加中文、多字节标点、全角英文 / 数字混排的 `search_replace` 单测。
## 6. 修复
已修复:
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,归一化匹配时不再把 `str::find` 的 byte offset 当作字符序号使用。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:959) 新增 `normalize_line_with_byte_map`,在归一化字符串与原始行之间保留 byte 边界映射。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:670) 增加中文归一化替换回归测试,覆盖 `一 段` 命中 `一段` 时的多字节切片边界。
## 7. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web test_search_replace_normalized_chinese_byte_boundaries -- --nocapture
```
结果:1 个测试通过。
@@ -0,0 +1,91 @@
# 7-17 [done][bug] mnote.doc.markdown_edit 同块多操作写回会覆盖前序修改 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
> 关联代码:
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:779-824` — markdown 字符串层顺序执行 operations
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:854-878` — Convex 写回重新转换成 block ops
> - `rust/crates/mnote-web/src/hermes_tools/doc.rs:976-1008` — `build_block_ops_from_markdown_edit`
## 1. 问题定义
`doc_markdown_edit` 会先把所有 search/replace 顺序应用到 `md`
```rust
match search_replace(&md, &search, &replace) {
Ok(new_md) => {
md = new_md;
applied += 1;
}
...
}
```
但在线 Convex 文档写回时没有使用这个最终 `md`。当前实现重新读取 Page Aggregate blocks,然后对每条 operation 基于原始 block 文本生成一个 replace block op
```rust
let block_text = block.get("text").and_then(Value::as_str).unwrap_or("");
let new_text = block_text.replacen(search, replace, 1);
```
如果两条 operation 命中同一个 block,第二条 operation 的 `new_text` 仍从原始 `block_text` 计算,会覆盖第一条 operation 的结果。
## 2. 影响
用户在页面 AI 中一次提出多个同段修改时,可能只保留最后一个修改。例如:
```json
[
{ "search": "A", "replace": "B" },
{ "search": "C", "replace": "D" }
]
```
`A``C` 都在同一个 block 中,markdown 层预期最终是 `B ... D`,但写回层可能生成两个 replace block op
```text
op1 content = 原始文本中 A -> B
op2 content = 原始文本中 C -> D
```
最终第二次 replace 会把 block 写成只包含 `C -> D` 的版本,前一次 `A -> B` 被丢失。
## 3. 根因
`doc_markdown_edit` 有两套编辑结果:
- `md`:顺序应用 operations 后的真实 markdown 结果。
- `block_ops`:从原始 blocks 和原始 operations 重新推导出来的写回操作。
Convex 写回使用的是 `block_ops`,而不是 `md`。这让“markdown 编辑主路径”在在线文档里退化成了不完整的 block ops 转换层。
## 4. 建议修复
- 最小修复:`build_block_ops_from_markdown_edit` 先按 block 聚合 operations,并基于当前已更新文本连续计算同块最终 content。
- 更稳妥修复:以最终 `md` 作为单一结果,走 markdown -> EditorBlockDocument / page.body.save 的正式转换链,不从原始 operations 二次推导写回结果。
- 增加同一 block 多 operation 的单测和真实 Page Aggregate 回读 smoke。
- 明确部分失败是否允许写入;默认建议任一 operation 失败时不写入,除非调用方显式允许 partial apply。
## 5. 修复
已修复:
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1026) `build_block_ops_from_markdown_edit` 现在维护每个块的当前文本,按 operations 顺序累积同块修改。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1061) 同一块多次命中时只输出一次最终 `replace` block op,避免后续 op 基于原始文本覆盖前序修改。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1772) 增加 dry-run 路由级回归测试,覆盖第二个 search 依赖第一个替换结果的同块多操作场景。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_merges_same_block_operations -- --nocapture
```
结果:1 个测试通过。
## 7. 剩余风险
本修复解决“同块多操作覆盖前序修改”的最小 P0;跨块 Markdown 重写、full_content 与最终 Markdown 作为唯一写回真源仍归属于 `7-24`
@@ -0,0 +1,51 @@
# 7-18 [done][bug] AI markdown_edit 阶段状态合同漂移 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
当前仓库的顶层协作口径仍描述 AI 侧“当前只做 Phase APhase B 退役 `local_rule` planner 仍未实施”,但 `design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` 与源码已经把 `mnote.doc.markdown_edit` 推到实际运行链路。
这造成阶段合同漂移:文档与实现同时在表达“仍处 Phase A”和“Phase A / B 已开始切主”两种状态。
## 2. 影响
- 后续开发者无法判断 `page_ai_workflow` 是否应继续保留 `local_rule` 快路径。
- AI 编辑验收标准会混乱:到底验 `apply_block_ops`,还是验 `markdown_edit` 回读一致性。
- 新功能容易继续堆到过渡路径,而不是收敛到正式工具合同。
## 3. 证据
- `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` 已直接调用 `doc::doc_markdown_edit`
- `design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` 已记录在线 / 本地 markdown 编辑收敛方向。
- 根目录协作口径仍强调 Phase B 退役 `local_rule` planner 属于后续项。
## 4. 建议修复
- 明确当前真实阶段:`markdown_edit` 是否已经是页面 AI 简单编辑主路径。
- 若已切主,应把 `page_ai_workflow`、Hermes guidance、manifest 和 smoke 都同步到该口径。
- 若未切主,应限制 `markdown_edit` 的 runtime 使用面,并把设计稿状态回退为实验态。
## 5. 修复
2026-05-18 已完成运行时侧收口与顶层协作口径同步:
- `page_ai_workflow` 当前运行时已统一走模型输出 `search/replace``mnote.doc.markdown_edit` → 统一 mnote tool executor。
- Hermes run guidance 已改为普通正文编辑优先 `mnote_doc_markdown_edit``apply_block_ops` 仅作为结构性块操作辅助。
- `markdown_edit` 在线写回在无法安全映射最终 markdown 时明确拒绝,避免 `ok=true` 伪成功。
- 根目录 [AGENTS.md](/mnt/Data1T/mnote/AGENTS.md:14) 已同步为当前真实阶段:简单正文编辑主路径是 `mnote.doc.markdown_edit``page_ai_workflow` 通过模型生成 markdown 编辑意图后调用统一 mnote tool executor`apply_block_ops` / `mnote.block.*` 仅作为结构性辅助。
## 6. 验证
- `rg -n "local_rule|Phase A|Phase B|当前只做|过渡实现为 .*block-edit-workflow" AGENTS.md`
- 结果:无输出,旧阶段合同表述已移除。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
- 结果:通过。
@@ -0,0 +1,44 @@
# 7-19 [done][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` 已被设计为普通页面正文编辑的主路径,但 Hermes run 的工具指导仍要求简单正文编辑优先调用 `mnote_doc_apply_block_ops`
这导致模型策略与工具主线冲突。
## 2. 证据
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2351) 构造工具指导。
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2357) 要求上下文足够时直接调用 `mnote_doc_apply_block_ops`
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2360) 明确“简单小段落编辑”优先用 `apply_block_ops`
## 3. 影响
- Hermes 与 page AI fast-path 会走不同编辑工具。
- markdown 级 search/replace 的收敛设计无法成为唯一验收口径。
- `apply_block_ops` 的 revision / conflictDetectionKey 合同缺陷会被继续放大。
## 4. 建议修复
- 将普通正文 search/replace、局部段落替换、全文 markdown 替换统一引导到 `mnote_doc_markdown_edit`
- 仅在需要结构性块移动、资源块、复杂定位时才引导模型使用 `apply_block_ops``mnote.block.*`
- 同步更新 smoke:模型生成工具调用时应优先产出 `mnote_doc_markdown_edit`
## 5. 修复
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2456) 将 Hermes run instructions 改为:普通正文 search/replace、局部段落替换、全文 markdown 替换优先调用 `mnote_doc_markdown_edit`
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2494) 将 `blockEditingToolOrder` 首位调整为 `mnote_doc_markdown_edit``mnote_doc_apply_block_ops` 仅保留给 markdown_edit 不能表达的结构性块操作。
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:4559) 增加 run guidance 合同测试,防止普通正文编辑重新退回 `apply_block_ops` 优先口径。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_body -- --nocapture`
- 结果:2 passed
@@ -0,0 +1,48 @@
# 7-20 [done][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`/api/page-ai/block-edit-workflow` 仍是独立模型调用链:直接读取 Hermes profile、调用 chat/completions、解析模型 JSON,并直接调用 Rust 工具函数。
它没有通过 `/api/hermes/tools/mnote/call` 的工具执行壳,因此绕过了 tool toggle、audit、统一幂等和统一调用追踪。
## 2. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 直接调用上游模型。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 在 route 内构造 `ToolCallInput`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) 直接调用 `doc::doc_markdown_edit`
## 3. 影响
- 关闭或限制 mnote tool 时,该 fast-path 仍可能写入。
- 工具调用审计与普通 Hermes run 不一致。
- 幂等、dryRun、runId、toolCallId 等字段无法统一治理。
## 4. 建议修复
- 将 fast-path 的工具写入改为调用统一 tool executor。
- fast-path 只负责 prompt / plan,不直接执行写入函数。
- 增加 smoke:当对应 tool disabled 时,`page_ai_workflow` 不得绕过限制写入。
## 5. 修复
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:62) 将统一 mnote tool 执行壳抽为 `execute_mnote_tool_call`,保留 profile disabled、audit、idempotency、auth/workspace 校验和统一结果包装。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) `block_edit_workflow` 的写入阶段改为调用统一 executor,不再直接调用 `doc::doc_markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:657) 增加路由级测试:当 profile 禁用 `mnote.doc.markdown_edit` 时,页面 AI fast-path 必须返回 `mnote_tool_disabled`,不得绕过限制写入。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:3 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_call_rejects_profile_disabled_tool -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_maps_normalized_search_to_block -- --nocapture`
- 结果:1 passed
@@ -0,0 +1,46 @@
# 7-21 [done][bug] mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` 的本地文件分支没有调用统一写入合同校验,也没有检查 `dryRun``idempotencyKey`,在匹配成功后直接 `fs::write`
## 2. 证据
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:714) 进入 `doc_markdown_edit` 后没有先调用 `ensure_write_contract`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:842) 注释说明本地文件直接写回。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:845) 对本地路径执行 `fs::write`
## 3. 影响
- `dryRun=true` 时也可能落盘,违反工具预览语义。
- 缺少幂等键会让重试请求重复写入,尤其是 ACP / Hermes 断线重试场景。
- 本地 `.md` 与在线 Convex 文档的写入安全级别不一致。
## 4. 建议修复
-`doc_markdown_edit` 写入前统一调用 `ensure_write_contract`
-`dryRun=true` 返回 diff / preview,不落盘。
- 增加本地 `.md` dryRun 单测,断言文件内容不变。
## 5. 修复
已修复:
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:631) 将统一写入合同校验开放为 `pub(crate)`,供 `mnote.doc.markdown_edit` 复用。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:722) `mnote.doc.markdown_edit` 入口统一调用 `ensure_write_contract`,本地 `.md` 与在线文档一致要求 `idempotencyKey` 与显式 `dryRun`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:845) 本地文件 `dryRun=true` 时只返回 `written=false` 预览结果,不再写入磁盘。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1557) 增加路由级回归测试,覆盖缺少 `idempotencyKey` 与本地 dry-run 不落盘。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_local -- --nocapture
```
结果:2 个测试通过。
@@ -0,0 +1,50 @@
# 7-22 [done][bug] mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.apply_block_ops` 的批量入口只校验 dryRun / idempotency 写入合同,没有调用页面 revision、conflictDetectionKey、blockRevisionRef 的写前校验。
同文件中已有 `ensure_page_write_preconditions`,但该批量入口没有使用它。
## 2. 证据
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:385) 定义 `doc_apply_block_ops`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:390) 仅调用 `ensure_write_contract`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:774) 存在 `ensure_page_write_preconditions`,但批量入口未调用。
## 3. 影响
- AI 可能基于旧 `PageAggregate` 对正文写入,覆盖用户新编辑。
- 多轮 Hermes / ACP run 同时写入时缺少冲突保护。
- `markdown_edit` 在线分支会转调 `apply_block_ops`,因此该缺陷会影响 markdown 主路径。
## 4. 建议修复
- `doc_apply_block_ops` 真实写入前必须校验 page revision 与 conflictDetectionKey。
- 块级操作应支持并校验 blockRevisionRef 或等价版本字段。
- 增加负向测试:缺少 revision / conflictDetectionKey 时真实写入必须拒绝。
## 5. 修复
已修复:
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:413) `doc_apply_block_ops` 真实写入前调用 `ensure_page_write_preconditions`,要求 `revision``conflictDetectionKey`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:439) replace / delete / move 目标块现在校验 operation 内的 `blockRevisionRef`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:472) insert_after / move_after anchor 现在校验 operation 内的 `anchorRevisionRef`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:875) `mnote.doc.markdown_edit` 在线转调 `doc_apply_block_ops` 时自动携带当前 aggregate 的 `revision``conflictDetectionKey` 与每个 replace op 的 `blockRevisionRef`
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1490) 增加批量入口负向测试,覆盖缺少 page precondition 与缺少 block revision ref。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_apply_block_ops_requires_page_preconditions -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
结果:批量前置条件测试通过,5 个 markdown_edit 相关测试通过。
@@ -0,0 +1,47 @@
# 7-23 [done][bug] mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` manifest 的 input schema 没有完整声明 `required` 字段,也缺少 `dryRun``idempotencyKey` 等写入合同字段。
这会让模型、ACP wrapper 和外部调用方无法从 manifest 得知真实写入要求。
## 2. 证据
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:413) 附近定义 `mnote.doc.markdown_edit` manifest。
- 当前 schema 更像参数提示,没有完整表达写入前置条件。
## 3. 影响
- 调用方可能不传幂等字段或 dryRun 字段。
- tool toggle / schema validation 无法提前拒绝不合规请求。
- Reasonix / ACP 等多 runtime 接入时会继续复制不完整合同。
## 4. 建议修复
-`mnote.doc.markdown_edit` 补全 JSON Schema`required`、互斥的 `operations` / `full_content`、写入合同字段。
- manifest 与 Rust runtime 校验保持一致。
- 增加 manifest snapshot 测试,防止字段再次漂移。
## 5. 修复
已修复:
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:413) `mnote.doc.markdown_edit` 改为复用统一 `write_tool` schema 生成器。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:302) manifest 现在声明 `dryRun``idempotencyKey` 与 actor/session/run/toolCall/trace 等写入合同字段。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:449) 通过 `anyOf` 声明 `operations` / `full_content` 至少提供一个。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:730) 增加 manifest 路由级测试,防止 schema 再次漂移。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest_describes_markdown_edit_write_contract -- --nocapture
```
结果:1 个测试通过。
@@ -0,0 +1,44 @@
# 7-24 [done][bug] 在线 markdown_edit 写回不以最终 Markdown 为真源 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
在线 Convex 文档的 `mnote.doc.markdown_edit` 先在 `md` 字符串上顺序应用 search/replace,但写回时没有用最终 `md` 生成新的文档真相,而是重新读取原始 blocks,并用原始 operations 反推 `apply_block_ops`
因此 markdown 层已经算出的最终结果不一定能被忠实落库。
## 2. 证据
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:811) 在 `md` 上执行 search/replace。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:855) 写回前重新读取 aggregate / blocks。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:857) 使用原始 operations 构造 block ops,而不是解析最终 `md`
## 3. 影响
- 跨块替换、全文替换、标题 / 列表格式变化可能在 markdown 层成功,但无法正确转换成 block ops。
- 部分失败仍可能返回成功写入,用户看到的正文与工具返回摘要不一致。
- 该问题与 `7-17` 的同块多操作覆盖同源,但影响面更宽。
## 4. 建议修复
- 在线分支应明确选择一种真源:要么最终 `md` 可被解析并完整生成 EditorBlockDocument,要么限制 `markdown_edit` 只支持可安全映射的单块操作。
- 对 full_content、跨块 search/replace、列表/标题变更增加回读一致性测试。
- route 成功条件必须从“operationsApplied > 0”提升为“Page Aggregate 回读等于预期最终 markdown”。
## 5. 修复
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:868) 在线分支在 markdown 层命中后重新映射为 block ops;若最终 markdown 变化无法安全映射到可写块,会返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再把空 operations 交给 `apply_block_ops` 或返回伪成功。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1857) 增加 `full_content` 在线写回合同测试:当前没有完整 markdown->block 真源写回时必须明确拒绝,不能以 `ok=true` 伪成功。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1805) 保留 markdown 命中但块映射为空的提前失败测试,避免回退到 `mnote.doc.apply_block_ops operations 不能为空`
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:8 passed
@@ -0,0 +1,108 @@
# 7-25 [done][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
> 关联缺陷:`7-24 在线 markdown_edit 写回不以最终 Markdown 为真源`
## 1. 用户可见症状
在页面 AI 中切换到 `ACP · Reasonix`,输入:
```text
检查你是否能读取到本页第一段,同时请修改第二段为:测试123
```
工具调用失败:
```text
mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
结果 mnote.doc.apply_block_ops operations 不能为空
```
## 2. 问题定义
`page_ai.block_edit_workflow` 当前已切到:
```text
模型输出 search/replace
-> mnote.doc.markdown_edit
-> 在线文档再转换为 mnote.doc.apply_block_ops
```
但在线写回层没有以最终 Markdown 为真源。它在 markdown 字符串中 search/replace 成功后,又用原始 `operations` 去原始 `blocks` 中反查目标块。如果反查不到块,就生成空 `block_ops`,随后仍调用 `mnote.doc.apply_block_ops`,最终抛出“operations 不能为空”。
## 3. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 调用模型生成 markdown operations。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 构造 `mnote.doc.markdown_edit` 输入。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:811) 在 `md` 字符串上执行 search/replace。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:857) 调用 `build_block_ops_from_markdown_edit`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:989) 只用 `block.text.contains(search)` 反查 block。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1008) 可能返回空 `block_ops`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:878) 即使 `block_ops` 为空,也继续调用 `doc_apply_block_ops`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:405) 对空 operations 报错:`mnote.doc.apply_block_ops operations 不能为空`
## 4. 根因判断
这条错误不是最终 root cause,只是下游暴露出来的症状。
真正根因是 markdown 层与 block ops 写回层存在二次定位:
1. `search_replace(&md, search, replace)` 可以通过精确、归一化或 fuzzy 匹配成功。
2. `build_block_ops_from_markdown_edit` 却只支持 `block.text.contains(search)`
3. 两套匹配规则不一致时,markdown 层显示已命中,block 层却生成空 operations。
4. 空 operations 没有在 `markdown_edit` 层转成语义化错误,而是继续传给 `apply_block_ops`
## 5. 影响
- ACP Reasonix 的页面编辑会向用户暴露底层 `apply_block_ops` 错误,而不是说明“markdown 命中但无法映射到块”。
- “读取第一段 + 修改第二段”这类混合任务可能进入 fast workflow,但该 workflow 只能表达写入,不保证先读后答。
- 如果模型给出的 `search` 是段落序号、fuzzy 片段、带格式文本或跨块文本,markdown 层可能成功,block 写回层仍失败。
## 6. 建议修复:建议以长期,彻底的修复优先,建议参考/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main中对全局替换和单块替换的判断逻辑。
短期:
- `doc_markdown_edit` 在调用 `doc_apply_block_ops` 前,如果 `applied > 0``block_ops.is_empty()`,应返回明确错误,例如 `mnote_doc_markdown_edit_block_mapping_empty`,并包含 failed operation 的 search 摘要。
- `page_ai_workflow` 应把该错误翻译为用户可理解的提示,不要暴露 `apply_block_ops operations 不能为空`
中期:
- `build_block_ops_from_markdown_edit` 必须复用 `search_replace` 的定位结果,或让 `search_replace` 返回原始命中范围 / paragraph / block 映射。
- 对“第一段/第二段”等序号型指令,应由模型输出对应段落原文作为 `search`,并增加回读校验。
长期:
-`7-24` 收口:在线 `markdown_edit` 应以最终 Markdown 为写回真源,或明确限制只支持可安全映射的单块精确替换。
## 7. 复现 / 验证建议
- 浏览器 smokeACP Reasonix,页面含至少两段正文,输入“检查第一段并修改第二段为:测试123”。
- 断言失败时错误码不能是 `mnote.doc.apply_block_ops operations 不能为空`,应是 markdown 到 block 映射失败的明确错误。
- 修复后回读 Page Aggregate,断言第二段实际变为 `测试123`,同时 AI 回复能正常说明已读取到第一段。
## 8. 修复
已修复:
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:862) 在 `mnote.doc.markdown_edit` 调用 `doc_apply_block_ops` 前拦截 `applied > 0 && block_ops.is_empty()`,返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再泄露 `mnote.doc.apply_block_ops operations 不能为空`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,让 markdown 层与 block 映射层共享“精确 / 忽略空白 / 全半角归一化”规则。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1038) 在线 block ops 构建不再用 `block.text.contains(search)`,改为对单块文本执行同一套安全匹配;fuzzy 仍只保留在 markdown 预处理层,避免写错块。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1670) 增加回归测试,覆盖空映射语义错误与忽略空白后正确映射到 `p_2`
## 9. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
结果:4 个相关测试通过。
## 10. 剩余风险
本修复解决单块 search/replace 映射和空 ops 下沉;跨块 Markdown 重写、完整最终 Markdown 作为写回真源仍归属于 `7-24`
@@ -0,0 +1,38 @@
# 7-26 [done] Resource 块保存后在 AI block projection 中被降级为 paragraph
## 状态
- Owner`07-ai` / Page Block AI projection 写入前置合同
- 优先级:P0,影响 AI 精确块编辑安全边界
- 状态:已修复并验证
## 现象
真实 3000 smoke 扩展复杂块后,`mnote.page.save` 写入 `type:"resource"` 的块,再经 Page Aggregate / AI block projection 回读时,`resource_1` 被显示为:
- `type="paragraph"`
- `editable=true`
- `unsupportedReason=null`
这会让 AI 工具把资源占位误判为普通可编辑文本块,违反复杂块不得伪装完全可编辑的合同。
## 根因
`core-protocol``EditorBlockType` 没有 `Resource` 变体,`bridge-runtime``normalize_editor_block_type_for_save()` 因此把未知 `resource` 类型落入默认 `Paragraph`。后续 `legacy_content_from_editor_document()``project_legacy_content_to_block_document()` 只能看到 paragraph,导致 AI projection 误报可编辑。
## 修复
-`rust/crates/core-protocol/src/editor/model.rs` 增加 `EditorBlockType::Resource`
- 在 Tiptap bridge 中用 `mnoteBlockType=resource` 保留 resource 类型往返。
-`bridge-runtime` 中让 `resource/resource_block` 规范化为 `EditorBlockType::Resource`legacy 回写继续输出 `type:"resource"`
- 保留 resource props 到 `resourceProps`legacy 回写时恢复。
-`editor_actor` block 类型摘要中补 `resource`
- 扩展 `scripts/task-page-block-ai-tools-smoke.js`,覆盖 resource 在真实 3000 的投影和 move 阻断。
## 验证
- `cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document_roundtrip_preserves_resource_block_type -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture`
- `PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
最新真实 smoke 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
@@ -0,0 +1,76 @@
# 7-27 [done][bug] 在线 markdown_edit full_content 会保留旧普通块
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
> 关联设计:`design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md`
## 1. 问题定义
`mnote.doc.markdown_edit` 的在线 Convex 文档路径已经切到 final markdown -> block content 直接写回,但 `full_content` 模式下,最终 markdown 通常不包含任何 `<!-- block:... -->` 原始块注释。
当前 `build_page_content()` 会把所有未处理的原始块追加到写回结果尾部,导致全文替换不是“替换全文”,而是:
```text
新全文块...
旧 heading / paragraph / todo 块...
```
## 2. 影响
- AI 执行全文改写、重写大纲、把页面替换为整理后的内容时,会保留旧正文残留。
- Page Aggregate / `mnote.doc.fetch` 回读会看到新旧内容混在一起。
- 7-27 的“最终 markdown 是唯一写回真源”合同被破坏。
## 3. 根因
`parse_final_markdown_to_blocks()` 对无注释的 final markdown 行会生成 `is_new=true` 块,这是正确的。
问题在 `build_page_content()` 的末尾:
```rust
for original in &original_blocks {
if let Some(id) = block_id_of(original) {
if !processed_ids.contains(&id) {
result.push(original.clone());
}
}
}
```
该逻辑没有区分:
- 部分 search/replace 丢掉某个块注释:应保守保留缺失旧块,避免误删。
- `full_content` / 全文替换没有任何原始块 ID:应只保留复杂块,普通旧文本块应被替换掉。
## 4. 修复
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:727) 给 `build_page_content()` 增加策略:
- 若 parsed 中至少引用了一个原始 block id,则保留未处理原始块,维持安全降级。
- 若 parsed 中没有任何原始 block id,则视为全文重建,只保留复杂块,不保留普通旧文本块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:799) 增加 `is_complex_markdown_original_block()`,用于 full_content 重建时保留 resource / mindmap / table / image / attachment / 非 editable / unsupported / 带 children 的复杂块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1059) 增加单元测试:
- `full_content` 无原始 ID 时不保留旧 heading / paragraph。
- 原始 resource 复杂块仍保留。
- 部分块注释丢失时仍保留缺失旧块。
## 5. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
git diff --check -- rust/crates/mnote-web/src/hermes_tools/doc.rs bugs/07-ai/done/7-27-markdown-edit-full-content-retains-old-blocks-v1.md
```
结果:
- `build_page_content` 相关测试:`2 passed`
- `hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping``1 passed`
- `hermes_tools_markdown_edit` 相关测试:`6 passed`
- Rust workspace format check 通过。
- 相关路径 diff check 通过。
@@ -0,0 +1,49 @@
# 7-28 [done][bug] markdown_edit 未强制 selection 允许块边界
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
页面 AI context 已经可以携带 `allowedTargetBlockIds`,用于表达 `scope=selection` 时本次 run 允许修改的块集合。但 `mnote.doc.markdown_edit` 当前只按文本 search/replace 写回,不检查最终变化的 block 是否落在允许集合内。
如果 fast workflow 或未来调用者传入了过宽的 `pageText`,模型返回了选区外文本的 search/replace,服务端仍可能写入选区外块。
## 2. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 调用 `mnote.doc.markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:299) 读取了 `allowedTargetBlockIds`,但未传给写工具或参与校验。
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2860) 工具指导要求 `scope=selection` 时只能修改 `allowedTargetBlockIds` 内的块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1220) `markdown_edit` 生成 `changedBlocks` 后直接 dry-run 或写回,没有 selection 范围校验。
## 3. 影响
- 页面 AI 对选中文本执行编辑时,服务端缺少最终兜底边界。
- 上下文构造或模型输出一旦漂移,选区外块可能被写入。
-`mnote.block.*` 已有 `allowedTargetBlockIds` / blockId 前置条件相比,`markdown_edit` 的写入安全边界较弱。
## 4. 修复
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 将 `aiContext.allowedTargetBlockIds` 透传给 `mnote.doc.markdown_edit`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:855) `mnote.doc.markdown_edit` 在生成 `changedBlocks` 后,如果存在 `allowedTargetBlockIds` / `selectedBlockIds`
- 任何带 `blockId` 的变更必须属于允许集合。
-`blockId` 的 insert/full_content 变更在 selection scope 下拒绝,避免无法归属的选区外写入。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:2308) 增加 route 测试:`allowedTargetBlockIds=["p_2"]` 时替换 `p_1` 必须返回 `mnote_markdown_edit_target_out_of_scope`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:742) 增加 fast workflow 测试:模型输出修改 `p_1`,而 selection 只允许 `p_2`,必须被服务端拒绝。
## 5. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit_rejects_selection_out_of_scope -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
```
结果:
- `hermes_tools_markdown_edit_rejects_selection_out_of_scope``1 passed`
- `block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit``1 passed`
- Rust workspace format check 通过。
@@ -0,0 +1,44 @@
# 7-29 [done][bug] markdown_edit 会把普通块 inline content 扁平化
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` 的 online 写回已经从“二次推导 block ops”切到 final markdown → block content,但 `build_page_content()` 仍会把普通块的 legacy `content` 统一写成扁平字符串。
这会让未修改的块也丢失原始 inline content 结构,修改过的单段落块则更容易丢失 bold / italic / link / code 等 marks。
## 2. 证据
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:762) 当前普通块写回直接 `obj.insert("content", json!(parsed_block.text))`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:826) 只有 `text``id` 被保留,原 inline 节点结构被覆盖。
- [7-27](/mnt/Data1T/mnote/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md:580) 已明确指出当前 `blocks_to_markdown` / 写回链会丢弃 inline marks。
## 3. 影响
- 未修改块在 `full_content` / 局部替换后也可能失去 inline 节点结构。
- 已修改块中的 bold / link / code 等 marks 可能被扁平化成普通字符串。
- 页面 AI 的正文重写与可视格式回读不一致。
## 4. 修复结果
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs) 的 `build_page_content()` 已改为区分未变更、可安全局部替换、复杂块保留三类写回路径。
- 未变更的普通块会直接保留原始 block 结构,不再被重写成纯字符串 content。
- 已变更且原始 `content` / `contentNodes` 是单 inline text 节点的普通块,只替换该 text 节点文本,保留原 marks / attrs。
- 复杂块继续原样保留;无法证明可安全替换的已变更普通块才退回纯文本 content。
## 5. 验证证据
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
验证结果:
- `build_page_content` 定向测试通过,覆盖 full_content 不保留旧普通块、局部替换保留未变更块、单 inline text marks 保留。
- `hermes_tools_markdown_edit` 定向测试通过,覆盖 markdown edit 在线写回链路。
@@ -0,0 +1,48 @@
# 7-30 [done][bug] 单个 mnote.block 写工具未强制 selection scope
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.apply_block_ops` 已经通过 `allowedTargetBlockIds` 限制 AI selection 范围内的结构性写入,但单个 `mnote.block.replace` / `mnote.block.insert_after` / `mnote.block.delete` / `mnote.block.move_after` 写工具没有同等服务端兜底。
这会导致模型或 ACP 调用者在 `scope=selection` 场景下,绕过批量工具直接调用单块写工具时,仍可能改到 selection 外的块。
## 2. 证据
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的 `doc_apply_block_ops()` 对每个操作调用 `ensure_allowed_target()`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的单个 block 写工具当前只校验 revision / editable / precondition,缺少同等 `allowedTargetBlockIds` 校验。
- [7-10](/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md:70) 已标注“单个 `mnote.block.*` 写工具尚未完成 `allowedTargetBlockIds` 矩阵”。
## 3. 影响
- AI 页面 selection 编辑边界不完整。
- `markdown_edit``apply_block_ops` 已拒绝越界写入,但单块工具仍可能成为绕过路径。
- ACP / Hermes tool guidance 即使要求 selection 内写入,也缺少 runtime 级强约束。
## 4. 修复结果
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 已为 `mnote.block.replace``mnote.block.insert_after``mnote.block.delete``mnote.block.move_after` 增加统一 `allowedTargetBlockIds` 服务端校验。
- `move_after` 同时校验被移动块和 anchor 块,避免 selection 内块被移动到 selection 外 anchor 后。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs) 已在单块写工具 schema 中公开 `allowedTargetBlockIds`
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs) 已增加回归测试,覆盖单块写工具越界时返回 `mnote_block_target_out_of_scope`
## 5. 验证证据
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_tools_selection_scope -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
```
验证结果:
- RED:修复前 `block_tools_selection_scope` 失败,越界单块写工具返回 `200`
- GREEN:修复后 `block_tools_selection_scope` 通过:`2 passed`
- `hermes_tools_block` 通过:`6 passed`
- `hermes_tools_manifest` 通过:`3 passed`
@@ -0,0 +1,119 @@
# 页面 AI 块编辑 — model 主路径操作缺少 contentdirect path 架构定位错误)
> 发现时间:2026-05-16
>
> 更新时间:2026-05-18(运行时收口验证)
>
> 状态:`[done]`
>
> 关联主线:`07-ai` / `05-editor-mainline`
>
> 关联设计稿:`design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md`
>
> 关联代码:
> - `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` — block_edit_workflow, direct_block_edit_operations(应退役), call_block_edit_model(唯一正确路径)
> - `rust/crates/mnote-web/src/hermes_tools/block.rs:436` — doc_apply_block_ops, replace 操作缺少 content
## 症状
用户用自然语言在页面 AI 面板输入块编辑指令时,`mnote.page_ai.block_edit_workflow` 返回 400
```
replace 操作缺少 content
```
## 复现步骤
1. 打开任意文档
2. 点击右下角「AI 助手」
3. 输入自然语言指令,如 `把第一段文字改成:AI成功修改了这一段。`
4.`POST /api/page-ai/block-edit-workflow` 返回 400
5. 页面上显示 `mnote.page_ai.block_edit_workflow 失败`
## 架构问题:两条路径的设计是错误的
当前 `block_edit_workflow` 有两条路径,但**正确的路径只有一条**:
### 唯一正确路径:Model path`call_block_edit_model`
```
用户自然语言 → 模型理解语义 → 产出 operations JSON → doc_apply_block_ops → 写入
```
这是 AI 面板应有的行为:用户说人话,模型理解意图,产出操作。这是**唯一主路径**。
### 应退役路径:Direct path`direct_block_edit_operations`
```
用户特定格式 → 正则抠「」内文本 → 直接拼 operations → 写入
```
这不是 AI,这是**命令行**。它要求用户按固定格式输入(「」引号),本质是把 AI 面板当成 shell 在用。当前它"能用"只是因为绕过了模型调用,看起来"快",但:
- 不能处理自然语言("把这段话改简洁一些")
- 不能批量推理("把所有 TODO 改成已完成"
- 不能跨块理解("把第一段和第二段合并")
- 和 AI 对话的本意完全背离
**结论**direct path 应该退役,model path 是唯一主路径。
## 当前 model 主路径的具体缺陷
系统 prompt`page_ai_workflow.rs:274`)只说了 `op` 四选一,**没有告诉模型每种 operation 需要的字段**
```
当前 prompt:
"operations 的 op 只能是 replace、insert_after、delete、move_after。优先使用 page_xml 中的 block id;禁止输出解释文字。"
缺少的信息:
- replace 需要 blockId(或 matchText+ content
- insert_after 需要 blockId(或 matchText+ content
- delete 只需要 blockId(或 matchText
- move_after 需要 blockId + targetBlockId
```
模型不知道 schema,自然会漏掉 `content` 字段。DeepSeek v4 Flash 的 `response_format: json_object` 只保证输出是合法 JSON,不保证字段齐全。
## 更深层问题:块操作粒度是否合理
当前 AI 编辑通过 `operations: [{ op: "replace", blockId: "...", content: "..." }]` 这种逐个块操作的方式执行。但:
1. **mnote 的在线文档本质上是一个 markdown 文件**,Tiptap 只是其块级 UI 表现层
2. **本地 md 文件模式**下,Tiptap 退化为纯显示层,编辑直接在 markdown 文本上进行
3. 在线文档应该**向本地文档靠拢**——对 AI 而言,最自然的编辑方式是"给我一段 markdown,我返回修改后的 markdown",而不是"给我 blocks 数组,我逐个块产出 op"
如果在线文档和本地文档走两套 AI 编辑口径(一套块操作、一套文本操作),长期维护成本翻倍。
## 建议修复方向
### 立即修复(让 model 主路径可用)
1. **补全 system prompt 的 operation JSON schema**:明确 replace/insert_after 需要 `content` 字段,给出完整示例
### 架构收口(应该做的)
2. **退役 direct path**`direct_block_edit_operations` + `quoted_segments` 整条路径标记 deprecated
3. **考虑降低块操作粒度**AI 编辑是否可以走 markdown diff 而非逐个块 ops?在线文档和本地 md 能否共用同一条 AI 写入路径?
4. **在线/本地口径收敛**:在 `design/07-ai/` 中明确在线文档 AI 编辑应向本地 md 的简洁模型靠拢
## 证据
- Browser smoke (2026-05-16)
- Direct path(「」quote,本质是命令行): ✅ 177ms — 这说明不了 AI 能力,只是正则匹配
- Model path(自然语言,真 AI: ❌ 400replace 缺少 content
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
- 用户反馈:direct path 不是 AI 面板应有的行为,应退役
## 运行时修复
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:67) 当前运行时已退役 direct path,所有块编辑请求统一走模型输出 `search/replace` 对,再进入 `mnote.doc.markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:307) system prompt 已改为明确要求 `operations[].search``operations[].replace`,不再要求模型输出旧的 block op `content` 字段。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) fast-path 写入已改走统一 mnote tool executor,避免绕过 tool toggle / audit / idempotency。
## 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:3 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:8 passed
## 剩余说明
旧的 `direct_block_edit_operations` / `quoted_segments` helper 当前仅作为 dead code 与历史单测保留,不再是 `block_edit_workflow` 的运行时入口;后续可单独清理测试语义,但不影响本缺陷的运行时修复。