# 7-27 [done] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2

> 更新：2026-05-18（v2：整合 CLI Main 参考实现分析，确认方向，补充见解）
>
> 2026-05-19 local-first 口径补充：
> - 本文仍适用于 `convex_workspace` / 在线文档的 `mnote.doc.markdown_edit` 修复，但当前默认产品形态已切到 local-first workspace。
> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”。
> - 后续新增 AI 编辑能力默认先保证本地 `.md` 与 `{mdBase}.assets/` 相对路径不被改写为 Convex media asset；在线 Convex 文档路径只作为可选 cloud / sync / share source。
>
> 2026-05-22 口径修正：`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback。本文只保留在线 / compat 写回修复的历史证据；当前 active 设计以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。
>
> 当前状态：`DONE`（已归档，代码验证通过 2026-05-21）
>
> 关联缺陷：`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
>                  `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
>                  `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
>
> 历史上位设计：`design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
>
> 当前 active 设计：`design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
>
> 参考实现：`design/05-editor-mainline/reference-code/cli-main/shortcuts/doc/`（str_replace skill）
>                  `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`（已有 GFM→blocks 解析器）

---

## 0. 参考实现分析：CLI Main（Lark Doc）怎么做？

### 0.1 实现结构

CLI Main 的 `docs +update --api-version v2` 将 `str_replace`、`block_replace`、`block_insert_after` 等命令以 PUT 请求发送到 Lark OpenAPI：

```go
// docs_update_v2.go:140-157
body := map[string]interface{}{
    "format":  runtime.Str("doc-format"),
    "command": cmd,           // "str_replace" | "block_replace" | ...
}
body["pattern"] = runtime.Str("pattern")    // str_replace 的搜索文本
body["content"] = runtime.Str("content")    // 替换文本或新内容
body["block_id"] = blockID                  // block_* 操作的目标块
body["revision_id"] = runtime.Int("revision-id")

// API: PUT /open-apis/docs_ai/v1/documents/{id}
```

**关键：`str_replace` 只传 `pattern` + `content`，不传 `final_md`。** 服务端（Lark OpenAPI）在收到 `command: "str_replace"` 后，由服务端负责在文档的 XML/block 存储中找到匹配的文本并替换。

### 0.2 与 mnote 的架构对应

| CLI Main (Lark Doc) | mnote | 角色 |
|---|---|---|
| Lark OpenAPI 服务端 | Rust `doc_markdown_edit` + `execute_page_body_save` | 执行文本替换→block 持久化 |
| `str_replace` API 端点 | `mnote.doc.markdown_edit` 工具 | AI 调用的文本级编辑入口 |
| 块操作 API 端点 | `mnote.doc.apply_block_ops` + `mnote.block.*` | AI 调用的块级编辑入口 |
| CLI 客户端（lark-cli） | Hermes agent / page_ai_workflow | AI 编排层，只产生`{pattern, content}`对 |

**mnote 的 Rust kernel 就是那个"服务端"。** 所以在 Rust 层实现 markdown→block content 的转换是正确的方向——这不是"在客户端做服务端的事"，而是 mnote 的 Rust 层本来就承担服务端职责。

### 0.3 CLI Main 对我们设计的启发

1. **`str_replace` 是不可拆分的原子操作** — CLI Main 的 `str_replace` 由服务端完整执行。一旦 `pattern` + `content` 送出，客户端不需要处理块映射。**mnote 的 `doc_markdown_edit` 也应该是一条完整的原子路径**：接收 `operations` → 搜索替换 → 产生 final_md → **直接转换 final_md 为 blocks 并写回**。不应该把 operations 暴露给下游做二次推导。

2. **文档中的所有纯文本搜索替换只需一条 API 调用** — CLI Main 不支持在一次请求中组合多个 `str_replace`。mnote 支持多条 operation 是更灵活的设计，但兑现这个灵活性的前提是：**多条 operation 在服务端累积应用到 `md` 后，只产出一组最终的 block content 写回**，而不是每条 operation 独立映射到 block。

3. **`pattern` 不限制为精确文本** — CLI Main 的 `str_replace` 文档鼓励 Markdown 模式下的"前缀...后缀"省略号语法（`"start...end"`），允许模糊定位。这和我们已有的四级匹配策略（精确→忽略空白→段落 fuzzy→失败）方向一致。

4. **CLI Main 在客户端做预检查** — `docs_update_check.go` 中有 `checkDocsUpdateReplaceMultilineMarkdown`，在发送请求前就检查 Markdown 中的空行是否会违反服务端的单块内文本替换限制。**同理，mnote 在转换 final_md 时也应该做预检**：发现跨块内容变化时，要么走 GFM 解析器重建多块 content，要么清楚告知 AI "这条 operation 需要多块替换"。

5. **mnote 的 final_md 方法比 CLI Main 更可靠** — CLI Main 把 `pattern` 发给服务端，由服务端重新执行匹配。如果服务端的匹配策略与模型预期的不同，结果会意外。mnote 的做法（在 Rust 层计算 final_md，然后用 final_md 生成 block content）把匹配阶段和写回阶段解耦——匹配结果对 AI 可见（文档中说"已验证将 X 替换为 Y"），不会出现「客户端匹配成功、服务端匹配失败」的不一致。

### 0.4 与 CLI Main 的根本差异：为何不能照搬

CLI Main 的方案（客户端只传 `pattern` + `content`，服务端做匹配+转换）依赖 Lark OpenAPI 对文档 block 存储的完全控制。mnote 的 Convex 后台（`documents:updateContent`）只接受完整的 blocks 数组作为 `content`，没有 `str_replace` 端点。

**这迫使 mnote 的 Rust 层必须自己做 final_md → block content 的转换。这个转换就是我们的"服务端逻辑"，是正确且必要的。**

---

---

## 1. 问题

### 1.1 当前架构

`mnote.doc.markdown_edit` 在在线 Convex 文档路径下有两套编辑结果：

```
搜索替换 → 最终 md ✅（正确的结果）

                  ↓
      build_block_ops_from_markdown_edit(blocks, operations, applied)
                  ↓ 二次推导
      doc_apply_block_ops → page.body.save
```

- **第一层**: `search_replace(&md, &search, &replace)` — 在完整的 markdown 字符串上顺序执行操作，支持精确/忽略空白/段落 fuzzy 四级匹配。
- **第二层**: `build_block_ops_from_markdown_edit` — 不从最终 `md` 提取修改，而是从原始 blocks 和原始 operations 重新推导每次命中。

**最终 markdown 被丢弃了。** 写入层使用的不是计算好的文本结果，而是从原始 operations 反查 blocks 的二次推导。

### 1.2 由此导致的已知缺陷

| 缺陷 | 表现 | 根因 |
|------|------|------|
| 7-17 同块多操作覆盖 | 两次 op 命中同块 → 第二 op 从原始文本计算，覆盖第一 op | 推导层不累积 |
| 7-24 在线不以最终 md 为真源 | `full_content` 无法映射到块 operations → 全部拒绝 | 推导层不支持全文替换 |
| 7-25 命中后空 block_ops | markdown 层匹配成功，block 映射失败 → 空 ops | 两套匹配规则不一致 |

当前「修复」是安全降级：推导失败从伪成功变成明确错误码，但推导本身仍然存在。

### 1.3 为什么推导不可靠

`build_block_ops_from_markdown_edit` 当前采用「每 operation 从 `block.text` 反查 + 维护 block_states 累积文本」策略 [doc.rs:1048-1103]，已在共享匹配函数和累积方面改进，但根本问题仍在：

1. **full_content 模式必须跳过推导**：全文替换的最终 md 与原始 blocks 没有逐 op 对应关系
2. **跨块替换不可映射**：一条 `search: "TODO"` 命中多个块时，推导层只产生一次 replace op
3. **格式变化丢失**：heading/todo 前缀在 markdown 中记录了结构语义，推导层只传文本

---

## 2. 设计目标

### 2.1 核心原则

> **最终 markdown 是写回的唯一真源。** 文本层算出的结果不经二次推导直接落地。

### 2.2 目标范围

- 在线 Convex 文档的 `mnote.doc.markdown_edit` 以最终 `md` 为唯一真源生成 `page.body.save` 的 `content` 载荷
- 本地文件路径不变（已经以最终 md 直接 `fs::write`）
- `mnote.block.*` / `mnote.doc.apply_block_ops` 不做改动
- `full_content` 模式不再被拒绝，支持
- 复杂块（mindmap、resource、table、image）在往返中保持不变

### 2.3 非目标（Phase C 范畴）

- 流式 apply（见 7-14 §7）
- suggest/review 模式
- 本地 `.md` 文件的块标识持久化（本地文件没有 block identity）

---

## 3. 方案：Markdown → Block Content 直接写回

### 3.1 流程图（替换后）

```
搜索替换 → 最终 md ✅
                ↓
   parse_final_markdown_to_blocks(&md, &original_blocks)
                ↓
   [blocks with preserved IDs + metadata + new blocks]
                ↓
   build_page_content(&original_body_content, &parsed_blocks)
                ↓
   execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks)
```

### 3.2 数据流

```
原始 blocks (来自 aggregate.body.blockDocument.blocks):
  b1: { type: "paragraph", text: "第一段", revisionRef: "r1" }
  b2: { type: "heading", text: "标题", revisionRef: "r2", props: { level: 2 } }
  b3: { type: "resource", ... }  ← 复杂块，全文不做修改

↓ blocks_to_markdown (当前，增强前)

当前 md (include_ids=true):
  第一段 <!-- block:b1 -->
  标题 <!-- block:b2 -->
  [resource: 资源块] <!-- block:b3 -->

↓ 搜索替换

最终 md:
  新第一段 <!-- block:b1 -->
  新标题 <!-- block:b2 -->
  [resource: 资源块] <!-- block:b3 -->

↓ parse_final_markdown_to_blocks (新增)

解析后的 blocks:
  b1: { type: "paragraph", text: "新第一段", revisionRef: "r1" }  ← 文本更新，其他不变
  b2: { type: "heading", text: "新标题", revisionRef: "r2", props: { level: 2 } }
  b3: { type: "resource", ... }  ← 保持原始内容不动（复杂块不可编辑）

↓ build_page_content (新增)

最终 content (aggregate.body.content 格式):
  [b1_legacy, b2_legacy, b3_legacy]  ← 复用原始块的非文本属性
```

### 3.3 组件设计

#### 3.3.1 `blocks_to_markdown` 增强（已有函数，扩展）

当前只输出 `<!-- block:ID -->`，需要扩展为带类型和 revisionRef：

```rust
// 当前:
format!("{text} <!-- block:{id} -->")

// 增强后:
format!("{text} <!-- block:{id}:{block_type}:{revision_ref} -->")

对 heading 块额外附加 `level` 字段：

```rust
// heading 块:
format!("{text} <!-- block:{id}:heading:{revision_ref}:level={level} -->")
```

解析器还原后从 `level` 字段恢复正确的 heading 级别。
```

对 heading 和 todo 等结构化块，前缀已经正确输出（`## `、`- [ ] `），解析器据此还原类型。

对复杂块（mindmap、resource、table、image）——即 `is_editable == false` 或在 `block_projection_blocks` 中标记了 `unsupportedReason` 的块——输出特殊标记使其不会被解析器修改：

```text
[mnote-raw-block:block_id]  <!-- block:{id}:{block_type}:{revision_ref} -->
```

然后写回层把这些块从原始 blocks 中按 ID 复制，不做任何修改。

#### 3.3.2 `parse_final_markdown_to_blocks`（新增函数）

签名：

```rust
/// 解析最终 markdown 为块列表，保留块元数据
///
/// * `final_md` — 搜索替换后的 markdown
/// * `original_blocks` — 从 aggregate.body.blockDocument.blocks 读取的原始块
///
/// 返回解析后的块列表，每个块包含后续写回所需的所有字段
fn parse_final_markdown_to_blocks(
    final_md: &str,
    original_blocks: &[Value],
) -> Vec<ParsedBlockInfo> {
    // 1. 利用 comrak 或逐行解析提取
    //    - `<!-- block:id:type:rev -->` 中的块元数据
    //    - 对带 ID 的块：保留原始块的属性
    //    - 对无 ID 的文本块：标记为"新块"
    //    - 对 [mnote-raw-block:...] 标记：原样保留原始块
    // 2. 返回重建后的块列表
}
```

返回值 `ParsedBlockInfo`:

```rust
struct ParsedBlockInfo {
    block_id: Option<String>,        // None = 新块（无原始 ID）
    block_type: String,              // "paragraph" / "heading" / ...
    text: String,
    block_revision_ref: Option<String>,  // 从注释或原始块继承
    is_new: bool,                    // true = 非原始块，需要分配新 ID
    original_block: Option<Value>,   // 从原始 blocks 复制（若 block_id 匹配）
    props: Option<Value>,            // 从原始块保留的属性
}
```

解析步骤：

1. **按换行分割 markdown 行**，忽略空行
2. **对每行提取 `<!-- block:id:type:rev -->` 注释**（正则：`<!-- block:([^:]+):([^:]+):([^: ]+) -->` 或类似）
3. **若无注释但有 `[mnote-raw-block:id]` 标记 → 从原始 blocks 按 ID 复制**
4. **若既无注释也无原始标记 → 创建新 paragraph 块**，无 `blockId`（由写入层生成）
5. **从注释中获取 `block_type`；若无注释，从行前缀推断**（`## ` → heading，`- [ ] ` → todo）
6. **用行中 `<!--` 前的部分作为文本内容**
7. **对带 ID 的块，从原始 blocks 查找并复制 `revisionRef`、`props`**

#### 3.3.3 `build_page_content`（新增函数）

将解析后的块列表与原始 `body/content` 合并，生成最终的 Convex `content` 数组：

```rust
fn build_page_content(
    original_content: &Value,    // 原始的 aggregate.body.content
    parsed_blocks: &[ParsedBlockInfo],
) -> Value {
    // 按 parsed_blocks 顺序遍历
    //   - 若 block_id 在 original 中存在：
    //       复制 original 中该块的完整结构，仅替换 text
    //   - 若 block_id 为 None（新块）：
    //       生成新 block_id，作为 paragraph 追加
    //   - 若 original 中的复杂块不在 parsed 中：
    //       保留（markdown_edit 不主动删块）
    // 返回 Value::Array
}
```

#### 3.3.4 在 `doc_markdown_edit` 中的集成（替换现有 online 写回）

```rust
// 当前（doc.rs ~line 856）:
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
if applied > 0 && block_ops.is_empty() { ... }
let revision = ...;
let conflict_detection_key = ...;
let apply_result = doc_apply_block_ops_with_meta(state, context, input, &aggregate, block_ops, revision, conflict_detection_key).await?;

// 替换为:
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
let next_content = build_page_content(&current_body_content(&aggregate), &parsed);
let changed_blocks = build_changed_blocks_summary(&original_blocks, &parsed);
execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks).await?
```

---

## 4. 边界情况处理

### 4.1 原始块 ID 在最终 md 中消失

可能发生在：
- 用户手动删除了 `<!-- block:id -->` 注释（极罕见，通过 AI 输出不可能）
- 模型输出的 search/replace 跨段合并了内容

**策略**：保留该原始块在 `content` 中的位置。`build_page_content` 对在 original 中存在但不在 parsed 中的块，按原样复制到最终 content。

### 4.2 新建块（无原始 ID）

用户可能要求"在末尾加一段总结"。模型输出 `full_content` 或 search/replace 产生的新文本带 `<!-- block:new_paragraph -->`。解析器标记为 `is_new=true`，写入层分配新的 `blockId`。

**策略**：新块使用临时 ID 格式 `ai_block_{timestamp}_{counter}`，与现有 `mnote.block.insert_after` 的块 ID 风格一致。

### 4.3 复杂块保留

resource/mindmap/table/image 块在 `blocks_to_markdown` 中被序列化为特殊标记 `[mnote-raw-block:id]`。搜索替换通常不会命中这些行（因为它们不含普通文本），但如果意外命中：

**策略**：在 `search_replace` 中，如果 search 路径包含了复杂块的特殊标记，整条 operation 标记为 failed。解析器遇到 `[mnote-raw-block:...]` 标记时，直接从 original blocks 复制。

### 4.4 搜索替换未命中任何块（applied > 0 但所有命中都是新文本）

当模型输出的 full_content 完全不同于原文时可能出现。

**策略**：`parse_final_markdown_to_blocks` 对每一行都产生块。如果没有任何 `<!-- block:id -->` 注释，所有块标记为 `is_new=true`。`build_page_content` 追加新块在后面，同时保留所有原始复杂块。

### 4.5 revision / conflictDetectionKey 一致性

`build_block_ops_from_markdown_edit` 当前依赖 `doc_apply_block_ops_with_meta` 来传递 revision。替换为直接调用 `execute_page_body_save_from_aggregate`，它从 aggregate 读取 revision/conflictDetectionKey [block.rs:1178-1189]。

**策略**：新路径从已经读到的 `aggregate` 中获取 revision/conflictDetectionKey，与现有路径完全一致。

### 4.6 dryRun 模式

当前 online 路径中 dry_run 由 `doc_apply_block_ops` 内部处理（不真正写入）。新路径也需要支持：

**策略**：若 `dryRun == true`，调用 `build_page_content` 但不调用 `execute_page_body_save_from_aggregate`，返回 diff 预览。diff 格式与当前 `docs.md:plan_update` 的 diff 格式一致。

---

## 5. 与已有代码的互动

### 5.1 `build_block_ops_from_markdown_edit` 的删除

该函数不再被 `doc_markdown_edit` 调用。它是一个私有 `fn`（仅 `doc.rs` 内部可见），唯一调用者被移除后成为死代码。

**策略**：直接删除函数体。保留调用处的行作为注释（`// 退役：7-27 改为 final_md→blocks 直接写回`），供后续参考。

### 5.2 `block_projection_blocks` 的继续使用

仍然需要原始 blocks 作为元数据源（提取 revisionRef、props、复杂块）。不改变。

### 5.3 `local_markdown_parser` 的复用

`parse_final_markdown_to_blocks` 可以复用 `local_markdown_parser::markdown_to_blocks` 对 GFM 结构的解析（heading、todo、code block、list），但需要用自己的逻辑提取 `<!-- block:... -->` 注释。不一定要用 comrak 的 `HtmlBlock` 解析；更可靠的方法是正则提取注释，然后从剩余内容中推断块类型。

**策略**：默认使用简单的行级处理（非 comrak），因为 `blocks_to_markdown` 的输出是每行一块的简单格式，不需要完整的 GFM AST。

> **⚠️ 跨行约束**：行级处理仅适用于 `blocks_to_markdown` 产出的单行块格式。以下情况需要 fallback 到 `local_markdown_parser::markdown_to_blocks`：
> - 代码块（`block_text` 含 `\n`）
> - 用户通过 `full_content` 自由书写的多段 markdown
> - 解析跳过了 `<!-- block:id -->` 的行之间的纯段落
>
> fallback 策略：对无 `<!-- block:... -->` 注释的连续行，收集后一次性通过 GFM 解析器分割为多个常规块。

### 5.4 `changed_blocks` 摘要生成

当前返回的 `applyResult.changedBlocks` 由 `doc_apply_block_ops` 产生。新路径需要自己生成：

```rust
fn build_changed_blocks_summary(
    original_blocks: &[Value],
    parsed_blocks: &[ParsedBlockInfo],
) -> Vec<Value> {
    // 对比原始 blocks 和解析后的 blocks
    // 对文本改变的块输出 { op: "replace", blockId, content }
}
```

响应体中的 `changedBlocks` 字段格式不变，保持与下游消费者（SSE delta 等）的兼容。

---

## 6. 实施计划

### 步骤 1：增强 `blocks_to_markdown`（小）

**文件**: `doc.rs:574`

改动：
- 注释格式从 `<!-- block:{id} -->` 改为 `<!-- block:{id}:{type}:{rev} -->`
- 对复杂块（`editable==false` 或 `unsupportedReason!=null`）输出 `[mnote-raw-block:{id}]  <!-- block:{id}:{type}:{rev} -->`

**风险**: 低。纯格式变更，向前兼容——旧注释格式的 md 在解析器看来只是缺类型/rev，可通过 fallback 从原始 blocks 查。

**测试**: 更新现有 `test_blocks_to_markdown_with_ids`，验证新格式。

### 步骤 2：实现 `parse_final_markdown_to_blocks`（中）

**新建模块**：`doc_md_to_blocks.rs` 或放在 `doc.rs` 末尾

核心逻辑（~120 行）：

```rust
struct ParsedBlock {
    block_id: Option<String>,
    block_type: String,
    text: String,
    revision_ref: Option<String>,
    original: Option<Value>,
    is_new: bool,
}

fn parse_final_markdown_to_blocks(md: &str, originals: &[Value]) -> Vec<ParsedBlock> {
    // 1. 为 originals 建立 block_id → Value 的 HashMap
    // 2. 将 md 按行分割
    // 3. 对每行：
    //    a. 用正则提取 <!-- block:id:type:rev --> 或 [mnote-raw-block:id]
    //    b. 若无注释 → is_new=true, type="paragraph"
    //    c. 若有注释 → 查 originals_map 继承 revision_ref/props
    //    d. 提取 <!-- 前的纯文本
    // 4. 判断块类型：若注释中有 type 则用注释的，否则从行前缀推断
    // 5. 返回 ParsedBlock 列表
}
```

**正则示例**：

```rust
lazy_static! {
    static ref BLOCK_COMMENT_RE: Regex = Regex::new(
        r"<!--\s*block:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]*)\s*-->"
    ).unwrap();
    static ref RAW_BLOCK_RE: Regex = Regex::new(
        r"\[mnote-raw-block:([a-zA-Z0-9_-]+)\]"
    ).unwrap();
}
```

**测试**：
- `test_parse_empty_md` → 空输入 → 空输出
- `test_parse_with_block_ids` → 包含 `<!-- block:b1:paragraph:r1 -->` → 返回正确 ParsedBlock
- `test_parse_no_ids` → 纯文本 → 全部 is_new
- `test_parse_raw_block` → `[mnote-raw-block:b3]` → 从 originals 复制
- `test_parse_type_from_prefix` → `## Title` → type="heading"
- `test_parse_todo_prefix` → `- [x] Done` → type="todo"

### 步骤 3：实现 `build_page_content`（中）

**文件**: 与步骤 2 同模块（~80 行）

```rust
fn build_page_content(original_content: &Value, parsed: &[ParsedBlock]) -> Value {
    // 1. 为 original_content 建立 block_id → full_block 映射
    // 2. 遍历 parsed_blocks：
    //    - 有 block_id 且 original 中存在 → 复制 original 块，更新 text
    //    - 无 block_id → 创建新 paragraph 块
    //    - 有 block_id 但不在 original 中 → 复制 original 中能找到的（从已处理集合中移除）
    // 3. 遍历 original_content 中的复杂块（不在 parsed 中出现的）→ 追加
    // 4. 返回 Value::Array
}
```

**测试**：
- `test_build_content_preserves_unmodified_blocks`
- `test_build_content_updates_text`
- `test_build_content_preserves_complex_blocks`
- `test_build_content_new_blocks_appended`

### 步骤 4：替换 `doc_markdown_edit` 的 online 写回路径（小）

**文件**: `doc.rs:854-900`

将现有的：
```rust
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
if applied > 0 && block_ops.is_empty() { ... }
let apply_result = doc_apply_block_ops_with_meta(...).await?;
```

替换为：
```rust
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
let original_content = current_body_content(&aggregate);
let next_content = build_page_content(&original_content, &parsed);
let changed_blocks = build_changed_blocks_summary(&blocks, &parsed);
let apply_result = execute_page_body_save_from_aggregate(
    state, context, input, &aggregate, next_content, changed_blocks
).await?;
```

新增 `build_changed_blocks_summary`（~30 行）。

**风险**: 中。第一次替换时需要与现有测试对照输出，确保 changed_blocks 格式一致。

**测试**：
- 现有 `hermes_tools_markdown_edit_*` 测试全部重新运行——断言现有行为不变
- 新增 `test_markdown_edit_online_single_replacement`：验证单块替换后 content 正确
- 新增 `test_markdown_edit_online_full_content`：验证全文替换不再被拒绝
- 新增 `test_markdown_edit_online_preserves_complex_blocks`：验证 mindmap 块不变

### 步骤 5：更新 full_content 处理（小）

**文件**: `doc.rs:785-800`

当前 `full_content` 构造一个 `search: current_md.trim(), replace: full.trim()` 的 operation。替换后不再需要这个折中——直接解析 full_content 为 blocks。

改动：在 `parse_final_markdown_to_blocks` 中，若没有任何 `<!-- block:id -->` 注释，所有块标记为 is_new。`build_page_content` 只追加新块 + 保留原始复杂块。

**测试**：
- `test_markdown_edit_online_full_content_creates_new_blocks`
- `test_markdown_edit_online_full_content_preserves_complex`

### 步骤 6：清理（小）

- 删除 `build_block_ops_from_markdown_edit` 函数体（私有 fn，唯一调用者已移除）
- 在原调用位置保留注释：`// 退役：7-27 改为 final_md→blocks 直接写回`
- 更新 manifest/guidance 文字：full_content 不再被拒绝

---

## 7. 测试矩阵

| 场景 | 输入 | 期望输出 | 优先级 |
|------|------|---------|--------|
| 单块精确 search/replace | `{search:"第一段", replace:"新内容"}` | 文本更新，blockId/revisionRef 不变 | P0 |
| 单块忽略空白 match | `{search:"第 一   段", replace:"新内容"}` | 文本更新（原块中"第一段"→"新内容"） | P0 |
| 多 operation 命中同块 | `[{op1},{op2}]` 同块 | 最终文本 = 两次替换累积结果 | P0 |
| full_content | `"新全文"` | 新文本创建新块，原复杂块保留 | P0 |
| 影响 heading | search 命中 heading 文本 | heading 类型不变，文本更新 | P1 |
| 包含复杂块文档的替换 | 只改 paragraph 文本 | resource/mindmap 块不变 | P1 |
| dryRun 不落盘 | `dryRun: true` | 返回 diff，无写入 | P1 |
| revision 过期 | aggregate revision 已过期 | 冲突错误，不写入 | P1 |
| 原始块 ID 消失 | 搜索替换删除了 `<!-- block -->` 注释 | 该原始块保留（不从 content 删除） | P2 |
| 空 operations | `[]` | 错误码 | P2 |
| 纯文本 md（无块注释） | `"摘要\n\n补充"` | 全部 is_new，追加到末尾 | P2 |

---

## 8. 灰度/回滚

不设 feature flag。直接替换 online 写回路径。理由：

- 旧路径（7-24/7-25 修复后）在 `full_content` 等场景是**安全降级（拒绝写入）**，新路径是功能增强。不会出现「旧路径能写入、新路径不能」的退化。
- 现有 `hermes_tools_markdown_edit_*` 测试集 + 新增 5 个测试组合足够兜底。
- 若确实需要回滚，用 `git revert` 回退本次改动即可。

灰度策略：默认开启，观察到新增测试全部 pass 后合入主线。

---

## 9. 关联文件清单

| 文件 | 变更类型 | 变更内容 |
|------|---------|---------|
| `doc.rs` `blocks_to_markdown` | 修改 | 注释格式增强 |
| `doc.rs` `doc_markdown_edit` | 修改 | online 写回路径 |
| 新建 `doc_md_to_blocks.rs` | 新增 | `parse_final_markdown_to_blocks` + `build_page_content` + `build_changed_blocks_summary` |
| `doc.rs` `build_block_ops_from_markdown_edit` | 标记 | `#[deprecated]` |
| `hermes_tools.rs` tests | 新增 | ~5 个新测试 |
| `manifest.rs` / guidance.md | 微调 | full_content 不再标记为拒绝 |

---

## 10. 开放问题

1. **`<!-- block:id:type:rev -->` 注释在行中可能被模型生成的 search/replace 破坏**。例如模型输出 `{search: "第一段 <!-- block:", replace: "新段"}` 前半个注释。这是用户级错误（模型错误地替换了元数据），写入层应在 parse 阶段检测不完整的注释并报错。
2. **`full_content` 场景下的块类型保留**。如果用户要求"把整个文档改写成大纲格式"，模型输出不包含 `<!-- block-->` 注释的全部新文本，此时 heading/todo 类型从 GFM 前缀推断。但列表、引用、代码块等格式需确认 `local_markdown_parser` 的 `markdown_to_blocks` 返回的结构是否足够完整。
3. **行内格式（bold、italic、link）能否在往返中保持**。当前 `blocks_to_markdown` 只输出文本（`block_text`），丢弃所有 marks。在线文档的 text 存储包含 marks（bold/italic/code/link），当前 `doc_markdown_edit` 的整体设计不保证行内格式——这超出了 7-24/7-25 的范围，但建议在 7-14 的 Phase A/B 之后评估。
