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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms).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(),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
'A'..='Z' => ((ch as u32).saturating_sub('A' as u32) + 'A' as u32).try_into().unwrap_or(ch),
|
||||
'a'..='z' => ((ch as u32).saturating_sub('a' as u32) + 'a' as u32).try_into().unwrap_or(ch),
|
||||
'0'..='9' => ((ch as u32).saturating_sub('0' as u32) + '0' as u32).try_into().unwrap_or(ch),
|
||||
'A'..='Z' => ((ch as u32).saturating_sub('A' as u32) + 'A' as u32)
|
||||
.try_into()
|
||||
.unwrap_or(ch),
|
||||
'a'..='z' => ((ch as u32).saturating_sub('a' as u32) + 'a' as u32)
|
||||
.try_into()
|
||||
.unwrap_or(ch),
|
||||
'0'..='9' => ((ch as u32).saturating_sub('0' 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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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(®istration.profile) {
|
||||
let run_id = registration.session_id.clone(); // session_id serves as run_id
|
||||
let runtime_state = register_acp_runtime(®istration);
|
||||
if acp_runtime_for_payload(&payload, ®istration.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(®istration));
|
||||
payload["runId"] = Value::String(run_id.clone());
|
||||
let runtime_state = register_acp_runtime(®istration, &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 对
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user