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:
@@ -6,11 +6,11 @@
|
||||
/// Configuration:
|
||||
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
|
||||
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
|
||||
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
|
||||
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
|
||||
///
|
||||
/// Or via JSON env var:
|
||||
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
|
||||
|
||||
use crate::acp_client::{AcpClient, AcpError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -22,7 +22,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
// ── Config ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpRuntimeConfig {
|
||||
/// Display name (e.g. "hermes", "reasonix").
|
||||
@@ -42,11 +42,17 @@ pub struct AcpRuntimeConfig {
|
||||
|
||||
impl AcpRuntimeConfig {
|
||||
/// Create a Hermes ACP runtime config.
|
||||
pub fn hermes(bin: Option<&str>) -> Self {
|
||||
pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self {
|
||||
let profile = profile.unwrap_or("default").trim();
|
||||
let args = if profile.is_empty() {
|
||||
vec!["acp".into()]
|
||||
} else {
|
||||
vec!["-p".into(), profile.to_string(), "acp".into()]
|
||||
};
|
||||
Self {
|
||||
name: "hermes".into(),
|
||||
bin: bin.unwrap_or("hermes").to_string(),
|
||||
args: vec!["acp".into()],
|
||||
args,
|
||||
env: None,
|
||||
title: Some("Hermes".into()),
|
||||
}
|
||||
@@ -68,13 +74,15 @@ impl AcpRuntimeConfig {
|
||||
.to_string();
|
||||
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
|
||||
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
|
||||
let resolved_path = wrapper_path.map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("{project_root}/{p}")
|
||||
}
|
||||
}).unwrap_or(default_path);
|
||||
let resolved_path = wrapper_path
|
||||
.map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("{project_root}/{p}")
|
||||
}
|
||||
})
|
||||
.unwrap_or(default_path);
|
||||
Self {
|
||||
name: "reasonix".into(),
|
||||
bin: "node".into(),
|
||||
@@ -114,9 +122,7 @@ impl AcpRuntimeManager {
|
||||
|
||||
// Check for JSON-based configuration first
|
||||
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
|
||||
if let Ok(custom_runtimes) =
|
||||
serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json)
|
||||
{
|
||||
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
|
||||
for rt in custom_runtimes {
|
||||
let name = rt.name.clone();
|
||||
runtimes.insert(name, rt);
|
||||
@@ -128,20 +134,26 @@ impl AcpRuntimeManager {
|
||||
|
||||
// Always add default Hermes if not already configured
|
||||
if !runtimes.contains_key("hermes") {
|
||||
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN")
|
||||
.unwrap_or_else(|_| "hermes".into());
|
||||
runtimes.insert("hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin)));
|
||||
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into());
|
||||
let hermes_profile =
|
||||
env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into());
|
||||
runtimes.insert(
|
||||
"hermes".into(),
|
||||
AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)),
|
||||
);
|
||||
}
|
||||
|
||||
// Always add default Reasonix if not already configured
|
||||
if !runtimes.contains_key("reasonix") {
|
||||
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
|
||||
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
|
||||
runtimes.insert("reasonix".into(), AcpRuntimeConfig::reasonix(Some(&wrapper)));
|
||||
runtimes.insert(
|
||||
"reasonix".into(),
|
||||
AcpRuntimeConfig::reasonix(Some(&wrapper)),
|
||||
);
|
||||
}
|
||||
|
||||
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME")
|
||||
.unwrap_or_else(|_| "hermes".into());
|
||||
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "hermes".into());
|
||||
|
||||
Self {
|
||||
runtimes,
|
||||
@@ -167,7 +179,11 @@ impl AcpRuntimeManager {
|
||||
|
||||
/// Get the currently active runtime name, if any.
|
||||
pub async fn active_runtime_name(&self) -> Option<String> {
|
||||
self.active.lock().await.as_ref().map(|a| a.config.name.clone())
|
||||
self.active
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|a| a.config.name.clone())
|
||||
}
|
||||
|
||||
/// Get a reference to the currently active [`AcpClient`], if any.
|
||||
@@ -190,11 +206,22 @@ impl AcpRuntimeManager {
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
|
||||
self.switch_to_config(config).await
|
||||
}
|
||||
|
||||
/// Activate a runtime from an explicit config.
|
||||
///
|
||||
/// This is used by Hermes ACP because the binary is the same runtime name,
|
||||
/// but the selected Hermes profile changes the launch args.
|
||||
pub async fn switch_to_config(
|
||||
&self,
|
||||
config: AcpRuntimeConfig,
|
||||
) -> Result<Arc<AcpClient>, AcpError> {
|
||||
let name = config.name.clone();
|
||||
// Shutdown current active runtime
|
||||
let mut active_guard = self.active.lock().await;
|
||||
if let Some(ref current) = *active_guard {
|
||||
if current.config.name == name {
|
||||
if current.config == config {
|
||||
// Already active — return existing client
|
||||
return Ok(current.client.clone());
|
||||
}
|
||||
@@ -202,11 +229,15 @@ impl AcpRuntimeManager {
|
||||
// (via AcpClient's Drop impl)
|
||||
}
|
||||
|
||||
info!("ACP runtime: switching to {name} (bin={}, args={:?})", config.bin, config.args);
|
||||
info!(
|
||||
"ACP runtime: switching to {name} (bin={}, args={:?})",
|
||||
config.bin, config.args
|
||||
);
|
||||
|
||||
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
|
||||
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
|
||||
let client = AcpClient::spawn(&config.bin, &args_refs).await?;
|
||||
let client =
|
||||
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
*active_guard = Some(ActiveRuntime {
|
||||
@@ -277,9 +308,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None);
|
||||
let cfg = AcpRuntimeConfig::hermes(None, None);
|
||||
assert_eq!(cfg.name, "hermes");
|
||||
assert_eq!(cfg.bin, "hermes");
|
||||
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes_profile_can_be_disabled() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
|
||||
assert_eq!(cfg.args, vec!["acp"]);
|
||||
}
|
||||
|
||||
@@ -288,7 +325,8 @@ mod tests {
|
||||
let cfg = AcpRuntimeConfig::reasonix(None);
|
||||
assert_eq!(cfg.name, "reasonix");
|
||||
assert_eq!(cfg.bin, "node");
|
||||
assert_eq!(cfg.args, vec!["scripts/reasonix-acp-wrapper.mjs"]);
|
||||
assert_eq!(cfg.args.len(), 1);
|
||||
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user