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
+74 -42
View File
@@ -4,7 +4,6 @@
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
use crate::acp_runtime::AcpRuntimeManager;
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
use crate::acp_types::ContentBlock;
@@ -59,9 +58,14 @@ impl AcpRunBridge {
) -> Result<Self, AcpBridgeError> {
// Get or activate the runtime
let client = if runtime_mgr.is_active().await {
runtime_mgr.active_client().await.ok_or(AcpBridgeError::NoActiveRuntime)?
runtime_mgr
.active_client()
.await
.ok_or(AcpBridgeError::NoActiveRuntime)?
} else {
runtime_mgr.switch_to(runtime_name).await
runtime_mgr
.switch_to(runtime_name)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
};
@@ -80,7 +84,8 @@ impl AcpRunBridge {
});
// Create session
let sid = mgr.create_session(None, None)
let sid = mgr
.create_session(None, None)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
@@ -134,10 +139,12 @@ impl AcpRunBridge {
loop {
match broadcast_rx.recv().await {
Ok(event) => {
let json = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(
format!("event: {}\ndata: {}\n\n", event.event, json)
);
let json =
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(format!(
"event: {}\ndata: {}\n\n",
event.event, json
));
if tx.send(Ok(bytes)).await.is_err() {
break; // receiver dropped
}
@@ -175,33 +182,27 @@ impl AcpRunBridge {
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
AcpSessionEvent::TextDelta { text } => {
Some(SseEvent {
event: "message.delta".into(),
data: json!({ "delta": text }),
})
}
AcpSessionEvent::ThoughtDelta { text } => {
Some(SseEvent {
event: "thought.delta".into(),
data: json!({ "delta": text }),
})
}
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
event: "message.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent {
event: "thought.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ToolCall {
tool_call_id,
title,
kind,
..
} => {
Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
}),
})
}
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
}),
}),
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
@@ -215,24 +216,21 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => {
Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
})
}
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
AcpSessionEvent::PlanUpdate { .. } => {
None // Not forwarded (Phase C)
}
AcpSessionEvent::Disconnected { reason } => {
Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
})
}
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
}),
}
}
@@ -246,4 +244,38 @@ pub fn runtime_name_for_profile(profile: &str) -> &str {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_normal_session_close_does_not_emit_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "session closed".into(),
};
assert!(acp_event_to_sse(event).is_none());
}
#[test]
fn acp_unexpected_disconnect_emits_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "transport lost".into(),
};
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
assert_eq!(sse.event, "run.failed");
assert_eq!(sse.data["error"], "transport lost");
}
#[test]
fn acp_thought_delta_does_not_emit_message_delta() {
let event = AcpSessionEvent::ThoughtDelta {
text: "internal reasoning".into(),
};
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
assert_eq!(sse.event, "thought.delta");
assert_eq!(sse.data["delta"], "internal reasoning");
}
}
+39 -19
View File
@@ -12,7 +12,6 @@
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};
@@ -21,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{Mutex, oneshot};
use tokio::sync::{oneshot, Mutex};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
@@ -120,27 +119,40 @@ impl AcpClient {
///
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
let mut child = Command::new(bin)
Self::spawn_with_env(bin, args, None).await
}
/// Spawn an ACP subprocess with extra environment variables.
pub async fn spawn_with_env(
bin: &str,
args: &[&str],
env_overrides: Option<&HashMap<String, String>>,
) -> Result<Self, AcpError> {
let mut command = Command::new(bin);
command
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(AcpError::Spawn)?;
.kill_on_drop(true);
if let Some(env) = env_overrides {
command.envs(env);
}
let mut child = command.spawn().map_err(AcpError::Spawn)?;
let stdin = child.stdin.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdin".into())
})?;
let stdout = child.stdout.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdout".into())
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
let writer = BufWriter::new(stdin);
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> =
Arc::new(Mutex::new(HashMap::new()));
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
@@ -182,7 +194,8 @@ impl AcpClient {
method: &str,
params: P,
) -> Result<R, AcpError> {
self.request_with_timeout(method, params, Duration::from_secs(300)).await
self.request_with_timeout(method, params, Duration::from_secs(300))
.await
}
/// Same as [`request`] but with a configurable timeout.
@@ -298,7 +311,10 @@ impl AcpClient {
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
warn!("ACP parse error: {e} (line: {})", &trimmed[..trimmed.len().min(80)]);
warn!(
"ACP parse error: {e} (line: {})",
&trimmed[..trimmed.len().min(80)]
);
continue;
}
};
@@ -322,7 +338,11 @@ impl AcpClient {
notification_handler: &Arc<NotificationHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg.get("method").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
let has_method = msg
.get("method")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
if has_id && has_method {
// Incoming request from agent (e.g. session/request_permission)
@@ -458,14 +478,14 @@ rl.on('line', (line) => {
}
});
// Send a notification that the mock server will echo back as...
// Send a notification that the mock server will echo back as...
// Actually the mock doesn't send unsolicited notifications.
// This test just validates the handler registration doesn't crash.
client
.notification("test_push", json!({}))
.await
.expect("notification");
// Give background task time to process
tokio::time::sleep(Duration::from_millis(100)).await;
// In this mock, no notification will be received; that's OK
+64 -26
View File
@@ -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]
@@ -6,11 +6,10 @@
/// Reference:
/// - `reference-code/hermes-vscode-main/src/sessionManager.ts` (primary)
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
use crate::acp_client::AcpClient;
use crate::acp_types::{
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams,
SessionPromptResult, SessionUpdate, ToolCallStatus,
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult,
SessionUpdate, ToolCallStatus,
};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
@@ -89,8 +88,7 @@ impl AcpSessionManager {
pub fn new(client: Arc<AcpClient>) -> Self {
let session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let state: Arc<Mutex<SessionState>> = Arc::new(Mutex::new(SessionState::Idle));
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> =
Arc::new(Mutex::new(None));
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> = Arc::new(Mutex::new(None));
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
@@ -164,9 +162,15 @@ impl AcpSessionManager {
cwd: Option<&str>,
_page_context: Option<Value>,
) -> Result<String, crate::acp_client::AcpError> {
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let params = SessionNewParams {
cwd: cwd.map(|s| s.to_string()),
mcp_servers: None,
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
mcp_servers: Some(Vec::new()),
};
let result: SessionNewResult = self.client.request("session/new", params).await?;
@@ -203,6 +207,7 @@ impl AcpSessionManager {
let sid = self.session_id.lock().unwrap().clone();
let session_id = sid.ok_or_else(|| {
self.reset_prompt_state();
crate::acp_client::AcpError::Internal(
"no session created yet — call create_session first".into(),
)
@@ -214,26 +219,17 @@ impl AcpSessionManager {
};
debug!("ACP session/prompt (session={})", session_id);
let result: SessionPromptResult = self.client.request("session/prompt", params).await?;
debug!(
"ACP session/prompt done (session={}, stop_reason={:?})",
session_id, result.stop_reason
);
let result: Result<SessionPromptResult, crate::acp_client::AcpError> =
self.client.request("session/prompt", params).await;
if let Ok(ref prompt_result) = result {
debug!(
"ACP session/prompt done (session={}, stop_reason={:?})",
session_id, prompt_result.stop_reason
);
}
self.reset_prompt_state();
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Idle;
}
{
let mut in_prompt = self.in_prompt.lock().unwrap();
*in_prompt = false;
}
{
let mut acc = self.accumulated.lock().unwrap();
acc.clear();
}
Ok(result)
result
}
/// Cancel the current prompt.
@@ -241,9 +237,8 @@ impl AcpSessionManager {
/// Sends `session/cancel` notification to the agent.
pub async fn cancel(&self) -> Result<(), crate::acp_client::AcpError> {
let sid = self.session_id.lock().unwrap().clone();
let session_id = sid.ok_or_else(|| {
crate::acp_client::AcpError::Internal("no active session".into())
})?;
let session_id =
sid.ok_or_else(|| crate::acp_client::AcpError::Internal("no active session".into()))?;
{
let mut state = self.state.lock().unwrap();
@@ -251,10 +246,7 @@ impl AcpSessionManager {
}
self.client
.notification(
"session/cancel",
json!({ "sessionId": session_id }),
)
.notification("session/cancel", json!({ "sessionId": session_id }))
.await?;
info!("ACP session cancelled: {}", session_id);
@@ -285,6 +277,21 @@ impl AcpSessionManager {
self.state.lock().unwrap().clone()
}
fn reset_prompt_state(&self) {
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Idle;
}
{
let mut in_prompt = self.in_prompt.lock().unwrap();
*in_prompt = false;
}
{
let mut acc = self.accumulated.lock().unwrap();
acc.clear();
}
}
// ── Internal: session/update → event mapping ─────
/// Convert a parsed [`SessionUpdate`] into an [`AcpSessionEvent`],
@@ -382,12 +389,10 @@ impl AcpSessionManager {
})
}
SessionUpdate::UsageUpdate { used, size, .. } => {
Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
})
}
SessionUpdate::UsageUpdate { used, size, .. } => Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
}),
SessionUpdate::SessionInfoUpdate { title, .. } => {
Some(AcpSessionEvent::SessionInfoUpdate {
@@ -396,8 +401,7 @@ impl AcpSessionManager {
}
SessionUpdate::Plan { entries, .. } => {
let summaries: Vec<String> =
entries.iter().map(|e| e.content.clone()).collect();
let summaries: Vec<String> = entries.iter().map(|e| e.content.clone()).collect();
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
@@ -490,10 +494,7 @@ rl.on('line', (line) => {
text: "Hello agent".into(),
}];
let result = mgr.run_prompt(prompt).await.expect("run_prompt");
assert_eq!(
format!("{:?}", result.stop_reason),
"EndTurn".to_string()
);
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
// After prompt, state should be idle again
assert_eq!(mgr.state().await, SessionState::Idle);
}
@@ -532,6 +533,29 @@ rl.on('line', (line) => {
// Give the notification handler time to process
sleep(Duration::from_millis(200)).await;
assert!(received.load(Ordering::SeqCst), "should have received TextDelta");
assert!(
received.load(Ordering::SeqCst),
"should have received TextDelta"
);
}
#[test]
fn test_thought_chunk_maps_to_thought_delta() {
let accumulated = Arc::new(Mutex::new(String::new()));
let update = SessionUpdate::AgentThoughtChunk {
session_update: SessionUpdate::AGENT_THOUGHT_CHUNK.into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: "internal reasoning".into(),
},
};
let event = AcpSessionManager::session_update_to_event(&update, &accumulated, true)
.expect("thought chunk should emit event");
match event {
AcpSessionEvent::ThoughtDelta { text } => assert_eq!(text, "internal reasoning"),
other => panic!("expected ThoughtDelta, got {other:?}"),
}
}
}
+181 -17
View File
@@ -6,8 +6,7 @@
/// Reference:
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
use serde::{Deserialize, Serialize};
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::Value;
// ── JSON-RPC 2.0 basics ──────────────────────────────
@@ -140,19 +139,11 @@ pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "resource")]
Resource {
resource: ResourceContent,
},
Resource { resource: ResourceContent },
#[serde(rename = "image")]
Image {
mime_type: String,
data: String,
},
Image { mime_type: String, data: String },
#[serde(rename = "audio")]
Audio {
mime_type: String,
data: String,
},
Audio { mime_type: String, data: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -207,7 +198,7 @@ pub struct SessionUpdateParams {
pub update: SessionUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum SessionUpdate {
AgentMessageChunk {
@@ -269,6 +260,155 @@ pub enum SessionUpdate {
},
}
impl<'de> Deserialize<'de> for SessionUpdate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let kind = value
.get("sessionUpdate")
.and_then(Value::as_str)
.ok_or_else(|| de::Error::missing_field("sessionUpdate"))?
.to_string();
match kind.as_str() {
SessionUpdate::AGENT_MESSAGE_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentMessageChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::AGENT_THOUGHT_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentThoughtChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::TOOL_CALL => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
title: Option<String>,
kind: Option<ToolCallKind>,
status: Option<ToolCallStatus>,
raw_input: Option<Value>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCall {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
title: raw.title,
kind: raw.kind,
status: raw.status,
raw_input: raw.raw_input,
})
}
SessionUpdate::TOOL_CALL_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
status: Option<ToolCallStatus>,
content: Option<Vec<ContentBlockWrapper>>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCallUpdate {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
status: raw.status,
content: raw.content,
})
}
SessionUpdate::PLAN => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
entries: Vec<PlanEntry>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::Plan {
session_update: raw.session_update,
entries: raw.entries,
})
}
SessionUpdate::USAGE_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
used: u64,
size: u64,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::UsageUpdate {
session_update: raw.session_update,
used: raw.used,
size: raw.size,
})
}
SessionUpdate::SESSION_INFO_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
title: String,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::SessionInfoUpdate {
session_update: raw.session_update,
title: raw.title,
})
}
_ => {
let mut extra = match value {
Value::Object(map) => map.into_iter().collect(),
_ => std::collections::HashMap::new(),
};
extra.remove("sessionUpdate");
Ok(SessionUpdate::Unknown {
session_update: kind,
extra,
})
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
@@ -455,10 +595,11 @@ mod tests {
fn test_session_new_params() {
let params = SessionNewParams {
cwd: Some("/mnt/Data1T/mnote".into()),
mcp_servers: None,
mcp_servers: Some(Vec::new()),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
assert_eq!(json["mcpServers"], serde_json::json!([]));
}
#[test]
@@ -480,6 +621,25 @@ mod tests {
}
}
#[test]
fn test_session_update_agent_thought_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": { "type": "text", "text": "thinking" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentThoughtChunk { content, .. } => {
assert_eq!(content.text, "thinking");
}
_ => panic!("expected AgentThoughtChunk"),
}
}
#[test]
fn test_session_update_tool_call() {
let json = serde_json::json!({
@@ -524,7 +684,9 @@ mod tests {
#[test]
fn test_content_block_text() {
let block = ContentBlock::Text { text: "hello".into() };
let block = ContentBlock::Text {
text: "hello".into(),
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
@@ -533,7 +695,9 @@ mod tests {
#[test]
fn test_flatten_prompt() {
let blocks = vec![
ContentBlock::Text { text: "Hello".into() },
ContentBlock::Text {
text: "Hello".into(),
},
ContentBlock::Resource {
resource: ResourceContent {
uri: "file:///test.md".into(),
+13 -8
View File
@@ -90,9 +90,7 @@ pub enum DeltaOperation {
block_type: Option<String>,
},
#[serde(rename = "delete")]
DeleteBlock {
block_id: String,
},
DeleteBlock { block_id: String },
#[serde(rename = "move_after")]
MoveBlock {
block_id: String,
@@ -134,12 +132,19 @@ impl EditorRuntimeActor {
.read()
.map_err(|e| WebError::internal(format!("EditorRuntimeActor 锁失败:{e}")))?;
let state = documents.get(document_id).ok_or_else(|| {
WebError::bad_request_code("mnote_editor_document_not_loaded", format!("文档 {document_id} 尚未加载"))
WebError::bad_request_code(
"mnote_editor_document_not_loaded",
format!("文档 {document_id} 尚未加载"),
)
})?;
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state.document.blocks.iter().find(|b| b.block_id == cmd.block_id);
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
@@ -254,9 +259,9 @@ impl EditorRuntimeActor {
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
})?;
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(
|error| WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")),
)?;
state.revision += 1;
state.conflict_detection_key = format!(
@@ -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",
@@ -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))
}
+33 -13
View File
@@ -4117,6 +4117,10 @@ const SIDEBAR_TREE_JS: &str = r##"
return pageAiProfileValue(selected) || 'mnoteai';
}
function pageAiRunProfile() {
return String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile();
}
function pageAiMnoteToolModel() {
return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
}
@@ -4570,7 +4574,8 @@ const SIDEBAR_TREE_JS: &str = r##"
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: pageUiState.pageAiActiveSessionId,
profile: pageAiCurrentProfile(),
profile: pageAiRunProfile(),
acpRuntime: pageUiState.pageAiAcpRuntime || '',
reason: 'page_ai_user_stop'
})
});
@@ -4610,6 +4615,11 @@ const SIDEBAR_TREE_JS: &str = r##"
var source = String(skill && skill.source || '').trim();
if (source === 'hub') return '';
if (source === 'builtin') return '';
if (source === 'reasonix') {
if (origin === 'project') return 'Reasonix ';
if (origin === 'global') return 'Reasonix ';
return 'Reasonix';
}
return '';
}
@@ -4716,7 +4726,11 @@ const SIDEBAR_TREE_JS: &str = r##"
async function pageAiLoadSkills() {
try {
var response = await fetch('/api/hermes/client/skills?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
var runtime = String(pageUiState.pageAiAcpRuntime || '').trim();
var params = runtime === 'reasonix'
? 'runtime=reasonix'
: 'profile=' + encodeURIComponent(pageAiCurrentProfile());
var response = await fetch('/api/hermes/client/skills?' + params, {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
@@ -4734,6 +4748,7 @@ const SIDEBAR_TREE_JS: &str = r##"
async function pageAiToggleSkill(skillName, enabled) {
var name = String(skillName || '').trim();
if (!name) return;
if (String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix') return;
var previous = null;
pageAiSkillListEntries().forEach(function(skill) {
if (skill.name === name && previous == null) previous = skill.enabled !== false;
@@ -4828,7 +4843,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function renderPageAiControls() {
var drawer = ensurePageAiDrawer();
var isAcp = pageUiState.pageAiAcpRuntime !== '';
var activeProfile = isAcp ? pageUiState.pageAiAcpRuntime : pageAiCurrentProfile();
var activeProfile = pageAiRunProfile();
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || '');
// Populate ACP runtime dropdown
@@ -4844,7 +4859,7 @@ const SIDEBAR_TREE_JS: &str = r##"
// Show/hide Hermes-specific profile select
var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]');
if (profileLabel instanceof HTMLElement) {
profileLabel.style.display = isAcp ? 'none' : '';
profileLabel.style.display = pageUiState.pageAiAcpRuntime === 'reasonix' ? 'none' : '';
}
// When ACP is selected, populate agent panel with ACP runtime info
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
@@ -4982,12 +4997,16 @@ const SIDEBAR_TREE_JS: &str = r##"
if (skillList instanceof HTMLElement) {
var skills = pageAiFilteredSkillEntries();
if (!skills.length) {
skillList.innerHTML = '<div class="wolai-page-ai-empty"> Hermes skill</div>';
var emptyText = String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix'
? ' Reasonix skill'
: ' Hermes skill';
skillList.innerHTML = '<div class="wolai-page-ai-empty">' + escapeHtml(emptyText) + '</div>';
} else {
skillList.innerHTML = skills.map(function(skill) {
var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : '');
var description = String(skill.description || '').trim();
var hasDescription = description && description !== '---' && description !== '';
var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || '').trim() !== 'reasonix';
return '' +
'<div class="wolai-page-ai-skill-row">' +
'<div class="wolai-page-ai-skill-copy">' +
@@ -4997,7 +5016,7 @@ const SIDEBAR_TREE_JS: &str = r##"
'</div>' +
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
'</div>' +
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '">' +
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '"' + (canToggle ? '' : ' disabled title="Reasonix skills 当前为只读展示"') + '>' +
'<span></span>' +
'</button>' +
'</div>';
@@ -5515,7 +5534,8 @@ const SIDEBAR_TREE_JS: &str = r##"
workspaceId: resolveWorkspaceId(document.body),
documentId: currentDocumentId(),
sessionId: pageUiState.pageAiActiveSessionId,
profile: pageUiState.pageAiAcpRuntime || pageAiCurrentProfile(),
profile: pageAiRunProfile(),
acpRuntime: pageUiState.pageAiAcpRuntime || '',
contextScope: pageUiState.pageAiContextScope,
message: prompt,
model: pageAiMnoteToolModel(),
@@ -6557,7 +6577,10 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) {
var next = String(pageAiAcpRuntimeSelect.value || '').trim();
pageUiState.pageAiAcpRuntime = next;
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillError = '';
void pageAiLoadProfiles();
void pageAiLoadSkills();
renderPageAiControls();
renderPageAiProviderButtons();
return;
@@ -7326,12 +7349,9 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
));
assert!(SIDEBAR_TREE_JS.contains(
r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#
));
assert!(SIDEBAR_TREE_JS.contains(
r#".wolai-breadcrumb-current [data-page-title-current]"#
));
assert!(SIDEBAR_TREE_JS
.contains(r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#));
assert!(SIDEBAR_TREE_JS.contains(r#".wolai-breadcrumb-current [data-page-title-current]"#));
assert!(!SIDEBAR_TREE_JS.contains(
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
@@ -1088,15 +1088,15 @@ mod tests {
idempotency_key: None,
source: json!({}),
payload_json: "{}".into(),
args_json: json!({
"id": "doc_1",
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
"domainEventHint": {"eventType": "tree.node.archived"},
"domainEventPlan": {"eventType": "tree.node.archived"},
"domainEventPlans": [{"eventType": "tree.node.archived"}],
"commandProtocol": {"family": "tree"},
}),
};
args_json: json!({
"id": "doc_1",
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
"domainEventHint": {"eventType": "tree.node.archived"},
"domainEventPlan": {"eventType": "tree.node.archived"},
"domainEventPlans": [{"eventType": "tree.node.archived"}],
"commandProtocol": {"family": "tree"},
}),
};
let args = convex_command_args_for_plan(&plan);