feat: stabilize page AI ACP runtimes

实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。

主要内容:

- 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。

- 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。

- 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。

- 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。

- 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。

验证:

- node --check scripts/reasonix-acp-wrapper.mjs

- cargo test -p mnote-web acp -- --nocapture

- 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
This commit is contained in:
lix-2026
2026-05-17 20:11:39 +08:00
parent 2ea559beaa
commit bb2f190f50
19 changed files with 1307 additions and 451 deletions
@@ -956,9 +956,10 @@ fn compute_next_content_via_actor(
}
// 在内存中 apply
let _apply_result = state
.editor_actor
.apply_command(&document_id, command.clone(), command_name)?;
let _apply_result =
state
.editor_actor
.apply_command(&document_id, command.clone(), command_name)?;
// 从 actor 获取 legacy content(用于 Convex save 的 payload
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
+28 -12
View File
@@ -108,8 +108,7 @@ pub async fn doc_fetch(
.map(|t| t == "heading")
.unwrap_or(false)
{
if let Some(hl) =
block.pointer("/attrs/level").and_then(Value::as_u64)
if let Some(hl) = block.pointer("/attrs/level").and_then(Value::as_u64)
{
if hl <= level {
end = idx;
@@ -206,7 +205,10 @@ pub async fn doc_fetch(
"maxChars": max_chars
}));
}
let source = if document_id.starts_with('/') || document_id.starts_with("./") || document_id.contains('/') {
let source = if document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.contains('/')
{
"local_fs"
} else {
"convex"
@@ -901,9 +903,15 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
s.trim()
.chars()
.map(|ch| match ch {
''..='' => ((ch as u32).saturating_sub('' as u32) + 'A' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'a' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + '0' as u32).try_into().unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'A' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + 'a' as u32)
.try_into()
.unwrap_or(ch),
''..='' => ((ch as u32).saturating_sub('' as u32) + '0' as u32)
.try_into()
.unwrap_or(ch),
'\u{3000}' => ' ',
_ => ch,
})
@@ -919,7 +927,11 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
"{}{}{}",
&line[..line.char_indices().nth(start).map(|(i, _)| i).unwrap_or(0)],
replace,
&line[line.char_indices().nth(end).map(|(i, _)| i).unwrap_or(line.len())..]
&line[line
.char_indices()
.nth(end)
.map(|(i, _)| i)
.unwrap_or(line.len())..]
);
return Ok(text.replacen(line, &replaced, 1));
}
@@ -933,7 +945,14 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
}
}
// Level 4: 失败
Err(format!("无法匹配 \"{}\"", if search.len() > 60 { format!("{}...", &search[..60]) } else { search.to_string() }))
Err(format!(
"无法匹配 \"{}\"",
if search.len() > 60 {
format!("{}...", &search[..60])
} else {
search.to_string()
}
))
}
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
@@ -977,10 +996,7 @@ fn build_block_ops_from_markdown_edit(
if let Some(block) = matched_block {
let block_id = block_id_of(block).unwrap_or_default();
let block_text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or("");
let block_text = block.get("text").and_then(Value::as_str).unwrap_or("");
let new_text = block_text.replacen(search, replace, 1);
block_ops.push(json!({
"op": "replace",