Files
mnote/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md
T
lix-2026 cdff672aa5 feat: align local-first workspace direction
Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
2026-05-19 08:11:58 +08:00

586 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 7-27 [process] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2
> 更新:2026-05-18v2:整合 CLI Main 参考实现分析,确认方向,补充见解)
>
> 2026-05-19 local-first 口径补充:
> - 本文仍适用于 `convex_workspace` / 在线文档的 `mnote.doc.markdown_edit` 修复,但当前默认产品形态已切到 local-first workspace。
> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;`mnote.doc.markdown_edit` 只作为本地受控代理 fallback、cloud / remote agent 或 compat 路径。
> - 后续新增 AI 编辑能力默认先保证本地 `.md` 与 `{mdBase}.assets/` 相对路径不被改写为 Convex media asset;在线 Convex 文档路径只作为可选 cloud / sync / share source。
>
> 当前状态:`PROCESS`
>
> 关联缺陷:`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/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-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 MainLark 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 存储包含 marksbold/italic/code/link),当前 `doc_markdown_edit` 的整体设计不保证行内格式——这超出了 7-24/7-25 的范围,但建议在 7-14 的 Phase A/B 之后评估。