use crate::acp_types::ContentBlock; use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::manifest; use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name}; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; use axum::Json; use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput}; use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::env; use std::fs; use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Path as FsPath, PathBuf}; 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"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store"; const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500; const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json"; const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE"; static HERMES_RUNTIME_REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static HERMES_RUN_QUEUE: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// Store ACP run payloads keyed by run_id, so stream_events can read them. static ACP_RUN_PAYLOADS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_ACTIVE_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); const LOCAL_AGENT_AUDIT_DIR: &str = "/mnt/Data1T/Mnote_data/control-plane/agent-audit"; const LOCAL_AGENT_AUDIT_JSONL: &str = "agent-audit.jsonl"; #[derive(Debug, Clone)] struct HermesRuntimeState { session_id: String, run_id: String, profile: String, document_id: String, trace_id: String, status: String, started_at: u128, last_event_at: u128, last_event: Option, last_tool_name: Option, last_tool_call_id: Option, last_audit_id: Option, } #[derive(Debug, Clone)] struct HermesRunRegistration { session_id: String, profile: String, document_id: String, trace_id: String, } #[derive(Debug, Clone)] struct HermesQueuedRun { queue_id: String, session_id: String, profile: String, document_id: String, trace_id: String, actor_id: String, actor_type: String, input: String, context_summary: Value, queued_at: u128, } #[derive(Clone)] struct AcpActiveRun { manager: Arc, mnote_session_id: String, acp_session_id: String, event_tx: broadcast::Sender, } #[derive(Debug, Clone)] struct LocalAgentAuditFileSnapshot { size: u64, modified_ms: u128, hash: u64, markdown_content: Option, } #[derive(Debug, Clone)] struct LocalAgentAuditSnapshot { root_uri: String, files: BTreeMap, } #[derive(Debug, Clone)] struct LocalShareGrant { share_id: String, owner_id: Option, target_user_id: Option, root_uri: Option, permission: String, capabilities: HashSet, allowed_resource_ids: Vec, revoked: bool, } impl LocalShareGrant { fn permission_level(&self) -> &'static str { if local_share_permission_allows_write(&self.permission, &self.capabilities) { "shared_write" } else { "shared_read" } } fn share_context(&self) -> Value { json!({ "shareId": self.share_id, "ownerId": self.owner_id, "targetUserId": self.target_user_id, "rootUri": self.root_uri, "permission": self.permission, "capabilities": self.capabilities.iter().cloned().collect::>() }) } fn allowed_file_paths(&self) -> Vec { let Some(root_uri) = self.root_uri.as_deref() else { return Vec::new(); }; let Some(root_path) = file_root_uri_to_permission_path(root_uri) else { return Vec::new(); }; self.allowed_resource_ids .iter() .filter_map(|resource_id| local_resource_id_to_relative_path(resource_id)) .map(|relative_path| { FsPath::new(&root_path) .join(relative_path) .to_string_lossy() .to_string() }) .collect() } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { workspace_id: Option, document_id: Option, source_kind: Option, root_uri: Option, share_id: Option, permission_level: Option, trace_id: Option, title: Option, profile: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameSessionRequest { title: String, } pub async fn list_sessions( State(state): State, Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let profile = query .get("profile") .map(String::as_str) .unwrap_or("default"); if is_acp_profile(profile) || matches!(query.get("source").map(String::as_str), Some("acp")) { return list_acp_sessions(&state, &context, &query).await; } let Some(upstream) = configured_upstream_for_profile(profile) else { return hermes_unconfigured(&context); }; let mut path = "/api/hermes/sessions".to_string(); if !query.is_empty() { let params = query .iter() .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value))) .collect::>() .join("&"); path.push('?'); path.push_str(¶ms); } proxy_json( &context, reqwest::Method::GET, &upstream, &path, None, Some(profile), ) .await } pub async fn search_sessions( State(state): State, Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let profile = query .get("profile") .map(String::as_str) .unwrap_or("default"); if is_acp_profile(profile) || matches!(query.get("source").map(String::as_str), Some("acp")) { let user_id = effective_session_store_user_id(&state, &context).await?; let q = query .get("q") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 q") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(20) .clamp(1, 50); if !use_legacy_convex_acp_store(&query) { let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), None, None, limit as usize, ) .map_err(|error| { WebError::internal(format!("SQLite ACP session 搜索失败: {error}")) .with_context(&context) })?; let needle = q.to_lowercase(); let results = runs .iter() .filter(|run| { run.title .as_deref() .unwrap_or_default() .to_lowercase() .contains(&needle) || run.payload_json.to_lowercase().contains(&needle) }) .map(|run| { let mut value = ai_runtime_run_to_json(run); value["snippet"] = Value::String(run.title.clone().unwrap_or_else(|| run.session_id.clone())); value["score"] = Value::from(1); value }) .collect::>(); return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "results": results })), )); } let mut args = serde_json::Map::new(); args.insert("userId".into(), Value::String(user_id)); args.insert("q".into(), Value::String(q.to_string())); args.insert("limit".into(), Value::from(limit)); if let Some(workspace_id) = workspace_id.clone() { args.insert("workspaceId".into(), Value::String(workspace_id)); } let results = execute_convex_query_by_name( state.config(), &context, "aiSessions:searchRuntimeSessions", Value::Object(args), workspace_id.as_deref(), "acp_runtime_session_search", ) .await?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": "convex_acp_runtime_store", "results": results })), )); } let Some(upstream) = configured_upstream_for_profile(profile) else { return hermes_unconfigured(&context); }; let params = query .iter() .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value))) .collect::>() .join("&"); let path = if params.is_empty() { "/api/hermes/search/sessions".to_string() } else { format!("/api/hermes/search/sessions?{params}") }; proxy_json( &context, reqwest::Method::GET, &upstream, &path, None, Some(profile), ) .await } async fn list_acp_sessions( state: &AppState, context: &RequestContext, query: &HashMap, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") { let root_uri = query .get("rootUri") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; crate::routes::ensure_local_workspace_access(context, root_uri) .map_err(|error| error.with_context(context))?; let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(50) .clamp(1, 100); let sessions = list_local_ai_sessions(root_uri, limit)?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": "local_ai_session_jsonl", "sessionStorage": "local_private", "sessions": sessions })), )); } let user_id = effective_session_store_user_id(state, context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let document_id = query .get("documentId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let session_id = query .get("sessionId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(50) .clamp(1, 100); if !use_legacy_convex_acp_store(query) { let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), document_id.as_deref(), session_id.as_deref(), limit as usize, ) .map_err(|error| { WebError::internal(format!("SQLite ACP session 列表读取失败: {error}")) .with_context(context) })?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessions": runs.iter().map(ai_runtime_run_to_json).collect::>() })), )); } let mut args = serde_json::Map::new(); args.insert("userId".into(), Value::String(user_id)); if let Some(workspace_id) = workspace_id { args.insert("workspaceId".into(), Value::String(workspace_id)); } if let Some(document_id) = document_id { args.insert("documentId".into(), Value::String(document_id)); } if let Some(session_id) = session_id { args.insert("sessionId".into(), Value::String(session_id)); } args.insert("limit".into(), Value::from(limit)); let sessions = execute_convex_query_by_name( state.config(), context, "aiSessions:listRuntimeRuns", Value::Object(args), query .get("workspaceId") .map(String::as_str) .or(context.workspace.workspace_id.as_deref()), "acp_runtime_session_list", ) .await?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": "convex_acp_runtime_store", "sessions": sessions })), )) } pub async fn create_session( State(state): State, Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let trace_id = payload .trace_id .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| context.trace.trace_id.clone()); let document_id = payload .document_id .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("current"); let session_id = stable_session_id(document_id, &trace_id); let profile = payload .profile .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); let mut persistence = "hermes_on_first_run"; let session_store_user_id = effective_session_store_user_id(&state, &context).await?; if payload.source_kind.as_deref().map(str::trim) == Some("local_folder") { let root_uri = payload .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let share_id = payload .share_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let share_grant = if let Some(share_id) = share_id { let grant = load_local_share_grant_for_actor( share_id, Some(&context.auth.actor_id), Some(root_uri), ) .map_err(|error| error.with_context(&context))?; Some(grant) } else { crate::routes::ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; None }; let permission_level = share_grant .as_ref() .map(LocalShareGrant::permission_level) .or_else(|| { payload .permission_level .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) }) .unwrap_or(if share_id.is_some() { "shared_read" } else { "private" }); if share_id.is_some() && permission_level == "shared_read" { return Err(WebError::new( StatusCode::FORBIDDEN, "local_ai_session_shared_read_write_forbidden", "共享只读 AI 会话不能创建可写本地会话记录", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")); } let shared_write = share_id.is_some() && permission_level == "shared_write"; let visibility = if shared_write { "shared" } else { "private" }; let session_storage = if shared_write { "local_shared" } else { "local_private" }; let event = json!({ "schema": "mnote.local_ai_session_event.v1", "eventType": "session.created", "sessionId": session_id, "workspaceId": payload.workspace_id, "documentId": payload.document_id, "sourceKind": "local_folder", "visibility": visibility, "permissionLevel": permission_level, "shareId": share_id, "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null), "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_else(|| vec![document_id.to_string()]), "title": payload.title.as_deref().unwrap_or("当前页问答"), "profile": profile, "userId": session_store_user_id, "traceId": trace_id, "createdAt": now_ms() }); append_local_ai_session_event(root_uri, &session_id, &event, share_id) .map_err(|error| error.with_context(&context))?; if shared_write { let audit_event = json!({ "schema": "mnote.local_ai_session_event.v1", "eventType": "audit.shared_write", "sessionId": session_id, "workspaceId": payload.workspace_id, "documentId": payload.document_id, "sourceKind": "local_folder", "permissionLevel": permission_level, "shareId": share_id, "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null), "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_default(), "actorId": context.auth.actor_id, "userId": session_store_user_id, "traceId": trace_id, "changedFiles": [], "diffSummary": "shared AI session created", "createdAt": now_ms() }); append_local_ai_session_event(root_uri, &session_id, &audit_event, share_id) .map_err(|error| error.with_context(&context))?; } return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "sessionId": session_id, "workspaceId": payload.workspace_id, "documentId": payload.document_id, "profile": profile, "title": payload.title.unwrap_or_else(|| "当前页问答".into()), "traceId": trace_id, "persistence": "local_ai_session_jsonl", "sessionStorage": session_storage, "permissionLevel": permission_level, "shareId": share_id })), )); } let runtime_payload = json!({ "workspaceId": payload.workspace_id.clone(), "documentId": payload.document_id.clone(), "sessionId": session_id.clone(), "traceId": trace_id.clone(), "profile": profile, "title": payload.title.clone(), "actorId": session_store_user_id, "actorType": "user", }); if let Some(acp_runtime) = acp_runtime_for_payload(&runtime_payload, profile) { let registration = HermesRunRegistration { session_id: session_id.clone(), profile: profile.to_string(), document_id: document_id.to_string(), trace_id: trace_id.clone(), }; let session_index_run_id = format!("{}_session", session_id); let runtime_state = json!({ "sessionId": session_id.clone(), "runId": session_index_run_id.clone(), "profile": profile, "documentId": document_id, "traceId": trace_id.clone(), "status": "session.created" }); persist_acp_runtime_run( &state, &context, ®istration, &session_index_run_id, &acp_runtime, &runtime_state, &runtime_payload, ) .await?; persistence = ACP_RUNTIME_SQLITE_STORE; } Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "sessionId": session_id, "workspaceId": payload.workspace_id, "documentId": payload.document_id, "profile": profile, "title": payload.title.unwrap_or_else(|| "当前页问答".into()), "traceId": trace_id, "persistence": persistence })), )) } pub async fn list_profiles( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_client_headers(), Json(list_profiles_payload()), )) } pub async fn gateway_health( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let profile = query .get("profile") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(active_profile_name) .unwrap_or_else(|| "default".into()); let profile_status = profile_gateway_status(&profile); if is_acp_profile(&profile) { let runtime_name = crate::acp_bridge::runtime_name_for_profile(&profile); return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "profile": profile_status, "gateway": { "configured": true, "upstream": Value::Null, "ok": true, "status": "acp", "runtime": runtime_name, "transport": "acp", "message": "页面 AI 默认使用 ACP runtime,Hermes HTTP gateway 已退役。" }, "suggestions": [] })), )); } let upstream = configured_upstream_for_profile(&profile); let mut suggestions = profile_status .get("suggestions") .and_then(Value::as_array) .cloned() .unwrap_or_default(); if upstream.is_none() { suggestions.push(Value::String( "未配置 MNOTE_WEB_HERMES_UPSTREAM_URL;请先启动 Hermes gateway,并让 mnote-web 指向该 gateway。" .into(), )); } let mut gateway = json!({ "configured": upstream.is_some(), "upstream": upstream, "ok": false, "status": if upstream.is_some() { "checking" } else { "unconfigured" } }); if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) { let probe = probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await; gateway["ok"] = Value::Bool(probe.ok); gateway["status"] = Value::String(probe.status); gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null); gateway["path"] = probe.path.map(Value::from).unwrap_or(Value::Null); gateway["message"] = probe.message.map(Value::from).unwrap_or(Value::Null); if !probe.ok { suggestions.extend( hermes_settings_suggestions( gateway["httpStatus"].as_u64(), gateway["message"].as_str(), ) .into_iter() .map(Value::String), ); } } let ok = gateway["ok"].as_bool().unwrap_or(false) && profile_status["modelConfigured"].as_bool().unwrap_or(false) && profile_status["apiKeyConfigured"] .as_bool() .unwrap_or(false); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": ok, "traceId": context.trace.trace_id, "profile": profile_status, "gateway": gateway, "suggestions": unique_string_values(suggestions) })), )) } pub async fn get_profile( Extension(context): Extension, Path(profile_name): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "profile": profile_detail_payload(&profile_name) })), )) } pub async fn switch_active_profile( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let name = payload .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 profile name") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; switch_active_profile_local(name).map_err(|error| { WebError::bad_gateway_code( "hermes_client_profile_switch_failed", format!("切换 Hermes profile 失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "profile": profile_detail_payload(name) })), )) } pub async fn get_profile_memory( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = query .get("profile") .map(String::as_str) .unwrap_or(fallback_profile.as_str()); Ok(( StatusCode::OK, stamp_client_headers(), Json(profile_memory_payload(profile)), )) } pub async fn save_profile_memory( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .unwrap_or(fallback_profile.as_str()); let section = payload .get("section") .and_then(Value::as_str) .map(str::trim) .filter(|value| matches!(*value, "memory" | "user" | "soul")) .ok_or_else(|| { WebError::bad_request_code( "hermes_client_bad_request", "section 必须是 memory、user 或 soul", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let content = payload .get("content") .and_then(Value::as_str) .unwrap_or_default(); save_profile_memory_local(profile, section, content).map_err(|error| { WebError::bad_gateway_code( "hermes_client_memory_save_failed", format!("保存 Hermes profile memory 失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true})), )) } pub async fn list_skills( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = query .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(if runtime == "reasonix" { reasonix_skills_payload() } else { skills_payload(profile) }), )) } pub async fn toggle_skill( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let name = payload .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 skill name") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let enabled = payload .get("enabled") .and_then(Value::as_bool) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .unwrap_or(fallback_profile.as_str()); set_skill_enabled(profile, name, enabled).map_err(|error| { WebError::bad_gateway_code( "hermes_client_skill_toggle_failed", format!("更新 Hermes skill 设置失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true})), )) } pub async fn toggle_tool( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let name = payload .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 tool name") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let enabled = payload .get("enabled") .and_then(Value::as_bool) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .unwrap_or(fallback_profile.as_str()); set_mnote_tool_enabled(profile, name, enabled).map_err(|error| { WebError::bad_gateway_code( "hermes_client_tool_toggle_failed", format!("更新 mnote tool 设置失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true})), )) } pub async fn get_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; if is_acp_session_query(&query) { return get_acp_session(&state, &context, &session_id, &query).await; } let runtime = runtime_state_for_session(&session_id); if let Some(session) = load_session_from_hermes_cli(&session_id).await { return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "sessionId": session_id, "session": session, "runtime": runtime })), )); } Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "sessionId": session_id, "session": { "sessionId": session_id, "messages": [] }, "runtime": runtime })), )) } pub async fn resume_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { if is_acp_session_query(&query) { let result = get_acp_session(&state, &context, &session_id, &query).await?; let mut payload = result.2 .0; payload["resumed"] = Value::Bool(true); payload["resumeSource"] = payload .get("persistence") .cloned() .unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into())); return Ok((result.0, result.1, Json(payload))); } get_session( State(state), Extension(context), Path(session_id), Query(query), ) .await } pub async fn delete_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; if !use_legacy_convex_acp_store(&query) { let user_id = effective_session_store_user_id(&state, &context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let deleted = state .control_plane() .delete_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref()) .map_err(|error| { WebError::internal(format!("SQLite ACP session 删除失败: {error}")) .with_context(&context) })?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "result": { "ok": true, "sessionId": session_id, "deleted": deleted } })), )); } let result = execute_acp_session_mutation( &state, &context, &session_id, &query, "aiSessions:deleteRuntimeSession", None, "acp_runtime_session_delete", ) .await?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": "convex_acp_runtime_store", "result": result })), )) } pub async fn rename_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let title = payload.title.trim(); if title.is_empty() { return Err( WebError::bad_request_code("hermes_client_bad_request", "缺少 title") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"), ); } if !use_legacy_convex_acp_store(&query) { let user_id = effective_session_store_user_id(&state, &context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let runs = state .control_plane() .rename_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref(), title) .map_err(|error| { WebError::internal(format!("SQLite ACP session 重命名失败: {error}")) .with_context(&context) })?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "result": { "ok": true, "sessionId": session_id, "title": title, "runs": runs.iter().map(ai_runtime_run_to_json).collect::>() } })), )); } let result = execute_acp_session_mutation( &state, &context, &session_id, &query, "aiSessions:renameRuntimeSession", Some(json!({"title": title})), "acp_runtime_session_rename", ) .await?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": "convex_acp_runtime_store", "result": result })), )) } pub async fn auto_title_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; if !use_legacy_convex_acp_store(&query) { let user_id = effective_session_store_user_id(&state, &context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let titled = state .control_plane() .auto_title_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref()) .map_err(|error| { WebError::internal(format!("SQLite ACP session 自动标题失败: {error}")) .with_context(&context) })?; let title = titled.as_ref().and_then(|run| run.title.clone()); return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "result": { "ok": true, "sessionId": session_id, "title": title, "run": titled.as_ref().map(ai_runtime_run_to_json) } })), )); } let result = execute_acp_session_mutation( &state, &context, &session_id, &query, "aiSessions:autoTitleRuntimeSession", None, "acp_runtime_session_auto_title", ) .await?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": "convex_acp_runtime_store", "result": result })), )) } async fn execute_acp_session_mutation( state: &AppState, context: &RequestContext, session_id: &str, query: &HashMap, function_name: &str, extra: Option, error_phase: &'static str, ) -> Result { let user_id = effective_session_store_user_id(state, context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let mut args = serde_json::Map::new(); args.insert("userId".into(), Value::String(user_id)); args.insert("sessionId".into(), Value::String(session_id.to_string())); if let Some(workspace_id) = workspace_id.clone() { args.insert("workspaceId".into(), Value::String(workspace_id)); } if let Some(Value::Object(extra)) = extra { args.extend(extra); } execute_convex_mutation_by_name( state.config(), context, function_name, Value::Object(args), workspace_id.as_deref(), Some(session_id), error_phase, ) .await } fn is_acp_session_query(query: &HashMap) -> bool { matches!(query.get("source").map(String::as_str), Some("acp")) || query .get("profile") .map(String::as_str) .map(is_acp_profile) .unwrap_or(false) } async fn get_acp_session( state: &AppState, context: &RequestContext, session_id: &str, query: &HashMap, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { if query.get("sourceKind").map(String::as_str).map(str::trim) == Some("local_folder") { let root_uri = query .get("rootUri") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; crate::routes::ensure_local_workspace_access(context, root_uri) .map_err(|error| error.with_context(context))?; let events = read_local_ai_session_events(root_uri, session_id, None)?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": "local_ai_session_jsonl", "sessionStorage": "local_private", "sessionId": session_id, "session": { "sessionId": session_id, "messages": [], "events": events }, "runtime": runtime_state_for_session(session_id), "events": events })), )); } let user_id = effective_session_store_user_id(state, context).await?; let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); if !use_legacy_convex_acp_store(query) { let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), None, Some(session_id), 20, ) .map_err(|error| { WebError::internal(format!("SQLite ACP session 详情读取失败: {error}")) .with_context(context) })?; let latest_run = runs.first().cloned(); let events = if let Some(run) = latest_run.as_ref() { state .control_plane() .list_ai_runtime_events(&user_id, &run.run_id, 200) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime events 读取失败: {error}")) .with_context(context) })? } else { Vec::new() }; let runtime = latest_run .as_ref() .and_then(|run| serde_json::from_str::(&run.runtime_json).ok()) .unwrap_or_else(|| runtime_state_for_session(session_id)); return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionId": session_id, "session": { "sessionId": session_id, "messages": [], "runs": runs.iter().map(ai_runtime_run_to_json).collect::>() }, "runtime": runtime, "events": events.iter().map(ai_runtime_event_to_json).collect::>() })), )); } let mut run_args = serde_json::Map::new(); run_args.insert("userId".into(), Value::String(user_id.clone())); run_args.insert("sessionId".into(), Value::String(session_id.to_string())); if let Some(workspace_id) = workspace_id.clone() { run_args.insert("workspaceId".into(), Value::String(workspace_id)); } run_args.insert("limit".into(), Value::from(20u64)); let runs = execute_convex_query_by_name( state.config(), context, "aiSessions:listRuntimeRuns", Value::Object(run_args), workspace_id.as_deref(), "acp_runtime_session_detail_runs", ) .await?; let latest_run = runs .as_array() .and_then(|rows| rows.first()) .cloned() .unwrap_or(Value::Null); let events = if let Some(run_id) = latest_run .get("runId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { execute_convex_query_by_name( state.config(), context, "aiSessions:listRuntimeEvents", json!({ "userId": user_id, "runId": run_id, "limit": 200 }), workspace_id.as_deref(), "acp_runtime_session_detail_events", ) .await? } else { Value::Array(Vec::new()) }; let runtime = latest_run .get("runtime") .cloned() .unwrap_or_else(|| runtime_state_for_session(session_id)); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": "convex_acp_runtime_store", "sessionId": session_id, "session": { "sessionId": session_id, "messages": [], "runs": runs }, "runtime": runtime, "events": events })), )) } async fn load_session_from_hermes_cli(session_id: &str) -> Option { let session_id = session_id.to_string(); tokio::task::spawn_blocking(move || { let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "hermes".into()); let output = Command::new(hermes_bin) .args(["sessions", "export", "--session-id", &session_id, "-"]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8(output.stdout).ok()?; stdout .lines() .find_map(|line| serde_json::from_str::(line).ok()) }) .await .ok() .flatten() } pub async fn create_run( State(state): State, Extension(context): Extension, Json(mut payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let (actor_id, actor_type) = resolve_run_actor(&state, &context).await; stamp_run_actor(&mut payload, &actor_id, &actor_type); let registration = run_registration_from_payload(&context, &payload); // ACP path: skip the HTTP proxy, just register and return run info if let Some(acp_runtime) = acp_runtime_for_payload(&payload, ®istration.profile) { 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() .expect("acp run payloads") .insert(run_id.clone(), payload.clone()); let persistence_result = persist_acp_runtime_run( &state, &context, ®istration, &run_id, &acp_runtime, &runtime_state, &payload, ) .await?; if payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) == Some("local_folder") { if let Some(root_uri) = payload.get("rootUri").and_then(Value::as_str) { match local_agent_audit_collect_snapshot(root_uri) { Ok(snapshot) => local_agent_audit_store_snapshot(&run_id, snapshot), Err(error) => { warn!(error = ?error, run_id = %run_id, "本地 agent audit 初始快照采集失败"); } } } } let response = json!({ "ok": true, "runId": run_id, "sessionId": registration.session_id, "profile": registration.profile, "traceId": context.trace.trace_id, "runtime": runtime_state, "persistence": persistence_result .get("persistence") .and_then(Value::as_str) .unwrap_or(ACP_RUNTIME_SQLITE_STORE), "sessionStorage": persistence_result .get("sessionStorage") .cloned() .unwrap_or(Value::Null), }); return Ok((StatusCode::OK, stamp_client_headers(), Json(response))); } if session_has_active_run(®istration.session_id) { let queued = enqueue_run(&context, ®istration, &payload)?; return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued))); } let Some(upstream) = configured_upstream_for_profile(®istration.profile) else { return hermes_unconfigured(&context); }; let upstream_body = build_run_upstream_body(&context, payload)?; let result = proxy_json( &context, reqwest::Method::POST, &upstream, "/v1/runs", Some(upstream_body), Some(®istration.profile), ) .await?; if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) { let mut payload = result.2 .0; payload["runtime"] = runtime; Ok((result.0, result.1, Json(payload))) } else { Ok(result) } } pub async fn cancel_queued_run( Extension(context): Extension, Path((session_id, queue_id)): Path<(String, String)>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let cancelled = remove_queued_run(&session_id, &queue_id); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "sessionId": session_id, "queueId": queue_id, "cancelled": cancelled, "queueLength": queue_len_for_session(&session_id) })), )) } /// ACP variant of stream_events: creates an ACP session, runs the prompt, /// and returns an SSE stream of events. async fn acp_stream_events( state: AppState, context: RequestContext, run_id: &str, profile: &str, acp_runtime_name: &str, ) -> Result { // Get the stored payload from create_run let payload = acp_run_payload_for_stream(run_id).ok_or_else(|| { WebError::bad_gateway_code( "acp_run_payload_not_found", format!("ACP run payload not found for run_id={run_id}"), ) .with_context(&context) })?; // Build prompt from the message field (frontend sends "message", not "input") let input = payload .get("message") .or_else(|| payload.get("input")) .and_then(Value::as_str) .unwrap_or("请读取当前文档内容"); 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 = acp_runtime_name; // Ensure runtime is active; switch_to either activates it or returns existing let access_env = acp_allowed_roots_env_for_payload(&payload); 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 = merge_acp_runtime_env(acp_hermes_env_for_profile(profile), access_env); state.acp_runtime.switch_to_config(config).await } else { match state.acp_runtime.get_config(runtime_name).cloned() { Some(mut config) => { config.env = merge_acp_runtime_env(config.env, access_env); state.acp_runtime.switch_to_config(config).await } None => Err(crate::acp_client::AcpError::Internal(format!( "unknown runtime: {runtime_name}" ))), } } .map_err(|e| { WebError::bad_gateway_code( "acp_runtime_switch_failed", format!("Failed to activate ACP runtime '{runtime_name}': {e}"), ) .with_context(&context) })?; // Create session manager and start the prompt let mgr = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); let (event_tx, _event_rx) = broadcast::channel(256); let event_tx_clone = event_tx.clone(); mgr.on_event(move |event| { if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) { let _ = event_tx_clone.send(sse); } }); // Try to resume stored ACP session, or create a new one. // Reference: hermes-vscode-main sessionManager.ts ensureSession() let stored_acp_session_id = payload .get("acpSessionId") .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()); let acp_session_id = mgr .ensure_session(None, stored_acp_session_id) .await .map_err(|e| { WebError::bad_gateway_code( "acp_session_ensure_failed", format!("ACP session ensure 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: mnote_session_id.clone(), acp_session_id, event_tx: event_tx.clone(), }, ); // 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::>(256); let mut event_rx = event_tx.subscribe(); let state_for_events = state.clone(); let context_for_events = context.clone(); let event_registration = run_registration_from_payload(&context, &payload); let event_payload = payload.clone(); let run_id_for_events = run_id.to_string(); let acp_runtime_for_events = acp_runtime_name.to_string(); tokio::spawn(async move { loop { match event_rx.recv().await { Ok(event) => { if let Err(error) = persist_acp_runtime_event( &state_for_events, &context_for_events, &event_registration, &run_id_for_events, &acp_runtime_for_events, &event.event, &event.data, &event_payload, ) .await { warn!(error = ?error, run_id = %run_id_for_events, event = %event.event, "ACP runtime 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(); let mgr_clone = Arc::clone(&mgr); let event_tx_prompt = event_tx.clone(); let audit_context = context.clone(); let audit_payload = payload.clone(); let audit_runtime = acp_runtime_name.to_string(); let mnote_tool_context = crate::acp_session_manager::AcpMnoteToolContext { mnote_session_id: Some(mnote_session_id.clone()), run_id: Some(run_id.to_string()), actor_id: payload .get("actorId") .and_then(Value::as_str) .map(ToOwned::to_owned), trace_id: Some(context.trace.trace_id.clone()), workspace_id: payload .get("workspaceId") .and_then(Value::as_str) .map(ToOwned::to_owned), document_id: payload .get("documentId") .and_then(Value::as_str) .map(ToOwned::to_owned), }; update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None); tokio::spawn(async move { match mgr_clone .run_prompt_with_mnote_context(prompt_blocks, Some(mnote_tool_context)) .await { Ok(result) => { info!("ACP prompt completed: stop_reason={:?}", result.stop_reason); let prompt_cancelled = matches!(result.stop_reason, crate::acp_types::StopReason::Cancelled); let runtime_aborted = matches!( runtime_status_for_run(&run_id_owned).as_deref(), Some("aborting" | "aborted") ); let agent_audit = if audit_payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) == Some("local_folder") { match local_agent_audit_finalize_run( &audit_context, &audit_payload, &run_id_owned, &audit_runtime, "completed", ) { Ok(event) => event, Err(error) => { warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 完成事件写入失败"); Value::Null } } } else { Value::Null }; let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent { event: if prompt_cancelled || runtime_aborted { "run.aborted".into() } else { "run.completed".into() }, data: json!({ "stopReason": format!("{:?}", result.stop_reason), "agentAudit": agent_audit, }), }); } Err(e) => { warn!("ACP prompt failed: {e}"); let agent_audit = if audit_payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) == Some("local_folder") { match local_agent_audit_finalize_run( &audit_context, &audit_payload, &run_id_owned, &audit_runtime, "failed", ) { Ok(event) => event, Err(error) => { warn!(error = ?error, run_id = %run_id_owned, "本地 agent audit 失败事件写入失败"); Value::Null } } } else { Value::Null }; let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "error": e.to_string(), "agentAudit": agent_audit }), }); } } let was_aborted = matches!( runtime_status_for_run(&run_id_owned).as_deref(), Some("aborting" | "aborted") ); mgr_clone.close().await; ACP_ACTIVE_RUNS .lock() .expect("acp active runs") .remove(&run_id_owned); if !was_aborted { update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None); } }); let stream = tokio_stream::wrappers::ReceiverStream::new(rx); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") .header(header::CACHE_CONTROL, "no-cache, no-transform") .header("x-accel-buffering", "no") .body(Body::from_stream(stream)) .map_err(|e| { WebError::internal(format!("SSE response build failed: {e}")).with_context(&context) })?; stamp_client_headers_into(response.headers_mut()); Ok(response) } pub async fn stream_events( State(state): State, Extension(context): Extension, Path(run_id): Path, ) -> Result { ensure_authenticated(&context)?; let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into()); // ACP path: start AcpRunBridge and return SSE stream 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 { return Err(hermes_unconfigured_error(&context)); }; let url = upstream_url( &upstream, &format!("/v1/runs/{}/events", url_escape(&run_id)), )?; let mut request = reqwest::Client::builder() .timeout(Duration::from_secs(1800)) .build() .map_err(|error| { WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context) })? .get(url); if let Some(api_key) = configured_api_key_for_profile(&profile) { request = request.bearer_auth(api_key); } let upstream_response = request.send().await.map_err(|error| { WebError::bad_gateway_code( "hermes_client_upstream_unavailable", format!("Hermes events upstream 连接失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; if !upstream_response.status().is_success() { let status = upstream_response.status(); let text = upstream_response.text().await.unwrap_or_default(); return Err(upstream_error(&context, status, text)); } let run_id_for_stream = run_id.clone(); let context_for_queue = context.clone(); update_runtime_by_run_id(&run_id, "running", Some("events.connected"), None); let stream = upstream_response .bytes_stream() .map_ok(move |chunk| { let text = String::from_utf8_lossy(&chunk); let (normalized, terminal_sessions) = normalize_sse_chunk(&run_id_for_stream, text.as_ref()); for session_id in terminal_sessions { let context = context_for_queue.clone(); tokio::spawn(async move { start_next_queued_run(context, session_id).await; }); } axum::body::Bytes::from(normalized) }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::Other, format!("Hermes events stream 读取失败: {error}"), ) }); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") .header(header::CACHE_CONTROL, "no-cache, no-transform") .header("x-accel-buffering", "no") .body(Body::from_stream(stream)) .map_err(|error| WebError::internal(format!("Hermes events 响应构造失败: {error}")))?; stamp_client_headers_into(response.headers_mut()); Ok(response) } pub async fn abort_run( Extension(context): Extension, Path(run_id): Path, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), 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"} ] })), )); }; let cancel_result = tokio::time::timeout( Duration::from_millis(ACP_ABORT_NOTIFICATION_TIMEOUT_MS), active.manager.cancel(), ) .await; let cancel_status = match cancel_result { Ok(Ok(())) => json!({"ok": true}), Ok(Err(error)) => { warn!(error = ?error, run_id = %run_id, "ACP abort notification failed; marking run aborted"); json!({"ok": false, "error": error.to_string()}) } Err(_) => { warn!( run_id = %run_id, timeout_ms = ACP_ABORT_NOTIFICATION_TIMEOUT_MS, "ACP abort notification timed out; marking run aborted" ); json!({"ok": false, "error": "abort notification timed out"}) } }; update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None); let _ = active.event_tx.send(crate::acp_bridge::SseEvent { event: "run.aborted".into(), data: json!({ "reason": payload .get("reason") .and_then(Value::as_str) .unwrap_or("client_abort"), "cancel": cancel_status.clone(), }), }); 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), "cancel": cancel_status, "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); }; let queued_session_id = session_id_for_run(&run_id); update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None); let result = proxy_json( &context, reqwest::Method::POST, &upstream, &format!("/v1/runs/{}/stop", url_escape(&run_id)), Some(payload), Some(&profile), ) .await; match result { Ok((status, headers, Json(mut payload))) => { update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None); payload["runtime"] = runtime_state_for_run(&run_id).unwrap_or(Value::Null); payload["events"] = json!([ {"event": "abort.started", "runId": run_id}, {"event": "abort.completed", "runId": run_id} ]); if let Some(session_id) = queued_session_id { start_next_queued_run(context.clone(), session_id).await; } Ok((status, headers, Json(payload))) } Err(error) => { update_runtime_by_run_id(&run_id, "failed", Some("abort.failed"), None); Err(error) } } } /// Resolve a pending permission request from an ACP agent. /// /// POST /api/hermes/client/runs/{run_id}/resolve-permission /// Body: { "permissionId": "...", "decision": "allow"|"deny" } /// /// Looks up the active run, forwards the decision to the ACP subprocess, /// and emits `permission.allowed` / `permission.denied` SSE event. pub async fn resolve_permission( Extension(context): Extension, Path(run_id): Path, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let permission_id = payload .get("permissionId") .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId") .with_context(&context) })?; let decision = payload .get("decision") .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)") .with_context(&context) })?; let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into()); if acp_runtime_for_run(&run_id, &profile).is_none() { return Err(WebError::bad_request_code( "hermes_client_not_acp_run", format!("run_id={run_id} 不是 ACP run"), ) .with_context(&context)); } let active = ACP_ACTIVE_RUNS .lock() .expect("acp active runs") .get(&run_id) .cloned(); let Some(active) = active else { return Err(WebError::bad_request_code( "hermes_client_no_active_run", format!("run_id={run_id} 没有活跃 ACP run"), ) .with_context(&context)); }; active .manager .resolve_permission(permission_id, decision) .await .map_err(|e| { WebError::bad_request_code( "hermes_client_permission_resolve_failed", format!("resolve permission 失败: {e}"), ) .with_context(&context) })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "runId": run_id, "permissionId": permission_id, "decision": decision, })), )) } pub async fn list_models( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let Some(upstream) = configured_upstream() else { return hermes_unconfigured(&context); }; proxy_json( &context, reqwest::Method::GET, &upstream, "/v1/models", None, None, ) .await } pub async fn list_tools( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = query .get("profile") .map(String::as_str) .unwrap_or(fallback_profile.as_str()); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "profile": profile, "tools": mnote_tools_payload(profile) })), )) } fn hermes_bin() -> String { std::env::var("MNOTE_WEB_HERMES_BIN") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "hermes".into()) } fn hermes_home() -> PathBuf { std::env::var("HERMES_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { std::env::var("HOME") .ok() .map(|home| PathBuf::from(home).join(".hermes")) }) .unwrap_or_else(|| PathBuf::from(".hermes")) } pub(crate) fn active_profile_name() -> Option { fs::read_to_string(hermes_home().join("active_profile")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn profile_home(profile: &str) -> PathBuf { let home = hermes_home(); let profile = profile.trim(); if profile.is_empty() || profile == "default" { return home; } let candidate = home.join("profiles").join(profile); if candidate.exists() { candidate } else { home } } fn profile_config_path(profile: &str) -> PathBuf { profile_home(profile).join("config.yaml") } fn parse_profile_list(stdout: &str) -> Vec { let active = active_profile_name().unwrap_or_else(|| "default".into()); stdout .replace("\r\n", "\n") .replace('\r', "\n") .lines() .filter_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("Profile") || trimmed.starts_with('─') { return None; } let is_active = trimmed.starts_with('◆'); let without_marker = trimmed.trim_start_matches('◆').trim(); let parts = without_marker .split_whitespace() .map(str::to_string) .collect::>(); if parts.is_empty() { return None; } let name = parts[0].clone(); let model = parts .get(1) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); let gateway = parts .get(2) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); let alias = parts .get(3) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); Some(json!({ "name": name, "active": name == active || (active == "default" && is_active), "model": model, "gateway": gateway, "alias": alias })) }) .collect() } fn fallback_profiles() -> Vec { let active = active_profile_name().unwrap_or_else(|| "default".into()); let default_status = profile_gateway_status("default"); let mut profiles = vec![json!({ "name": "default", "active": active == "default", "model": default_status["modelDefault"].as_str().unwrap_or_default(), "gateway": default_status["gateway"].as_str().unwrap_or_default(), "provider": default_status["provider"].as_str().unwrap_or_default(), "alias": "" })]; let profiles_dir = hermes_home().join("profiles"); if let Ok(entries) = fs::read_dir(profiles_dir) { let mut names = entries .filter_map(Result::ok) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|entry| entry.file_name().into_string().ok()) .collect::>(); names.sort(); profiles.extend(names.into_iter().map(|name| { let status = profile_gateway_status(&name); json!({ "name": name, "active": name == active, "model": status["modelDefault"].as_str().unwrap_or_default(), "gateway": status["gateway"].as_str().unwrap_or_default(), "provider": status["provider"].as_str().unwrap_or_default(), "alias": "" }) })); } profiles } fn list_profiles_payload() -> Value { let profiles = Command::new(hermes_bin()) .args(["profile", "list"]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|stdout| parse_profile_list(&stdout)) .filter(|profiles| !profiles.is_empty()) .unwrap_or_else(fallback_profiles); let reasonix_model = std::env::var("REASONIX_MODEL").unwrap_or_else(|_| "deepseek-chat".into()); let reasonix_preset = std::env::var("REASONIX_PRESET").unwrap_or_else(|_| "auto".into()); let reasonix_has_key = std::env::var("DEEPSEEK_API_KEY").is_ok(); json!({ "ok": true, "profiles": profiles, "acpRuntimes": json!([ { "name": "hermes", "title": "ACP · Hermes", "description": "通过 ACP 协议直连 Hermes agent runtime · model/default 来自 Hermes profile" }, { "name": "reasonix", "title": "ACP · Reasonix", "description": "通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)", "model": reasonix_model, "preset": reasonix_preset, "apiKeyConfigured": reasonix_has_key, "version": "0.43.0" } ]) }) } fn profile_detail_payload(profile: &str) -> Value { let dir = profile_home(profile); let gateway_status = profile_gateway_status(profile); json!({ "name": if profile.trim().is_empty() { "default" } else { profile.trim() }, "path": dir.to_string_lossy(), "model": gateway_status["modelDefault"].as_str().unwrap_or_default(), "provider": gateway_status["provider"].as_str().unwrap_or_default(), "gateway": gateway_status["gateway"].as_str().unwrap_or_default(), "modelConfigured": gateway_status["modelConfigured"].as_bool().unwrap_or(false), "apiKeyConfigured": gateway_status["apiKeyConfigured"].as_bool().unwrap_or(false), "suggestions": gateway_status["suggestions"].clone(), "skills": skills_payload(profile)["categories"].as_array().map(Vec::len).unwrap_or_default(), "hasEnv": dir.join(".env").exists(), "hasSoulMd": dir.join("SOUL.md").exists() }) } fn switch_active_profile_local(profile: &str) -> std::io::Result<()> { let command_result = Command::new(hermes_bin()) .args(["profile", "use", profile]) .output(); if command_result .as_ref() .map(|output| output.status.success()) .unwrap_or(false) { return Ok(()); } let home = hermes_home(); fs::create_dir_all(&home)?; if profile != "default" { fs::create_dir_all(home.join("profiles").join(profile))?; } fs::write(home.join("active_profile"), profile.as_bytes()) } fn file_text(path: &FsPath) -> String { fs::read_to_string(path).unwrap_or_default() } fn file_mtime_ms(path: &FsPath) -> Option { let modified = fs::metadata(path).ok()?.modified().ok()?; let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?; Some(duration.as_millis() as i128) } fn profile_memory_payload(profile: &str) -> Value { let dir = profile_home(profile); let memory_path = dir.join("memories").join("MEMORY.md"); let user_path = dir.join("memories").join("USER.md"); let soul_path = dir.join("SOUL.md"); json!({ "ok": true, "memory": file_text(&memory_path), "user": file_text(&user_path), "soul": file_text(&soul_path), "memory_mtime": file_mtime_ms(&memory_path), "user_mtime": file_mtime_ms(&user_path), "soul_mtime": file_mtime_ms(&soul_path) }) } fn save_profile_memory_local(profile: &str, section: &str, content: &str) -> std::io::Result<()> { let dir = profile_home(profile); let path = match section { "soul" => dir.join("SOUL.md"), "user" => dir.join("memories").join("USER.md"), _ => dir.join("memories").join("MEMORY.md"), }; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, content) } fn disabled_skills(profile: &str) -> Vec { let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); let mut disabled = Vec::new(); let mut in_skills = false; let mut in_disabled = false; for line in content.lines() { let trimmed = line.trim(); if !line.starts_with(' ') && !trimmed.is_empty() { in_skills = trimmed == "skills:"; in_disabled = false; continue; } if in_skills && trimmed == "disabled:" { in_disabled = true; continue; } if in_disabled { if let Some(value) = trimmed.strip_prefix("- ") { disabled.push( value .trim() .trim_matches('"') .trim_matches('\'') .to_string(), ); } else if !trimmed.is_empty() && !trimmed.starts_with('#') { in_disabled = false; } } } disabled } fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec { let mut stack: Vec<(usize, String)> = Vec::new(); let mut values = Vec::new(); for raw_line in content.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); if let Some(value) = trimmed.strip_prefix("- ") { let keys = stack .iter() .map(|(_, key)| key.as_str()) .collect::>(); if keys == path { let normalized = value.trim().trim_matches('"').trim_matches('\''); if !normalized.is_empty() { values.push(normalized.to_string()); } } continue; } while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } let Some((key, _value)) = trimmed.split_once(':') else { continue; }; let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); stack.push((indent, key)); } values } pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec { let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); yaml_disabled_list(&content, &["mnote", "tools", "disabled"]) } pub(crate) fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool { disabled_mnote_tools(profile) .iter() .any(|item| item == name) } fn mnote_tools_payload(profile: &str) -> Vec { let disabled = disabled_mnote_tools(profile); manifest::manifest() .get("tools") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|tool| mnote_tool_entry(tool, &disabled)) .collect() } fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option { let name = tool.get("name").and_then(Value::as_str)?.trim(); if name.is_empty() { return None; } let capability_scope = tool .get("capabilityScope") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(str::to_string) .collect::>() }) .unwrap_or_default(); let disabled = disabled.iter().any(|item| item == name); let status = if disabled { "disabled" } else { tool.get("status") .and_then(Value::as_str) .unwrap_or("available") }; Some(json!({ "name": name, "description": tool.get("description").and_then(Value::as_str).unwrap_or_default(), "scope": capability_scope.first().cloned().unwrap_or_else(|| "mnote".into()), "capabilityScope": capability_scope, "kind": if capability_scope.iter().any(|scope| scope.ends_with(".write")) { "write" } else { "read" }, "schemaVersion": tool.get("schemaVersion").and_then(Value::as_str).unwrap_or(manifest::TOOL_SCHEMA_VERSION), "status": status, "enabled": !disabled, "unavailableReason": if disabled { "当前 Hermes profile 已关闭该 mnote tool" } else { "" } })) } fn extract_skill_description(markdown: &str) -> String { markdown .lines() .find_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { return None; } Some( trimmed .trim_start_matches("Description:") .trim() .to_string(), ) }) .unwrap_or_default() } struct SkillCatalogMeta { bundled: HashSet, hub_installed: HashSet, usage: HashMap, } impl SkillCatalogMeta { fn read(skills_dir: &FsPath) -> Self { Self { bundled: read_bundled_skill_names(&skills_dir.join(".bundled_manifest")), hub_installed: read_hub_installed_skill_names( &skills_dir.join(".hub").join("lock.json"), ), usage: read_skill_usage(&skills_dir.join(".usage.json")), } } fn usage_for(&self, name: &str) -> Option<&Value> { self.usage.get(name) } } fn read_bundled_skill_names(path: &FsPath) -> HashSet { fs::read_to_string(path) .ok() .map(|content| { content .lines() .filter_map(|line| { line.split_once(':') .map(|(name, _)| name.trim().to_string()) }) .filter(|name| !name.is_empty()) .collect() }) .unwrap_or_default() } fn read_hub_installed_skill_names(path: &FsPath) -> HashSet { let Ok(content) = fs::read_to_string(path) else { return HashSet::new(); }; let Ok(payload) = serde_json::from_str::(&content) else { return HashSet::new(); }; payload .get("installed") .and_then(Value::as_object) .map(|installed| { installed .iter() .flat_map(|(name, entry)| { let mut names = vec![name.to_string()]; if let Some(path) = entry.get("install_path").and_then(Value::as_str) { if let Some(last) = path.rsplit('/').find(|part| !part.trim().is_empty()) { names.push(last.to_string()); } } names }) .collect() }) .unwrap_or_default() } fn read_skill_usage(path: &FsPath) -> HashMap { let Ok(content) = fs::read_to_string(path) else { return HashMap::new(); }; serde_json::from_str::(&content) .ok() .and_then(|payload| payload.as_object().cloned()) .map(|items| items.into_iter().collect()) .unwrap_or_default() } fn skill_source(name: &str, meta: &SkillCatalogMeta) -> &'static str { if meta.bundled.contains(name) { "builtin" } else if meta.hub_installed.contains(name) { "hub" } else { "local" } } fn skill_is_agent_generated(usage: Option<&Value>) -> bool { usage .and_then(Value::as_object) .map(|record| { record .get("created_by") .and_then(Value::as_str) .map(|value| value == "agent") .unwrap_or(false) || record .get("agent_created") .and_then(Value::as_bool) .unwrap_or(false) }) .unwrap_or(false) } fn skill_origin(source: &str, usage: Option<&Value>) -> &'static str { if skill_is_agent_generated(usage) { "generated" } else if source == "builtin" { "builtin" } else if source == "hub" { "installed" } else { "copied" } } fn skill_entry( name: String, category: String, path: PathBuf, disabled: &[String], meta: &SkillCatalogMeta, ) -> Option { let content = fs::read_to_string(path).ok()?; let source = skill_source(&name, meta); let usage = meta.usage_for(&name); Some(json!({ "name": name, "description": extract_skill_description(&content), "enabled": !disabled.iter().any(|item| item == &name), "source": source, "origin": skill_origin(source, usage), "createdBy": usage.and_then(|record| record.get("created_by")).and_then(Value::as_str), "createdAt": usage.and_then(|record| record.get("created_at")).and_then(Value::as_str), "patchCount": usage.and_then(|record| record.get("patch_count")).and_then(Value::as_i64), "category": category })) } fn skills_payload(profile: &str) -> Value { let skills_dir = hermes_home().join("skills"); let disabled = disabled_skills(profile); let meta = SkillCatalogMeta::read(&skills_dir); let mut categories = Vec::new(); let Ok(entries) = fs::read_dir(&skills_dir) else { return json!({"ok": true, "categories": [], "archived": []}); }; let mut dirs = entries .filter_map(Result::ok) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) .collect::>(); dirs.sort_by_key(|entry| entry.file_name()); for entry in dirs { let name = entry.file_name().to_string_lossy().to_string(); let dir = entry.path(); let direct_skill = dir.join("SKILL.md"); if direct_skill.exists() { if let Some(skill) = skill_entry(name, "misc".into(), direct_skill, &disabled, &meta) { categories.push(json!({ "name": "misc", "description": "misc", "skills": [skill] })); } continue; } let mut skills = fs::read_dir(&dir) .ok() .into_iter() .flat_map(|entries| entries.filter_map(Result::ok)) .filter(|child| child.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|child| { let skill_name = child.file_name().to_string_lossy().to_string(); skill_entry( skill_name, name.clone(), child.path().join("SKILL.md"), &disabled, &meta, ) }) .collect::>(); if !skills.is_empty() { skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); categories.push(json!({ "name": name, "description": "", "skills": skills })); } } json!({ "ok": true, "categories": merge_misc_categories(categories), "archived": [] }) } 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 { 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 { 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> = 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::>(); 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::>(); 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) -> Vec { let mut merged = Vec::new(); let mut misc = Vec::new(); for category in categories { if category["name"].as_str() == Some("misc") { if let Some(skills) = category["skills"].as_array() { misc.extend(skills.iter().cloned()); } } else { merged.push(category); } } if !misc.is_empty() { misc.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); merged.insert( 0, json!({ "name": "misc", "description": "misc", "skills": misc }), ); } merged } fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> { let path = profile_config_path(profile); let mut disabled = disabled_skills(profile); if enabled { disabled.retain(|item| item != name); } else if !disabled.iter().any(|item| item == name) { disabled.push(name.to_string()); } disabled.sort(); let existing = fs::read_to_string(&path).unwrap_or_default(); let mut kept = Vec::new(); let mut in_skills = false; let mut in_disabled = false; for line in existing.lines() { let trimmed = line.trim(); if !line.starts_with(' ') && !trimmed.is_empty() { in_skills = trimmed == "skills:"; in_disabled = false; } if in_skills && trimmed == "disabled:" { in_disabled = true; continue; } if in_disabled { if trimmed.starts_with("- ") || trimmed.is_empty() { continue; } in_disabled = false; } kept.push(line.to_string()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut output = kept.join("\n"); if !output.ends_with('\n') && !output.is_empty() { output.push('\n'); } output.push_str("skills:\n disabled:\n"); for skill in disabled { output.push_str(" - "); output.push_str(&skill); output.push('\n'); } fs::write(path, output) } fn set_mnote_tool_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> { let path = profile_config_path(profile); let mut disabled = disabled_mnote_tools(profile); if enabled { disabled.retain(|item| item != name); } else if !disabled.iter().any(|item| item == name) { disabled.push(name.to_string()); } disabled.sort(); let existing = fs::read_to_string(&path).unwrap_or_default(); let mut kept = Vec::new(); let mut stack: Vec<(usize, String)> = Vec::new(); let mut skipping_disabled_list = false; let mut disabled_indent = 0usize; for line in existing.lines() { let trimmed = line.trim(); let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); if skipping_disabled_list { if trimmed.starts_with("- ") && indent > disabled_indent { continue; } skipping_disabled_list = false; } if trimmed.is_empty() || trimmed.starts_with('#') { kept.push(line.to_string()); continue; } if !trimmed.starts_with("- ") { while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } if let Some((key, _value)) = trimmed.split_once(':') { let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); stack.push((indent, key)); let keys = stack .iter() .map(|(_, key)| key.as_str()) .collect::>(); if keys == ["mnote", "tools", "disabled"] { skipping_disabled_list = true; disabled_indent = indent; continue; } } } kept.push(line.to_string()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut output = kept.join("\n"); if !output.ends_with('\n') && !output.is_empty() { output.push('\n'); } output.push_str("mnote:\n tools:\n disabled:\n"); for tool in disabled { output.push_str(" - "); output.push_str(&tool); output.push('\n'); } fs::write(path, output) } fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { let has_actor = context.auth.actor_id.trim() != "anonymous"; if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return Ok(()); } Err(WebError::new( StatusCode::UNAUTHORIZED, "hermes_client_unauthorized", "页面 AI Hermes client 需要登录后访问", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")) } fn configured_upstream() -> Option { [ "MNOTE_WEB_HERMES_UPSTREAM_URL", "MNOTE_HERMES_UPSTREAM_URL", "MNOTE_HERMES_API_BASE_URL", ] .into_iter() .find_map(env_or_dotenv) .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) } fn profile_config_value(profile: &str, path: &[&str]) -> Option { let content = fs::read_to_string(profile_config_path(profile)).ok()?; yaml_path_value(&content, path) } fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String { let normalized = profile .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_uppercase() } else { '_' } }) .collect::(); format!("{prefix}_{normalized}_{suffix}") } fn hermes_http_proxy_enabled() -> bool { [ "MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "MNOTE_ENABLE_HERMES_HTTP_PROXY", ] .into_iter() .find_map(env_or_dotenv) .map(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" ) }) .unwrap_or(false) } fn default_acp_runtime_name() -> String { env_or_dotenv("MNOTE_WEB_ACP_DEFAULT_RUNTIME") .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| value == "hermes" || value == "reasonix") .unwrap_or_else(|| "reasonix".into()) } /// 判断当前 profile 是否应走 ACP,而不是已退役的 Hermes HTTP proxy。 /// /// Hermes HTTP 不再是页面 AI 默认主路径;仅在显式兼容开关打开时, /// 给尚未迁移的调用方保留短期入口。 fn is_acp_profile(profile: &str) -> bool { if !hermes_http_proxy_enabled() { return true; } // ACP runtimes: "reasonix" and "hermes" both use ACP protocol when selected from the UI. // The "reasonix" name is hardcoded; "hermes" as ACP is triggered by env var or UI selection. if profile == "reasonix" || profile == "hermes" { return true; } let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_TYPE"); env_or_dotenv(&env_key) .map(|v| v.trim().to_lowercase() == "acp") .unwrap_or(false) } fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option { 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(default_acp_runtime_name()); } if hermes_http_proxy_enabled() { None } else { Some(default_acp_runtime_name()) } } fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option { 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 acp_run_payload_for_stream(run_id: &str) -> Option { ACP_RUN_PAYLOADS .lock() .expect("acp run payloads") .get(run_id) .cloned() } 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> { 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) } fn merge_acp_runtime_env( base: Option>, extra: Option>, ) -> Option> { let mut merged = base.unwrap_or_default(); if let Some(extra) = extra { merged.extend(extra); } if merged.is_empty() { None } else { Some(merged) } } fn local_ai_payload_permission_level(payload: &Value) -> &'static str { if let Some(grant) = local_share_grant_for_payload(payload).ok().flatten() { return grant.permission_level(); } let raw = payload .get("permissionLevel") .or_else(|| payload.get("permission_level")) .and_then(Value::as_str) .map(str::trim) .unwrap_or("read_write") .to_ascii_lowercase(); if raw == "shared_read" { "shared_read" } else if raw == "shared_write" { "shared_write" } else if raw == "read" || raw == "readonly" || raw == "read_only" { "read_only" } else { "read_write" } } fn local_ai_payload_is_read_only(payload: &Value) -> bool { matches!( local_ai_payload_permission_level(payload), "read_only" | "shared_read" ) } fn local_share_grants_path() -> PathBuf { env::var(ENV_LOCAL_SHARE_GRANTS_FILE) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(LOCAL_SHARE_GRANTS_JSON)) } fn local_resource_id_to_relative_path(resource_id: &str) -> Option { let trimmed = resource_id.trim(); let encoded = trimmed.strip_prefix("local-md:")?; let bytes = encoded.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'~' { if index + 2 >= bytes.len() { return None; } let hex = &encoded[index + 1..index + 3]; let byte = u8::from_str_radix(hex, 16).ok()?; decoded.push(byte); index += 3; } else { decoded.push(bytes[index]); index += 1; } } String::from_utf8(decoded).ok() } fn local_share_permission_allows_write(permission: &str, capabilities: &HashSet) -> bool { let permission = permission.trim().to_ascii_lowercase(); let has_ai = capabilities .iter() .any(|capability| capability.trim().eq_ignore_ascii_case("ai")); (permission == "write" || permission == "shared_write") && has_ai } fn local_share_grant_for_payload(payload: &Value) -> Result, WebError> { let share_id = payload .get("shareId") .or_else(|| payload.get("share_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let Some(share_id) = share_id else { return Ok(None); }; Ok(Some(load_local_share_grant_for_actor( share_id, payload.get("actorId").and_then(Value::as_str), payload.get("rootUri").and_then(Value::as_str), )?)) } fn load_local_share_grant_for_actor( share_id: &str, actor_id: Option<&str>, root_uri: Option<&str>, ) -> Result { let path = local_share_grants_path(); let content = fs::read_to_string(&path).map_err(|error| { WebError::new( StatusCode::FORBIDDEN, "local_share_grant_required", format!("共享 AI 会话需要 canonical share grant: {error}"), ) })?; let payload: Value = serde_json::from_str(&content).map_err(|error| { WebError::bad_request_code( "local_share_grants_invalid", format!("share-grants.json 格式非法: {error}"), ) })?; let grants = if let Some(array) = payload.as_array() { array } else { payload .get("grants") .and_then(Value::as_array) .ok_or_else(|| { WebError::bad_request_code( "local_share_grants_invalid", "share-grants.json 缺少 grants 数组", ) })? }; let Some(raw_grant) = grants.iter().find(|grant| { grant .get("shareId") .or_else(|| grant.get("share_id")) .and_then(Value::as_str) .map(str::trim) == Some(share_id) }) else { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_not_found", "共享 AI 会话使用的 shareId 不存在", )); }; let grant = parse_local_share_grant(raw_grant)?; if grant.revoked { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_revoked", "共享 AI 会话使用的 share grant 已撤销", )); } if let (Some(target), Some(actor_id)) = (grant.target_user_id.as_deref(), actor_id) { if target.trim() != actor_id.trim() { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_actor_mismatch", "当前用户不是 share grant 的 target 用户", )); } } if let (Some(grant_root), Some(root_uri)) = (grant.root_uri.as_deref(), root_uri) { if grant_root.trim() != root_uri.trim() { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_root_mismatch", "请求 rootUri 与 canonical share grant 不一致", )); } } if !grant .capabilities .iter() .any(|capability| capability.trim().eq_ignore_ascii_case("ai")) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_ai_capability_required", "共享 AI 会话需要 share grant 包含 ai capability", )); } Ok(grant) } fn parse_local_share_grant(value: &Value) -> Result { let share_id = value .get("shareId") .or_else(|| value.get("share_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_share_grant_invalid", "share grant 缺少 shareId") })? .to_string(); let owner_id = value .get("ownerId") .or_else(|| value.get("ownerUserId")) .or_else(|| value.get("owner_id")) .or_else(|| value.get("owner_user_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let target_user_id = value .get("targetUserId") .or_else(|| value.get("target_user_id")) .or_else(|| value.get("targetId")) .or_else(|| value.get("target_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let root_uri = value .get("rootUri") .or_else(|| value.get("root_uri")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let permission = value .get("permission") .or_else(|| value.get("permissionLevel")) .or_else(|| value.get("permission_level")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("read") .to_ascii_lowercase(); let capabilities = value .get("capabilities") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_lowercase()) .collect::>() }) .unwrap_or_default(); let allowed_resource_ids = value .get("allowedResourceIds") .or_else(|| value.get("allowed_resource_ids")) .or_else(|| value.get("resourceIds")) .or_else(|| value.get("resource_ids")) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default(); let revoked = value .get("revoked") .and_then(Value::as_bool) .unwrap_or(false) || value .get("active") .and_then(Value::as_bool) .map(|active| !active) .unwrap_or(false) || value .get("revokedAt") .or_else(|| value.get("revoked_at")) .and_then(Value::as_str) .map(str::trim) .map(|revoked_at| !revoked_at.is_empty()) .unwrap_or(false) || value .get("status") .and_then(Value::as_str) .map(str::trim) .map(|status| status.eq_ignore_ascii_case("revoked")) .unwrap_or(false); Ok(LocalShareGrant { share_id, owner_id, target_user_id, root_uri, permission, capabilities, allowed_resource_ids, revoked, }) } fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option> { if payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) != Some("local_folder") { return None; } let root_uri = payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let root_path = file_root_uri_to_permission_path(root_uri)?; let workspace_id = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); let session_id = payload .get("sessionId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("session"); let document_id = payload .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("current"); let actor_id = payload .get("actorId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("anonymous"); let share_grant = local_share_grant_for_payload(payload).ok().flatten(); let permission_level = share_grant .as_ref() .map(LocalShareGrant::permission_level) .unwrap_or_else(|| local_ai_payload_permission_level(payload)); let allowed_resource_ids = share_grant .as_ref() .map(|grant| grant.allowed_resource_ids.clone()) .filter(|ids| !ids.is_empty()) .unwrap_or_else(|| vec![document_id.to_string()]); let allowed_file_paths = share_grant .as_ref() .map(LocalShareGrant::allowed_file_paths) .unwrap_or_default(); let mut access_scope = json!({ "userId": actor_id, "workspaceId": workspace_id, "sessionId": session_id, "sourceKind": "local_folder", "permissionLevel": permission_level, "allowedRoots": [root_uri], "allowedFilePaths": allowed_file_paths, "allowedResourceIds": allowed_resource_ids }); if let Some(grant) = share_grant.as_ref() { access_scope["shareContext"] = grant.share_context(); } let mut env = HashMap::new(); env.insert( "MNOTE_AI_ALLOWED_ROOTS_JSON".into(), serde_json::to_string( &share_grant .as_ref() .map(LocalShareGrant::allowed_file_paths) .filter(|paths| !paths.is_empty()) .unwrap_or_else(|| vec![root_path.clone()]), ) .ok()?, ); env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path); env.insert( "MNOTE_AI_ACCESS_SCOPE_JSON".into(), serde_json::to_string(&access_scope).ok()?, ); Some(env) } fn file_root_uri_to_permission_path(root_uri: &str) -> Option { let trimmed = root_uri.trim(); let path = trimmed.strip_prefix("file://").unwrap_or(trimmed).trim(); if path.is_empty() || path == "/" { return None; } Some(path.to_string()) } /// 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. #[allow(dead_code)] fn configured_runtime_for_profile(profile: &str) -> Option { if !is_acp_profile(profile) { return None; } if profile == "reasonix" { return Some("reasonix".into()); } let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_NAME"); let name = env_or_dotenv(&env_key) .filter(|v| !v.trim().is_empty()) .map(|v| v.trim().to_lowercase()); Some(name.unwrap_or_else(|| profile.to_lowercase())) } fn configured_upstream_for_profile(profile: &str) -> Option { let profile = profile.trim(); if !profile.is_empty() && profile != "default" { let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "UPSTREAM_URL"); if let Some(value) = env_or_dotenv(&env_key) { return Some(value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()); } let enabled = profile_config_value(profile, &["API_SERVER_ENABLED"]) .map(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" ) }) .unwrap_or(false); let port = profile_config_value(profile, &["API_SERVER_PORT"]); if enabled { if let Some(port) = port .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { let host = profile_config_value(profile, &["API_SERVER_HOST"]) .unwrap_or_else(|| "127.0.0.1".into()); return Some(format!("http://{}:{}", host.trim(), port)); } } } configured_upstream() } fn configured_api_key() -> Option { [ "MNOTE_WEB_HERMES_API_KEY", "MNOTE_HERMES_API_KEY", "HERMES_API_SERVER_KEY", "API_SERVER_KEY", ] .into_iter() .find_map(env_or_dotenv) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn configured_api_key_for_profile(profile: &str) -> Option { let profile = profile.trim(); if !profile.is_empty() && profile != "default" { let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "API_KEY"); if let Some(value) = env_or_dotenv(&env_key) { return Some(value); } if let Some(value) = profile_config_value(profile, &["API_SERVER_KEY"]) { let trimmed = value.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } configured_api_key() } #[derive(Debug)] struct GatewayProbe { ok: bool, status: String, http_status: Option, path: Option, message: Option, } async fn probe_gateway_health(upstream: &str, api_key: Option) -> GatewayProbe { let client = match reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build() { Ok(client) => client, Err(error) => { return GatewayProbe { ok: false, status: "client_error".into(), http_status: None, path: None, message: Some(format!("Hermes gateway health client 构造失败: {error}")), }; } }; for path in ["/health", "/v1/models"] { let Ok(url) = upstream_url(upstream, path) else { continue; }; let mut request = client.get(url); if let Some(api_key) = api_key.as_deref() { request = request.bearer_auth(api_key); } match request.send().await { Ok(response) if response.status().is_success() => { return GatewayProbe { ok: true, status: "ok".into(), http_status: Some(response.status().as_u16() as u64), path: Some(path.into()), message: None, }; } Ok(response) => { let status = response.status(); let text = response.text().await.unwrap_or_default(); if path == "/v1/models" || status != reqwest::StatusCode::NOT_FOUND { return GatewayProbe { ok: false, status: "upstream_error".into(), http_status: Some(status.as_u16() as u64), path: Some(path.into()), message: Some(text.chars().take(600).collect()), }; } } Err(error) => { return GatewayProbe { ok: false, status: "unreachable".into(), http_status: None, path: Some(path.into()), message: Some(error.to_string()), }; } } } GatewayProbe { ok: false, status: "unknown".into(), http_status: None, path: None, message: Some("Hermes gateway health 探测未返回有效结果".into()), } } fn env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } if cfg!(test) { return None; } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = fs::read_to_string(root).ok()?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } let Some((candidate_key, value)) = line.split_once('=') else { continue; }; if candidate_key.trim() != key { continue; } let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } fn yaml_path_value(content: &str, path: &[&str]) -> Option { let mut stack: Vec<(usize, String)> = Vec::new(); for raw_line in content.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') { continue; } let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } let Some((key, value)) = trimmed.split_once(':') else { continue; }; let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); stack.push((indent, key)); let keys = stack .iter() .map(|(_, key)| key.as_str()) .collect::>(); if keys == path && !value.is_empty() { return Some(value); } } None } fn profile_gateway_status(profile: &str) -> Value { let normalized_profile = if profile.trim().is_empty() { "default" } else { profile.trim() }; let config_path = profile_config_path(normalized_profile); let config = fs::read_to_string(&config_path).unwrap_or_default(); let model_default = yaml_path_value(&config, &["model", "default"]).unwrap_or_default(); let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default(); let gateway = yaml_path_value(&config, &["model", "gateway"]) .or_else(|| yaml_path_value(&config, &["model", "base_url"])) .unwrap_or_default(); let model_api_key = yaml_path_value(&config, &["model", "api_key"]); let model_key_env = yaml_path_value(&config, &["model", "key_env"]); let provider_api_key = if provider.is_empty() { None } else { yaml_path_value(&config, &["providers", &provider, "api_key"]) }; let provider_key_env = if provider.is_empty() { None } else { yaml_path_value(&config, &["providers", &provider, "key_env"]) }; let key_env_configured = model_key_env .as_deref() .or(provider_key_env.as_deref()) .and_then(env_or_dotenv) .is_some(); let api_key_configured = model_api_key .as_deref() .or(provider_api_key.as_deref()) .map(|value| !value.trim().is_empty()) .unwrap_or(false) || key_env_configured; let mut suggestions = Vec::new(); if model_default.is_empty() { suggestions .push("当前 Hermes profile 缺少 model.default,请在 Hermes 设置中选择默认模型。"); } if provider.is_empty() { suggestions .push("当前 Hermes profile 缺少 model.provider,请在 Hermes 设置中选择 provider。"); } if !api_key_configured { suggestions.push("当前 Hermes profile 未检测到 provider API key 或 key_env,请在 Hermes 设置中补齐 API key。"); } json!({ "name": normalized_profile, "path": profile_home(normalized_profile).to_string_lossy(), "configPath": config_path.to_string_lossy(), "configExists": config_path.exists(), "modelDefault": model_default, "provider": provider, "gateway": gateway, "modelConfigured": !model_default.is_empty(), "apiKeyConfigured": api_key_configured, "suggestions": suggestions }) } fn unique_string_values(values: Vec) -> Vec { let mut seen = HashSet::new(); values .into_iter() .filter_map(|value| value.as_str().map(str::trim).map(ToOwned::to_owned)) .filter(|value| !value.is_empty()) .filter(|value| seen.insert(value.clone())) .map(Value::String) .collect() } fn hermes_settings_suggestions(status: Option, message: Option<&str>) -> Vec { let lower = message.unwrap_or_default().to_lowercase(); let mut suggestions = Vec::new(); if lower.contains("model.default") || (lower.contains("model") && (lower.contains("default") || lower.contains("missing"))) { suggestions.push( "当前 Hermes profile 可能缺少 model.default,请在 Hermes 设置中选择默认模型。".into(), ); } if lower.contains("api key") || lower.contains("apikey") || lower.contains("unauthorized") || lower.contains("forbidden") || matches!(status, Some(401 | 403)) { suggestions.push("当前 Hermes provider 可能缺少 API key 或 key_env 未生效,请在 Hermes 设置中补齐 API key。".into()); } if lower.contains("profile") || lower.contains("provider") { suggestions .push("请检查当前页面 AI 选择的 Hermes profile、provider 与 gateway 是否匹配。".into()); } if suggestions.is_empty() { suggestions.push("请检查 Hermes gateway 是否正在运行,并确认当前 profile 的 model.default、provider 与 API key 已配置。".into()); } suggestions } fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { let message = extract_run_message(&payload).ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 message") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let document_id = payload .get("documentId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or("current"); let trace_id = payload .get("traceId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.trace.trace_id); let session_id = payload .get("sessionId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| stable_session_id(document_id, trace_id)); let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null); let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null); let workspace_id_string = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()) .unwrap_or_else(|| "default".into()); let source_kind = payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("convex_workspace"); let root_uri = payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let is_local_source = source_kind == "local_folder"; let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let tool_guidance = concat!( "每次调用 mnote 工具都必须显式透传 workspaceId、documentId、actorId、sessionId、runId、traceId;", "actorId 必须使用本 instructions 中的 actorId,不允许省略或填 hermes/anonymous。", "凡用户要求新增、删除、修改、替换、移动正文段落或块,必须走块级链路。", "本 instructions 的 pageContext.aiContext 是本次 run 开始时冻结的 mnote.page_ai_context.v1,", "其中 contextBlocks/pageXml/pageText 可直接作为 tiptapRead 风格的已读上下文使用;", "普通正文 search/replace、局部段落替换或全文 markdown 替换,应优先调用 mnote_doc_markdown_edit,", "使用 pageText 或 contextBlocks.text 构造 search/replace,写入后必须回读验证。", "如果 aiContext 已包含足够唯一的目标文本,应直接调用 mnote_doc_markdown_edit,", "不要为了同一上下文再先调用 mnote_doc_fetch。", "scope=selection 时只能修改 allowedTargetBlockIds 内的块;", "mnote_doc_apply_block_ops 仅用于 markdown_edit 无法表达的结构性块操作,例如块移动、资源块、复杂子块或必须按 blockId 精确处理的场景;", "该结构性工具可用唯一 matchText/anchorText 或已知 blockId/anchorBlockId 定位,", "避免 fetch、plan、多个单步写入造成多轮模型往返。", "只有当文本不唯一、目标不明确、涉及复杂块/子块/表格/资源块,或 markdown_edit 返回歧义/不支持时,", "再降级为 mnote_doc_fetch(scope=full 或 keyword, detail=with_ids) 定位块,", "mnote_block_fetch 读取目标块 revisionRef/context,", "mnote_doc_plan_update(dryRun=true) 生成并检查 diff,", "最后调用 mnote_block_replace、mnote_block_insert_after、mnote_block_delete 或 mnote_block_move_after。", "写入后再 mnote_doc_fetch 或 mnote_block_fetch 回读验证。", "mnote_page_get 只用于读取页面标题、页面设置或粗略摘要;", "mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级工具无法表达时作为高风险兜底,", "不能作为正文块新增、删除、修改、移动的首选工具。", "不要依据非 aiContext 的页面摘要猜测正文内容或块 id。" ); let actor_id = payload .get("actorId") .and_then(Value::as_str) .unwrap_or(&context.auth.actor_id); let actor_type = payload .get("actorType") .and_then(Value::as_str) .unwrap_or(&context.auth.actor_type); let share_grant = if is_local_source { local_share_grant_for_payload(&payload)? } else { None }; let local_file_reference = if is_local_source { root_uri.map(|root_uri| { json!({ "sourceKind": source_kind, "workspaceId": workspace_id_string, "documentId": document_id, "rootUri": root_uri, "selection": { "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null) } }) }) } else { None }; let local_ai_access_scope = if is_local_source { root_uri.map(|root_uri| { let permission_level = share_grant .as_ref() .map(LocalShareGrant::permission_level) .unwrap_or_else(|| local_ai_payload_permission_level(&payload)); let allowed_resource_ids = share_grant .as_ref() .map(|grant| grant.allowed_resource_ids.clone()) .filter(|ids| !ids.is_empty()) .unwrap_or_else(|| vec![document_id.to_string()]); let allowed_file_paths = share_grant .as_ref() .map(LocalShareGrant::allowed_file_paths) .unwrap_or_default(); let mut scope = json!({ "userId": actor_id, "workspaceId": workspace_id_string, "sessionId": session_id, "sourceKind": "local_folder", "permissionLevel": permission_level, "allowedRoots": [root_uri], "allowedFilePaths": allowed_file_paths, "allowedResourceIds": allowed_resource_ids }); if let Some(grant) = share_grant.as_ref() { scope["shareContext"] = grant.share_context(); } scope }) } else { None }; let sanitized_page_context = if is_local_source { sanitize_run_local_page_context(page_context) } else { sanitize_run_page_context(page_context) }; let instructions = json!({ "role": "mnote_page_ai_context", "workspaceId": workspace_id, "documentId": document_id, "sourceKind": source_kind, "rootUri": if is_local_source { root_uri.map(Value::from).unwrap_or(Value::Null) } else { Value::Null }, "profile": profile.unwrap_or("default"), "actorId": actor_id, "actorType": actor_type, "sessionId": session_id, "runId": payload .get("runId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&session_id), "traceId": trace_id, "toolGuidance": tool_guidance, "fileReference": local_file_reference.unwrap_or(Value::Null), "aiAccessScope": local_ai_access_scope.unwrap_or(Value::Null), "blockEditingToolOrder": [ "mnote_doc_markdown_edit for plain body search/replace or full markdown replacement", "mnote_doc_fetch | mnote_doc_find only when target text is ambiguous or structure is complex", "mnote_doc_apply_block_ops only for structural block operations that markdown_edit cannot express", "mnote_doc_fetch", "mnote_block_fetch", "mnote_doc_plan_update(dryRun=true)", "mnote_block_replace | mnote_block_insert_after | mnote_block_delete | mnote_block_move_after", "mnote_doc_fetch | mnote_block_fetch" ], "pageSavePolicy": { "mnote_page_save": "fallback_only_for_explicit_whole_page_write", "forBlockEditing": "forbidden_as_first_choice" }, "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null), "pageContext": sanitized_page_context }) .to_string(); let mut body = json!({ "input": message, "session_id": session_id, "instructions": instructions }); if let Some(model) = payload .get("model") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) { body["model"] = Value::String(model.to_string()); } if let Some(profile) = profile { body["profile"] = Value::String(profile.to_string()); } Ok(body) } fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return (actor_id.to_string(), context.auth.actor_type.clone()); } if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return (state.config().dev_user_id.clone(), "devFallback".into()); } ("anonymous".into(), "anonymous".into()) } async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return (actor_id.to_string(), context.auth.actor_type.clone()); } if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() { return ("anonymous".into(), "anonymous".into()); } if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await { return (user_id, "user".into()); } effective_run_actor(state, context) } async fn resolve_current_convex_user_id( state: &AppState, context: &RequestContext, ) -> Result, WebError> { let payload = execute_convex_query_by_name( state.config(), context, "users:currentUser", json!({}), None, "hermes_run_current_user", ) .await?; Ok(payload .get("_id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned)) } async fn effective_session_store_user_id( state: &AppState, context: &RequestContext, ) -> Result { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return Ok(actor_id.to_string()); } if let Some(user_id) = resolve_current_convex_user_id(state, context).await? { return Ok(user_id); } Err(WebError::new( StatusCode::UNAUTHORIZED, "hermes_client_unauthorized", "页面 AI session store 需要登录后访问", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")) } fn stamp_run_actor(payload: &mut Value, actor_id: &str, actor_type: &str) { let Value::Object(map) = payload else { return; }; map.insert("actorId".into(), Value::String(actor_id.to_string())); map.insert("actorType".into(), Value::String(actor_type.to_string())); } fn sanitize_run_page_context(page_context: Value) -> Value { let Some(source) = page_context.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "contextScope", "node", "pageSubtreeSource", "evidence", "pageOptions", "contentAccess", "aiContext", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } sanitized.insert( "contentAccess".to_string(), sanitized .get("contentAccess") .cloned() .unwrap_or_else(|| Value::String("mnote.doc.fetch".into())), ); Value::Object(sanitized) } fn sanitize_run_local_page_context(page_context: Value) -> Value { let Some(source) = page_context.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in ["contextScope", "node", "evidence"] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } sanitized.insert( "contentAccess".to_string(), Value::String("file.reference".into()), ); if let Some(ai_context) = source.get("aiContext").and_then(Value::as_object) { let mut slim_ai_context = serde_json::Map::new(); for key in [ "schema", "scope", "selectedBlockIds", "allowedTargetBlockIds", ] { if let Some(value) = ai_context.get(key) { slim_ai_context.insert(key.to_string(), value.clone()); } } if !slim_ai_context.is_empty() { sanitized.insert("aiContext".to_string(), Value::Object(slim_ai_context)); } } Value::Object(sanitized) } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default() } fn runtime_state_to_json(state: &HermesRuntimeState) -> Value { json!({ "sessionId": state.session_id, "runId": state.run_id, "profile": state.profile, "documentId": state.document_id, "traceId": state.trace_id, "status": state.status, "startedAt": state.started_at, "lastEventAt": state.last_event_at, "lastEvent": state.last_event, "lastToolName": state.last_tool_name, "lastToolCallId": state.last_tool_call_id, "lastAuditId": state.last_audit_id }) } fn runtime_state_for_session(session_id: &str) -> Value { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .get(session_id) .map(runtime_state_to_json) .unwrap_or(Value::Null) } fn runtime_state_for_run(run_id: &str) -> Option { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .values() .find(|state| state.run_id == run_id) .map(runtime_state_to_json) } fn runtime_status_for_run(run_id: &str) -> Option { 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, ) -> HermesRunRegistration { let document_id = payload .get("documentId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or("current") .to_string(); let trace_id = payload .get("traceId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.trace.trace_id) .to_string(); let session_id = payload .get("sessionId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| stable_session_id(&document_id, &trace_id)); let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default") .to_string(); HermesRunRegistration { session_id, profile, document_id, trace_id, } } /// Register a runtime state from a registration (without upstream response). /// Used by the ACP path where no Hermes HTTP upstream exists. 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 state = HermesRuntimeState { session_id: registration.session_id.clone(), run_id: run_id.to_string(), profile: registration.profile.clone(), document_id: registration.document_id.clone(), trace_id: registration.trace_id.clone(), status: "acp_pending".into(), started_at: now, last_event_at: now, last_event: Some("run.started".into()), last_tool_name: None, last_tool_call_id: None, last_audit_id: None, }; let json = runtime_state_to_json(&state); HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry") .insert(registration.session_id.clone(), state); json } fn acp_runtime_run_store_args( context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, acp_runtime: &str, runtime_state: &Value, payload: &Value, ) -> Value { let workspace_id = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); json!({ "schema": "mnote.acp_runtime_run.v1", "source": "acp", "userId": runtime_store_user_id(context, payload), "workspaceId": workspace_id, "documentId": registration.document_id, "sessionId": registration.session_id, "runId": run_id, "title": payload .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()), "profile": registration.profile, "acpRuntime": acp_runtime, "traceId": registration.trace_id, "status": runtime_state .get("status") .and_then(Value::as_str) .unwrap_or("acp_pending"), "runtime": runtime_state, "payload": payload, "retention": { "kind": "ttl", "ttlMs": 7 * 24 * 60 * 60 * 1000i64 }, "createdAt": now_ms(), "updatedAt": now_ms() }) } fn json_string(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value { let runtime = serde_json::from_str::(&record.runtime_json).unwrap_or(Value::Null); let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); json!({ "sessionId": record.session_id, "runId": record.run_id, "workspaceId": record.workspace_id, "documentId": record.document_id, "title": record.title, "profile": record.profile, "acpRuntime": record.acp_runtime, "traceId": record.trace_id, "status": record.status, "runtime": runtime, "payload": payload, "createdAt": record.created_at, "updatedAt": record.updated_at, "persistence": ACP_RUNTIME_SQLITE_STORE, }) } fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value { let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); json!({ "eventId": record.id, "sessionId": record.session_id, "runId": record.run_id, "workspaceId": record.workspace_id, "documentId": record.document_id, "profile": record.profile, "acpRuntime": record.acp_runtime, "eventType": record.event_type, "payload": payload, "createdAt": record.created_at, "persistence": ACP_RUNTIME_SQLITE_STORE, }) } fn query_bool(query: &HashMap, key: &str) -> bool { query .get(key) .map(String::as_str) .map(str::trim) .map(|value| matches!(value, "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false) } fn use_legacy_convex_acp_store(query: &HashMap) -> bool { query_bool(query, "legacyConvex") || query_bool(query, "convex") } fn local_session_share_id(payload: &Value) -> Option { payload .get("shareId") .or_else(|| payload.get("share_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String { payload .get("actorId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty() && *value != "anonymous") .map(ToOwned::to_owned) .unwrap_or_else(|| context.auth.actor_id.clone()) } fn local_ai_session_root_dir(root_uri: &str) -> Result { let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") { stripped.trim() } else { root_uri.trim() }; if root_path.is_empty() { return Err(WebError::bad_request_code( "local_ai_session_root_required", "缺少本地会话 rootUri", )); } Ok(PathBuf::from(root_path)) } fn local_agent_audit_root_dir() -> PathBuf { env::var("MNOTE_LOCAL_AGENT_AUDIT_DIR") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(LOCAL_AGENT_AUDIT_DIR)) } fn local_agent_audit_path() -> PathBuf { local_agent_audit_root_dir().join(LOCAL_AGENT_AUDIT_JSONL) } fn local_agent_audit_should_skip(path: &FsPath) -> bool { matches!( path.file_name() .and_then(|value| value.to_str()) .map(str::trim), Some(".mnote") | Some("ai-sessions") ) } fn local_agent_audit_snapshot_entry( path: &FsPath, ) -> Result { let metadata = fs::metadata(path).map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("无法读取本地文件快照: {error}"), ) })?; let modified_ms = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default(); let size = metadata.len(); let bytes = fs::read(path).map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("无法读取本地文件快照内容: {error}"), ) })?; let hash = { let mut hasher = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hasher); hasher.finish() }; let markdown_content = if path.extension().and_then(|value| value.to_str()) == Some("md") { String::from_utf8(bytes).ok() } else { None }; Ok(LocalAgentAuditFileSnapshot { size, modified_ms, hash, markdown_content, }) } fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result { let root = local_ai_session_root_dir(root_uri)?; let canonical_root = root.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; let mut files = BTreeMap::new(); let mut stack = vec![canonical_root.clone()]; while let Some(dir) = stack.pop() { for entry in fs::read_dir(&dir).map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("读取本地文件夹失败: {error}"), ) })? { let entry = entry.map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("读取本地文件夹失败: {error}"), ) })?; let path = entry.path(); if local_agent_audit_should_skip(&path) { continue; } let metadata = entry.metadata().map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("读取本地文件夹失败: {error}"), ) })?; if metadata.is_dir() { stack.push(path); continue; } if !metadata.is_file() { continue; } let relative_path = path .strip_prefix(&canonical_root) .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); if relative_path.is_empty() { continue; } files.insert(relative_path, local_agent_audit_snapshot_entry(&path)?); } } Ok(LocalAgentAuditSnapshot { root_uri: root_uri.to_string(), files, }) } fn local_agent_audit_diff_summary( before: Option<&LocalAgentAuditFileSnapshot>, after: Option<&LocalAgentAuditFileSnapshot>, ) -> String { match (before, after) { (None, Some(after)) => format!("新增文件 size={} hash={}", after.size, after.hash), (Some(before), None) => format!("删除文件 size={} hash={}", before.size, before.hash), (Some(before), Some(after)) => { if before.hash == after.hash && before.size == after.size { "内容无变化".into() } else { let before_lines = before .markdown_content .as_deref() .map(|text| text.lines().count()) .unwrap_or_default(); let after_lines = after .markdown_content .as_deref() .map(|text| text.lines().count()) .unwrap_or_default(); format!( "修改文件 size:{}→{} lines:{}→{} hash:{}→{}", before.size, after.size, before_lines, after_lines, before.hash, after.hash ) } } (None, None) => String::new(), } } fn local_agent_audit_change_files( before: &LocalAgentAuditSnapshot, after: &LocalAgentAuditSnapshot, ) -> Value { let mut paths = BTreeMap::new(); for path in before.files.keys().chain(after.files.keys()) { paths.insert(path.clone(), ()); } let changed = paths .into_keys() .filter_map(|path| { let before_entry = before.files.get(&path); let after_entry = after.files.get(&path); if matches!((before_entry, after_entry), (Some(before), Some(after)) if before.hash == after.hash && before.size == after.size) { return None; } Some(json!({ "path": path, "changeType": match (before_entry, after_entry) { (None, Some(_)) => "added", (Some(_), None) => "deleted", _ => "modified", }, "summary": local_agent_audit_diff_summary(before_entry, after_entry), "sizeBefore": before_entry.map(|entry| entry.size).unwrap_or(0), "sizeAfter": after_entry.map(|entry| entry.size).unwrap_or(0), "modifiedBeforeMs": before_entry.map(|entry| entry.modified_ms).unwrap_or_default(), "modifiedAfterMs": after_entry.map(|entry| entry.modified_ms).unwrap_or_default(), "hashBefore": before_entry.map(|entry| entry.hash).unwrap_or_default(), "hashAfter": after_entry.map(|entry| entry.hash).unwrap_or_default(), })) }) .collect::>(); Value::Array(changed) } fn local_agent_audit_write_event(event: &Value) -> Result<(), WebError> { let audit_dir = local_agent_audit_root_dir(); fs::create_dir_all(&audit_dir).map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法创建本地审计目录: {error}"), ) })?; let path = local_agent_audit_path(); let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法打开本地审计文件: {error}"), ) })?; let line = serde_json::to_string(event).map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法序列化本地审计事件: {error}"), ) })?; writeln!(file, "{line}").map_err(|error| { WebError::bad_request_code( "local_ai_audit_write_failed", format!("无法写入本地审计事件: {error}"), ) }) } fn local_agent_audit_store_snapshot(run_id: &str, snapshot: LocalAgentAuditSnapshot) { ACP_LOCAL_AUDIT_SNAPSHOTS .lock() .expect("acp local audit snapshots") .insert(run_id.to_string(), snapshot); } fn local_agent_audit_take_snapshot(run_id: &str) -> Option { ACP_LOCAL_AUDIT_SNAPSHOTS .lock() .expect("acp local audit snapshots") .remove(run_id) } fn local_agent_audit_event( context: &RequestContext, payload: &Value, run_id: &str, acp_runtime: &str, status: &str, changed_files: Value, source_snapshot: Option<&LocalAgentAuditSnapshot>, _target_snapshot: Option<&LocalAgentAuditSnapshot>, write_attempt_rejected: bool, ) -> Value { let root_uri = payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| source_snapshot.map(|snapshot| snapshot.root_uri.as_str())) .unwrap_or(""); let permission = if local_ai_payload_is_read_only(payload) { "read" } else { "write" }; let actor_id = payload .get("actorId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.auth.actor_id); let actor_type = payload .get("actorType") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.auth.actor_type); let changed_file_count = changed_files.as_array().map(Vec::len).unwrap_or_default(); json!({ "eventId": format!("local_audit:{}:{}", sanitize_id_part(run_id), now_ms()), "actorId": actor_id, "actorType": actor_type, "agentKind": acp_runtime, "runId": run_id, "sessionId": payload.get("sessionId").cloned().unwrap_or(Value::Null), "workspaceId": payload.get("workspaceId").cloned().unwrap_or(Value::Null), "documentId": payload.get("documentId").cloned().unwrap_or(Value::Null), "rootUri": root_uri, "permission": permission, "status": if write_attempt_rejected { "read_only_write_rejected" } else { status }, "writeAttemptRejected": write_attempt_rejected, "changedFiles": changed_files, "diffSummary": format!("{changed_file_count} changed file(s)"), "createdAt": now_ms(), }) } fn local_agent_audit_finalize_run( context: &RequestContext, payload: &Value, run_id: &str, acp_runtime: &str, status: &str, ) -> Result { let before = local_agent_audit_take_snapshot(run_id); let after = payload .get("rootUri") .and_then(Value::as_str) .map(local_agent_audit_collect_snapshot) .transpose()?; let changed_files = match (&before, &after) { (Some(before), Some(after)) => local_agent_audit_change_files(before, after), (None, Some(after)) => local_agent_audit_change_files( &LocalAgentAuditSnapshot { root_uri: after.root_uri.clone(), files: BTreeMap::new(), }, after, ), (Some(before), None) => local_agent_audit_change_files( before, &LocalAgentAuditSnapshot { root_uri: before.root_uri.clone(), files: BTreeMap::new(), }, ), (None, None) => Value::Array(vec![]), }; let write_attempt_rejected = local_ai_payload_is_read_only(payload) && changed_files.as_array().map(Vec::len).unwrap_or_default() > 0; let event = local_agent_audit_event( context, payload, run_id, acp_runtime, status, changed_files, before.as_ref(), after.as_ref(), write_attempt_rejected, ); local_agent_audit_write_event(&event)?; Ok(event) } pub(crate) fn local_agent_audit_record_write_rejected( context: &RequestContext, payload: Value, run_id: &str, acp_runtime: &str, ) -> Result { let mut event = local_agent_audit_event( context, &payload, run_id, acp_runtime, "read_only_write_rejected", Value::Array(Vec::new()), None, None, true, ); if let Some(object) = event.as_object_mut() { object.insert( "toolName".into(), payload.get("toolName").cloned().unwrap_or(Value::Null), ); object.insert( "toolCallId".into(), payload.get("toolCallId").cloned().unwrap_or(Value::Null), ); object.insert( "rejection".into(), payload.get("rejection").cloned().unwrap_or(Value::Null), ); } local_agent_audit_write_event(&event)?; Ok(event) } pub(crate) fn local_agent_audit_record_tool_write( context: &RequestContext, payload: Value, run_id: &str, acp_runtime: &str, ) -> Result { let changed_files = payload .get("changedFiles") .cloned() .unwrap_or_else(|| Value::Array(Vec::new())); let mut event = local_agent_audit_event( context, &payload, run_id, acp_runtime, "completed", changed_files, None, None, false, ); if let Some(object) = event.as_object_mut() { object.insert( "toolName".into(), payload.get("toolName").cloned().unwrap_or(Value::Null), ); object.insert( "toolCallId".into(), payload.get("toolCallId").cloned().unwrap_or(Value::Null), ); object.insert( "commandName".into(), payload.get("commandName").cloned().unwrap_or(Value::Null), ); object.insert("origin".into(), Value::String("mnote_tool".into())); } local_agent_audit_write_event(&event)?; Ok(event) } fn local_ai_session_dir(root_uri: &str, share_id: Option<&str>) -> Result { let root = local_ai_session_root_dir(root_uri)?; let mut dir = root.join("ai-sessions"); if share_id .map(str::trim) .filter(|value| !value.is_empty()) .is_some() { dir = dir.join("shared").join(sanitize_id_part(share_id.unwrap())); } else { dir = dir.join("private"); } Ok(dir) } fn local_ai_session_path( root_uri: &str, session_id: &str, share_id: Option<&str>, ) -> Result { Ok(local_ai_session_dir(root_uri, share_id)? .join(format!("{}.jsonl", sanitize_id_part(session_id)))) } fn append_local_ai_session_event( root_uri: &str, session_id: &str, event: &Value, share_id: Option<&str>, ) -> Result<(), WebError> { let dir = local_ai_session_dir(root_uri, share_id)?; fs::create_dir_all(&dir).map_err(|error| { WebError::bad_request_code( "local_ai_session_dir_create_failed", format!("无法创建本地 AI 会话目录 {}: {error}", dir.display()), ) })?; let path = local_ai_session_path(root_uri, session_id, share_id)?; let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| { WebError::bad_request_code( "local_ai_session_write_failed", format!("无法写入本地 AI 会话文件 {}: {error}", path.display()), ) })?; writeln!( file, "{}", serde_json::to_string(event).map_err(|error| { WebError::bad_request_code( "local_ai_session_serialize_failed", format!("无法序列化本地 AI 会话事件: {error}"), ) })? ) .map_err(|error| { WebError::bad_request_code( "local_ai_session_write_failed", format!("无法写入本地 AI 会话文件 {}: {error}", path.display()), ) })?; Ok(()) } fn read_local_ai_session_events( root_uri: &str, session_id: &str, share_id: Option<&str>, ) -> Result, WebError> { let path = local_ai_session_path(root_uri, session_id, share_id)?; let content = fs::read_to_string(&path).unwrap_or_default(); Ok(content .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .collect()) } fn list_local_ai_sessions(root_uri: &str, limit: usize) -> Result { let dir = local_ai_session_dir(root_uri, None)?; let mut rows = Vec::new(); let Ok(entries) = fs::read_dir(&dir) else { return Ok(Value::Array(Vec::new())); }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } let session_id = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or_default() .to_string(); let events = fs::read_to_string(&path) .unwrap_or_default() .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .collect::>(); let first = events.first().cloned().unwrap_or(Value::Null); let last = events.last().cloned().unwrap_or(Value::Null); rows.push(json!({ "sessionId": session_id, "title": first.get("title").and_then(Value::as_str).unwrap_or("当前页问答"), "profile": first.get("profile").and_then(Value::as_str).unwrap_or("reasonix"), "sourceKind": "local_folder", "sessionStorage": "local_private", "persistence": "local_ai_session_jsonl", "createdAt": first.get("createdAt").cloned().unwrap_or(Value::Null), "updatedAt": last.get("createdAt").cloned().unwrap_or(Value::Null), "status": last.get("status").or_else(|| last.get("eventType")).cloned().unwrap_or(Value::Null), "payload": first })); } rows.sort_by(|a, b| { b.get("updatedAt") .and_then(Value::as_u64) .cmp(&a.get("updatedAt").and_then(Value::as_u64)) }); rows.truncate(limit); Ok(Value::Array(rows)) } async fn persist_acp_runtime_run( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, acp_runtime: &str, runtime_state: &Value, payload: &Value, ) -> Result { let args = acp_runtime_run_store_args( context, registration, run_id, acp_runtime, runtime_state, payload, ); if payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) == Some("local_folder") { let root_uri = payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; let share_id = local_session_share_id(payload); if let Some(share_id) = share_id.as_deref() { load_local_share_grant_for_actor( share_id, payload.get("actorId").and_then(Value::as_str), Some(root_uri), ) .map_err(|error| error.with_context(context))?; } else { crate::routes::ensure_local_workspace_access(context, root_uri) .map_err(|error| error.with_context(context))?; } let mut event = args.clone(); if let Value::Object(map) = &mut event { map.insert("eventType".into(), Value::String("run.started".into())); map.insert( "persistence".into(), Value::String("local_ai_session_jsonl".into()), ); } append_local_ai_session_event( root_uri, ®istration.session_id, &event, share_id.as_deref(), ) .map_err(|error| error.with_context(context))?; return Ok(json!({ "ok": true, "persistence": "local_ai_session_jsonl", "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" }, "sessionId": registration.session_id, "runId": run_id })); } let workspace_id = args .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let document_id = args .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let title = args .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let status = args .get("status") .and_then(Value::as_str) .unwrap_or("acp_pending") .to_string(); let record = state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: None, user_id: runtime_store_user_id(context, payload), workspace_id, document_id, session_id: registration.session_id.clone(), run_id: run_id.to_string(), title, profile: registration.profile.clone(), acp_runtime: acp_runtime.to_string(), trace_id: Some(registration.trace_id.clone()), status, runtime_json: json_string(runtime_state), payload_json: json_string(payload), }) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}")) .with_context(context) })?; Ok(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "sessionId": record.session_id, "runId": record.run_id })) } fn acp_runtime_event_store_args( context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, acp_runtime: &str, event_type: &str, event_payload: &Value, run_payload: &Value, ) -> Value { let workspace_id = run_payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); let created_at = now_ms(); json!({ "schema": "mnote.acp_runtime_event.v1", "source": "acp", "eventId": format!( "acp_event:{}:{}:{}", sanitize_id_part(run_id), created_at, sanitize_id_part(event_type) ), "userId": runtime_store_user_id(context, run_payload), "workspaceId": workspace_id, "documentId": registration.document_id, "sessionId": registration.session_id, "runId": run_id, "profile": registration.profile, "acpRuntime": acp_runtime, "eventType": event_type, "payload": event_payload, "createdAt": created_at }) } async fn persist_acp_runtime_event( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, acp_runtime: &str, event_type: &str, event_payload: &Value, run_payload: &Value, ) -> Result { let args = acp_runtime_event_store_args( context, registration, run_id, acp_runtime, event_type, event_payload, run_payload, ); if run_payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) == Some("local_folder") { let root_uri = run_payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; let share_id = local_session_share_id(run_payload); if let Some(share_id) = share_id.as_deref() { load_local_share_grant_for_actor( share_id, run_payload.get("actorId").and_then(Value::as_str), Some(root_uri), ) .map_err(|error| error.with_context(context))?; } else { crate::routes::ensure_local_workspace_access(context, root_uri) .map_err(|error| error.with_context(context))?; } let mut event = args.clone(); if let Value::Object(map) = &mut event { map.insert( "persistence".into(), Value::String("local_ai_session_jsonl".into()), ); } append_local_ai_session_event( root_uri, ®istration.session_id, &event, share_id.as_deref(), ) .map_err(|error| error.with_context(context))?; return Ok(json!({ "ok": true, "persistence": "local_ai_session_jsonl", "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" }, "sessionId": registration.session_id, "runId": run_id })); } state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { id: None, user_id: runtime_store_user_id(context, run_payload), workspace_id: args .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), document_id: args .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), session_id: registration.session_id.clone(), run_id: run_id.to_string(), profile: registration.profile.clone(), acp_runtime: acp_runtime.to_string(), event_type: event_type.to_string(), payload_json: json_string(event_payload), }) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}")) .with_context(context) })?; Ok(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "sessionId": registration.session_id, "runId": run_id })) } fn register_runtime_from_create_run_response( registration: &HermesRunRegistration, payload: &Value, ) -> Option { let upstream = payload.get("upstream").unwrap_or(payload); let run_id = upstream .get("run_id") .or_else(|| upstream.get("runId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())? .to_string(); let now = now_ms(); let state = HermesRuntimeState { session_id: registration.session_id.clone(), run_id, profile: registration.profile.clone(), document_id: registration.document_id.clone(), trace_id: registration.trace_id.clone(), status: "running".into(), started_at: now, last_event_at: now, last_event: Some("run.started".into()), last_tool_name: None, last_tool_call_id: None, last_audit_id: None, }; let json = runtime_state_to_json(&state); HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry") .insert(registration.session_id.clone(), state); Some(json) } fn update_runtime_by_run_id( run_id: &str, status: &str, event: Option<&str>, tool: Option<(Option, Option, Option)>, ) { let mut registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); let Some(state) = registry.values_mut().find(|state| state.run_id == run_id) else { return; }; state.status = status.to_string(); state.last_event_at = now_ms(); if let Some(event) = event { state.last_event = Some(event.to_string()); } if let Some((tool_name, tool_call_id, audit_id)) = tool { if let Some(tool_name) = tool_name { state.last_tool_name = Some(tool_name); } if let Some(tool_call_id) = tool_call_id { state.last_tool_call_id = Some(tool_call_id); } if let Some(audit_id) = audit_id { state.last_audit_id = Some(audit_id); } } } fn normalize_sse_chunk(run_id: &str, chunk: &str) -> (String, Vec) { let events = sse_json_events(chunk); let terminal_sessions = update_runtime_from_events(run_id, &events); let mut output = String::new(); for event in events { let normalized = normalize_runtime_event(run_id, event); let event_name = normalized .get("event") .and_then(Value::as_str) .unwrap_or("runtime.event"); output.push_str("event: "); output.push_str(event_name); output.push_str("\ndata: "); output.push_str(&normalized.to_string()); output.push_str("\n\n"); } if output.is_empty() { output.push_str(chunk); } (output, terminal_sessions) } fn update_runtime_from_events(run_id: &str, events: &[Value]) -> Vec { let mut terminal_sessions = Vec::new(); for event in events { let event_name = canonical_event_name(event); if event_name.is_empty() { continue; } let next_status = match event_name.as_str() { "tool.started" => "tool_calling", "tool.completed" | "tool.failed" => "running", "run.completed" => "completed", "run.failed" => "failed", "run.aborted" => "aborted", _ => "running", }; let tool = if event_name.starts_with("tool.") { Some(( event .get("name") .or_else(|| event.get("tool")) .or_else(|| event.get("toolName")) .and_then(Value::as_str) .map(ToOwned::to_owned), event .get("tool_call_id") .or_else(|| event.get("toolCallId")) .or_else(|| event.get("id")) .and_then(Value::as_str) .map(ToOwned::to_owned), event .get("audit_id") .or_else(|| event.get("auditId")) .and_then(Value::as_str) .map(ToOwned::to_owned), )) } else { None }; update_runtime_by_run_id(run_id, next_status, Some(&event_name), tool); if matches!(next_status, "completed" | "failed" | "aborted") { if let Some(session_id) = session_id_for_run(run_id) { terminal_sessions.push(session_id); } } } terminal_sessions } fn canonical_event_name(event: &Value) -> String { let raw = event .get("event") .and_then(Value::as_str) .or_else(|| event.get("type").and_then(Value::as_str)) .unwrap_or_default(); match raw { "assistant_message" | "response.output_text.delta" => "message.delta", "response.completed" => "run.completed", "response.failed" => "run.failed", "run.cancelled" | "run.canceled" | "abort.completed" => "run.aborted", value => value, } .to_string() } fn normalize_runtime_event(run_id: &str, event: Value) -> Value { let event_name = canonical_event_name(&event); let session_id = event .get("session_id") .or_else(|| event.get("sessionId")) .and_then(Value::as_str) .map(ToOwned::to_owned) .or_else(|| session_id_for_run(run_id)); let trace_id = event .get("trace_id") .or_else(|| event.get("traceId")) .and_then(Value::as_str) .unwrap_or_default(); let mut normalized = json!({ "schemaVersion": "mnote.hermes_runtime_event.v1", "event": event_name, "runId": event.get("run_id").or_else(|| event.get("runId")).and_then(Value::as_str).unwrap_or(run_id), "sessionId": session_id, "traceId": trace_id, "upstream": event }); if event_name == "message.delta" { normalized["delta"] = event .get("delta") .or_else(|| event.get("text")) .or_else(|| event.get("output_text")) .cloned() .unwrap_or(Value::Null); } if event_name.starts_with("tool.") { normalized["toolName"] = event .get("name") .or_else(|| event.get("tool")) .or_else(|| event.get("toolName")) .cloned() .unwrap_or(Value::Null); normalized["toolCallId"] = event .get("tool_call_id") .or_else(|| event.get("toolCallId")) .or_else(|| event.get("id")) .cloned() .unwrap_or(Value::Null); normalized["args"] = event .get("arguments") .or_else(|| event.get("args")) .or_else(|| event.get("input")) .cloned() .unwrap_or(Value::Null); normalized["summary"] = event .get("summary") .or_else(|| event.get("result")) .or_else(|| event.get("output")) .cloned() .unwrap_or(Value::Null); normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null); normalized["error"] = event.get("error").cloned().unwrap_or(Value::Null); normalized["auditId"] = event .get("audit_id") .or_else(|| event.get("auditId")) .cloned() .unwrap_or(Value::Null); } if event_name == "run.completed" { normalized["output"] = event.get("output").cloned().unwrap_or(Value::Null); normalized["usage"] = event.get("usage").cloned().unwrap_or(Value::Null); } if event_name == "run.failed" { normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null); normalized["message"] = event .get("message") .or_else(|| event.get("error")) .cloned() .unwrap_or(Value::Null); } normalized } fn session_id_for_run(run_id: &str) -> Option { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .values() .find(|state| state.run_id == run_id) .map(|state| state.session_id.clone()) } fn profile_for_run(run_id: &str) -> Option { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .values() .find(|state| state.run_id == run_id) .map(|state| state.profile.clone()) } fn sse_json_events(chunk: &str) -> Vec { chunk .split("\n\n") .filter_map(|block| { let data = block .lines() .filter_map(|line| line.strip_prefix("data:")) .map(str::trim) .collect::>() .join("\n"); if data.is_empty() || data == "[DONE]" { return None; } serde_json::from_str::(&data).ok() }) .collect() } async fn proxy_json( context: &RequestContext, method: reqwest::Method, upstream: &str, path: &str, body: Option, profile: Option<&str>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let url = upstream_url(upstream, path)?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(1800)) .build() .map_err(|error| { WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context) })?; let mut request = client.request(method, url); if let Some(api_key) = profile .and_then(configured_api_key_for_profile) .or_else(configured_api_key) { request = request.bearer_auth(api_key); } if let Some(body) = body { request = request.json(&body); } let response = request.send().await.map_err(|error| { WebError::bad_gateway_code( "hermes_client_upstream_unavailable", format!("Hermes upstream 连接失败: {error}"), ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let status = response.status(); let text = response.text().await.unwrap_or_default(); if !status.is_success() { return Err(upstream_error(context, status, text)); } let payload = serde_json::from_str::(&text).unwrap_or_else(|_| { json!({ "ok": true, "traceId": context.trace.trace_id, "raw": text }) }); Ok(( StatusCode::OK, stamp_client_headers(), Json(normalize_success_payload(context, payload)), )) } fn session_has_active_run(session_id: &str) -> bool { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .get(session_id) .map(|state| { matches!( state.status.as_str(), "queued" | "running" | "tool_calling" | "aborting" ) }) .unwrap_or(false) } fn queue_len_for_session(session_id: &str) -> usize { HERMES_RUN_QUEUE .lock() .expect("hermes run queue") .get(session_id) .map(VecDeque::len) .unwrap_or_default() } fn queued_run_to_json(queued: &HermesQueuedRun, queue_length: usize) -> Value { json!({ "ok": true, "queued": true, "queueId": queued.queue_id, "sessionId": queued.session_id, "profile": queued.profile, "documentId": queued.document_id, "traceId": queued.trace_id, "status": "queued", "queueLength": queue_length, "queuedAt": queued.queued_at, "contextSummary": queued.context_summary }) } fn enqueue_run( context: &RequestContext, registration: &HermesRunRegistration, payload: &Value, ) -> Result { let input = extract_run_message(payload).ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 message") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let queued = HermesQueuedRun { queue_id: format!( "queue_{}_{}", sanitize_id_part(®istration.trace_id), now_ms() ), session_id: registration.session_id.clone(), profile: registration.profile.clone(), document_id: registration.document_id.clone(), trace_id: registration.trace_id.clone(), actor_id: payload .get("actorId") .and_then(Value::as_str) .unwrap_or("anonymous") .to_string(), actor_type: payload .get("actorType") .and_then(Value::as_str) .unwrap_or("anonymous") .to_string(), input, context_summary: queue_context_summary(payload), queued_at: now_ms(), }; let queue_length = { let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); let entries = queue.entry(registration.session_id.clone()).or_default(); entries.push_back(queued.clone()); entries.len() }; Ok(queued_run_to_json(&queued, queue_length)) } async fn start_next_queued_run(context: RequestContext, session_id: String) { if session_has_active_run(&session_id) { return; } let Some(queued) = pop_next_queued_run(&session_id) else { return; }; let Some(upstream) = configured_upstream_for_profile(&queued.profile) else { warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run"); return; }; let payload = queued_run_payload(&queued); let upstream_body = match build_run_upstream_body(&context, payload) { Ok(body) => body, Err(error) => { warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 构造 run body 失败"); return; } }; let registration = HermesRunRegistration { session_id: queued.session_id.clone(), profile: queued.profile.clone(), document_id: queued.document_id.clone(), trace_id: queued.trace_id.clone(), }; match proxy_json( &context, reqwest::Method::POST, &upstream, "/v1/runs", Some(upstream_body), Some(&queued.profile), ) .await { Ok((_, _, Json(payload))) => { let _ = register_runtime_from_create_run_response(®istration, &payload); } Err(error) => { warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 自动启动下一条 run 失败"); } } } fn queued_run_payload(queued: &HermesQueuedRun) -> Value { json!({ "documentId": queued.document_id, "sessionId": queued.session_id, "profile": queued.profile, "message": queued.input, "traceId": queued.trace_id, "actorId": queued.actor_id, "actorType": queued.actor_type, "contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null) }) } fn remove_queued_run(session_id: &str, queue_id: &str) -> bool { let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); let Some(entries) = queue.get_mut(session_id) else { return false; }; let before = entries.len(); entries.retain(|item| item.queue_id != queue_id); let removed = entries.len() != before; if entries.is_empty() { queue.remove(session_id); } removed } fn pop_next_queued_run(session_id: &str) -> Option { let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); let entries = queue.get_mut(session_id)?; let next = entries.pop_front(); if entries.is_empty() { queue.remove(session_id); } next } fn extract_run_message(payload: &Value) -> Option { payload .get("message") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { payload .get("messages") .and_then(Value::as_array) .and_then(|messages| messages.last()) .and_then(|message| message.get("content")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) } fn queue_context_summary(payload: &Value) -> Value { let page_context = payload.get("pageContext").unwrap_or(&Value::Null); json!({ "contextScope": payload.get("contextScope").cloned().unwrap_or(Value::Null), "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "hasSelectedText": payload.get("selectedText").and_then(Value::as_str).map(|value| !value.trim().is_empty()).unwrap_or(false), "hasPageContext": !page_context.is_null(), "pageContextKeys": page_context.as_object().map(|object| object.keys().take(12).cloned().collect::>()).unwrap_or_default() }) } fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value { if payload.get("ok").is_some() { payload } else { json!({ "ok": true, "traceId": context.trace.trace_id, "upstream": payload }) } } fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: String) -> WebError { let (response_status, code) = match status { reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => ( StatusCode::BAD_GATEWAY, "hermes_client_upstream_unauthorized", ), reqwest::StatusCode::TOO_MANY_REQUESTS => ( StatusCode::TOO_MANY_REQUESTS, "hermes_client_upstream_rate_limited", ), status if status.is_server_error() => ( StatusCode::BAD_GATEWAY, "hermes_client_upstream_unavailable", ), _ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"), }; let suggestions = hermes_settings_suggestions(Some(status.as_u16() as u64), Some(&text)); WebError::new( response_status, code, format!( "Hermes upstream 返回 HTTP {}: {}。建议:{}", status.as_u16(), text.chars().take(600).collect::(), suggestions.join(";") ), ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") .with_header("x-hermes-client-suggestion", suggestions.join(" | ")) } fn hermes_unconfigured( context: &RequestContext, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { Err(hermes_unconfigured_error(context)) } fn hermes_unconfigured_error(context: &RequestContext) -> WebError { WebError::service_unavailable_code( "hermes_client_unconfigured", "Hermes client proxy 未配置 MNOTE_WEB_HERMES_UPSTREAM_URL", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") } fn upstream_url(upstream: &str, path: &str) -> Result { let url = format!( "{}/{}", upstream.trim_end_matches('/'), path.trim_start_matches('/') ); reqwest::Url::parse(&url) .map(|url| url.to_string()) .map_err(|error| WebError::internal(format!("Hermes upstream URL 无效: {error}"))) } fn stable_session_id(document_id: &str, trace_id: &str) -> String { format!( "mnote_{}_{}", sanitize_id_part(document_id), sanitize_id_part(trace_id) ) } fn sanitize_id_part(value: &str) -> String { let sanitized = value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { ch } else { '_' } }) .collect::(); if sanitized.is_empty() { "current".into() } else { sanitized } } fn url_escape(value: &str) -> String { value .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { vec![byte as char] } _ => format!("%{byte:02X}").chars().collect(), }) .collect() } fn stamp_client_headers() -> HeaderMap { let mut headers = HeaderMap::new(); stamp_client_headers_into(&mut headers); headers } fn stamp_client_headers_into(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_CLIENT_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web-hermes-client")); } } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::Request; use axum::routing::{get, post}; use control_plane::UpsertAiRuntimeRunInput; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tower::util::ServiceExt; fn env_lock() -> &'static Mutex<()> { crate::test_support::hermes_env_lock() } fn runtime_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn clear_runtime_registry() { HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry") .clear(); } fn clear_run_queue() { HERMES_RUN_QUEUE.lock().expect("hermes run queue").clear(); } fn clear_acp_run_payloads() { ACP_RUN_PAYLOADS.lock().expect("acp run payloads").clear(); } #[test] fn local_agent_audit_snapshot_detects_changed_files() { let root = std::env::temp_dir().join(format!("mnote-local-agent-audit-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); std::fs::create_dir_all(root.join("ai-sessions/private")).expect("create session dir"); std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a"); std::fs::write(root.join(".mnote/ignored.md"), "ignored").expect("write ignored"); std::fs::write(root.join("ai-sessions/private/event.jsonl"), "ignored") .expect("write session"); let root_uri = format!("file://{}", root.display()); let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot"); std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a"); std::fs::write(root.join("b.md"), "# B\n").expect("write b"); let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot"); let changed = local_agent_audit_change_files(&before, &after); let files = changed.as_array().expect("changed files"); assert_eq!(files.len(), 2); assert!(files .iter() .any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" })); assert!(files .iter() .any(|file| { file["path"] == "b.md" && file["changeType"] == "added" })); assert!(!files.iter().any(|file| { file["path"] .as_str() .map(|path| path.contains(".mnote") || path.contains("ai-sessions")) .unwrap_or(false) })); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_agent_audit_snapshot_detects_resource_files() { let root = std::env::temp_dir().join(format!( "mnote-local-agent-audit-resources-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("maps")).expect("create maps dir"); std::fs::create_dir_all(root.join("office")).expect("create office dir"); std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"old"}"#) .expect("write mindmap"); std::fs::write(root.join("office/a.docx"), [0xff, 1, 2, 3]).expect("write office"); let root_uri = format!("file://{}", root.display()); let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot"); std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"new"}"#) .expect("modify mindmap"); std::fs::write(root.join("office/a.docx"), [0xfe, 1, 2, 3]).expect("modify office"); let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot"); let changed = local_agent_audit_change_files(&before, &after); let files = changed.as_array().expect("changed files"); assert!(files.iter().any(|file| { file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified" })); assert!(files .iter() .any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" })); let _ = std::fs::remove_dir_all(&root); } fn app() -> axum::Router { app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) } fn app_with_config(config: AppConfig) -> axum::Router { build_app(AppState::new(config)) } fn seeded_acp_state() -> AppState { let state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: None, user_id: "user_1".to_string(), workspace_id: Some("ws_1".to_string()), document_id: Some("doc_1".to_string()), session_id: "sess_1".to_string(), run_id: "run_1".to_string(), title: None, profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_1".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\",\"runId\":\"run_1\"}".to_string(), payload_json: "{\"message\":\"自动标题\"}".to_string(), }) .expect("seed acp runtime run"); state } fn test_state() -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) } #[test] fn empty_runtime_defaults_to_acp_reasonix_when_http_proxy_disabled() { let _env_guard = env_lock().lock().expect("env lock"); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY"); let payload = json!({ "profile": "mnoteai", "acpRuntime": "" }); assert_eq!( acp_runtime_for_payload(&payload, "mnoteai"), Some("reasonix".into()) ); } #[test] fn empty_runtime_can_still_use_retired_http_proxy_behind_explicit_flag() { let _env_guard = env_lock().lock().expect("env lock"); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); std::env::remove_var("MNOTE_ENABLE_HERMES_HTTP_PROXY"); let payload = json!({ "profile": "mnoteai", "acpRuntime": "" }); assert_eq!(acp_runtime_for_payload(&payload, "mnoteai"), None); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[tokio::test] async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() { let _env_guard = env_lock().lock().expect("env lock"); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_API_BASE_URL"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-health-unconfigured-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); fs::create_dir_all(&hermes_home).expect("hermes home"); std::env::set_var("HERMES_HOME", &hermes_home); let response = app() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/gateway/health?profile=default") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], false); assert_eq!(payload["gateway"]["status"], "unconfigured"); assert_eq!(payload["profile"]["modelConfigured"], false); let suggestions = payload["suggestions"].to_string(); assert!(suggestions.contains("MNOTE_WEB_HERMES_UPSTREAM_URL")); assert!(suggestions.contains("model.default")); std::env::remove_var("HERMES_HOME"); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn hermes_client_gateway_health_reports_profile_model_and_api_key_gaps() { let _env_guard = env_lock().lock().expect("env lock"); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-health-profile-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); let profile_dir = hermes_home.join("profiles").join("chemist"); fs::create_dir_all(&profile_dir).expect("profile dir"); fs::write( profile_dir.join("config.yaml"), "model:\n provider: openai\nproviders:\n openai:\n base_url: https://api.openai.com/v1\n", ) .expect("profile config"); std::env::set_var("HERMES_HOME", &hermes_home); let mock = axum::Router::new().route( "/v1/models", get(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let upstream = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock upstream"); }); std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); let response = app() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/gateway/health?profile=chemist") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], false); assert_eq!(payload["gateway"]["status"], "upstream_error"); assert_eq!(payload["profile"]["name"], "chemist"); assert_eq!(payload["profile"]["modelConfigured"], false); assert_eq!(payload["profile"]["apiKeyConfigured"], false); let suggestions = payload["suggestions"].to_string(); assert!(suggestions.contains("model.default")); assert!(suggestions.contains("API key")); std::env::remove_var("HERMES_HOME"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn hermes_client_upstream_error_message_points_to_hermes_settings() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); clear_runtime_registry(); clear_run_queue(); let mock = axum::Router::new().route( "/v1/runs", post(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let upstream = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock upstream"); }); std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "documentId": "doc_error", "sessionId": "mnote_doc_error_trace_1", "profile": "chemist", "message": "测试错误提示", "traceId": "trace_error_1" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "hermes_client_upstream_error"); let message = payload["message"].as_str().unwrap_or_default(); assert!(message.contains("model.default")); assert!(message.contains("API key")); assert!(message.contains("Hermes 设置")); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[tokio::test] async fn hermes_client_unauthenticated_requests_return_401() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .body(Body::from(json!({"documentId":"doc_1"}).to_string())) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("hermes_client_unauthorized") ); } #[tokio::test] async fn hermes_client_unconfigured_run_returns_stable_error() { let _guard = env_lock().lock().expect("env lock"); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "ping", "traceId": "trace_1" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("hermes_client_unconfigured") ); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[tokio::test] async fn hermes_client_session_create_uses_control_plane_store_without_storing_chat() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "workspaceId": "ws_1", "documentId": "doc_1", "traceId": "trace_1", "title": "当前页问答" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert!(payload.get("messages").is_none()); } #[tokio::test] async fn hermes_client_local_acp_session_create_writes_private_jsonl() { let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-private-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "traceId": "trace_local_session_1", "profile": "reasonix", "title": "本地会话" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], "local_ai_session_jsonl"); assert_eq!(payload["sessionStorage"], "local_private"); let session_id = payload["sessionId"].as_str().expect("session id"); let jsonl_path = root .join("ai-sessions") .join("private") .join(format!("{session_id}.jsonl")); let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); assert!(jsonl.contains("\"eventType\":\"session.created\"")); assert!(jsonl.contains("\"title\":\"本地会话\"")); let list_uri = format!( "/api/hermes/client/sessions?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1&documentId=local-md%3AREADME.md", url_escape(&root_uri) ); let list_response = app() .oneshot( Request::builder() .uri(list_uri) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("list response"); assert_eq!(list_response.status(), StatusCode::OK); let list_body = to_bytes(list_response.into_body(), usize::MAX) .await .expect("list body"); let list_payload: Value = serde_json::from_slice(&list_body).expect("list json"); assert_eq!(list_payload["persistence"], "local_ai_session_jsonl"); assert_eq!(list_payload["sessions"][0]["sessionId"], session_id); let detail_uri = format!( "/api/hermes/client/sessions/{}?source=acp&sourceKind=local_folder&rootUri={}&workspaceId=local-ws-user-1", session_id, url_escape(&root_uri) ); let detail_response = app() .oneshot( Request::builder() .uri(detail_uri) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("detail response"); assert_eq!(detail_response.status(), StatusCode::OK); let detail_body = to_bytes(detail_response.into_body(), usize::MAX) .await .expect("detail body"); let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json"); assert_eq!(detail_payload["persistence"], "local_ai_session_jsonl"); assert_eq!(detail_payload["events"][0]["eventType"], "session.created"); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_client_local_acp_run_writes_private_jsonl_without_convex() { let root = std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sessionId": "sess_local_run_1", "sourceKind": "local_folder", "rootUri": root_uri, "traceId": "trace_local_run_1", "profile": "reasonix", "message": "本地运行" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], "local_ai_session_jsonl"); assert_eq!(payload["sessionStorage"], "local_private"); let jsonl_path = root .join("ai-sessions") .join("private") .join("sess_local_run_1.jsonl"); let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); assert!(jsonl.contains("\"eventType\":\"run.started\"")); assert!(jsonl.contains("\"persistence\":\"local_ai_session_jsonl\"")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() { let _guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-{}", std::process::id() )); let config_root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-config-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(&config_root).expect("config root"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); let root_uri = format!("file://{}", root.display()); let share_grants_file = config_root.join("share-grants.json"); fs::write( &share_grants_file, json!({ "grants": [{ "id": "share_grant_1", "shareId": "share_1", "ownerUserId": "user_1", "targetUserId": "user_1", "rootUri": root_uri, "documentId": "local-md:README.md", "allowedResourceIds": ["local-md:README.md"], "permission": "write", "capabilities": ["ai", "share"], "createdAt": "1", "active": true }] }) .to_string(), ) .expect("share grants"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "shareId": "share_1", "permissionLevel": "shared_write", "traceId": "trace_shared_session_1", "profile": "reasonix", "title": "共享会话" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["sessionStorage"], "local_shared"); let session_id = payload["sessionId"].as_str().expect("session id"); let jsonl_path = root .join("ai-sessions") .join("shared") .join("share_1") .join(format!("{session_id}.jsonl")); let jsonl = fs::read_to_string(&jsonl_path).expect("jsonl"); assert!(jsonl.contains("\"eventType\":\"session.created\"")); assert!(jsonl.contains("\"eventType\":\"audit.shared_write\"")); assert!(jsonl.contains("\"shareId\":\"share_1\"")); assert!(jsonl.contains("\"allowedResourceIds\":[\"local-md:README.md\"]")); assert!(jsonl.contains("\"shareContext\"")); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&config_root); } #[tokio::test] async fn hermes_client_local_shared_read_session_write_is_forbidden() { let _guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-read-{}", std::process::id() )); let config_root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-read-config-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(&config_root).expect("config root"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, ) .expect("manifest"); let root_uri = format!("file://{}", root.display()); let share_grants_file = config_root.join("share-grants.json"); fs::write( &share_grants_file, json!({ "grants": [{ "id": "share_grant_read_1", "shareId": "share_1", "ownerUserId": "user_1", "targetUserId": "user_1", "rootUri": root_uri, "documentId": "local-md:README.md", "allowedResourceIds": ["local-md:README.md"], "permission": "read", "capabilities": ["ai", "share"], "createdAt": "1", "active": true }] }) .to_string(), ) .expect("share grants"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-user-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "shareId": "share_1", "permissionLevel": "shared_read", "traceId": "trace_shared_read_1", "profile": "reasonix", "title": "共享只读会话" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(!root.join("ai-sessions").join("shared").exists()); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&config_root); } #[tokio::test] async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "workspaceId": "ws_1", "documentId": "doc_1", "traceId": "trace_1", "title": "当前页问答", "profile": "chemist" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1"); assert_eq!(payload["profile"], "chemist"); assert!(payload.get("messages").is_none()); } #[tokio::test] async fn hermes_client_acp_session_create_indexes_session_in_convex_store() { let captured_body = Arc::new(Mutex::new(Value::Null)); let captured_for_route = Arc::clone(&captured_body); let mock = axum::Router::new().route( "/api/mutation", post(move |Json(body): Json| { let captured = Arc::clone(&captured_for_route); async move { *captured.lock().expect("captured convex body") = body; Json(json!({ "status": "success", "value": {"ok": true, "created": true} })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let convex_url = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock convex"); }); let response = app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: Some(convex_url), convex_admin_key: Some("admin-demo".into()), allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "workspaceId": "ws_1", "documentId": "doc_1", "traceId": "trace_1", "title": "当前页问答", "profile": "reasonix" }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!( captured_body.lock().expect("captured convex body").clone(), Value::Null ); } #[tokio::test] async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let router = app(); let response = router .clone() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-workspace-id", "ws_header") .body(Body::from( json!({ "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "读取当前页面", "profile": "reasonix", "acpRuntime": "reasonix", "runId": "run_trace_1", "traceId": "trace_1", "pageContext": {"title": "页面标题"} }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); let response = router .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("list response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("list body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); let run = &payload["sessions"][0]; assert_eq!(run["sessionId"], "sess_1"); assert_eq!(run["runId"], "run_trace_1"); assert_eq!(run["workspaceId"], "ws_1"); assert_eq!(run["documentId"], "doc_1"); assert_eq!(run["profile"], "reasonix"); assert_eq!(run["acpRuntime"], "reasonix"); assert_eq!(run["runtime"]["status"], "acp_pending"); assert_eq!(run["payload"]["message"], "读取当前页面"); assert!(run.get("messages").is_none()); } #[tokio::test] async fn hermes_client_acp_session_list_reads_current_user_convex_store() { let captured_body = Arc::new(Mutex::new(Value::Null)); let captured_for_route = Arc::clone(&captured_body); let mock = axum::Router::new().route( "/api/query", get(|| async { StatusCode::METHOD_NOT_ALLOWED }).post( move |Json(body): Json| { let captured = Arc::clone(&captured_for_route); async move { *captured.lock().expect("captured convex body") = body; Json(json!({ "status": "success", "value": [ { "sessionId": "sess_1", "runId": "run_1", "workspaceId": "ws_1", "documentId": "doc_1", "profile": "reasonix", "status": "completed" } ] })) } }, ), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let convex_url = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock convex"); }); let response = app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: Some(convex_url), convex_admin_key: Some("admin-demo".into()), allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["sessions"][0]["sessionId"], "sess_1"); assert_eq!(payload["persistence"], "convex_acp_runtime_store"); let query_body = captured_body.lock().expect("captured convex body").clone(); assert_eq!(query_body["path"], "aiSessions:listRuntimeRuns"); assert_eq!(query_body["args"]["userId"], "user_1"); assert_eq!(query_body["args"]["workspaceId"], "ws_1"); assert_eq!(query_body["args"]["documentId"], "doc_1"); } #[tokio::test] async fn hermes_client_acp_session_detail_reads_runtime_runs_and_events() { let captured_bodies = Arc::new(Mutex::new(Vec::::new())); let captured_for_route = Arc::clone(&captured_bodies); let mock = axum::Router::new().route( "/api/query", post(move |Json(body): Json| { let captured = Arc::clone(&captured_for_route); async move { captured .lock() .expect("captured convex bodies") .push(body.clone()); let path = body["path"].as_str().unwrap_or_default(); let value = match path { "aiSessions:listRuntimeRuns" => json!([ { "sessionId": "sess_1", "runId": "run_1", "workspaceId": "ws_1", "documentId": "doc_1", "profile": "reasonix", "status": "completed", "runtime": {"status": "completed", "runId": "run_1"} } ]), "aiSessions:listRuntimeEvents" => json!([ { "eventType": "message.delta", "payload": {"text": "hello"} } ]), _ => Value::Null, }; Json(json!({"status": "success", "value": value})) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let convex_url = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock convex"); }); let response = app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: Some(convex_url), convex_admin_key: Some("admin-demo".into()), allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) .oneshot( Request::builder() .method("GET") .uri( "/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1&legacyConvex=1", ) .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["persistence"], "convex_acp_runtime_store"); assert_eq!(payload["session"]["sessionId"], "sess_1"); assert_eq!(payload["session"]["runs"][0]["runId"], "run_1"); assert_eq!(payload["runtime"]["runId"], "run_1"); assert_eq!(payload["events"][0]["eventType"], "message.delta"); assert_eq!( payload["session"]["messages"].as_array().map(Vec::len), Some(0) ); let bodies = captured_bodies.lock().expect("captured convex bodies"); assert_eq!(bodies[0]["path"], "aiSessions:listRuntimeRuns"); assert_eq!(bodies[0]["args"]["userId"], "user_1"); assert_eq!(bodies[0]["args"]["sessionId"], "sess_1"); assert_eq!(bodies[1]["path"], "aiSessions:listRuntimeEvents"); assert_eq!(bodies[1]["args"]["runId"], "run_1"); } #[tokio::test] async fn hermes_client_acp_resume_marks_convex_runtime_history_source() { let mock = axum::Router::new().route( "/api/query", post(|Json(body): Json| async move { let path = body["path"].as_str().unwrap_or_default(); let value = match path { "aiSessions:listRuntimeRuns" => json!([ { "sessionId": "sess_1", "runId": "run_1", "workspaceId": "ws_1", "documentId": "doc_1", "profile": "reasonix", "status": "completed", "runtime": {"status": "completed", "runId": "run_1"} } ]), "aiSessions:listRuntimeEvents" => json!([]), _ => Value::Null, }; Json(json!({"status": "success", "value": value})) }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let convex_url = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock convex"); }); let response = app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: Some(convex_url), convex_admin_key: Some("admin-demo".into()), allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["resumed"], true); assert_eq!(payload["resumeSource"], "convex_acp_runtime_store"); assert_eq!(payload["session"]["runs"][0]["runId"], "run_1"); } #[tokio::test] async fn hermes_client_acp_session_rename_uses_sqlite_store() { let response = build_app(seeded_acp_state()) .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from(json!({"title": "新标题"}).to_string())) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["result"]["title"], "新标题"); assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1"); } #[tokio::test] async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() { let router = build_app(seeded_acp_state()); let response = router .clone() .oneshot( Request::builder() .method("POST") .uri( "/api/hermes/client/sessions/sess_1/auto-title?source=acp&workspaceId=ws_1", ) .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["result"]["title"], "自动标题"); let response = router .oneshot( Request::builder() .method("DELETE") .uri("/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("delete body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["result"]["deleted"], 1); } #[tokio::test] async fn hermes_client_acp_session_search_returns_convex_snippets() { let captured_body = Arc::new(Mutex::new(Value::Null)); let captured_for_route = Arc::clone(&captured_body); let mock = axum::Router::new().route( "/api/query", post(move |Json(body): Json| { let captured = Arc::clone(&captured_for_route); async move { *captured.lock().expect("captured convex body") = body; Json(json!({ "status": "success", "value": [ { "sessionId": "sess_1", "runId": "run_1", "title": "化学总结", "snippet": "帮我总结化学页面", "score": 1 } ] })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let convex_url = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock convex"); }); let response = app_with_config(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: Some(convex_url), convex_admin_key: Some("admin-demo".into()), allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["results"][0]["sessionId"], "sess_1"); assert_eq!(payload["results"][0]["snippet"], "帮我总结化学页面"); let query_body = captured_body.lock().expect("captured convex body").clone(); assert_eq!(query_body["path"], "aiSessions:searchRuntimeSessions"); assert_eq!(query_body["args"]["userId"], "user_1"); assert_eq!(query_body["args"]["workspaceId"], "ws_1"); assert_eq!(query_body["args"]["q"], "化学"); assert_eq!(query_body["args"]["limit"], 5); } #[tokio::test] async fn hermes_client_resume_returns_runtime_state_without_chat_store() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let registration = HermesRunRegistration { session_id: "mnote_doc_1_trace_1".into(), profile: "chemist".into(), document_id: "doc_1".into(), trace_id: "trace_1".into(), }; let runtime = register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "upstream": {"run_id": "run_1"} }), ) .expect("runtime"); assert_eq!(runtime["sessionId"], "mnote_doc_1_trace_1"); assert_eq!(runtime["runId"], "run_1"); assert!(runtime.get("messages").is_none()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions/mnote_doc_1_trace_1/resume") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!( payload["session"]["messages"].as_array().map(Vec::len), Some(0) ); assert_eq!(payload["runtime"]["sessionId"], "mnote_doc_1_trace_1"); assert_eq!(payload["runtime"]["runId"], "run_1"); assert_eq!(payload["runtime"]["status"], "running"); } #[test] fn hermes_client_active_session_queues_second_run_without_chat_truth() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let registration = HermesRunRegistration { session_id: "mnote_doc_queue_trace_1".into(), profile: "chemist".into(), document_id: "doc_queue".into(), trace_id: "trace_queued_1".into(), }; register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "upstream": {"run_id": "run_active_1"} }), ) .expect("runtime"); assert!(session_has_active_run("mnote_doc_queue_trace_1")); let queued = enqueue_run( &context, ®istration, &json!({ "workspaceId": "ws_1", "documentId": "doc_queue", "sessionId": "mnote_doc_queue_trace_1", "profile": "chemist", "message": "第二条问题", "traceId": "trace_queued_1", "selectedText": "选区", "pageContext": { "title": "页面标题", "body": "正文不应进入 queue 响应", "blocks": [{"id": "block_1"}] }, "messages": [ {"role": "assistant", "content": "历史消息不应保存为 mnote 真相"} ] }), ) .expect("queued"); assert_eq!(queued["queued"], true); assert_eq!(queued["queueLength"], 1); assert_eq!(queued["sessionId"], "mnote_doc_queue_trace_1"); assert!(queued.get("messages").is_none()); assert!(queued.get("pageContext").is_none()); assert_eq!(queued["contextSummary"]["hasPageContext"], true); assert_eq!(queued["contextSummary"]["hasSelectedText"], true); let popped = pop_next_queued_run("mnote_doc_queue_trace_1").expect("queued run"); assert_eq!(popped.input, "第二条问题"); assert_eq!(popped.profile, "chemist"); assert!(popped.context_summary.get("pageContext").is_none()); assert_eq!(queue_len_for_session("mnote_doc_queue_trace_1"), 0); } #[test] fn hermes_client_acp_stream_payload_lookup_keeps_payload_for_reconnect() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_acp_run_payloads(); ACP_RUN_PAYLOADS .lock() .expect("acp run payloads") .insert("run_keep_1".into(), json!({"message": "hello"})); let first = acp_run_payload_for_stream("run_keep_1").expect("first payload"); let second = acp_run_payload_for_stream("run_keep_1").expect("second payload"); assert_eq!(first["message"], "hello"); assert_eq!(second["message"], "hello"); } #[test] fn hermes_client_acp_runtime_event_store_args_are_user_scoped() { let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_1".parse().unwrap()); headers.insert("x-mnote-workspace-id", "ws_header".parse().unwrap()); let context = RequestContext::from_http_parts( &axum::http::Method::GET, &"/api/hermes/client/events/run_1".parse().expect("uri"), &headers, ); let registration = HermesRunRegistration { session_id: "sess_1".into(), profile: "reasonix".into(), document_id: "doc_1".into(), trace_id: "trace_1".into(), }; let args = acp_runtime_event_store_args( &context, ®istration, "run_1", "reasonix", "message.delta", &json!({"text": "hello"}), &json!({"workspaceId": "ws_1"}), ); assert_eq!(args["schema"], "mnote.acp_runtime_event.v1"); assert_eq!(args["userId"], "user_1"); assert_eq!(args["workspaceId"], "ws_1"); assert_eq!(args["documentId"], "doc_1"); assert_eq!(args["sessionId"], "sess_1"); assert_eq!(args["runId"], "run_1"); assert_eq!(args["profile"], "reasonix"); assert_eq!(args["acpRuntime"], "reasonix"); assert_eq!(args["eventType"], "message.delta"); assert_eq!(args["payload"]["text"], "hello"); assert!(args.get("messages").is_none()); } #[test] fn hermes_client_cancel_queued_run_removes_pending_item() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let registration = HermesRunRegistration { session_id: "mnote_doc_cancel_trace_1".into(), profile: "default".into(), document_id: "doc_cancel".into(), trace_id: "trace_cancel_1".into(), }; let queued = enqueue_run( &context, ®istration, &json!({ "documentId": "doc_cancel", "sessionId": "mnote_doc_cancel_trace_1", "message": "等待取消", "traceId": "trace_cancel_1" }), ) .expect("queued"); let queue_id = queued["queueId"].as_str().expect("queue id"); assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 1); assert!(remove_queued_run("mnote_doc_cancel_trace_1", queue_id)); assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 0); assert!(pop_next_queued_run("mnote_doc_cancel_trace_1").is_none()); } #[tokio::test] async fn hermes_client_terminal_run_can_start_next_queued_run() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let hits = Arc::new(AtomicUsize::new(0)); let hits_for_route = Arc::clone(&hits); let mock = axum::Router::new().route( "/v1/runs", post(move |Json(_body): Json| { let hits = Arc::clone(&hits_for_route); async move { hits.fetch_add(1, Ordering::SeqCst); Json(json!({ "run_id": "run_queued_next", "trace_id": "trace_queued_next" })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let upstream = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock upstream"); }); std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let registration = HermesRunRegistration { session_id: "mnote_doc_auto_trace_1".into(), profile: "chemist".into(), document_id: "doc_auto".into(), trace_id: "trace_auto_1".into(), }; register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "upstream": {"run_id": "run_active_auto"} }), ) .expect("runtime"); enqueue_run( &context, ®istration, &json!({ "workspaceId": "ws_1", "documentId": "doc_auto", "sessionId": "mnote_doc_auto_trace_1", "profile": "chemist", "message": "自动出队问题", "traceId": "trace_auto_1", "pageContext": {"title": "自动出队"} }), ) .expect("queued"); let (_, terminal_sessions) = normalize_sse_chunk( "run_active_auto", &format!( "data: {}\n\n", json!({ "event": "run.completed", "run_id": "run_active_auto", "session_id": "mnote_doc_auto_trace_1" }) ), ); assert_eq!( terminal_sessions, vec!["mnote_doc_auto_trace_1".to_string()] ); start_next_queued_run(context, "mnote_doc_auto_trace_1".into()).await; assert_eq!(hits.load(Ordering::SeqCst), 1); assert_eq!(queue_len_for_session("mnote_doc_auto_trace_1"), 0); let runtime = runtime_state_for_session("mnote_doc_auto_trace_1"); assert_eq!(runtime["runId"], "run_queued_next"); assert_eq!(runtime["status"], "running"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); } #[tokio::test] async fn hermes_client_abort_returns_events_and_continues_queue() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1"); let stop_hits = Arc::new(AtomicUsize::new(0)); let run_hits = Arc::new(AtomicUsize::new(0)); let stop_hits_for_route = Arc::clone(&stop_hits); let run_hits_for_route = Arc::clone(&run_hits); let mock = axum::Router::new() .route( "/v1/runs/{run_id}/stop", post(move || { let stop_hits = Arc::clone(&stop_hits_for_route); async move { stop_hits.fetch_add(1, Ordering::SeqCst); Json(json!({"ok": true, "status": "aborted"})) } }), ) .route( "/v1/runs", post(move |Json(_body): Json| { let run_hits = Arc::clone(&run_hits_for_route); async move { run_hits.fetch_add(1, Ordering::SeqCst); Json(json!({ "run_id": "run_after_abort", "trace_id": "trace_after_abort" })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("listener"); let upstream = format!("http://{}", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, mock).await.expect("mock upstream"); }); std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs/run_abort_1/abort" .parse() .expect("uri"), &HeaderMap::new(), ); let registration = HermesRunRegistration { session_id: "mnote_doc_abort_trace_1".into(), profile: "default".into(), document_id: "doc_abort".into(), trace_id: "trace_abort_1".into(), }; register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "upstream": {"run_id": "run_abort_1"} }), ) .expect("runtime"); enqueue_run( &context, ®istration, &json!({ "documentId": "doc_abort", "sessionId": "mnote_doc_abort_trace_1", "message": "abort 后继续", "traceId": "trace_abort_1" }), ) .expect("queued"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs/run_abort_1/abort") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from(json!({"reason":"test_abort"}).to_string())) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["events"][0]["event"], "abort.started"); assert_eq!(payload["events"][1]["event"], "abort.completed"); assert_eq!(stop_hits.load(Ordering::SeqCst), 1); assert_eq!(run_hits.load(Ordering::SeqCst), 1); assert_eq!(queue_len_for_session("mnote_doc_abort_trace_1"), 0); let runtime = runtime_state_for_session("mnote_doc_abort_trace_1"); assert_eq!(runtime["runId"], "run_after_abort"); assert_eq!(runtime["status"], "running"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY"); } #[test] fn hermes_client_runtime_registry_tracks_tool_event_summary_only() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let registration = HermesRunRegistration { session_id: "mnote_doc_2_trace_2".into(), profile: "default".into(), document_id: "doc_2".into(), trace_id: "trace_2".into(), }; register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "runId": "run_2" }), ) .expect("runtime"); let _ = normalize_sse_chunk( "run_2", &format!( "data: {}\n\n", json!({ "event": "tool.started", "run_id": "run_2", "session_id": "mnote_doc_2_trace_2", "name": "mnote.page.get", "toolCallId": "call_1", "arguments": {"includeBody": false} }) ), ); let runtime = runtime_state_for_session("mnote_doc_2_trace_2"); assert_eq!(runtime["status"], "tool_calling"); assert_eq!(runtime["lastToolName"], "mnote.page.get"); assert_eq!(runtime["lastToolCallId"], "call_1"); assert!(runtime.get("arguments").is_none()); assert!(runtime.get("messages").is_none()); } #[test] fn hermes_client_normalizes_upstream_sse_events_to_runtime_contract() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); let registration = HermesRunRegistration { session_id: "mnote_doc_norm_trace_1".into(), profile: "default".into(), document_id: "doc_norm".into(), trace_id: "trace_norm_1".into(), }; register_runtime_from_create_run_response( ®istration, &json!({ "ok": true, "runId": "run_norm_1" }), ) .expect("runtime"); let (normalized, terminal_sessions) = normalize_sse_chunk( "run_norm_1", &format!( "data: {}\n\ndata: {}\n\n", json!({ "event": "assistant_message", "run_id": "run_norm_1", "session_id": "mnote_doc_norm_trace_1", "text": "hello" }), json!({ "event": "tool.failed", "run_id": "run_norm_1", "session_id": "mnote_doc_norm_trace_1", "toolCallId": "call_failed_1", "name": "mnote.page.save", "code": "permission_denied", "error": "写入被拒绝" }) ), ); assert!(normalized.contains("event: message.delta")); assert!(normalized.contains("\"schemaVersion\":\"mnote.hermes_runtime_event.v1\"")); assert!(normalized.contains("\"event\":\"tool.failed\"")); assert!(normalized.contains("\"toolCallId\":\"call_failed_1\"")); assert!(normalized.contains("\"code\":\"permission_denied\"")); assert!(terminal_sessions.is_empty()); } #[test] fn hermes_client_run_body_carries_minimal_page_context_into_run_input() { let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let body = build_run_upstream_body( &context, json!({ "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "概括当前页面", "pageContext": { "contextScope": "page", "node": {"documentId": "doc_1", "title": "页面标题"}, "documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}], "subtree": {"children": [{"title": "不应进入 Hermes instructions"}]}, "outline": [{"title": "不应进入 Hermes instructions"}], "contentAccess": "mnote.page.get", "aiContext": { "schema": "mnote.page_ai_context.v1", "contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}], "pageXml": "允许进入 Hermes instructions", "allowedTargetBlockIds": ["block_1"] } }, "selectedBlockId": "block_1", "selectedText": "选中文本", "traceId": "trace_1" }), ) .expect("body"); assert_eq!(body["input"], "概括当前页面"); assert_eq!(body["session_id"], "sess_1"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"workspaceId\":\"ws_1\"")); assert!(instructions.contains("\"documentId\":\"doc_1\"")); assert!(instructions.contains("\"title\":\"页面标题\"")); assert!(instructions.contains("\"selectedBlockId\":\"block_1\"")); assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\"")); assert!(instructions.contains("\"schema\":\"mnote.page_ai_context.v1\"")); assert!(instructions.contains("允许进入 Hermes instructions")); assert!(!instructions.contains("不应进入 Hermes instructions")); assert!(!instructions.contains("\"documentBlocks\"")); assert!(!instructions.contains("\"subtree\"")); assert!(!instructions.contains("\"outline\"")); } #[test] fn hermes_client_run_body_local_source_uses_file_scope_not_full_page_context() { let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_1".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &headers, ); let body = build_run_upstream_body( &context, json!({ "workspaceId": "local-workspace-1", "documentId": "local-md:README.md", "sessionId": "sess_local_1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "message": "把选中的句子改短", "selectedText": "选中的句子", "pageContext": { "node": {"documentId": "local-md:README.md", "title": "README"}, "aiContext": { "schema": "mnote.page_ai_context.v1", "pageText": "完整页面正文不应进入本地 agent instructions", "pageXml": "完整页面 XML 不应进入本地 agent instructions", "contextBlocks": [{"blockId": "block_1", "text": "完整块内容不应进入本地 agent instructions"}], "allowedTargetBlockIds": ["block_1"] } }, "traceId": "trace_local_1" }), ) .expect("body"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"sourceKind\":\"local_folder\"")); assert!(instructions.contains("\"fileReference\"")); assert!(instructions .contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"")); assert!(instructions.contains("\"aiAccessScope\"")); assert!(instructions.contains("\"allowedRoots\"")); assert!(instructions.contains("\"selectedText\":\"选中的句子\"")); assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions")); assert!(!instructions.contains("完整页面 XML 不应进入本地 agent instructions")); assert!(!instructions.contains("完整块内容不应进入本地 agent instructions")); assert!(!instructions.contains("\"contextBlocks\"")); assert!(!instructions.contains("\"pageXml\"")); assert!(!instructions.contains("\"pageText\"")); } #[test] fn hermes_client_run_body_shared_scope_uses_share_grant_resources() { let _guard = env_lock().lock().expect("env lock"); let config_root = std::env::temp_dir().join(format!( "mnote-shared-run-body-config-{}", std::process::id() )); let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(&config_root).expect("config root"); let share_grants_file = config_root.join("share-grants.json"); fs::write( &share_grants_file, json!({ "grants": [{ "id": "share_grant_scope_1", "shareId": "share_scope_1", "ownerUserId": "owner_1", "targetUserId": "target_1", "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", "documentId": "local-md:docs~2FShared.md", "allowedResourceIds": ["local-md:docs~2FShared.md"], "permission": "write", "capabilities": ["ai", "share"], "createdAt": "1", "active": true }] }) .to_string(), ) .expect("share grants"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "target_1".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &headers, ); let body = build_run_upstream_body( &context, json!({ "workspaceId": "local-workspace-shared", "documentId": "local-md:docs~2FShared.md", "sessionId": "sess_shared_scope_1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", "shareId": "share_scope_1", "actorId": "target_1", "message": "编辑共享页面", "traceId": "trace_shared_scope_1" }), ) .expect("body"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"permissionLevel\":\"shared_write\"")); assert!(instructions.contains("\"shareContext\"")); assert!(instructions.contains("\"shareId\":\"share_scope_1\"")); assert!(instructions.contains("\"allowedResourceIds\":[\"local-md:docs~2FShared.md\"]")); assert!(instructions.contains( "\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/docs/Shared.md\"]" )); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&config_root); } #[test] fn acp_runtime_env_limits_local_source_to_allowed_root() { let payload = json!({ "workspaceId": "local-workspace-1", "documentId": "local-md:README.md", "sessionId": "sess_local_1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "actorId": "user_1" }); let env = acp_allowed_roots_env_for_payload(&payload).expect("env"); let allowed_roots = serde_json::from_str::>( env.get("MNOTE_AI_ALLOWED_ROOTS_JSON") .expect("allowed roots"), ) .expect("allowed roots json"); assert_eq!( allowed_roots, vec!["/mnt/Data1T/Mnote_data/users/user_1/我的空间"] ); assert_eq!( env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str), Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间") ); assert!(!env .get("MNOTE_AI_ALLOWED_ROOTS_JSON") .unwrap() .contains("/mnt/Data1T/Mnote_data/users/user_2")); let scope = serde_json::from_str::( env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"), ) .expect("scope json"); assert_eq!(scope["permissionLevel"], "read_write"); assert_eq!( scope["allowedRoots"][0], "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间" ); } #[test] fn acp_runtime_env_limits_shared_scope_to_granted_files() { let _guard = env_lock().lock().expect("env lock"); let config_root = std::env::temp_dir().join(format!( "mnote-shared-acp-env-config-{}", std::process::id() )); let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(&config_root).expect("config root"); let share_grants_file = config_root.join("share-grants.json"); fs::write( &share_grants_file, json!({ "grants": [{ "id": "share_grant_env_1", "shareId": "share_env_1", "ownerUserId": "owner_1", "targetUserId": "target_1", "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", "allowedResourceIds": ["local-md:README.md"], "permission": "read", "capabilities": ["ai", "share"], "createdAt": "1", "active": true }] }) .to_string(), ) .expect("share grants"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let payload = json!({ "workspaceId": "local-workspace-shared", "documentId": "local-md:README.md", "sessionId": "sess_shared_env_1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", "shareId": "share_env_1", "actorId": "target_1" }); let env = acp_allowed_roots_env_for_payload(&payload).expect("env"); let allowed_roots = serde_json::from_str::>( env.get("MNOTE_AI_ALLOWED_ROOTS_JSON") .expect("allowed roots"), ) .expect("allowed roots json"); assert_eq!( allowed_roots, vec!["/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"] ); assert_eq!( env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str), Some("/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space") ); let scope = serde_json::from_str::( env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"), ) .expect("scope json"); assert_eq!(scope["permissionLevel"], "shared_read"); assert_eq!(scope["shareContext"]["shareId"], "share_env_1"); assert_eq!(scope["allowedResourceIds"][0], "local-md:README.md"); assert_eq!( scope["allowedFilePaths"][0], "/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md" ); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&config_root); } #[test] fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() { let state = test_state(); let mut headers = HeaderMap::new(); headers.insert("cookie", "mnote_web_convex_token=token_1".parse().unwrap()); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &headers, ); let (actor_id, actor_type) = effective_run_actor(&state, &context); assert_eq!(actor_id, "dev-user"); assert_eq!(actor_type, "devFallback"); let mut payload = json!({"message": "读取当前页面"}); stamp_run_actor(&mut payload, &actor_id, &actor_type); let body = build_run_upstream_body(&context, payload).expect("body"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"actorId\":\"dev-user\"")); assert!(instructions.contains("\"actorType\":\"devFallback\"")); assert!(!instructions.contains("\"actorId\":\"anonymous\"")); } #[test] fn hermes_client_run_body_carries_agent_profile() { let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let body = build_run_upstream_body( &context, json!({ "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "用当前 agent 回答", "profile": "chemist", "traceId": "trace_1" }), ) .expect("body"); assert_eq!(body["profile"], "chemist"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"profile\":\"chemist\"")); } #[test] fn hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits() { let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let body = build_run_upstream_body( &context, json!({ "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "把第一段改成测试123", "pageContext": { "aiContext": { "schema": "mnote.page_ai_context.v1", "pageText": "第一段\n第二段", "contextBlocks": [{"blockId": "block_1", "text": "第一段"}] } }, "traceId": "trace_1" }), ) .expect("body"); let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("普通正文 search/replace、局部段落替换或全文 markdown 替换,应优先调用 mnote_doc_markdown_edit")); assert!(instructions.contains("\"mnote_doc_markdown_edit for plain body search/replace or full markdown replacement\"")); assert!(!instructions.contains( "简单小段落编辑或同一页内多个普通叶子块操作,应优先用 mnote_doc_apply_block_ops" )); } #[tokio::test] async fn hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream() { let _guard = env_lock().lock().expect("env lock"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-client-test-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); for name in ["draft", "installed-demo", "builtin-demo"] { fs::create_dir_all(hermes_home.join("skills").join("writing").join(name)) .expect("test skill dir"); fs::write( hermes_home .join("skills") .join("writing") .join(name) .join("SKILL.md"), format!("# {name}\n\n写作助手"), ) .expect("test skill"); } fs::create_dir_all(hermes_home.join("skills").join("copied-demo")) .expect("test copied skill dir"); fs::write( hermes_home .join("skills") .join("copied-demo") .join("SKILL.md"), "# copied-demo\n\n复制技能", ) .expect("test copied skill"); fs::write( hermes_home.join("skills").join(".bundled_manifest"), "builtin-demo:abc123\n", ) .expect("test bundled manifest"); fs::create_dir_all(hermes_home.join("skills").join(".hub")).expect("test hub dir"); fs::write( hermes_home.join("skills").join(".hub").join("lock.json"), json!({ "installed": { "installed-demo": { "source": "skills.sh", "install_path": "writing/installed-demo" } } }) .to_string(), ) .expect("test hub lock"); fs::write( hermes_home.join("skills").join(".usage.json"), json!({ "draft": { "created_by": "agent", "created_at": "2026-05-14T00:00:00Z", "patch_count": 2 } }) .to_string(), ) .expect("test usage"); std::env::set_var("HERMES_HOME", &hermes_home); std::env::set_var("MNOTE_WEB_HERMES_BIN", "__missing_hermes_for_test__"); let cases = [ ("GET", "/api/hermes/client/profiles", None), ("GET", "/api/hermes/client/profiles/chemist", None), ( "GET", "/api/hermes/client/profile-memory?profile=chemist", None, ), ("GET", "/api/hermes/client/skills?profile=chemist", None), ( "PUT", "/api/hermes/client/profiles/active", Some(json!({"name": "chemist"})), ), ( "POST", "/api/hermes/client/profile-memory", Some(json!({"profile": "chemist", "section": "soul", "content": "test"})), ), ( "PUT", "/api/hermes/client/skills/toggle", Some(json!({"name": "chem", "enabled": true})), ), ]; for (method, uri, body) in cases { let request = Request::builder() .method(method) .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from(body.unwrap_or_else(|| json!({})).to_string())) .expect("request"); let response = app().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK, "{uri}"); } assert_eq!( fs::read_to_string(hermes_home.join("active_profile")) .expect("active profile") .trim(), "chemist" ); assert_eq!( fs::read_to_string(hermes_home.join("profiles").join("chemist").join("SOUL.md")) .expect("soul"), "test" ); let request = Request::builder() .method("GET") .uri("/api/hermes/client/skills?profile=chemist") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"); let response = app().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("skills json"); let skills = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["skills"].as_array().into_iter().flatten()) .map(|skill| { ( skill["name"].as_str().unwrap_or_default().to_string(), skill.clone(), ) }) .collect::>(); assert_eq!(skills["draft"]["origin"], "generated"); assert_eq!(skills["draft"]["createdBy"], "agent"); assert_eq!(skills["installed-demo"]["source"], "hub"); assert_eq!(skills["installed-demo"]["origin"], "installed"); assert_eq!(skills["builtin-demo"]["source"], "builtin"); assert_eq!(skills["copied-demo"]["origin"], "copied"); std::env::remove_var("HERMES_HOME"); std::env::remove_var("MNOTE_WEB_HERMES_BIN"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn hermes_client_tools_uses_manifest_and_profile_disabled_state() { let _guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-tools-local-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); let profile_dir = hermes_home.join("profiles").join("chemist"); fs::create_dir_all(&profile_dir).expect("profile dir"); fs::write( profile_dir.join("config.yaml"), "mnote:\n tools:\n disabled:\n - mnote.block.replace\n", ) .expect("profile config"); std::env::set_var("HERMES_HOME", &hermes_home); let response = app() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/tools?scope=mnote&profile=chemist") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("tools json"); let tools = payload["tools"] .as_array() .expect("tools") .iter() .map(|tool| { ( tool["name"].as_str().unwrap_or_default().to_string(), tool.clone(), ) }) .collect::>(); assert!(tools.contains_key("mnote.doc.fetch")); assert!(tools.contains_key("mnote.block.fetch")); assert!(tools.contains_key("mnote.block.replace")); assert!(tools.contains_key("mnote.block.insert_after")); assert!(tools.contains_key("mnote.block.delete")); assert!(tools.contains_key("mnote.block.move_after")); assert!(tools.contains_key("mnote.doc.apply_block_ops")); assert_eq!(tools["mnote.doc.fetch"]["enabled"], true); assert_eq!(tools["mnote.block.replace"]["enabled"], false); assert_eq!(tools["mnote.block.replace"]["status"], "disabled"); assert_eq!( tools["mnote.block.replace"]["unavailableReason"], "当前 Hermes profile 已关闭该 mnote tool" ); let toggle_response = app() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/tools/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from( json!({ "profile": "chemist", "name": "mnote.block.replace", "enabled": true }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(toggle_response.status(), StatusCode::OK); let response = app() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/tools?scope=mnote&profile=chemist") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("tools json"); let tools = payload["tools"] .as_array() .expect("tools") .iter() .map(|tool| { ( tool["name"].as_str().unwrap_or_default().to_string(), tool.clone(), ) }) .collect::>(); assert_eq!(tools["mnote.block.replace"]["enabled"], true); assert_eq!(tools["mnote.block.replace"]["status"], "available"); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } }