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
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::transport::convex::{
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
};
use serde_json::json;
use bridge_runtime::{
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire,
};
use serde_json::json;
use serde_json::Value;
pub fn runtime_context(
+1 -1
View File
@@ -571,7 +571,6 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
if (!workspaceId || !('EventSource' in window)) return;
var url = new URL('/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
url.searchParams.set('pollMs', '1000');
var source = new EventSource(url.toString());
root.__mnoteTrashEventSource = source;
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
@@ -1652,6 +1651,7 @@ mod tests {
assert!(html.contains("已删表格.luckysheet"));
assert!(html.contains("new EventSource"));
assert!(html.contains("/api/tree/events"));
assert!(!html.contains("pollMs"));
assert!(html.contains("refreshTrashWorkbenchFromServer"));
assert!(!html.contains("window.location.reload"));
}
+381 -48
View File
@@ -5,7 +5,6 @@ use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::execute_convex_query_by_name;
use axum::body::Body;
use tokio::sync::broadcast;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
@@ -20,6 +19,7 @@ use std::process::Command;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -32,6 +32,8 @@ static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static ACP_ACTIVE_RUNS: LazyLock<Mutex<HashMap<String, AcpActiveRun>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
struct HermesRuntimeState {
@@ -71,6 +73,13 @@ struct HermesQueuedRun {
queued_at: u128,
}
#[derive(Clone)]
struct AcpActiveRun {
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
mnote_session_id: String,
acp_session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
@@ -353,10 +362,15 @@ pub async fn list_skills(
.get("profile")
.map(String::as_str)
.unwrap_or(fallback_profile.as_str());
let runtime = query.get("runtime").map(String::as_str).unwrap_or(profile);
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(skills_payload(profile)),
Json(if runtime == "reasonix" {
reasonix_skills_payload()
} else {
skills_payload(profile)
}),
))
}
@@ -529,9 +543,15 @@ pub async fn create_run(
let registration = run_registration_from_payload(&context, &payload);
// ACP path: skip the HTTP proxy, just register and return run info
if is_acp_profile(&registration.profile) {
let run_id = registration.session_id.clone(); // session_id serves as run_id
let runtime_state = register_acp_runtime(&registration);
if acp_runtime_for_payload(&payload, &registration.profile).is_some() {
let run_id = payload
.get("runId")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| new_acp_run_id(&registration));
payload["runId"] = Value::String(run_id.clone());
let runtime_state = register_acp_runtime(&registration, &run_id);
// Store payload for stream_events to use
ACP_RUN_PAYLOADS
.lock()
@@ -600,6 +620,7 @@ async fn acp_stream_events(
context: RequestContext,
run_id: &str,
profile: &str,
acp_runtime_name: &str,
) -> Result<Response, WebError> {
// Get the stored payload from create_run
let payload = ACP_RUN_PAYLOADS
@@ -621,14 +642,36 @@ async fn acp_stream_events(
.and_then(Value::as_str)
.unwrap_or("请读取当前文档内容");
let prompt_blocks = vec![ContentBlock::Text {
let mut prompt_blocks = Vec::new();
if let Ok(upstream_body) = build_run_upstream_body(&context, payload.clone()) {
if let Some(instructions) = upstream_body.get("instructions").and_then(Value::as_str) {
prompt_blocks.push(ContentBlock::Text {
text: format!(
"以下是 mnote 页面 AI 的冻结上下文与工具约束,请在本轮回答中遵守:\n{instructions}"
),
});
}
}
prompt_blocks.push(ContentBlock::Text {
text: input.to_string(),
}];
});
let runtime_name = crate::acp_bridge::runtime_name_for_profile(profile);
let runtime_name = acp_runtime_name;
// Ensure runtime is active; switch_to either activates it or returns existing
let client = state.acp_runtime.switch_to(runtime_name).await.map_err(|e| {
let client = if runtime_name == "hermes" {
let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "hermes".into());
let mut config =
crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile));
config.env = acp_hermes_env_for_profile(profile);
state.acp_runtime.switch_to_config(config).await
} else {
state.acp_runtime.switch_to(runtime_name).await
}
.map_err(|e| {
WebError::bad_gateway_code(
"acp_runtime_switch_failed",
format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
@@ -647,15 +690,50 @@ async fn acp_stream_events(
}
});
mgr.create_session(None, None)
.await
.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
)
.with_context(&context)
})?;
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
)
.with_context(&context)
})?;
let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
run_id.to_string(),
AcpActiveRun {
manager: Arc::clone(&mgr),
mnote_session_id,
acp_session_id,
},
);
// Build SSE response from event channel FIRST (before running prompt),
// so that if the prompt fails quickly, events are not lost.
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut event_rx = event_tx.subscribe();
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok(event) => {
let json_str =
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(format!(
"event: {}\ndata: {}\n\n",
event.event, json_str
));
if tx.send(Ok(bytes)).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Run prompt in background
let run_id_owned = run_id.to_string();
@@ -681,29 +759,16 @@ async fn acp_stream_events(
});
}
}
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
});
// Build SSE response from event channel
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut event_rx = event_tx.subscribe();
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok(event) => {
let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(
format!("event: {}\ndata: {}\n\n", event.event, json_str)
);
if tx.send(Ok(bytes)).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
mgr_clone.close().await;
ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.remove(&run_id_owned);
if !matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
) {
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
}
});
@@ -715,8 +780,7 @@ async fn acp_stream_events(
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.map_err(|e| {
WebError::internal(format!("SSE response build failed: {e}"))
.with_context(&context)
WebError::internal(format!("SSE response build failed: {e}")).with_context(&context)
})?;
stamp_client_headers_into(response.headers_mut());
Ok(response)
@@ -731,8 +795,8 @@ pub async fn stream_events(
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
// ACP path: start AcpRunBridge and return SSE stream
if is_acp_profile(&profile) {
return acp_stream_events(state, context, &run_id, &profile).await;
if let Some(acp_runtime_name) = acp_runtime_for_run(&run_id, &profile) {
return acp_stream_events(state, context, &run_id, &profile, &acp_runtime_name).await;
}
let Some(upstream) = configured_upstream_for_profile(&profile) else {
@@ -808,6 +872,51 @@ pub async fn abort_run(
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
if acp_runtime_for_run(&run_id, &profile).is_some() {
let active = ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.remove(&run_id);
update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None);
let Some(active) = active else {
update_runtime_by_run_id(&run_id, "aborted", Some("abort.missing_active_run"), None);
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"runId": run_id,
"status": "aborted",
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
"events": [
{"event": "abort.started", "runId": run_id},
{"event": "abort.completed", "runId": run_id, "note": "active ACP run was already finished or missing"}
]
})),
));
};
active.manager.cancel().await.map_err(|error| {
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
.with_context(&context)
})?;
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"runId": run_id,
"sessionId": active.mnote_session_id,
"acpSessionId": active.acp_session_id,
"status": "aborted",
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
"events": [
{"event": "abort.started", "runId": run_id},
{"event": "abort.completed", "runId": run_id}
]
})),
));
}
let Some(upstream) = configured_upstream_for_profile(&profile) else {
return hermes_unconfigured(&context);
};
@@ -1467,6 +1576,141 @@ fn skills_payload(profile: &str) -> Value {
})
}
fn reasonix_skill_roots() -> Vec<(PathBuf, &'static str)> {
let mut roots = Vec::new();
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_path_buf();
roots.push((project_root.join(".reasonix").join("skills"), "project"));
roots.push((project_root.join(".agents").join("skills"), "project"));
if let Ok(home) = std::env::var("HOME") {
let home = PathBuf::from(home);
roots.push((home.join(".reasonix").join("skills"), "global"));
roots.push((home.join(".agents").join("skills"), "global"));
}
roots
}
fn reasonix_skill_entry(path: PathBuf, stem: String, scope: &str) -> Option<Value> {
let content = fs::read_to_string(&path).ok()?;
let metadata = parse_reasonix_skill_frontmatter(&content);
let name = metadata
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(&stem)
.to_string();
Some(json!({
"name": name,
"description": metadata
.get("description")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| extract_skill_description(&content)),
"enabled": true,
"toggleable": false,
"source": "reasonix",
"origin": scope,
"category": scope,
"path": path.to_string_lossy(),
"scope": scope,
"runAs": metadata.get("runAs").cloned().unwrap_or(Value::Null),
"model": metadata.get("model").cloned().unwrap_or(Value::Null)
}))
}
fn parse_reasonix_skill_frontmatter(markdown: &str) -> serde_json::Map<String, Value> {
let mut metadata = serde_json::Map::new();
let mut lines = markdown.lines();
if lines.next().map(str::trim) != Some("---") {
return metadata;
}
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let normalized = value.trim().trim_matches('"').trim_matches('\'');
if !key.trim().is_empty() && !normalized.is_empty() {
metadata.insert(
key.trim().to_string(),
Value::String(normalized.to_string()),
);
}
}
metadata
}
fn reasonix_skills_payload() -> Value {
let mut seen = HashSet::new();
let mut by_scope: HashMap<String, Vec<Value>> = HashMap::new();
for (root, scope) in reasonix_skill_roots() {
let Ok(entries) = fs::read_dir(&root) else {
continue;
};
let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let file_name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
let skill = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
reasonix_skill_entry(path.join("SKILL.md"), file_name, scope)
} else if entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
&& file_name.ends_with(".md")
{
let stem = file_name.trim_end_matches(".md").to_string();
reasonix_skill_entry(path, stem, scope)
} else {
None
};
let Some(skill) = skill else {
continue;
};
let Some(name) = skill.get("name").and_then(Value::as_str) else {
continue;
};
if !seen.insert(name.to_string()) {
continue;
}
by_scope.entry(scope.to_string()).or_default().push(skill);
}
}
let mut categories = by_scope
.into_iter()
.map(|(scope, mut skills)| {
skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
json!({
"name": scope,
"description": format!("Reasonix {scope} skills"),
"skills": skills
})
})
.collect::<Vec<_>>();
categories.sort_by_key(
|category| match category["name"].as_str().unwrap_or_default() {
"project" => 0,
"custom" => 1,
"global" => 2,
_ => 3,
},
);
json!({
"ok": true,
"runtime": "reasonix",
"categories": categories,
"archived": []
})
}
fn merge_misc_categories(categories: Vec<Value>) -> Vec<Value> {
let mut merged = Vec::new();
let mut misc = Vec::new();
@@ -1670,6 +1914,82 @@ fn is_acp_profile(profile: &str) -> bool {
.unwrap_or(false)
}
fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option<String> {
if let Some(runtime) = payload
.get("acpRuntime")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(runtime.to_lowercase());
}
if is_acp_profile(profile) {
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
}
None
}
fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option<String> {
if let Some(payload) = ACP_RUN_PAYLOADS
.lock()
.expect("acp run payloads")
.get(run_id)
.cloned()
{
return acp_runtime_for_payload(&payload, profile);
}
if ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.contains_key(run_id)
{
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
}
if is_acp_profile(profile) {
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
}
None
}
fn provider_default_key_env(provider: &str) -> Option<&'static str> {
match provider.trim().to_ascii_lowercase().as_str() {
"deepseek" => Some("DEEPSEEK_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
"omniroute" => Some("OMNIROUTE_API_KEY"),
"openai" | "custom" => Some("OPENAI_API_KEY"),
_ => None,
}
}
fn acp_hermes_env_for_profile(profile: &str) -> Option<HashMap<String, String>> {
let config = fs::read_to_string(profile_config_path(profile)).ok()?;
let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default();
let key_env = yaml_path_value(&config, &["model", "key_env"])
.or_else(|| {
if provider.is_empty() {
None
} else {
yaml_path_value(&config, &["providers", &provider, "key_env"])
}
})
.or_else(|| provider_default_key_env(&provider).map(str::to_string))?;
let key = env_or_dotenv(&key_env)
.or_else(|| yaml_path_value(&config, &["model", "api_key"]))
.or_else(|| {
if provider.is_empty() {
None
} else {
yaml_path_value(&config, &["providers", &provider, "api_key"])
}
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
let mut env = HashMap::new();
env.insert(key_env, key);
Some(env)
}
/// Returns the ACP runtime name for a profile.
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
/// The profile name is used as the runtime name unless overridden by env var.
@@ -2229,6 +2549,16 @@ fn runtime_state_for_run(run_id: &str) -> Option<Value> {
.map(runtime_state_to_json)
}
fn runtime_status_for_run(run_id: &str) -> Option<String> {
let registry = HERMES_RUNTIME_REGISTRY
.lock()
.expect("hermes runtime registry");
registry
.values()
.find(|state| state.run_id == run_id)
.map(|state| state.status.clone())
}
fn run_registration_from_payload(
context: &RequestContext,
payload: &Value,
@@ -2268,12 +2598,15 @@ fn run_registration_from_payload(
/// Register a runtime state from a registration (without upstream response).
/// Used by the ACP path where no Hermes HTTP upstream exists.
fn register_acp_runtime(registration: &HermesRunRegistration) -> Value {
fn new_acp_run_id(registration: &HermesRunRegistration) -> String {
format!("run_{}_{}", registration.trace_id, now_ms())
}
fn register_acp_runtime(registration: &HermesRunRegistration, run_id: &str) -> Value {
let now = now_ms();
let run_id = registration.session_id.clone(); // use session_id as run_id for ACP
let state = HermesRuntimeState {
session_id: registration.session_id.clone(),
run_id,
run_id: run_id.to_string(),
profile: registration.profile.clone(),
document_id: registration.document_id.clone(),
trace_id: registration.trace_id.clone(),
@@ -147,7 +147,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
}
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
// 新格式:直接是 search/replace 对
if operations.iter().any(|op| op.get("search").is_some() || op.get("replace").is_some()) {
if operations
.iter()
.any(|op| op.get("search").is_some() || op.get("replace").is_some())
{
return Ok(operations.clone());
}
// 旧格式(block ops):转换为 search/replace 对
+11 -13
View File
@@ -80,10 +80,7 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -106,10 +103,7 @@ async fn events_with_stream_delta(
"requestId": payload.get("requestId"),
"traceId": payload.get("traceId"),
});
return Some((
Ok(stream_event("delta", &hint)),
Some(state),
));
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -139,10 +133,7 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -237,7 +228,14 @@ pub async fn tree_events(
}
let block_delta_rx = state.block_delta_tx.subscribe();
let stream_delta_rx = state.stream_delta_tx.subscribe();
let sse = events_with_stream_delta(state, context, query, Some(block_delta_rx), Some(stream_delta_rx)).await?;
let sse = events_with_stream_delta(
state,
context,
query,
Some(block_delta_rx),
Some(stream_delta_rx),
)
.await?;
Ok((headers, sse))
}