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
28 KiB
7-27 [process] 在线 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 同步”;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.mdbugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.mdbugs/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 Main(Lark Doc)怎么做?
0.1 实现结构
CLI Main 的 docs +update --api-version v2 将 str_replace、block_replace、block_insert_after 等命令以 PUT 请求发送到 Lark OpenAPI:
// 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 对我们设计的启发
-
str_replace是不可拆分的原子操作 — CLI Main 的str_replace由服务端完整执行。一旦pattern+content送出,客户端不需要处理块映射。mnote 的doc_markdown_edit也应该是一条完整的原子路径:接收operations→ 搜索替换 → 产生 final_md → 直接转换 final_md 为 blocks 并写回。不应该把 operations 暴露给下游做二次推导。 -
文档中的所有纯文本搜索替换只需一条 API 调用 — CLI Main 不支持在一次请求中组合多个
str_replace。mnote 支持多条 operation 是更灵活的设计,但兑现这个灵活性的前提是:多条 operation 在服务端累积应用到md后,只产出一组最终的 block content 写回,而不是每条 operation 独立映射到 block。 -
pattern不限制为精确文本 — CLI Main 的str_replace文档鼓励 Markdown 模式下的"前缀...后缀"省略号语法("start...end"),允许模糊定位。这和我们已有的四级匹配策略(精确→忽略空白→段落 fuzzy→失败)方向一致。 -
CLI Main 在客户端做预检查 —
docs_update_check.go中有checkDocsUpdateReplaceMultilineMarkdown,在发送请求前就检查 Markdown 中的空行是否会违反服务端的单块内文本替换限制。同理,mnote 在转换 final_md 时也应该做预检:发现跨块内容变化时,要么走 GFM 解析器重建多块 content,要么清楚告知 AI "这条 operation 需要多块替换"。 -
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],已在共享匹配函数和累积方面改进,但根本问题仍在:
- full_content 模式必须跳过推导:全文替换的最终 md 与原始 blocks 没有逐 op 对应关系
- 跨块替换不可映射:一条
search: "TODO"命中多个块时,推导层只产生一次 replace op - 格式变化丢失: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:
// 当前:
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(新增函数)
签名:
/// 解析最终 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:
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>, // 从原始块保留的属性
}
解析步骤:
- 按换行分割 markdown 行,忽略空行
- 对每行提取
<!-- block:id:type:rev -->注释(正则:<!-- block:([^:]+):([^:]+):([^: ]+) -->或类似) - 若无注释但有
[mnote-raw-block:id]标记 → 从原始 blocks 按 ID 复制 - 若既无注释也无原始标记 → 创建新 paragraph 块,无
blockId(由写入层生成) - 从注释中获取
block_type;若无注释,从行前缀推断(##→ heading,- [ ]→ todo) - 用行中
<!--前的部分作为文本内容 - 对带 ID 的块,从原始 blocks 查找并复制
revisionRef、props
3.3.3 build_page_content(新增函数)
将解析后的块列表与原始 body/content 合并,生成最终的 Convex content 数组:
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 写回)
// 当前(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(¤t_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 产生。新路径需要自己生成:
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 行):
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 列表
}
正则示例:
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 -->→ 返回正确 ParsedBlocktest_parse_no_ids→ 纯文本 → 全部 is_newtest_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 行)
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_blockstest_build_content_updates_texttest_build_content_preserves_complex_blockstest_build_content_new_blocks_appended
步骤 4:替换 doc_markdown_edit 的 online 写回路径(小)
文件: doc.rs:854-900
将现有的:
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?;
替换为:
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_blockstest_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. 开放问题
<!-- block:id:type:rev -->注释在行中可能被模型生成的 search/replace 破坏。例如模型输出{search: "第一段 <!-- block:", replace: "新段"}前半个注释。这是用户级错误(模型错误地替换了元数据),写入层应在 parse 阶段检测不完整的注释并报错。full_content场景下的块类型保留。如果用户要求"把整个文档改写成大纲格式",模型输出不包含<!-- block-->注释的全部新文本,此时 heading/todo 类型从 GFM 前缀推断。但列表、引用、代码块等格式需确认local_markdown_parser的markdown_to_blocks返回的结构是否足够完整。- 行内格式(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 之后评估。