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::legacy_cloud_guard::{ execute_retired_mutation_by_name, execute_retired_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::{ AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AppendAiRuntimeEventInput, UpsertAiExternalConversationBindingInput, UpsertAiRuntimeRunInput, UpsertUserInput, }; use futures_util::{StreamExt, 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::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; 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"; const MNOTE_PERSONAL_SKILL_BASELINE_MARKER: &str = "mnotePersonalSkillBaseline: v1"; const MNOTE_PERSONAL_SKILL_ALLOWLIST: &[&str] = &["global-search", "vpn", "zhihu-search"]; const ENV_DOUBAO_CONVERSATION_DELETE_URL: &str = "MNOTE_WEB_DOUBAO_CONVERSATION_DELETE_URL"; const ENV_DOUBAO_CONVERSATION_LOOKUP_URL: &str = "MNOTE_WEB_DOUBAO_CONVERSATION_LOOKUP_URL"; const ENV_DEEPSEEK_CONVERSATION_DELETE_URL: &str = "MNOTE_WEB_DEEPSEEK_CONVERSATION_DELETE_URL"; const ENV_DEEPSEEK_CONVERSATION_LOOKUP_URL: &str = "MNOTE_WEB_DEEPSEEK_CONVERSATION_LOOKUP_URL"; const ENV_GEMINI_CONVERSATION_DELETE_URL: &str = "MNOTE_WEB_GEMINI_CONVERSATION_DELETE_URL"; const ENV_GEMINI_CONVERSATION_LOOKUP_URL: &str = "MNOTE_WEB_GEMINI_CONVERSATION_LOOKUP_URL"; const DEFAULT_DOUBAO_CONVERSATION_DELETE_URL: &str = "http://127.0.0.1:30343/mnote/provider-conversations/doubao-web/{conversationId}"; const DEFAULT_DOUBAO_CONVERSATION_LOOKUP_URL: &str = "http://127.0.0.1:30343/mnote/provider-conversations/doubao-web/mnote-session/{sessionId}"; const DEFAULT_DEEPSEEK_CONVERSATION_DELETE_URL: &str = "http://127.0.0.1:30341/mnote/provider-conversations/deepseek-web/{conversationId}"; const DEFAULT_DEEPSEEK_CONVERSATION_LOOKUP_URL: &str = "http://127.0.0.1:30341/mnote/provider-conversations/deepseek-web/mnote-session/{sessionId}"; const DEFAULT_GEMINI_CONVERSATION_DELETE_URL: &str = "http://127.0.0.1:30342/mnote/provider-conversations/gemini-web/{conversationId}"; const DEFAULT_GEMINI_CONVERSATION_LOOKUP_URL: &str = "http://127.0.0.1:30342/mnote/provider-conversations/gemini-web/mnote-session/{sessionId}"; 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 API_CHAT_RUN_PAYLOADS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_ACTIVE_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_LIVE_BINDINGS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_FINISHED_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::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"; const LOCAL_AGENT_AUDIT_MAX_FILES: usize = 512; const LOCAL_AGENT_AUDIT_MAX_BYTES: u64 = 32 * 1024 * 1024; const LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS: u128 = 2_500; #[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, PartialEq, Eq, Hash)] struct AcpLiveBindingKey { mnote_session_id: String, runtime: String, profile: String, workspace_key: String, } #[derive(Clone)] struct AcpLiveBinding { manager: Arc, acp_session_id: String, status: String, last_run_id: Option, last_event_seq: Option, created_at: u128, last_prompt_at: u128, } #[derive(Clone)] struct AcpPreparedSession { manager: Arc, acp_session_id: String, reasonix_session_mode: String, live_binding_key: Option, } #[derive(Debug, Clone)] struct AcpRuntimePolicy { runtime: String, context_carrier: &'static str, supports_session_load: bool, supports_history_replay: bool, prompt_continuity: &'static str, } impl AcpRuntimePolicy { fn to_json(&self) -> Value { json!({ "schema": "mnote.page_ai_acp_runtime_policy.v1", "runtime": self.runtime, "contextCarrier": self.context_carrier, "supportsSessionLoad": self.supports_session_load, "supportsHistoryReplay": self.supports_history_replay, "promptContinuity": self.prompt_continuity, }) } } fn acp_runtime_policy(runtime_name: &str) -> AcpRuntimePolicy { let runtime = runtime_name.trim().to_ascii_lowercase(); match runtime.as_str() { "hermes" => AcpRuntimePolicy { runtime, context_carrier: "adapter_loadable_session", supports_session_load: true, supports_history_replay: true, prompt_continuity: "load_then_prompt", }, "reasonix" => AcpRuntimePolicy { runtime, context_carrier: "adapter_live_loop", supports_session_load: false, supports_history_replay: false, prompt_continuity: "native_live_required", }, _ => AcpRuntimePolicy { runtime, context_carrier: "adapter_session", supports_session_load: false, supports_history_replay: false, prompt_continuity: "new_session", }, } } fn acp_live_binding_workspace_key(payload: &Value, context: &RequestContext) -> String { payload .get("rootUri") .or_else(|| payload.get("workspaceRoot")) .or_else(|| payload.get("cwd")) .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()) } fn acp_live_binding_key( mnote_session_id: &str, runtime: &str, profile: &str, payload: &Value, context: &RequestContext, ) -> AcpLiveBindingKey { AcpLiveBindingKey { mnote_session_id: mnote_session_id.to_string(), runtime: runtime.to_ascii_lowercase(), profile: profile.to_string(), workspace_key: acp_live_binding_workspace_key(payload, context), } } fn reasonix_live_binding_snapshot( key: &AcpLiveBindingKey, binding: &AcpLiveBinding, mode: &str, ) -> Value { json!({ "schema": "mnote.page_ai_acp_live_binding.v1", "mnoteSessionId": key.mnote_session_id, "acpRuntime": key.runtime, "profile": key.profile, "workspaceKey": key.workspace_key, "acpSessionId": binding.acp_session_id, "reasonixSessionMode": mode, "status": binding.status, "lastRunId": binding.last_run_id, "lastEventSeq": binding.last_event_seq, "createdAt": binding.created_at, "lastPromptAt": binding.last_prompt_at, }) } fn update_reasonix_live_binding( key: Option<&AcpLiveBindingKey>, status: &str, run_id: Option<&str>, event_seq: Option<&str>, ) { let Some(key) = key else { return; }; let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); if let Some(binding) = bindings.get_mut(key) { binding.status = status.to_string(); if let Some(run_id) = run_id { binding.last_run_id = Some(run_id.to_string()); } if let Some(event_seq) = event_seq { binding.last_event_seq = Some(event_seq.to_string()); } binding.last_prompt_at = now_ms(); } } fn mark_acp_adapter_replay_event( mut event: crate::acp_bridge::SseEvent, ) -> crate::acp_bridge::SseEvent { if !event.data.is_object() { event.data = json!({ "value": event.data }); } if let Value::Object(map) = &mut event.data { map.insert("source".into(), Value::String("adapter_replay".into())); map.insert("replay".into(), Value::Bool(true)); map.insert("replayPhase".into(), Value::String("session_load".into())); } event } #[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, scope: String, truncated: bool, truncated_reason: Option, file_count: usize, total_bytes: u64, elapsed_ms: u128, } #[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_retired_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::local_folder_source::ensure_local_workspace_write_access_with_state( state, context, root_uri, ) .map_err(|error| error.with_context(context))?; let user_id = effective_session_store_user_id(state, context).await?; let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(50) .clamp(1, 100); let workspace_id = query .get("workspaceId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let document_id = query .get("documentId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let session_id = query .get("sessionId") .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let sqlite_runs = state .control_plane() .list_ai_runtime_runs(&user_id, workspace_id, document_id, session_id, limit) .map_err(|error| { WebError::internal(format!("SQLite ACP session 列表读取失败: {error}")) .with_context(context) })?; if !sqlite_runs.is_empty() { return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "legacyPersistence": "local_ai_session_jsonl", "sessions": sqlite_runs.iter().map(ai_runtime_run_to_json).collect::>() })), )); } 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_retired_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::local_folder_source::ensure_local_workspace_write_access_with_state( &state, &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))?; } let session_index_run_id = format!("{}_session", session_id); let runtime_state = json!({ "sessionId": session_id, "runId": session_index_run_id, "profile": profile, "documentId": document_id, "traceId": trace_id, "status": "session.created" }); let runtime_payload = json!({ "workspaceId": payload.workspace_id.clone(), "documentId": payload.document_id.clone(), "sessionId": session_id, "sourceKind": "local_folder", "rootUri": root_uri, "profile": profile, "title": payload.title.clone(), "actorId": session_store_user_id.clone(), "actorType": "user", "traceId": trace_id, "permissionLevel": permission_level, "shareId": share_id, }); let session_acp_runtime = acp_runtime_for_payload(&runtime_payload, profile).unwrap_or_else(|| "hermes".into()); state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: None, user_id: session_store_user_id.clone(), workspace_id: payload.workspace_id.clone(), document_id: payload.document_id.clone(), session_id: session_id.clone(), run_id: session_index_run_id, title: payload.title.clone(), profile: profile.to_string(), acp_runtime: session_acp_runtime, trace_id: Some(trace_id.clone()), status: "session.created".to_string(), runtime_json: json_string(&runtime_state), payload_json: json_string(&runtime_payload), }) .map_err(|error| { WebError::internal(format!("SQLite ACP local session 写入失败: {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": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "legacyPersistence": "local_ai_session_jsonl", "legacySessionStorage": 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 Ok(api_profile) = crate::api_chat::resolve_api_chat_profile(profile) { let registration = HermesRunRegistration { session_id: session_id.clone(), profile: api_profile.isolated_profile.clone(), 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": api_profile.isolated_profile.clone(), "documentId": document_id, "traceId": trace_id.clone(), "status": "session.created", "transport": "api-chat", "providerKind": api_profile.provider_kind.clone(), "model": api_profile.model.clone(), "baseUrl": api_profile.base_url.clone() }); let mut api_runtime_payload = runtime_payload.clone(); api_runtime_payload["profile"] = Value::String(api_profile.isolated_profile.clone()); api_runtime_payload["profileId"] = Value::String(api_profile.profile_id.clone()); api_runtime_payload["providerKind"] = Value::String("api-chat".into()); api_runtime_payload["apiChatProfile"] = json!({ "profileId": api_profile.profile_id.clone(), "baseProfile": api_profile.base_profile.clone(), "isolatedProfile": api_profile.isolated_profile.clone(), "label": api_profile.label.clone(), "model": api_profile.model.clone(), "baseUrl": api_profile.base_url.clone(), "providerKind": api_profile.provider_kind.clone(), "status": api_profile.status.clone() }); persist_acp_runtime_run( &state, &context, ®istration, &session_index_run_id, "api-chat", &runtime_state, &api_runtime_payload, ) .await?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "sessionId": session_id, "workspaceId": payload.workspace_id, "documentId": payload.document_id, "profile": api_profile.isolated_profile.clone(), "profileId": api_profile.profile_id.clone(), "providerKind": "api-chat", "title": payload.title.unwrap_or_else(|| "当前页问答".into()), "traceId": trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane" })), )); } 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 let Ok(api_profile) = crate::api_chat::resolve_api_chat_profile(&profile) { let api_key_configured = api_profile.api_key.is_some(); return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": api_key_configured, "traceId": context.trace.trace_id, "profile": { "name": api_profile.isolated_profile, "profileId": api_profile.profile_id, "providerKind": "api-chat", "modelDefault": api_profile.model, "gateway": api_profile.base_url, "modelConfigured": true, "apiKeyConfigured": api_key_configured, "suggestions": if api_key_configured { Vec::::new() } else { vec!["请配置 MNOTE_API_CHAT_API_KEY 或 OPENAI_API_KEY。".to_string()] } }, "gateway": { "configured": true, "upstream": api_profile.base_url, "ok": api_key_configured, "status": if api_key_configured { "api-chat" } else { "api_key_missing" }, "transport": "api-chat" }, "suggestions": if api_key_configured { Vec::::new() } else { vec!["请配置 MNOTE_API_CHAT_API_KEY 或 OPENAI_API_KEY。".to_string()] } })), )); } 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( State(state): State, 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_access = resolve_hermes_profile_from_query(&state, &context, &query)?; let profile = profile_access .as_ref() .map(|access| access.profile.isolated_profile_name.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( State(state): State, 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_access = resolve_hermes_profile_from_payload(&state, &context, &payload)?; let profile = profile_access .as_ref() .map(|access| access.profile.isolated_profile_name.as_str()) .unwrap_or(fallback_profile.as_str()); if let Some(access) = &profile_access { if !access.grant.can_manage_config { return Err(ai_profile_forbidden( &context, "ai_profile_readonly", "当前用户不能修改该 Hermes profile 的记忆配置", )); } ensure_managed_profile_home(&access.profile.isolated_profile_name).map_err(|error| { WebError::bad_gateway_code( "hermes_profile_provision_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") })?; } 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_agent_profiles( State(state): State, Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let agent_id = query .get("agentId") .or_else(|| query.get("agent_id")) .map(String::as_str) .unwrap_or("hermes") .trim(); if agent_id != "hermes" { return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"agentId": agent_id, "profiles": []})), )); } let actor_id = page_ai_actor_id(&state, &context)?; let is_admin = page_ai_actor_is_admin(&context); ensure_page_ai_actor_user(&state, &actor_id, is_admin)?; let profiles = state .control_plane() .ensure_ai_agent_profile_policy(&actor_id, is_admin) .map_err(|error| { WebError::internal(format!("SQLite AI profile policy 读取失败: {error}")) })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "agentId": "hermes", "profiles": profiles.into_iter().map(agent_profile_access_json).collect::>() })), )) } pub async fn list_skills( State(state): State, 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 requested_profile = query .get("profileId") .or_else(|| query.get("profile_id")) .or_else(|| query.get("profile")) .map(String::as_str); let profile_access = resolve_hermes_profile_from_query(&state, &context, &query)?; let profile = profile_access .as_ref() .map(|access| access.profile.isolated_profile_name.as_str()) .unwrap_or(fallback_profile.as_str()); let runtime = query.get("runtime").map(String::as_str).unwrap_or(profile); if runtime != "mnote" && runtime != "reasonix" && requested_profile.is_some() && profile_access.is_none() { return Err(ai_profile_forbidden( &context, "ai_profile_forbidden", "当前用户无权访问该 Hermes profile", )); } Ok(( StatusCode::OK, stamp_client_headers(), Json(match runtime { "mnote" => { let mut payload = mnote_builtin_skills_payload(query.get("agentId").map(String::as_str)); stamp_mnote_builtin_skill_payload_policy(&state, &context, &mut payload)?; payload } "reasonix" => reasonix_skills_payload(), _ => { if let Some(access) = profile_access.as_ref() { ensure_personal_profile_skill_baseline(access).map_err(|error| { WebError::bad_gateway_code( "hermes_profile_skill_baseline_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") })?; } let mut payload = skills_payload(profile); if let Some(access) = profile_access { stamp_hermes_skill_payload_profile_policy(&mut payload, &access); } payload } }), )) } pub async fn toggle_skill( State(state): State, 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 skill_kind = payload .get("skillKind") .or_else(|| payload.get("kind")) .and_then(Value::as_str) .map(str::trim) .unwrap_or("hermes_profile"); if skill_kind == "mnote_builtin" { let actor_id = page_ai_actor_id(&state, &context)?; ensure_page_ai_actor_user(&state, &actor_id, page_ai_actor_is_admin(&context))?; let key = format!("ai.agent.mnote_builtin.skill.{name}.enabled"); state .control_plane() .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { id: None, user_id: actor_id, workspace_id: None, source_kind: None, scope_kind: "page_ai_skill".to_string(), scope_id: "mnote_builtin".to_string(), key, value_json: Value::Bool(enabled).to_string(), }) .map_err(|error| { WebError::internal(format!("SQLite MNote 内置 skill 偏好写入失败: {error}")) .with_context(&context) })?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_sqlite"})), )); } let access = resolve_hermes_profile_from_payload(&state, &context, &payload)?.ok_or_else(|| { ai_profile_forbidden( &context, "ai_profile_not_found", "未找到可访问的 Hermes profile", ) })?; if !access.grant.can_manage_skills { return Err(ai_profile_forbidden( &context, "ai_profile_readonly", "当前用户不能修改该 Hermes profile 的 skills", )); } ensure_personal_profile_skill_baseline(&access).map_err(|error| { WebError::bad_gateway_code( "hermes_profile_provision_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") })?; set_skill_enabled(&access.profile.isolated_profile_name, 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, "skillKind": "hermes_profile", "profileId": access.profile.id})), )) } pub async fn list_capabilities( State(state): State, Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let runtime = query.get("runtime").map(String::as_str).unwrap_or("mnote"); if runtime != "mnote" { return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "runtime": runtime, "categories": [], "archived": [] })), )); } 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 payload = mnote_capabilities_payload( &state, &context, query.get("agentId").map(String::as_str), profile, )?; Ok((StatusCode::OK, stamp_client_headers(), Json(payload))) } pub async fn list_agent_descriptors( State(state): State, 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 actor_id = page_ai_actor_id(&state, &context)?; let preference_values = ai_preference_values(&state, &actor_id)?; let descriptors = page_ai_agent_descriptors_payload(&state, &context, profile)?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.ai_agent_descriptors.v1", "profile": profile, "actorId": actor_id, "preferenceValues": preference_values, "descriptors": descriptors, })), )) } pub async fn create_page_ai_run( State(state): State, Extension(context): Extension, Json(mut payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let user_id = effective_session_store_user_id(&state, &context).await?; let request_id = page_ai_run_request_id(&payload, &context)?; 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()); let document_id = payload .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let session_id = payload .get("sessionId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| { stable_session_id(document_id.as_deref().unwrap_or("current"), &request_id) }); if let Some(existing) = find_page_ai_run_by_request_id( &state, &user_id, workspace_id.as_deref(), document_id.as_deref(), Some(&session_id), &request_id, )? { return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.ai_run_receipt.v1", "idempotent": true, "created": false, "hostRunId": existing.run_id, "requestId": request_id, "run": page_ai_run_to_journal_json(&existing), })), )); } let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("reasonix") .to_string(); let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(agent_id.as_str()) .to_string(); let acp_runtime = payload .get("acpRuntime") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| page_ai_default_acp_runtime(&agent_id).to_string()); let host_run_id = payload .get("hostRunId") .or_else(|| payload.get("runId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| new_page_ai_host_run_id(&request_id)); payload["requestId"] = Value::String(request_id.clone()); payload["hostRunId"] = Value::String(host_run_id.clone()); payload["runId"] = Value::String(host_run_id.clone()); payload["agentId"] = Value::String(agent_id.clone()); payload["sessionId"] = Value::String(session_id.clone()); payload["actorId"] = Value::String(user_id.clone()); if let Some(workspace_id) = workspace_id.as_deref() { payload["workspaceId"] = Value::String(workspace_id.to_string()); } if let Some(document_id) = document_id.as_deref() { payload["documentId"] = Value::String(document_id.to_string()); } let now = now_ms(); let runtime_state = json!({ "schema": "mnote.ai_run_runtime.v1", "hostRunId": host_run_id, "requestId": request_id, "providerRunId": Value::Null, "status": "pending", "createdAt": now, "updatedAt": now, }); let run = state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: None, user_id: user_id.clone(), workspace_id: workspace_id.clone(), document_id: document_id.clone(), session_id: session_id.clone(), run_id: host_run_id.clone(), title: payload .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), profile, acp_runtime: acp_runtime.clone(), trace_id: Some(context.trace.trace_id.clone()), status: "pending".to_string(), runtime_json: json_string(&runtime_state), payload_json: json_string(&payload), }) .map_err(|error| { WebError::internal(format!("SQLite Page AI run 创建失败: {error}")) .with_context(&context) })?; state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { id: None, user_id, workspace_id, document_id, session_id, run_id: host_run_id.clone(), profile: run.profile.clone(), acp_runtime, event_type: "run.created".to_string(), payload_json: json_string(&json!({ "schema": "mnote.ai_run_event_payload.v1", "requestId": request_id, "hostRunId": host_run_id, "status": "pending", })), }) .map_err(|error| { WebError::internal(format!("SQLite Page AI run 初始事件写入失败: {error}")) .with_context(&context) })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.ai_run_receipt.v1", "idempotent": false, "created": true, "hostRunId": run.run_id, "requestId": request_id, "run": page_ai_run_to_journal_json(&run), })), )) } pub async fn get_page_ai_run( State(state): State, Extension(context): Extension, Path(host_run_id): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let user_id = effective_session_store_user_id(&state, &context).await?; let Some(run) = state .control_plane() .find_ai_runtime_run(&user_id, &host_run_id) .map_err(|error| { WebError::internal(format!("SQLite Page AI run 读取失败: {error}")) .with_context(&context) })? else { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_run_not_found", "Page AI run 不存在或无权访问", ) .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, "schema": "mnote.ai_run.v1", "run": page_ai_run_to_journal_json(&run), })), )) } pub async fn list_page_ai_run_events( State(state): State, Extension(context): Extension, Path(host_run_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let user_id = effective_session_store_user_id(&state, &context).await?; let after_seq = query .get("afterSeq") .or_else(|| query.get("after_seq")) .and_then(|value| parse_ai_run_seq(value)) .unwrap_or(0); let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(200) .clamp(1, 500); let Some(run) = state .control_plane() .find_ai_runtime_run(&user_id, &host_run_id) .map_err(|error| { WebError::internal(format!("SQLite Page AI run 读取失败: {error}")) .with_context(&context) })? else { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_run_not_found", "Page AI run 不存在或无权访问", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")); }; let events = state .control_plane() .list_ai_runtime_journal_events(&user_id, &host_run_id, after_seq, limit) .map_err(|error| { WebError::internal(format!( "SQLite Page AI run event journal 读取失败: {error}" )) .with_context(&context) })?; let all_events = state .control_plane() .list_ai_runtime_journal_events(&user_id, &host_run_id, 0, 500) .map_err(|error| { WebError::internal(format!( "SQLite Page AI run event journal 终态检查失败: {error}" )) .with_context(&context) })?; let mut event_values = events .iter() .map(page_ai_journal_event_to_json) .collect::>(); let mut next_seq = events.last().map(|event| event.seq).unwrap_or(after_seq); if let Some(terminal_kind) = page_ai_terminal_event_kind(&run.status) { let has_terminal = all_events .iter() .any(|event| page_ai_terminal_event_kind(&event.event.event_type).is_some()); if !has_terminal && event_values.len() < limit { let synthetic_seq = all_events .last() .map(|event| event.seq + 1) .unwrap_or(1) .max(1); if synthetic_seq > after_seq { next_seq = next_seq.max(synthetic_seq); event_values.push(page_ai_synthetic_terminal_event_to_json( &run, terminal_kind, synthetic_seq, )); } } } Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.ai_run_events.v1", "hostRunId": host_run_id, "afterSeq": format_ai_run_seq(after_seq), "nextSeq": format_ai_run_seq(next_seq), "hasMore": events.len() == limit, "run": page_ai_run_to_journal_json(&run), "events": event_values, })), )) } pub async fn get_page_ai_session_active_run( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; 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 runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), document_id.as_deref(), Some(&session_id), 20, ) .map_err(|error| { WebError::internal(format!("SQLite Page AI active run 读取失败: {error}")) .with_context(&context) })?; let mut active_run = None; for run in runs { if !page_ai_run_status_is_active(&run.status) { continue; } let reconciled = reconcile_ai_runtime_run_from_terminal_events(&state, &context, &user_id, run)?; if page_ai_run_status_is_active(&reconciled.status) { active_run = Some(page_ai_run_to_journal_json(&reconciled)); break; } } Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.ai_active_run.v1", "sessionId": session_id, "active": active_run.is_some(), "run": active_run.unwrap_or(Value::Null), })), )) } pub async fn get_page_ai_runtime_status( State(state): State, Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let user_id = effective_session_store_user_id(&state, &context).await?; let workspace_id = trimmed_query_value(&query, "workspaceId") .or_else(|| context.workspace.workspace_id.clone()); let document_id = trimmed_query_value(&query, "documentId"); let session_id = trimmed_query_value(&query, "sessionId") .or_else(|| trimmed_query_value(&query, "mnoteSessionId")); let profile_hint = trimmed_query_value(&query, "profile") .unwrap_or_else(|| active_profile_name().unwrap_or_else(|| "reasonix".into())); let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), document_id.as_deref(), session_id.as_deref(), 20, ) .map_err(|error| { WebError::internal(format!("SQLite Page AI runtime status 读取失败: {error}")) .with_context(&context) })?; let mut latest_run = None; let mut active_run = None; for run in runs { let reconciled = if page_ai_run_status_is_active(&run.status) { reconcile_ai_runtime_run_from_terminal_events(&state, &context, &user_id, run)? } else { run }; if latest_run.is_none() { latest_run = Some(reconciled.clone()); } if active_run.is_none() && page_ai_run_status_is_active(&reconciled.status) { active_run = Some(reconciled); } } let status_run = active_run.as_ref().or(latest_run.as_ref()); let status_session_id = session_id .clone() .or_else(|| status_run.map(|run| run.session_id.clone())) .unwrap_or_default(); let profile = status_run .map(|run| run.profile.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or(profile_hint); let payload = status_run .and_then(|run| serde_json::from_str::(&run.payload_json).ok()) .unwrap_or_else(|| json!({})); let runtime_json = status_run .and_then(|run| serde_json::from_str::(&run.runtime_json).ok()) .unwrap_or_else(|| json!({})); let acp_runtime = status_run .map(|run| run.acp_runtime.clone()) .filter(|value| !value.trim().is_empty()) .or_else(|| { payload .get("acpRuntime") .and_then(Value::as_str) .map(str::to_string) }) .unwrap_or_else(|| page_ai_default_acp_runtime_for_status(&payload, &profile)); let runtime = page_ai_runtime_kind_for_status(&payload, &profile, &acp_runtime); let policy = if matches!(runtime.as_str(), "reasonix" | "hermes") { Some(acp_runtime_policy(&runtime)) } else { None }; let replay = status_run .map(|run| page_ai_replay_status_for_run(&state, &context, &user_id, run)) .transpose()? .unwrap_or_else(|| { json!({ "replaySeen": false, "replaySeq": Value::Null, "replayMessageCount": 0 }) }); let acp_session_id = page_ai_acp_session_id_for_status( status_run.map(|run| run.run_id.as_str()), &status_session_id, &payload, ); let reasonix_live_binding = if runtime == "reasonix" { page_ai_reasonix_live_binding_for_status(&status_session_id, &profile) } else { Value::Null }; let mode = page_ai_runtime_mode_for_status( &runtime, active_run.as_ref(), status_run, &payload, acp_session_id.as_deref(), &replay, &reasonix_live_binding, ); let queue = page_ai_queue_status_for_session(&status_session_id); let tools = page_ai_tools_status_for_profile(&profile); let roots = page_ai_roots_status_for_payload(&payload); let model = page_ai_model_status_for_profile(&profile, &runtime); let active_run_json = active_run.as_ref().map(page_ai_run_to_journal_json); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.page_ai_runtime_status.v1", "mnoteSessionId": status_session_id, "workspaceId": workspace_id, "documentId": document_id, "runtime": runtime, "profile": profile, "mode": mode, "activeRun": active_run_json.unwrap_or(Value::Null), "session": { "latestRun": status_run.map(page_ai_run_to_journal_json).unwrap_or(Value::Null), "queue": queue, }, "acp": { "acpSessionId": acp_session_id, "supportsSessionLoad": policy.as_ref().map(|policy| policy.supports_session_load).unwrap_or(false), "supportsHistoryReplay": policy.as_ref().map(|policy| policy.supports_history_replay).unwrap_or(false), "loadAttempted": runtime == "hermes" && payload.get("acpSessionId").and_then(Value::as_str).is_some(), "loadStatus": if runtime == "hermes" && payload.get("acpSessionId").and_then(Value::as_str).is_some() { "loaded" } else { "not_applicable" }, "replaySeen": replay.get("replaySeen").cloned().unwrap_or(Value::Bool(false)), "replaySeq": replay.get("replaySeq").cloned().unwrap_or(Value::Null), "replayMessageCount": replay.get("replayMessageCount").cloned().unwrap_or(Value::from(0)), "liveBinding": reasonix_live_binding, "runtimePolicy": policy.map(|policy| policy.to_json()).unwrap_or(Value::Null), }, "model": model, "roots": roots, "mcp": { "status": if matches!(runtime.as_str(), "reasonix" | "hermes") { "runtime_status_available" } else { "not_applicable" }, "toolCount": tools.get("total").and_then(Value::as_u64).unwrap_or(0), "failed": [], }, "tools": tools, "jobs": { "running": 0, "items": [], }, "usage": page_ai_usage_status_for_run(status_run, &runtime_json), "logs": page_ai_logs_tail_for_run(&state, &context, &user_id, status_run)?, })), )) } pub async fn reset_page_ai_runtime( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let session_id = payload .get("sessionId") .or_else(|| payload.get("mnoteSessionId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "page_ai_runtime_reset_session_required", "reset runtime 需要 sessionId", ) .with_context(&context) })? .to_string(); let runtime = payload .get("runtime") .or_else(|| payload.get("acpRuntime")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_lowercase()); let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let removed = { let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); let keys = bindings .keys() .filter(|key| { key.mnote_session_id == session_id && runtime .as_ref() .map(|runtime| &key.runtime == runtime) .unwrap_or(true) && profile .as_ref() .map(|profile| &key.profile == profile) .unwrap_or(true) }) .cloned() .collect::>(); keys.into_iter() .filter_map(|key| bindings.remove(&key).map(|binding| (key, binding))) .collect::>() }; for (_key, binding) in &removed { binding.manager.close().await; } Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "schema": "mnote.page_ai_runtime_reset.v1", "sessionId": session_id, "closedBindings": removed.len(), "deleteHistory": false, "deleteExternalProviderSession": false, })), )) } pub async fn toggle_capability( State(state): State, Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let capability_id = payload .get("id") .or_else(|| 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", "缺少 capability id") .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 runtime = payload .get("runtime") .and_then(Value::as_str) .map(str::trim) .unwrap_or("mnote"); if runtime != "mnote" { return Err(WebError::bad_request_code( "hermes_client_capability_runtime_unsupported", "当前只支持 MNote 内置能力开关", ) .with_context(&context)); } let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(fallback_profile.as_str()); let skill = crate::hermes_tools::skill::find_skill(capability_id, None).ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "mnote_capability_not_found", "未知 MNote AI 能力", ) .with_context(&context) })?; let actor_id = page_ai_actor_id(&state, &context)?; ensure_page_ai_actor_user(&state, &actor_id, page_ai_actor_is_admin(&context))?; set_mnote_builtin_capability_enabled(&state, &actor_id, capability_id, enabled, &context)?; for tool_name in skill.tool_names { if mnote_capability_tool_toggleable(tool_name) { set_mnote_tool_enabled(profile, tool_name, enabled).map_err(|error| { WebError::bad_gateway_code( "hermes_client_capability_tool_toggle_failed", format!("更新 MNote 能力工具设置失败: {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, "runtime": "mnote", "id": capability_id, "enabled": enabled, "profile": profile, "configScope": "user_sqlite+profile_tool_policy" })), )) } 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) })?; let provider_binding = find_provider_conversation_binding_for_session( state.control_plane(), &user_id, workspace_id.as_deref(), &session_id, ) .map_err(|error| { WebError::internal(format!( "SQLite provider conversation 绑定读取失败: {error}" )) .with_context(&context) })?; let provider_binding_local_deleted = mark_provider_conversation_local_deleted_for_session( state.control_plane(), &user_id, workspace_id.as_deref(), &session_id, ) .map_err(|error| { WebError::internal(format!( "SQLite provider conversation 删除标记失败: {error}" )) .with_context(&context) })?; let allow_external_delete = query .get("deleteExternalProviderSession") .or_else(|| query.get("remoteDelete")) .map(String::as_str) .map(str::trim) .map(|value| matches!(value, "1" | "true" | "yes")) .unwrap_or(false); let provider_conversation_delete = if allow_external_delete { delete_provider_conversation_for_session( state.control_plane(), &user_id, workspace_id.as_deref(), &session_id, provider_binding.as_ref(), ) .await .map_err(|error| { WebError::internal(format!( "SQLite provider conversation 远端删除状态写入失败: {error}" )) .with_context(&context) })? } else if provider_binding.is_some() { json!({ "attempted": false, "requiresConfirmation": true, "reason": "external_provider_delete_requires_explicit_confirmation" }) } else { json!({ "attempted": false, "reason": "api_chat_has_no_remote_conversation" }) }; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "result": { "ok": true, "sessionId": session_id, "deleted": deleted, "providerBindingLocalDeleted": provider_binding_local_deleted, "deleteExternalProviderSession": allow_external_delete, "providerConversationDelete": provider_conversation_delete, "remoteDelete": provider_conversation_delete } })), )); } 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 export_session( State(state): State, Extension(context): Extension, Path(session_id): Path, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let result = get_acp_session(&state, &context, &session_id, &query).await?; let mut detail = result.2 .0; let markdown = page_ai_session_export_markdown(&detail); let jsonl = page_ai_session_export_jsonl(&detail); detail["export"] = json!({ "schema": "mnote.page_ai_session_export.v1", "sessionId": session_id, "formats": ["json", "markdown", "jsonl"], "markdown": markdown, "jsonl": jsonl, "externalDelete": { "attempted": false, "reason": "export_only" } }); Ok((StatusCode::OK, stamp_client_headers(), Json(detail))) } 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_retired_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::local_folder_source::ensure_local_workspace_write_access_with_state( state, context, root_uri, ) .map_err(|error| error.with_context(context))?; 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() .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) })?; if let Some(latest_run) = runs.first() { let mut events = Vec::new(); for run in runs.iter().rev() { let run_events = 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) })?; events.extend(run_events); } let runtime = serde_json::from_str::(&latest_run.runtime_json) .unwrap_or_else(|_| runtime_state_for_session(session_id)); let session_messages = ai_runtime_session_messages_from_runs(state, context, &user_id, &runs)?; return Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "legacyPersistence": "local_ai_session_jsonl", "sessionId": session_id, "session": { "sessionId": session_id, "messages": session_messages, "runs": runs.iter().map(ai_runtime_run_to_json).collect::>() }, "runtime": runtime, "events": events.iter().map(ai_runtime_event_to_json).collect::>() })), )); } 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 mut events = Vec::new(); for run in runs.iter().rev() { let run_events = 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) })?; events.extend(run_events); } 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)); let session_messages = ai_runtime_session_messages_from_runs(state, context, &user_id, &runs)?; let session_provider_kind = latest_run .as_ref() .and_then(ai_runtime_run_provider_kind) .unwrap_or(Value::Null); 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": session_messages, "providerKind": session_provider_kind, "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_retired_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_retired_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); enforce_local_ai_run_access(&state, &context, &actor_id, &mut payload)?; apply_mnote_builtin_skill_policy_to_payload(&state, &actor_id, &mut payload)?; if payload_requests_hermes_profile(&payload) { let access = resolve_hermes_profile_from_payload(&state, &context, &payload)?.ok_or_else(|| { ai_profile_forbidden( &context, "ai_profile_forbidden", "当前用户无权访问该 Hermes profile", ) })?; stamp_agent_profile_ref(&mut payload, &access); } let registration = run_registration_from_payload(&context, &payload); inject_provider_conversation_binding_for_run( state.control_plane(), &context, ®istration, &mut payload, )?; if let Some(api_profile) = api_chat_profile_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()); payload["providerKind"] = Value::String("api-chat".into()); payload["apiChatProfile"] = json!({ "profileId": api_profile.profile_id.clone(), "baseProfile": api_profile.base_profile.clone(), "isolatedProfile": api_profile.isolated_profile.clone(), "label": api_profile.label.clone(), "model": api_profile.model.clone(), "baseUrl": api_profile.base_url.clone(), "providerKind": api_profile.provider_kind.clone(), "status": api_profile.status.clone() }); let runtime_state = register_api_chat_runtime(®istration, &run_id, &api_profile); API_CHAT_RUN_PAYLOADS .lock() .expect("api chat run payloads") .insert(run_id.clone(), payload.clone()); let persistence_result = persist_acp_runtime_run( &state, &context, ®istration, &run_id, "api-chat", &runtime_state, &payload, ) .await?; let response = json!({ "ok": true, "runId": run_id, "sessionId": registration.session_id, "profile": registration.profile, "profileId": api_profile.profile_id.clone(), "providerKind": "api-chat", "model": api_profile.model.clone(), "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), "legacyPersistence": persistence_result .get("legacyPersistence") .cloned() .unwrap_or(Value::Null), "legacySessionStorage": persistence_result .get("legacySessionStorage") .cloned() .unwrap_or(Value::Null), }); return Ok((StatusCode::OK, stamp_client_headers(), Json(response))); } // 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 payload.get("rootUri").and_then(Value::as_str).is_some() { match local_agent_audit_collect_snapshot_for_payload(&payload, None) { 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), "legacyPersistence": persistence_result .get("legacyPersistence") .cloned() .unwrap_or(Value::Null), "legacySessionStorage": persistence_result .get("legacySessionStorage") .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) })), )) } fn acp_sse_bytes(event: &crate::acp_bridge::SseEvent) -> axum::body::Bytes { let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into()); axum::body::Bytes::from(format!("event: {}\ndata: {}\n\n", event.event, json_str)) } fn attach_runtime_event_seq(event: &mut crate::acp_bridge::SseEvent, persistence: &Value) { let seq = persistence .get("seq") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let Some(seq) = seq else { return; }; if let Value::Object(map) = &mut event.data { map.entry("seq".to_string()) .or_insert_with(|| Value::String(seq.to_string())); } } fn acp_single_event_response( context: &RequestContext, event: crate::acp_bridge::SseEvent, ) -> Result { use tokio::sync::mpsc; let (tx, rx) = mpsc::channel::>(1); tokio::spawn(async move { let _ = tx.send(Ok(acp_sse_bytes(&event))).await; }); 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) } fn acp_existing_run_event_response( context: &RequestContext, event_tx: broadcast::Sender, ) -> Result { use tokio::sync::mpsc; let (tx, rx) = mpsc::channel::>(256); let mut event_rx = event_tx.subscribe(); tokio::spawn(async move { loop { match event_rx.recv().await { Ok(event) => { if tx.send(Ok(acp_sse_bytes(&event))).await.is_err() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!("ACP duplicate SSE subscriber lagged: {n} events dropped"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); 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) } /// 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) })?; if let Some(active) = ACP_ACTIVE_RUNS .lock() .expect("acp active runs") .get(run_id) .cloned() { return acp_existing_run_event_response(&context, active.event_tx); } if ACP_FINISHED_RUNS .lock() .expect("acp finished runs") .contains(run_id) { return acp_single_event_response( &context, crate::acp_bridge::SseEvent { event: "run.completed".into(), data: json!({ "replayed": true, "message": "ACP run already completed; not starting duplicate prompt." }), }, ); } // 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 capability_policy = page_ai_capability_policy(&payload, input); let registration = run_registration_from_payload(&context, &payload); let runtime_name = acp_runtime_name; let runtime_policy = acp_runtime_policy(runtime_name); // Ensure runtime is active; switch_to either activates it or returns existing let access_env = if capability_policy.attach_mnote_capabilities { acp_allowed_roots_env_for_payload(&payload) } else { None }; let client = if runtime_name == "hermes" { let explicit_hermes_config = std::env::var("MNOTE_WEB_ACP_RUNTIMES") .ok() .and_then(|raw| { serde_json::from_str::>(&raw).ok() }) .and_then(|configs| configs.into_iter().find(|config| config.name == "hermes")); let mut config = if let Some(config) = explicit_hermes_config { config } else { let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "hermes".into()); crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile)) }; config.env = merge_acp_runtime_env( merge_acp_runtime_env(config.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) => { let memory_env = if runtime_name == "reasonix" { reasonix_memory_env_for_payload(&state, &context, &payload)? } else { None }; let reasonix_env = if runtime_name == "reasonix" { reasonix_runtime_env_for_payload(&payload) } else { None }; config.env = merge_acp_runtime_env( merge_acp_runtime_env( merge_acp_runtime_env(config.env, access_env), memory_env, ), reasonix_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) })?; let (event_tx, _event_rx) = broadcast::channel(256); let event_tx_clone = event_tx.clone(); let hermes_load_replay_capture = Arc::new(AtomicBool::new(false)); let hermes_load_replay_events = Arc::new(Mutex::new(Vec::::new())); let mnote_session_id = registration.session_id.clone(); let stored_acp_session_id = payload .get("acpSessionId") .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()); let prepared_session = if runtime_policy.runtime == "reasonix" { let key = acp_live_binding_key(&mnote_session_id, runtime_name, profile, &payload, &context); let queue_deadline = Instant::now() + Duration::from_secs(600); let existing_binding = loop { let busy_acp_session_id = { let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); if let Some(binding) = bindings.get_mut(&key) { if binding.status == "running" { Some(binding.acp_session_id.clone()) } else { binding.status = "running".into(); binding.last_run_id = Some(run_id.to_string()); binding.last_prompt_at = now_ms(); break Some((binding.manager.clone(), binding.acp_session_id.clone())); } } else { break None; } }; update_runtime_by_run_id(run_id, "queued", Some("acp.reasonix.binding.busy"), None); if Instant::now() >= queue_deadline { return acp_single_event_response( &context, crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "code": "acp_reasonix_queue_timeout", "runId": run_id, "sessionId": mnote_session_id, "acpSessionId": busy_acp_session_id, "message": "Reasonix ACP live session stayed busy before this queued turn could start." }), }, ); } tokio::time::sleep(Duration::from_millis(150)).await; }; let existing_binding = if let Some((manager, acp_session_id)) = existing_binding { Some((manager, acp_session_id)) } else { let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); if let Some(binding) = bindings.get_mut(&key) { binding.status = "running".into(); binding.last_run_id = Some(run_id.to_string()); binding.last_prompt_at = now_ms(); Some((binding.manager.clone(), binding.acp_session_id.clone())) } else { None } }; if let Some((manager, acp_session_id)) = existing_binding { AcpPreparedSession { manager, acp_session_id, reasonix_session_mode: "native_live".into(), live_binding_key: Some(key), } } else { let manager = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); let acp_session_id = manager.ensure_session(None, None).await.map_err(|e| { WebError::bad_gateway_code( "acp_session_ensure_failed", format!("ACP session ensure failed: {e}"), ) .with_context(&context) })?; let mode = if stored_acp_session_id.is_some() { "cold_resumed" } else { "new_session" }; let binding = AcpLiveBinding { manager: manager.clone(), acp_session_id: acp_session_id.clone(), status: "running".into(), last_run_id: Some(run_id.to_string()), last_event_seq: None, created_at: now_ms(), last_prompt_at: now_ms(), }; ACP_LIVE_BINDINGS .lock() .expect("acp live bindings") .insert(key.clone(), binding); AcpPreparedSession { manager, acp_session_id, reasonix_session_mode: mode.into(), live_binding_key: Some(key), } } } else { let manager = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); let should_capture_load_replay = runtime_policy.supports_history_replay && runtime_policy.supports_session_load && stored_acp_session_id.is_some(); hermes_load_replay_capture.store(should_capture_load_replay, Ordering::SeqCst); let event_tx_for_load = event_tx.clone(); let replay_capture_for_load = hermes_load_replay_capture.clone(); let replay_events_for_load = hermes_load_replay_events.clone(); manager.on_event(move |event| { if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) { if replay_capture_for_load.load(Ordering::SeqCst) { replay_events_for_load .lock() .expect("hermes load replay events") .push(mark_acp_adapter_replay_event(sse)); } else { let _ = event_tx_for_load.send(sse); } } }); // Reference: hermes-vscode-main SessionManager.ensureSession(). let acp_session_id = manager .ensure_session( None, if runtime_policy.supports_session_load { stored_acp_session_id } else { None }, ) .await .map_err(|e| { WebError::bad_gateway_code( "acp_session_ensure_failed", format!("ACP session ensure failed: {e}"), ) .with_context(&context) })?; hermes_load_replay_capture.store(false, Ordering::SeqCst); AcpPreparedSession { manager, acp_session_id, reasonix_session_mode: "adapter_session".into(), live_binding_key: None, } }; let mgr = prepared_session.manager.clone(); let acp_session_id = prepared_session.acp_session_id.clone(); let reasonix_session_mode = prepared_session.reasonix_session_mode.clone(); let live_binding_key = prepared_session.live_binding_key.clone(); mgr.on_event(move |event| { if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) { let _ = event_tx_clone.send(sse); } }); let base_prompt_text = chatonly_provider_prompt_text(input, &payload, ®istration, &mnote_session_id, run_id); let prompt_text = if runtime_policy.runtime == "reasonix" && reasonix_session_mode == "cold_resumed" { match reasonix_cold_resume_context_packet( &state, &context, ®istration, &payload, run_id, )? { Some(packet) => format!("{packet}\n\nCurrent user request:\n{base_prompt_text}"), None => base_prompt_text, } } else { base_prompt_text }; let prompt_blocks = vec![ContentBlock::Text { text: prompt_text }]; 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: acp_session_id.clone(), event_tx: event_tx.clone(), }, ); let mut payload_with_acp_session = payload.clone(); payload_with_acp_session["acpSessionId"] = Value::String(acp_session_id.clone()); payload_with_acp_session["reasonixSessionMode"] = Value::String(reasonix_session_mode.clone()); ACP_RUN_PAYLOADS .lock() .expect("acp run payloads") .insert(run_id.to_string(), payload_with_acp_session); // 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 replay_events = hermes_load_replay_events .lock() .expect("hermes load replay events") .drain(..) .collect::>(); for replay_event in replay_events { let _ = event_tx.send(replay_event); } let state_for_events = state.clone(); let context_for_events = context.clone(); let event_registration = registration.clone(); let event_payload = payload.clone(); let run_id_for_events = run_id.to_string(); let acp_runtime_for_events = acp_runtime_name.to_string(); let acp_session_id_for_binding = acp_session_id.clone(); let binding_registration = event_registration.clone(); tokio::spawn(async move { loop { match event_rx.recv().await { Ok(mut event) => { match 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 { Ok(persistence) => attach_runtime_event_seq(&mut event, &persistence), Err(error) => { 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; } if page_ai_terminal_status_for_event(&event.event).is_some() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!("ACP SSE lagged: {n} events dropped"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); let reasonix_live_binding_state = live_binding_key .as_ref() .and_then(|key| { ACP_LIVE_BINDINGS .lock() .expect("acp live bindings") .get(key) .map(|binding| reasonix_live_binding_snapshot(key, binding, &reasonix_session_mode)) }) .unwrap_or(Value::Null); let _ = event_tx.send(crate::acp_bridge::SseEvent { event: "session.info.updated".into(), data: json!({ "sessionId": mnote_session_id.clone(), "acpSessionId": acp_session_id, "acpRuntime": runtime_name, "reasonixSessionMode": reasonix_session_mode, "reasonixLiveBinding": reasonix_live_binding_state, "runtimePolicy": runtime_policy.to_json(), }), }); // 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 binding_context = context.clone(); let binding_payload = payload.clone(); let binding_state = state.clone(); let binding_run_id = run_id.to_string(); let binding_runtime = acp_runtime_name.to_string(); let live_binding_key_for_prompt = live_binding_key.clone(); let mnote_tool_context = capability_policy.attach_mnote_capabilities.then(|| { 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), mnote_capabilities: Some(capability_policy.to_json()), } }); update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None); tokio::spawn(async move { let terminal_status = match mgr_clone .run_prompt_with_mnote_context(prompt_blocks, mnote_tool_context) .await { Ok(result) => { info!("ACP prompt completed: stop_reason={:?}", result.stop_reason); if let Err(error) = persist_provider_conversation_from_proxy( &binding_state, &binding_context, &binding_registration, &binding_run_id, &binding_runtime, &acp_session_id_for_binding, &binding_payload, ) .await { warn!(error = ?error, run_id = %binding_run_id, "Provider 远端会话绑定同步失败"); } 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 terminal_event = if prompt_cancelled || runtime_aborted { "run.aborted" } else { "run.completed" }; let terminal_status = page_ai_terminal_status_for_event(terminal_event).unwrap_or("completed"); let terminal_user_id = runtime_store_user_id(&binding_context, &binding_payload); if let Err(error) = reconcile_ai_runtime_run_terminal_status( &binding_state, &binding_context, &terminal_user_id, &binding_run_id, terminal_status, terminal_event, ) { warn!(error = ?error, run_id = %binding_run_id, terminal_event, "ACP terminal run status 补写失败"); } 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: terminal_event.into(), data: json!({ "stopReason": format!("{:?}", result.stop_reason), "agentAudit": agent_audit, }), }); terminal_status } Err(e) => { warn!("ACP prompt failed: {e}"); let terminal_user_id = runtime_store_user_id(&binding_context, &binding_payload); if let Err(error) = reconcile_ai_runtime_run_terminal_status( &binding_state, &binding_context, &terminal_user_id, &binding_run_id, "failed", "run.failed", ) { warn!(error = ?error, run_id = %binding_run_id, "ACP failed run status 补写失败"); } 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 }), }); "failed" } }; let was_aborted = matches!( runtime_status_for_run(&run_id_owned).as_deref(), Some("aborting" | "aborted") ); ACP_FINISHED_RUNS .lock() .expect("acp finished runs") .insert(run_id_owned.clone()); if binding_runtime == "reasonix" { update_reasonix_live_binding( live_binding_key_for_prompt.as_ref(), "idle", Some(&run_id_owned), None, ); } else { 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, terminal_status, 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) } async fn api_chat_stream_events( state: AppState, context: RequestContext, run_id: &str, ) -> Result { let payload = API_CHAT_RUN_PAYLOADS .lock() .expect("api chat run payloads") .get(run_id) .cloned() .ok_or_else(|| { WebError::bad_gateway_code( "api_chat_run_payload_not_found", format!("API Chat run payload not found for run_id={run_id}"), ) .with_context(&context) })?; let registration = run_registration_from_payload(&context, &payload); let api_profile = api_chat_profile_for_payload(&payload, ®istration.profile).ok_or_else(|| { WebError::bad_request_code( "api_chat_profile_unknown", format!("API Chat profile 未注册: {}", registration.profile), ) .with_context(&context) })?; let Some(api_key) = api_profile.api_key.clone() else { let event = crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "runId": run_id, "code": "api_chat_api_key_missing", "message": "API Chat 缺少 API key,请配置 MNOTE_API_CHAT_API_KEY 或 OPENAI_API_KEY。" }), }; persist_acp_runtime_event( &state, &context, ®istration, run_id, "api-chat", &event.event, &event.data, &payload, ) .await?; update_runtime_by_run_id(run_id, "failed", Some("run.failed"), None); return acp_single_event_response(&context, event); }; let messages = api_chat_messages_for_run(&state, &context, ®istration, run_id, &payload)?; let request_body = json!({ "model": api_profile.model.clone(), "messages": messages, "stream": true }); let url = format!( "{}/chat/completions", api_profile.base_url.trim_end_matches('/') ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(1800)) .build() .map_err(|error| { WebError::internal(format!("API Chat client 构造失败: {error}")).with_context(&context) })?; let upstream_response = client .post(url) .bearer_auth(api_key) .json(&request_body) .send() .await .map_err(|error| { WebError::bad_gateway_code( "api_chat_upstream_unavailable", format!("API Chat upstream 连接失败: {error}"), ) .with_context(&context) })?; if !upstream_response.status().is_success() { let status = upstream_response.status(); let text = upstream_response.text().await.unwrap_or_default(); let event = crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "runId": run_id, "code": "api_chat_upstream_error", "message": format!("API Chat upstream 返回 {status}: {text}"), "httpStatus": status.as_u16() }), }; persist_acp_runtime_event( &state, &context, ®istration, run_id, "api-chat", &event.event, &event.data, &payload, ) .await?; update_runtime_by_run_id(run_id, "failed", Some("run.failed"), None); return acp_single_event_response(&context, event); } use tokio::sync::mpsc; let (tx, rx) = mpsc::channel::>(256); let state_for_events = state.clone(); let context_for_events = context.clone(); let registration_for_events = registration.clone(); let payload_for_events = payload.clone(); let run_id_for_events = run_id.to_string(); update_runtime_by_run_id(run_id, "running", Some("api_chat.events.connected"), None); tokio::spawn(async move { let mut decoder = crate::api_chat::OpenAiSseDecoder::default(); let mut stream = upstream_response.bytes_stream(); while let Some(item) = stream.next().await { let events = match item { Ok(chunk) => { let text = String::from_utf8_lossy(&chunk); decoder.push_chunk(&run_id_for_events, text.as_ref()) } Err(error) => Ok(vec![crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "runId": run_id_for_events, "code": "api_chat_stream_read_error", "message": format!("API Chat stream 读取失败: {error}") }), }]), }; let should_stop = forward_api_chat_events( &state_for_events, &context_for_events, ®istration_for_events, &run_id_for_events, &payload_for_events, events, &tx, ) .await; if should_stop { break; } } if !matches!( runtime_status_for_run(&run_id_for_events).as_deref(), Some("completed" | "failed" | "aborted") ) { let events = decoder.finish(&run_id_for_events); let _ = forward_api_chat_events( &state_for_events, &context_for_events, ®istration_for_events, &run_id_for_events, &payload_for_events, events, &tx, ) .await; } API_CHAT_RUN_PAYLOADS .lock() .expect("api chat run payloads") .remove(&run_id_for_events); }); 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!("API Chat events 响应构造失败: {e}")).with_context(&context) })?; stamp_client_headers_into(response.headers_mut()); Ok(response) } async fn forward_api_chat_events( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, payload: &Value, events: Result, crate::api_chat::ApiChatError>, tx: &tokio::sync::mpsc::Sender>, ) -> bool { let events = match events { Ok(events) => events, Err(error) => vec![crate::acp_bridge::SseEvent { event: "run.failed".into(), data: json!({ "runId": run_id, "code": error.code, "message": error.message }), }], }; let mut should_stop = false; for mut event in events { match persist_acp_runtime_event( state, context, registration, run_id, "api-chat", &event.event, &event.data, payload, ) .await { Ok(persistence) => attach_runtime_event_seq(&mut event, &persistence), Err(error) => { warn!(error = ?error, run_id = %run_id, event = %event.event, "API Chat runtime event 持久化失败"); } } let next_status = match event.event.as_str() { "run.completed" => { should_stop = true; "completed" } "run.failed" => { should_stop = true; "failed" } _ => "running", }; update_runtime_by_run_id(run_id, next_status, Some(&event.event), None); if tx.send(Ok(acp_sse_bytes(&event))).await.is_err() { return true; } } should_stop } fn api_chat_messages_for_run( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, payload: &Value, ) -> Result, WebError> { let mut messages = vec![json!({ "role": "system", "content": "你是 MNote 的简洁聊天助手。不要声称能编辑文件;如果需要文件内容,只能基于用户显式提供的上下文回答。" })]; let user_id = runtime_store_user_id(context, payload); 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()); let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id.as_deref(), Some(®istration.document_id), Some(®istration.session_id), 40, ) .map_err(|error| { WebError::internal(format!("SQLite API Chat 历史读取失败: {error}")) .with_context(context) })?; for run in runs.iter().rev() { if run.run_id == run_id || run.status == "session.created" { continue; } if !crate::api_chat::api_chat_profile_by_id(&run.profile).is_some() { continue; } if let Ok(run_payload) = serde_json::from_str::(&run.payload_json) { if let Some(user_message) = run_payload .get("message") .or_else(|| run_payload.get("input")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { messages.push(json!({"role": "user", "content": user_message})); } } let events = state .control_plane() .list_ai_runtime_events(&user_id, &run.run_id, 200) .map_err(|error| { WebError::internal(format!("SQLite API Chat 历史事件读取失败: {error}")) .with_context(context) })?; let mut assistant = String::new(); for event in events { if event.event_type != "message.delta" { continue; } if let Ok(payload) = serde_json::from_str::(&event.payload_json) { if let Some(delta) = payload.get("delta").and_then(Value::as_str) { assistant.push_str(delta); } } } if !assistant.trim().is_empty() { messages.push(json!({"role": "assistant", "content": assistant})); } } let current_message = payload .get("message") .or_else(|| payload.get("input")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("请继续。"); messages.push(json!({"role": "user", "content": current_message})); Ok(messages) } 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()); if API_CHAT_RUN_PAYLOADS .lock() .expect("api chat run payloads") .contains_key(&run_id) { return api_chat_stream_events(state, context, &run_id).await; } // 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); ACP_FINISHED_RUNS .lock() .expect("acp finished runs") .insert(run_id.clone()); 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" } /// or `{ "permissionId": "...", "optionId": "allow_once" }`. /// /// 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()); let option_id = payload .get("optionId") .or_else(|| payload.get("option_id")) .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()); if decision.is_none() && option_id.is_none() { return Err(WebError::bad_request_code( "hermes_client_bad_request", "缺少 decision (allow/deny) 或 optionId", ) .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, option_id) .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, "optionId": option_id, })), )) } 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 ensure_managed_profile_home(profile: &str) -> std::io::Result<()> { let profile = profile.trim(); if profile.is_empty() || profile == "default" { return Ok(()); } let home = hermes_home(); let dir = home.join("profiles").join(profile); fs::create_dir_all(&dir)?; let config_path = dir.join("config.yaml"); if !config_path.exists() { let root_config = home.join("config.yaml"); if root_config.exists() { let _ = fs::copy(root_config, &config_path)?; } else { fs::write(&config_path, "{}\n")?; } } Ok(()) } fn available_hermes_skill_names(skills_dir: &FsPath) -> Vec { let Ok(entries) = fs::read_dir(skills_dir) else { return Vec::new(); }; let mut names = Vec::new(); for entry in entries.filter_map(Result::ok) { if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let dir = entry.path(); if dir.join("SKILL.md").exists() { names.push(name); continue; } if let Ok(children) = fs::read_dir(&dir) { names.extend( children .filter_map(Result::ok) .filter(|child| child.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|child| { if child.path().join("SKILL.md").exists() { Some(child.file_name().to_string_lossy().to_string()) } else { None } }), ); } } names.sort(); names.dedup(); names } fn is_mnote_managed_personal_profile(access: &AiAgentProfileAccessRecord) -> bool { access.profile.profile_kind == "personal" && access.profile.isolated_profile_name.starts_with("mnote-u-") } fn ensure_personal_profile_skill_baseline( access: &AiAgentProfileAccessRecord, ) -> std::io::Result<()> { ensure_managed_profile_home(&access.profile.isolated_profile_name)?; if !is_mnote_managed_personal_profile(access) { return Ok(()); } let config_path = profile_config_path(&access.profile.isolated_profile_name); let existing = fs::read_to_string(&config_path).unwrap_or_default(); if existing.contains(MNOTE_PERSONAL_SKILL_BASELINE_MARKER) { copy_personal_profile_initial_skills(&access.profile.isolated_profile_name)?; prune_personal_profile_disabled_skills(&access.profile.isolated_profile_name)?; return Ok(()); } copy_personal_profile_initial_skills(&access.profile.isolated_profile_name)?; write_disabled_skills_config(&config_path, &[], true) } fn copy_personal_profile_initial_skills(profile: &str) -> std::io::Result<()> { let source_root = hermes_home().join("skills"); let target_root = profile_home(profile).join("skills"); fs::create_dir_all(&target_root)?; for skill in MNOTE_PERSONAL_SKILL_ALLOWLIST { let Some(source) = find_skill_dir(&source_root, skill) else { continue; }; let target = target_root.join(skill); if target.exists() { continue; } copy_dir_recursive(&source, &target)?; } Ok(()) } fn prune_personal_profile_disabled_skills(profile: &str) -> std::io::Result<()> { let profile_skills = available_hermes_skill_names(&profile_home(profile).join("skills")) .into_iter() .collect::>(); let disabled = disabled_skills(profile) .into_iter() .filter(|skill| profile_skills.contains(skill.as_str())) .collect::>(); write_disabled_skills_config(&profile_config_path(profile), &disabled, false) } fn find_skill_dir(skills_root: &FsPath, skill: &str) -> Option { let direct = skills_root.join(skill); if direct.join("SKILL.md").exists() { return Some(direct); } let entries = fs::read_dir(skills_root).ok()?; for entry in entries.filter_map(Result::ok) { if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { continue; } let child = entry.path().join(skill); if child.join("SKILL.md").exists() { return Some(child); } } None } fn copy_dir_recursive(source: &FsPath, target: &FsPath) -> std::io::Result<()> { fs::create_dir_all(target)?; for entry in fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); let file_type = entry.file_type()?; if file_type.is_dir() { copy_dir_recursive(&source_path, &target_path)?; } else if file_type.is_file() { fs::copy(&source_path, &target_path)?; } } Ok(()) } fn page_ai_actor_id(state: &AppState, context: &RequestContext) -> Result { crate::routes::gateway::current_actor_id(state, context) .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "ai_profile_auth_required", "AI profile 需要登录用户", ) .with_context(context) }) } fn page_ai_actor_is_admin(context: &RequestContext) -> bool { crate::routes::local_folder_source::is_local_access_policy_admin_context(context) } fn ensure_page_ai_actor_user( state: &AppState, actor_id: &str, is_admin: bool, ) -> Result<(), WebError> { state .control_plane() .upsert_user(UpsertUserInput { id: Some(actor_id.to_string()), email: None, username: actor_id.to_string(), display_name: actor_id.to_string(), role: Some(if is_admin { "admin" } else { "user" }.to_string()), password_hash: None, }) .map(|_| ()) .map_err(|error| WebError::internal(format!("SQLite AI profile 用户初始化失败: {error}"))) } fn agent_profile_access_json(access: AiAgentProfileAccessRecord) -> Value { let provider_kind = if crate::api_chat::api_chat_profile_by_id(&access.profile.id).is_some() || crate::api_chat::api_chat_profile_by_id(&access.profile.base_profile_name).is_some() || crate::api_chat::api_chat_profile_by_id(&access.profile.isolated_profile_name).is_some() { "api-chat" } else { "" }; json!({ "profileId": access.profile.id, "agentId": access.profile.agent_id, "kind": access.profile.profile_kind, "ownerUserId": access.profile.owner_user_id, "baseProfile": access.profile.base_profile_name, "isolatedProfile": access.profile.isolated_profile_name, "providerKind": provider_kind, "displayName": access.profile.display_name, "canRun": access.grant.can_run, "canManageSkills": access.grant.can_manage_skills, "canManageConfig": access.grant.can_manage_config, "readonly": !access.grant.can_manage_skills, "grantRole": access.grant.role }) } fn ai_profile_forbidden(context: &RequestContext, code: &'static str, message: &str) -> WebError { WebError::new(StatusCode::FORBIDDEN, code, message) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") } fn select_profile_access( accesses: Vec, requested: Option<&str>, ) -> Option { let requested = requested.map(str::trim).filter(|value| !value.is_empty()); if let Some(value) = requested { let normalized = if value == "lite" { "shared_lite" } else { value }; return accesses.into_iter().find(|access| { access.profile.id == normalized || access.profile.isolated_profile_name == normalized || access.profile.display_name == normalized || ((normalized == "default" || normalized == "mnoteai") && access.profile.profile_kind == "personal") }); } accesses .iter() .find(|access| access.profile.profile_kind == "personal") .cloned() .or_else(|| { accesses .into_iter() .find(|access| access.profile.id == "shared_lite") }) } fn resolve_hermes_profile_access( state: &AppState, context: &RequestContext, requested: Option<&str>, ) -> Result, WebError> { let actor_id = page_ai_actor_id(state, context)?; let is_admin = page_ai_actor_is_admin(context); ensure_page_ai_actor_user(state, &actor_id, is_admin)?; let accesses = state .control_plane() .ensure_ai_agent_profile_policy(&actor_id, is_admin) .map_err(|error| { WebError::internal(format!("SQLite AI profile policy 解析失败: {error}")) })?; Ok(select_profile_access(accesses, requested)) } fn resolve_hermes_profile_from_query( state: &AppState, context: &RequestContext, query: &HashMap, ) -> Result, WebError> { let requested = query .get("profileId") .or_else(|| query.get("profile_id")) .or_else(|| query.get("profile")) .map(String::as_str); resolve_hermes_profile_access(state, context, requested) } fn resolve_hermes_profile_from_payload( state: &AppState, context: &RequestContext, payload: &Value, ) -> Result, WebError> { let requested = payload .get("profileId") .or_else(|| payload.get("profile_id")) .or_else(|| payload.get("profile")) .and_then(Value::as_str); resolve_hermes_profile_access(state, context, requested) } fn stamp_agent_profile_ref(payload: &mut Value, access: &AiAgentProfileAccessRecord) { payload["profile"] = Value::String(access.profile.isolated_profile_name.clone()); payload["profileId"] = Value::String(access.profile.id.clone()); payload["agentProfileRef"] = json!({ "kind": access.profile.profile_kind, "profileId": access.profile.id, "ownerUserId": access.profile.owner_user_id, "baseProfile": access.profile.base_profile_name, "isolatedProfile": access.profile.isolated_profile_name, "displayName": access.profile.display_name, "canRun": access.grant.can_run, "canManageSkills": access.grant.can_manage_skills, "canManageConfig": access.grant.can_manage_config, "readonly": !access.grant.can_manage_skills }); } fn payload_requests_hermes_profile(payload: &Value) -> bool { let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); let acp_runtime = payload .get("acpRuntime") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); agent_id == "hermes" || acp_runtime == "hermes" || payload.get("profileId").is_some() || payload.get("profile_id").is_some() || payload.get("agentProfileRef").is_some() } fn ai_preference_bool( state: &AppState, user_id: &str, key: &str, default_value: bool, ) -> Result { let preferences = state .control_plane() .list_user_ui_preferences(user_id, None, None) .map_err(|error| WebError::internal(format!("SQLite AI 偏好读取失败: {error}")))?; for preference in preferences { if preference.key == key { return Ok(serde_json::from_str::(&preference.value_json) .ok() .and_then(|value| value.as_bool()) .unwrap_or(default_value)); } } Ok(default_value) } fn apply_mnote_builtin_skill_policy_to_payload( state: &AppState, actor_id: &str, payload: &mut Value, ) -> Result<(), WebError> { let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let mut policy = serde_json::Map::new(); for skill in crate::hermes_tools::skill::skill_summaries_for_agent(agent_id) { let Some(skill_id) = skill.get("id").and_then(Value::as_str) else { continue; }; let key = format!("ai.agent.mnote_builtin.skill.{skill_id}.enabled"); policy.insert( skill_id.to_string(), Value::Bool(ai_preference_bool(state, actor_id, &key, true)?), ); } let skill_preferences = payload .as_object_mut() .expect("page ai run payload object") .entry("skillPreferences") .or_insert_with(|| json!({})); if !skill_preferences.is_object() { *skill_preferences = json!({}); } skill_preferences .as_object_mut() .expect("skillPreferences object") .insert("mnote".to_string(), Value::Object(policy)); Ok(()) } fn reasonix_memory_enabled( state: &AppState, context: &RequestContext, payload: &Value, ) -> Result { let actor_id = payload .get("actorId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty() && *value != "anonymous") .map(ToOwned::to_owned) .or_else(|| crate::routes::gateway::current_actor_id(state, context)) .unwrap_or_else(|| context.auth.actor_id.trim().to_string()); if actor_id.is_empty() || actor_id == "anonymous" { return Ok(false); } ai_preference_bool(state, &actor_id, "ai.agent.reasonix.memory_enabled", false) } fn reasonix_memory_env_for_payload( state: &AppState, context: &RequestContext, payload: &Value, ) -> Result>, WebError> { if reasonix_memory_enabled(state, context, payload)? { return Ok(Some(HashMap::from([( "REASONIX_MEMORY".to_string(), "on".to_string(), )]))); } Ok(Some(HashMap::from([( "REASONIX_MEMORY".to_string(), "off".to_string(), )]))) } fn reasonix_runtime_env_for_payload(payload: &Value) -> Option> { let settings = payload .get("reasonixSettings") .or_else(|| payload.get("reasonix_settings")); let model = settings .and_then(|value| value.get("modelId").or_else(|| value.get("model_id"))) .or_else(|| { payload .get("reasonixModel") .or_else(|| payload.get("reasonix_model")) }) .or_else(|| payload.get("model")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let approval_mode = settings .and_then(|value| { value .get("approvalMode") .or_else(|| value.get("approval_mode")) }) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let plan_mode = settings .and_then(|value| value.get("planMode").or_else(|| value.get("plan_mode"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let mut env = HashMap::new(); if let Some(model) = model { env.insert("REASONIX_MODEL".to_string(), model); } if let Some(approval_mode) = approval_mode { env.insert("MNOTE_REASONIX_APPROVAL_MODE".to_string(), approval_mode); } if let Some(plan_mode) = plan_mode { env.insert("MNOTE_REASONIX_PLAN_MODE".to_string(), plan_mode); } if env.is_empty() { None } else { Some(env) } } fn stamp_hermes_skill_payload_profile_policy( payload: &mut Value, access: &AiAgentProfileAccessRecord, ) { payload["profileId"] = Value::String(access.profile.id.clone()); payload["agentProfileRef"] = agent_profile_access_json(access.clone()); if let Some(categories) = payload.get_mut("categories").and_then(Value::as_array_mut) { for category in categories { if let Some(skills) = category.get_mut("skills").and_then(Value::as_array_mut) { for skill in skills { skill["profileId"] = Value::String(access.profile.id.clone()); skill["skillKind"] = Value::String("hermes_profile".to_string()); skill["builtin"] = Value::Bool(false); skill["configurable"] = Value::Bool(access.grant.can_manage_skills); skill["readonly"] = Value::Bool(!access.grant.can_manage_skills); skill["configScope"] = Value::String( if access.profile.profile_kind == "shared" { "hermes_shared_profile" } else { "hermes_personal_profile" } .to_string(), ); } } } } } fn stamp_mnote_builtin_skill_payload_policy( state: &AppState, context: &RequestContext, payload: &mut Value, ) -> Result<(), WebError> { let actor_id = page_ai_actor_id(state, context)?; if let Some(categories) = payload.get_mut("categories").and_then(Value::as_array_mut) { for category in categories { if let Some(skills) = category.get_mut("skills").and_then(Value::as_array_mut) { for skill in skills { let skill_id = skill .get("id") .or_else(|| skill.get("name")) .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if skill_id.is_empty() { continue; } let key = format!("ai.agent.mnote_builtin.skill.{skill_id}.enabled"); skill["enabled"] = Value::Bool(ai_preference_bool(state, &actor_id, &key, true)?); skill["builtin"] = Value::Bool(true); skill["configurable"] = Value::Bool(true); skill["configScope"] = Value::String("user_sqlite".to_string()); skill["skillKind"] = Value::String("mnote_builtin".to_string()); } } } } Ok(()) } 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 mnote_capabilities_payload( state: &AppState, context: &RequestContext, agent_id: Option<&str>, profile: &str, ) -> Result { let mut skills_payload = mnote_builtin_skills_payload(agent_id); stamp_mnote_builtin_skill_payload_policy(state, context, &mut skills_payload)?; let tools_by_name = mnote_tools_payload(profile) .into_iter() .filter_map(|tool| { let name = tool.get("name").and_then(Value::as_str)?.to_string(); Some((name, tool)) }) .collect::>(); let mut capability_categories: BTreeMap> = BTreeMap::new(); for category in skills_payload .get("categories") .and_then(Value::as_array) .into_iter() .flatten() { let mut capabilities = Vec::new(); for skill in category .get("skills") .and_then(Value::as_array) .into_iter() .flatten() { let Some(id) = skill.get("id").and_then(Value::as_str) else { continue; }; if id == "mnote-chat-only" { continue; } let tool_names = skill .get("toolNames") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .filter(|name| !name.trim().is_empty()) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default(); let tools = tool_names .iter() .filter_map(|name| tools_by_name.get(name).cloned()) .collect::>(); let disabled_tool_count = tools .iter() .filter(|tool| tool.get("enabled").and_then(Value::as_bool) == Some(false)) .count(); let enabled = skill .get("enabled") .and_then(Value::as_bool) .unwrap_or(true); let status = if !enabled { "disabled" } else if disabled_tool_count > 0 { "partial" } else { "available" }; let capability_category = skill .get("category") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("mnote"); capabilities.push(json!({ "id": id, "name": id, "title": skill.get("title").cloned().unwrap_or_else(|| json!(id)), "description": skill.get("description").cloned().unwrap_or(Value::Null), "enabled": enabled, "toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)), "builtin": true, "configurable": true, "configScope": "user_sqlite+profile_tool_policy", "skillKind": "mnote_capability", "source": "mnote", "origin": "builtin", "category": capability_category, "categoryTitle": mnote_capability_category_title(capability_category), "capabilityId": id, "capabilityKind": "mnote_builtin", "uiKind": mnote_capability_ui_kind(capability_category), "skillId": id, "readOnly": skill.get("readOnly").cloned().unwrap_or(Value::Bool(false)), "agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null), "requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null), "toolNames": tool_names, "tools": tools, "toolCount": tools.len(), "disabledToolCount": disabled_tool_count, "status": status, })); } for capability in capabilities { let category_name = capability .get("category") .and_then(Value::as_str) .unwrap_or("mnote") .to_string(); capability_categories .entry(category_name) .or_default() .push(capability); } } let categories = ordered_mnote_capability_categories(capability_categories) .into_iter() .map(|(name, capabilities)| { json!({ "name": name, "title": mnote_capability_category_title(&name), "description": mnote_capability_category_description(&name), "capabilities": capabilities.clone(), "skills": capabilities }) }) .collect::>(); Ok(json!({ "ok": true, "runtime": "mnote", "profile": profile, "categories": categories, "archived": [] })) } fn page_ai_agent_descriptors_payload( state: &AppState, context: &RequestContext, profile: &str, ) -> Result, WebError> { ["hermes", "reasonix", "chat_only"] .iter() .map(|agent_id| page_ai_agent_descriptor_payload(state, context, agent_id, profile)) .collect() } fn page_ai_agent_descriptor_payload( state: &AppState, context: &RequestContext, agent_id: &str, profile: &str, ) -> Result { let capabilities_payload = mnote_capabilities_payload(state, context, Some(agent_id), profile)?; let capability_packs = capabilities_payload .get("categories") .and_then(Value::as_array) .into_iter() .flatten() .flat_map(|category| { category .get("capabilities") .and_then(Value::as_array) .into_iter() .flatten() }) .cloned() .collect::>(); let mut tools = if agent_id == "chat_only" { Vec::new() } else { mnote_tools_payload(profile) }; page_ai_apply_capability_pack_tool_policy(&capability_packs, &mut tools); tools.sort_by(|left, right| { left.get("name") .and_then(Value::as_str) .unwrap_or_default() .cmp( right .get("name") .and_then(Value::as_str) .unwrap_or_default(), ) }); let capability_ids = page_ai_agent_capability_ids(agent_id, &capability_packs); let capability_states = page_ai_agent_capability_states(&capability_packs); let disabled_capability_ids = capability_states .as_object() .into_iter() .flat_map(|states| states.iter()) .filter_map(|(alias, state)| { (state.get("enabled").and_then(Value::as_bool) == Some(false)).then(|| alias.clone()) }) .collect::>(); Ok(json!({ "schema": "mnote.ai_agent_descriptor.v1", "agentId": agent_id, "displayName": page_ai_agent_display_name(agent_id), "provider": page_ai_agent_provider(agent_id), "acpRuntime": page_ai_agent_acp_runtime(agent_id), "canWriteFiles": agent_id != "chat_only", "capabilities": capability_ids, "defaultContextRefs": page_ai_agent_default_context_refs(agent_id), "settingScopes": page_ai_agent_setting_scopes(agent_id), "fields": page_ai_agent_setting_fields(agent_id), "capabilityStates": capability_states, "disabledCapabilities": disabled_capability_ids, "capabilityPacks": capability_packs, "tools": tools, "toolCount": tools.len(), })) } fn page_ai_agent_display_name(agent_id: &str) -> &'static str { match agent_id { "hermes" => "Hermes", "reasonix" => "Reasonix", "chat_only" => "Chat-only", _ => "Unknown", } } fn page_ai_agent_provider(agent_id: &str) -> &'static str { match agent_id { "hermes" => "hermes_client", "reasonix" => "acp_reasonix", "chat_only" => "chat_only", _ => "unknown", } } fn page_ai_agent_acp_runtime(agent_id: &str) -> &'static str { match agent_id { "reasonix" => "reasonix", "hermes" | "chat_only" => "hermes", _ => "unknown", } } fn page_ai_agent_default_context_refs(agent_id: &str) -> Value { match agent_id { "chat_only" => json!([]), "hermes" => json!(["current_page", "active_editor"]), "reasonix" => json!(["current_page", "active_editor", "folder"]), _ => json!([]), } } fn page_ai_agent_setting_scopes(agent_id: &str) -> Value { match agent_id { "hermes" => json!(["ai.common", "ai.agent.hermes"]), "reasonix" => json!(["ai.common", "ai.agent.reasonix"]), "chat_only" => json!(["ai.common", "ai.agent.chat_only"]), _ => json!(["ai.common"]), } } fn page_ai_agent_setting_fields(agent_id: &str) -> Value { match agent_id { "hermes" => json!([ { "key": "ai.agent.hermes.profile_id", "label": "Hermes profile", "kind": "select", "default": "default", "optionsSource": "hermes_profiles", "auth": "user", "secret": false } ]), "reasonix" => json!([ { "key": "ai.agent.reasonix.model_id", "label": "Reasonix model", "kind": "select", "default": reasonix_default_model_id(), "options": reasonix_model_options(), "auth": "user", "secret": false }, { "key": "ai.agent.reasonix.approval_mode", "label": "Approval mode", "kind": "select", "default": "ask", "options": reasonix_approval_mode_options(), "auth": "user", "secret": false }, { "key": "ai.agent.reasonix.plan_mode", "label": "Plan mode", "kind": "select", "default": "manual", "options": reasonix_plan_mode_options(), "auth": "user", "secret": false }, { "key": "ai.agent.reasonix.skill_panel_filter", "label": "Reasonix skill filter", "kind": "string", "default": "", "auth": "user", "secret": false } ]), "chat_only" => json!([ { "key": "ai.agent.chat_only.model_id", "label": "Chat-only model", "kind": "select", "default": "shared_api_deepseek_flash_chat", "optionsSource": "chat_only_profiles", "auth": "user", "secret": false } ]), _ => json!([]), } } fn reasonix_default_model_id() -> String { std::env::var("REASONIX_MODEL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "deepseek-flash".into()) } fn reasonix_model_options() -> Value { json!([ { "value": "deepseek-flash", "label": "deepseek-flash" }, { "value": "deepseek-pro", "label": "deepseek-pro" }, { "value": "deepseek-chat", "label": "deepseek-chat" }, { "value": "mimo-flash", "label": "mimo-flash" }, { "value": "mimo-pro", "label": "mimo-pro" } ]) } fn reasonix_approval_mode_options() -> Value { json!([ { "value": "ask", "label": "询问" }, { "value": "allow", "label": "自动允许" }, { "value": "deny", "label": "自动拒绝" } ]) } fn reasonix_plan_mode_options() -> Value { json!([ { "value": "manual", "label": "手动计划" }, { "value": "auto", "label": "自动计划" } ]) } fn page_ai_agent_capability_ids(agent_id: &str, capability_packs: &[Value]) -> Vec { let mut ids = match agent_id { "hermes" => vec!["mnote_tools".to_string()], "reasonix" => vec![ "file_read".to_string(), "file_write".to_string(), "native_patch".to_string(), ], "chat_only" => vec!["chat".to_string()], _ => Vec::new(), }; if capability_packs.iter().any(|capability| { capability.get("id").and_then(Value::as_str) == Some("mnote-knowledge-rag") && capability .get("enabled") .and_then(Value::as_bool) .unwrap_or(true) }) { ids.push("knowledge_rag".to_string()); } ids } fn page_ai_capability_alias(capability_id: &str) -> String { match capability_id { "mnote-knowledge-rag" => "knowledge_rag".to_string(), value => value .trim() .strip_prefix("mnote-") .unwrap_or(value.trim()) .replace('-', "_"), } } fn page_ai_agent_capability_states(capability_packs: &[Value]) -> Value { let mut states = serde_json::Map::new(); for capability in capability_packs { let Some(id) = capability.get("id").and_then(Value::as_str) else { continue; }; let alias = page_ai_capability_alias(id); states.insert( alias, json!({ "id": id, "enabled": capability.get("enabled").and_then(Value::as_bool).unwrap_or(true), "status": capability.get("status").and_then(Value::as_str).unwrap_or("available"), "toolNames": capability.get("toolNames").cloned().unwrap_or(Value::Null), }), ); } Value::Object(states) } fn page_ai_apply_capability_pack_tool_policy(capability_packs: &[Value], tools: &mut [Value]) { let disabled_tool_names = capability_packs .iter() .filter(|capability| capability.get("enabled").and_then(Value::as_bool) == Some(false)) .flat_map(|capability| { capability .get("toolNames") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .collect::>(); let enabled_tool_names = capability_packs .iter() .filter(|capability| capability.get("enabled").and_then(Value::as_bool) != Some(false)) .flat_map(|capability| { capability .get("toolNames") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .collect::>(); if disabled_tool_names.is_empty() { return; } for tool in tools { let name = tool .get("name") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if !disabled_tool_names.contains(name) || enabled_tool_names.contains(name) { continue; } tool["enabled"] = Value::Bool(false); tool["status"] = Value::String("disabled".to_string()); tool["unavailableReason"] = Value::String("当前 MNote capability 已关闭".to_string()); } } fn ai_preference_values(state: &AppState, actor_id: &str) -> Result { let preferences = state .control_plane() .list_user_ui_preferences(actor_id, None, None) .map_err(|error| WebError::internal(format!("SQLite AI 偏好读取失败: {error}")))?; let mut values = serde_json::Map::new(); for preference in preferences { if !preference.key.starts_with("ai.common.") && !preference.key.starts_with("ai.agent.") { continue; } let value = serde_json::from_str::(&preference.value_json).unwrap_or(Value::Null); values.insert(preference.key, value); } Ok(Value::Object(values)) } fn page_ai_run_request_id(payload: &Value, context: &RequestContext) -> Result { payload .get("requestId") .or_else(|| payload.get("request_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.source.idempotency_key.clone()) .ok_or_else(|| { WebError::bad_request_code( "page_ai_run_request_id_required", "创建 Page AI host run 需要 requestId 或 x-idempotency-key", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") }) } fn page_ai_default_acp_runtime(agent_id: &str) -> &'static str { match agent_id { "reasonix" => "reasonix", "chat_only" => "chat_only", _ => "hermes", } } fn new_page_ai_host_run_id(request_id: &str) -> String { format!( "host_run_{}_{}", sanitize_id_part(request_id), uuid::Uuid::new_v4().simple() ) } fn find_page_ai_run_by_request_id( state: &AppState, user_id: &str, workspace_id: Option<&str>, document_id: Option<&str>, session_id: Option<&str>, request_id: &str, ) -> Result, WebError> { let runs = state .control_plane() .list_ai_runtime_runs(user_id, workspace_id, document_id, session_id, 500) .map_err(|error| WebError::internal(format!("SQLite Page AI run 幂等查询失败: {error}")))?; Ok(runs.into_iter().find(|run| { serde_json::from_str::(&run.payload_json) .ok() .and_then(|payload| { payload .get("requestId") .or_else(|| payload.get("request_id")) .and_then(Value::as_str) .map(|value| value == request_id) }) .unwrap_or(false) })) } fn ordered_mnote_capability_categories( mut categories: BTreeMap>, ) -> Vec<(String, Vec)> { let mut ordered = Vec::new(); for name in ["mnote", "knowledge", "file", "resource", "office", "chat"] { if let Some(capabilities) = categories.remove(name) { ordered.push((name.to_string(), capabilities)); } } ordered.extend(categories); ordered } fn mnote_capability_category_title(category: &str) -> &'static str { match category { "knowledge" => "知识库与索引", "file" => "本地文件", "resource" => "资源编辑", "office" => "Office / ONLYOFFICE", "chat" => "聊天", _ => "MNote", } } fn mnote_capability_category_description(category: &str) -> &'static str { match category { "knowledge" => "本地索引、证据检索和资料范围管理。", "file" => "授权目录内的本地 Markdown 文件读写。", "resource" => "MNote 资源型编辑器能力,例如思维导图。", "office" => "Office 摘要、建议和 ONLYOFFICE 实时编辑桥。", "chat" => "不读取文档上下文的普通对话能力。", _ => "MNote 页面上下文与基础能力。", } } fn mnote_capability_ui_kind(category: &str) -> &'static str { if category == "chat" { "chat" } else { "ai_capability" } } fn set_mnote_builtin_capability_enabled( state: &AppState, actor_id: &str, capability_id: &str, enabled: bool, context: &RequestContext, ) -> Result<(), WebError> { let key = format!("ai.agent.mnote_builtin.skill.{capability_id}.enabled"); state .control_plane() .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { id: None, user_id: actor_id.to_string(), workspace_id: None, source_kind: None, scope_kind: "page_ai_capability".to_string(), scope_id: "mnote_builtin".to_string(), key, value_json: Value::Bool(enabled).to_string(), }) .map(|_| ()) .map_err(|error| { WebError::internal(format!("SQLite MNote AI 能力偏好写入失败: {error}")) .with_context(context) }) } fn mnote_capability_tool_toggleable(tool_name: &str) -> bool { !matches!( tool_name, "mnote.skill.read" | "mnote.context.snapshot" | "mnote.context.resolve_target" ) } 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 profile_skills_dir = profile_home(profile).join("skills"); let skills_dir = if profile_skills_dir.exists() && !available_hermes_skill_names(&profile_skills_dir).is_empty() { profile_skills_dir } else { 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 mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value { let skills = crate::hermes_tools::skill::skill_summaries_for_agent(agent_id) .into_iter() .map(|skill| { let id = skill .get("id") .and_then(Value::as_str) .unwrap_or("mnote-skill"); let title = skill .get("title") .and_then(Value::as_str) .unwrap_or(id); json!({ "id": id, "name": id, "title": title, "description": skill.get("description").cloned().unwrap_or(Value::Null), "enabled": true, "toggleable": true, "builtin": true, "configurable": true, "configScope": "user_sqlite", "skillKind": "mnote_builtin", "source": "mnote", "origin": "builtin", "category": skill.get("category").cloned().unwrap_or_else(|| json!("mnote")), "agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null), "toolNames": skill.get("toolNames").cloned().unwrap_or(Value::Null), "requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null) }) }) .collect::>(); json!({ "ok": true, "runtime": "mnote", "categories": [{ "name": "mnote", "description": "MNote builtin skills", "skills": skills }], "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); let name = name.trim(); if enabled { disabled.retain(|item| item.trim() != name); } else if !disabled.iter().any(|item| item == name) { disabled.push(name.to_string()); } disabled.sort(); write_disabled_skills_config(&path, &disabled, false) } fn write_disabled_skills_config( path: &FsPath, disabled: &[String], mark_personal_baseline: bool, ) -> std::io::Result<()> { let existing = fs::read_to_string(path).unwrap_or_default(); let has_personal_baseline_marker = existing.contains(MNOTE_PERSONAL_SKILL_BASELINE_MARKER); let mut kept = Vec::new(); let mut skipping_skills = false; for line in existing.lines() { let trimmed = line.trim(); if trimmed == MNOTE_PERSONAL_SKILL_BASELINE_MARKER { continue; } if skipping_skills { if line.starts_with(' ') || trimmed.is_empty() { continue; } skipping_skills = false; } if !line.starts_with(' ') && trimmed == "skills:" { skipping_skills = true; 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'); } if mark_personal_baseline || has_personal_baseline_marker { output.push_str(MNOTE_PERSONAL_SKILL_BASELINE_MARKER); output.push('\n'); } if !disabled.is_empty() { 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 crate::api_chat::payload_uses_api_chat_profile(payload, profile) { return None; } 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 API_CHAT_RUN_PAYLOADS .lock() .expect("api chat run payloads") .contains_key(run_id) || crate::api_chat::api_chat_profile_by_id(profile).is_some() { return None; } 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_directory_permission_allows_write(permission: &str) -> bool { matches!( permission.trim().to_ascii_lowercase().as_str(), "write" | "read_write" | "owner" | "admin" ) } fn local_directory_permission_allows_read(permission: &str) -> bool { matches!( permission.trim().to_ascii_lowercase().as_str(), "read" | "write" | "read_write" | "owner" | "admin" ) } fn local_ai_permission_level(permission: &str) -> &'static str { if local_directory_permission_allows_write(permission) { "read_write" } else if permission.trim().eq_ignore_ascii_case("read") { "read" } else { "none" } } fn enforce_local_ai_run_access( state: &AppState, context: &RequestContext, actor_id: &str, payload: &mut Value, ) -> Result<(), WebError> { if payload .get("sourceKind") .and_then(Value::as_str) .map(str::trim) != Some("local_folder") { return Ok(()); } if local_session_share_id(payload).is_some() { return Ok(()); } 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) })? .to_string(); let access = state .control_plane() .resolve_access(actor_id, &root_uri) .map_err(|error| WebError::internal(format!("SQLite 目录授权解析失败: {error}")))?; let agent_id = payload .get("agentId") .or_else(|| payload.get("agent_id")) .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); let chat_only = agent_id == "chat_only"; if chat_only && !local_directory_permission_allows_read(&access.permission) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_ai_workspace_read_access_denied", "当前用户无权让 AI 读取该本地工作区", ) .with_context(context)); } if !chat_only && !local_directory_permission_allows_write(&access.permission) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_ai_workspace_write_access_denied", "当前用户无权让 AI 写入该本地工作区", ) .with_context(context)); } payload["permissionLevel"] = Value::String(if chat_only { "read_only".into() } else { local_ai_permission_level(&access.permission).into() }); payload["allowedRoots"] = json!([{ "rootUri": root_uri, "permission": if chat_only { "read" } else { "write" }, "source": "sqlite_directory_grant", "grantIds": access.grant_ids }]); Ok(()) } 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 allowed_root_uris = payload .get("allowedRoots") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { item.as_object() .and_then(|object| object.get("rootUri").or_else(|| object.get("root_uri"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) .collect::>() }) .filter(|items| !items.is_empty()) .unwrap_or_else(|| vec![root_uri.to_string()]); let allowed_root_paths = allowed_root_uris .iter() .filter_map(|uri| file_root_uri_to_permission_path(uri)) .collect::>(); 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(|| { local_agent_target_allowed_resource_ids( payload .get("targetPackage") .or_else(|| payload.get("agentTargetPackage")), document_id, ) }); 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": allowed_root_uris, "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(|| { if allowed_root_paths.is_empty() { vec![root_path.clone()] } else { allowed_root_paths.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 } #[derive(Debug, Clone)] struct PageAiCapabilityPolicy { attach_mnote_capabilities: bool, reason: &'static str, intent_class: &'static str, available_skills: Vec, context_refs: Value, agent_id: String, workspace_id: Value, document_id: Value, source_kind: Value, root_uri: Value, profile: Value, allowed_roots: Value, ai_access_scope: Value, agent_run_envelope: Value, } impl PageAiCapabilityPolicy { fn to_json(&self) -> Value { json!({ "schema": "mnote.page_ai_capabilities.v1", "attachMnoteCapabilities": self.attach_mnote_capabilities, "reason": self.reason, "intentClass": self.intent_class, "agentId": self.agent_id, "contextRefs": self.context_refs, "availableSkills": self.available_skills, "workspaceId": self.workspace_id, "documentId": self.document_id, "sourceKind": self.source_kind, "rootUri": self.root_uri, "profile": self.profile, "allowedRoots": self.allowed_roots, "aiAccessScope": self.ai_access_scope, "agentRunEnvelope": self.agent_run_envelope }) } } fn page_ai_capability_policy(payload: &Value, _message: &str) -> PageAiCapabilityPolicy { let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); let (attach_mnote_capabilities, reason, intent_class) = if agent_id == "chat_only" { (false, "chat_only_agent", "chat") } else if context_refs_include_mnote_scope(payload) { (true, "selected_context_refs", "agent_decides") } else { (false, "no_context_refs", "chat") }; let available_skills = if attach_mnote_capabilities { crate::hermes_tools::skill::skill_summaries_for_agent(Some(agent_id)) } else { crate::hermes_tools::skill::skill_summaries_for_agent(Some("chat_only")) } .into_iter() .filter(|skill| page_ai_skill_enabled_by_payload(payload, skill)) .collect(); PageAiCapabilityPolicy { attach_mnote_capabilities, reason, intent_class, available_skills, context_refs: sanitize_context_refs(payload.get("contextRefs")), agent_id: agent_id.to_string(), workspace_id: payload.get("workspaceId").cloned().unwrap_or(Value::Null), document_id: payload.get("documentId").cloned().unwrap_or(Value::Null), source_kind: payload.get("sourceKind").cloned().unwrap_or(Value::Null), root_uri: payload.get("rootUri").cloned().unwrap_or(Value::Null), profile: payload.get("profile").cloned().unwrap_or(Value::Null), allowed_roots: payload.get("allowedRoots").cloned().unwrap_or(Value::Null), ai_access_scope: page_ai_capability_scope_for_tools(payload), agent_run_envelope: page_ai_capability_agent_run_envelope(payload), } } fn page_ai_capability_agent_run_envelope(payload: &Value) -> Value { let document_id = payload .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("current"); let workspace_id = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); 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 profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); let actor_id = payload .get("actorId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("anonymous"); let actor_type = payload .get("actorType") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("anonymous"); let session_id = payload .get("sessionId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("page-ai"); let run_id = payload .get("runId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(session_id); let trace_id = payload .get("traceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("page-ai"); let editor_target = payload .get("editorTarget") .map(sanitize_run_editor_target) .unwrap_or(Value::Null); let run_target_snapshot = payload .get("runTargetSnapshot") .map(sanitize_run_target_snapshot) .unwrap_or(Value::Null); let agent_target_package = if source_kind == "local_folder" { Some(build_local_agent_target_package( payload, document_id, workspace_id, source_kind, root_uri, &editor_target, &run_target_snapshot, )) } else { None }; build_agent_run_envelope( payload, document_id, workspace_id, source_kind, root_uri, profile, actor_id, actor_type, session_id, run_id, trace_id, &editor_target, &run_target_snapshot, agent_target_package.as_ref(), source_kind == "local_folder", ) } fn page_ai_skill_enabled_by_payload(payload: &Value, skill: &Value) -> bool { let Some(skill_id) = skill.get("id").and_then(Value::as_str) else { return true; }; payload .pointer("/skillPreferences/mnote") .and_then(Value::as_object) .and_then(|map| map.get(skill_id)) .and_then(Value::as_bool) .unwrap_or(true) } fn page_ai_capability_scope_for_tools(payload: &Value) -> Value { let document_id = payload .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("current"); let allowed_roots = payload .get("allowedRoots") .cloned() .unwrap_or_else(|| json!([])); let allowed_resource_ids = local_agent_target_allowed_resource_ids( payload .get("targetPackage") .or_else(|| payload.get("agentTargetPackage")), document_id, ); json!({ "permissionLevel": if allowed_roots.as_array().map(|roots| { roots.iter().any(|root| { root.get("permission") .and_then(Value::as_str) .map(|permission| permission == "write" || permission == "read_write") .unwrap_or(false) }) }).unwrap_or(false) { "read_write" } else { "read" }, "allowedRoots": allowed_roots, "allowedResourceIds": allowed_resource_ids }) } fn context_refs_include_mnote_scope(payload: &Value) -> bool { payload .get("contextRefs") .and_then(Value::as_array) .map(|refs| { refs.iter().any(|item| { let kind = item .as_str() .or_else(|| item.get("kind").and_then(Value::as_str)) .unwrap_or_default(); matches!( kind, "current_page" | "selection" | "active_editor" | "file" | "folder" | "changed_files" ) }) }) .unwrap_or(false) } 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 = if is_local_source { concat!( "这是本地文件夹上下文;MNote 只提供宿主侧任务信封、授权根和回执,不接管 agent loop。", "优先使用 agent 自身文件读取/编辑能力读取和修改 agentRunEnvelope.primaryTarget.workspacePath.relativePath 指向的真实文件,", "并遵守 fileReference.rootUri、aiAccessScope.allowedRoots、MNOTE_AI_ALLOWED_ROOTS_JSON 与 MNOTE_AI_ACCESS_SCOPE_JSON 的授权边界。", "不要调用已退役 cloud/Convex 文档读取工具读取本地 Markdown。", "普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件;写入后回读文件确认结果。", "不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑。", "只有复杂结构性块操作或 remote/cloud 兼容场景,才考虑 mnote block/doc 工具;", "mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级或文件 patch 无法表达时作为高风险兜底。", "当 agentRunEnvelope.targetPackage.resourceKind 为 only_office 或 targets 中包含 only_office 时,", "必须从 agentRunEnvelope.targetPackage.onlyofficeSessionId 或对应 target.onlyofficeSessionId 取值,", "并作为 onlyofficeSessionId 参数传给 mnote.onlyoffice.* 工具;不得猜测、不得省略、不得改用最近活跃 Office session。", "不要依据非 aiContext 的页面摘要猜测正文内容或块 id。" ) } else { 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 风格的已读上下文使用;", "远端 / cloud / compat 普通正文 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 sanitized_page_context = if is_local_source { sanitize_run_local_page_context(page_context) } else { sanitize_run_page_context(page_context) }; let editor_target = payload .get("editorTarget") .map(sanitize_run_editor_target) .or_else(|| { sanitized_page_context .get("aiContext") .and_then(|ai_context| ai_context.get("activeEditorTarget")) .cloned() }) .unwrap_or(Value::Null); let run_target_snapshot = payload .get("runTargetSnapshot") .map(sanitize_run_target_snapshot) .or_else(|| { sanitized_page_context .get("aiContext") .and_then(|ai_context| ai_context.get("runTargetSnapshot")) .map(sanitize_run_target_snapshot) }) .unwrap_or(Value::Null); let agent_target_package = if is_local_source { Some(build_local_agent_target_package( &payload, document_id, &workspace_id_string, source_kind, root_uri, &editor_target, &run_target_snapshot, )) } else { None }; let local_file_reference = if is_local_source { root_uri.map(|root_uri| { let mut reference = 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) } }); if let Some(target_package) = agent_target_package.as_ref() { reference["targetPackage"] = target_package.clone(); } reference }) } 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(|| { local_agent_target_allowed_resource_ids( agent_target_package.as_ref(), document_id, ) }); let allowed_file_paths = share_grant .as_ref() .map(LocalShareGrant::allowed_file_paths) .filter(|paths| !paths.is_empty()) .unwrap_or_else(|| { agent_target_package .as_ref() .map(|target_package| { local_agent_target_allowed_file_paths(root_uri, target_package) }) .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(target_package) = agent_target_package.as_ref() { scope["targetPackage"] = target_package.clone(); scope["allowedFiles"] = target_package .get("allowedFiles") .cloned() .unwrap_or_else(|| Value::Array(Vec::new())); } if let Some(grant) = share_grant.as_ref() { scope["shareContext"] = grant.share_context(); } scope }) } else { None }; let run_id = payload .get("runId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&session_id); let agent_run_envelope = build_agent_run_envelope( &payload, document_id, &workspace_id_string, source_kind, root_uri, profile.unwrap_or("default"), actor_id, actor_type, &session_id, run_id, trace_id, &editor_target, &run_target_snapshot, agent_target_package.as_ref(), is_local_source, ); let block_editing_tool_order = if is_local_source { json!([ "agent native file read/write within allowed roots", "agent native patch/diff for plain Markdown edits", "do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits", "mnote block/doc tools only for complex structural or remote/cloud fallback operations", "read back the changed file after writing" ]) } else { json!([ "mnote_doc_markdown_edit for remote/cloud/compat 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" ]) }; 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": run_id, "traceId": trace_id, "agentRunEnvelope": agent_run_envelope, "agentTargetPackage": agent_target_package.unwrap_or(Value::Null), "toolGuidance": tool_guidance, "fileReference": local_file_reference.unwrap_or(Value::Null), "aiAccessScope": local_ai_access_scope.unwrap_or(Value::Null), "editorTarget": editor_target, "runTargetSnapshot": run_target_snapshot, "blockEditingToolOrder": block_editing_tool_order, "pageSavePolicy": { "mnote_page_save": "fallback_only_for_explicit_whole_page_write", "forBlockEditing": "forbidden_as_first_choice", "forOrdinaryLocalMarkdown": if is_local_source { "forbidden_use_agent_native_file_patch" } else { "not_applicable" } }, "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_retired_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_workspace_path(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "schema", "workspaceId", "sourceKind", "rootUri", "relativePath", "documentId", "objectIdentity", "assetId", "resourceKind", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if sanitized.is_empty() { Value::Null } else { Value::Object(sanitized) } } fn sanitize_run_editor_target(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "schema", "source", "objectIdentity", "targetId", "paneRole", "documentId", "workspaceId", "editorKind", "resourceKind", "active", "dirtyState", "preview", "pinned", "lastActiveAt", "assetId", "path", "onlyofficeSessionId", "bridgeSessionId", "bridgeSessionReady", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if let Some(workspace_path) = source.get("workspacePath") { let sanitized_workspace_path = sanitize_workspace_path(workspace_path); if !sanitized_workspace_path.is_null() { sanitized.insert("workspacePath".to_string(), sanitized_workspace_path); } } if sanitized.is_empty() { Value::Null } else { Value::Object(sanitized) } } fn sanitize_open_editors_group(value: Option<&Value>) -> Value { let Some(source) = value.and_then(Value::as_object) else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in ["paneRole", "activeObjectIdentity"] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } for key in ["editors", "resourceEditors"] { let entries = source .get(key) .and_then(Value::as_array) .map(|items| { items .iter() .map(sanitize_run_editor_target) .filter(|item| !item.is_null()) .collect::>() }) .unwrap_or_default(); sanitized.insert(key.to_string(), Value::Array(entries)); } Value::Object(sanitized) } fn sanitize_run_open_editors_snapshot(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in ["schema", "generatedAt", "activeObjectIdentity"] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if let Some(active_editor) = source.get("activeEditor") { let sanitized_active = sanitize_run_editor_target(active_editor); if !sanitized_active.is_null() { sanitized.insert("activeEditor".to_string(), sanitized_active); } } for key in ["editors", "resourceEditors"] { let entries = source .get(key) .and_then(Value::as_array) .map(|items| { items .iter() .map(sanitize_run_editor_target) .filter(|item| !item.is_null()) .collect::>() }) .unwrap_or_default(); sanitized.insert(key.to_string(), Value::Array(entries)); } if let Some(groups) = source.get("groups").and_then(Value::as_object) { sanitized.insert( "groups".to_string(), json!({ "primary": sanitize_open_editors_group(groups.get("primary")), "secondary": sanitize_open_editors_group(groups.get("secondary")) }), ); } if sanitized.is_empty() { Value::Null } else { Value::Object(sanitized) } } fn sanitize_run_target_snapshot(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "schema", "source", "frozenAt", "workspaceId", "documentId", "sourceKind", "rootUri", "contextScope", "promptPreview", "runId", "sessionId", "traceId", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } for key in ["editorTarget", "activeEditorTarget"] { if let Some(value) = source.get(key) { let sanitized_target = sanitize_run_editor_target(value); if !sanitized_target.is_null() { sanitized.insert(key.to_string(), sanitized_target); } } } if let Some(value) = source.get("openEditorsSnapshot") { let sanitized_snapshot = sanitize_run_open_editors_snapshot(value); if !sanitized_snapshot.is_null() { sanitized.insert("openEditorsSnapshot".to_string(), sanitized_snapshot); } } if sanitized.is_empty() { Value::Null } else { 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 let Some(value) = ai_context.get("activeEditorTarget") { let sanitized = sanitize_run_editor_target(value); if !sanitized.is_null() { slim_ai_context.insert("activeEditorTarget".to_string(), sanitized); } } if let Some(value) = ai_context.get("openEditorsSnapshot") { let sanitized = sanitize_run_open_editors_snapshot(value); if !sanitized.is_null() { slim_ai_context.insert("openEditorsSnapshot".to_string(), sanitized); } } if let Some(value) = ai_context.get("runTargetSnapshot") { let sanitized = sanitize_run_target_snapshot(value); if !sanitized.is_null() { slim_ai_context.insert("runTargetSnapshot".to_string(), sanitized); } } if !slim_ai_context.is_empty() { sanitized.insert("aiContext".to_string(), Value::Object(slim_ai_context)); } } Value::Object(sanitized) } fn sanitize_context_refs(value: Option<&Value>) -> Value { let refs = value .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let source = item.as_object()?; let mut sanitized = serde_json::Map::new(); for key in [ "kind", "documentId", "rootUri", "relativePath", "editorKind", "source", "sinceRunTargetSnapshot", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if sanitized.is_empty() { None } else { Some(Value::Object(sanitized)) } }) .collect::>() }) .unwrap_or_default(); Value::Array(refs) } fn sanitize_relative_path(value: &str) -> Option { let normalized = value .trim() .replace('\\', "/") .trim_start_matches('/') .to_string(); if normalized.is_empty() || normalized.starts_with('/') || normalized .split('/') .any(|part| part.is_empty() || part == "..") { None } else { Some(normalized) } } fn target_workspace_path(value: &Value) -> Option<&Value> { value.get("workspacePath").filter(|path| path.is_object()) } fn target_relative_path(value: &Value) -> Option { value .get("currentFile") .and_then(|file| file.get("relativePath")) .and_then(Value::as_str) .and_then(sanitize_relative_path) .or_else(|| { target_workspace_path(value) .and_then(|path| path.get("relativePath")) .and_then(Value::as_str) .and_then(sanitize_relative_path) }) .or_else(|| { value .get("relativePath") .and_then(Value::as_str) .and_then(sanitize_relative_path) }) } fn local_agent_target_allowed_files(target_package: &Value) -> Vec { let mut files = target_package .get("allowedFiles") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .filter_map(sanitize_relative_path) .collect::>() }) .unwrap_or_default(); if let Some(relative_path) = target_relative_path(target_package) { files.push(relative_path); } files.sort(); files.dedup(); files } fn local_agent_target_allowed_file_paths(root_uri: &str, target_package: &Value) -> Vec { let Ok(root) = local_ai_session_root_dir(root_uri) else { return Vec::new(); }; let files = local_agent_target_allowed_files(target_package); files .into_iter() .map(|relative_path| root.join(relative_path).to_string_lossy().to_string()) .collect() } fn push_local_agent_resource_id(ids: &mut Vec, value: Option<&Value>) { let Some(value) = value.and_then(Value::as_str).map(str::trim) else { return; }; if value.is_empty() { return; } let owned = value.to_string(); if !ids.contains(&owned) { ids.push(owned); } } fn push_local_agent_resource_id_string(ids: &mut Vec, value: String) { let normalized = value.trim(); if normalized.is_empty() { return; } let owned = normalized.to_string(); if !ids.contains(&owned) { ids.push(owned); } } fn push_local_agent_target_resource_ids(ids: &mut Vec, target: &Value) { push_local_agent_resource_id(ids, target.get("targetId")); push_local_agent_resource_id(ids, target.get("objectIdentity")); push_local_agent_resource_id(ids, target.get("documentId")); push_local_agent_resource_id(ids, target.get("assetId")); push_local_agent_resource_id(ids, target.get("onlyofficeSessionId")); push_local_agent_resource_id(ids, target.get("bridgeSessionId")); if let Some(workspace_path) = target.get("workspacePath") { push_local_agent_resource_id(ids, workspace_path.get("objectIdentity")); push_local_agent_resource_id(ids, workspace_path.get("documentId")); push_local_agent_resource_id(ids, workspace_path.get("assetId")); } if let Some(current_file) = target.get("currentFile") { push_local_agent_resource_id(ids, current_file.get("objectIdentity")); push_local_agent_resource_id(ids, current_file.get("documentId")); push_local_agent_resource_id(ids, current_file.get("assetId")); } let document_id = target .get("documentId") .or_else(|| { target .get("workspacePath") .and_then(|path| path.get("documentId")) }) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let asset_id = target .get("assetId") .or_else(|| { target .get("workspacePath") .and_then(|path| path.get("assetId")) }) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); if let (Some(document_id), Some(asset_id)) = (document_id, asset_id) { push_local_agent_resource_id_string( ids, format!("resource:office:{document_id}:{asset_id}"), ); push_local_agent_resource_id_string( ids, format!("resource:onlyoffice:{document_id}:{asset_id}"), ); } } fn local_agent_target_allowed_resource_ids( target_package: Option<&Value>, fallback_document_id: &str, ) -> Vec { let mut ids = Vec::new(); if let Some(target_package) = target_package { push_local_agent_target_resource_ids(&mut ids, target_package); if let Some(targets) = target_package.get("targets").and_then(Value::as_array) { for target in targets { push_local_agent_target_resource_ids(&mut ids, target); } } } if ids.is_empty() { ids.push(fallback_document_id.to_string()); } ids } fn build_local_agent_target_package( payload: &Value, document_id: &str, workspace_id: &str, source_kind: &str, root_uri: Option<&str>, editor_target: &Value, run_target_snapshot: &Value, ) -> Value { let payload_package = payload .get("targetPackage") .or_else(|| payload.get("agentTargetPackage")) .map(sanitize_local_agent_target_package) .filter(|value| !value.is_null()); let target_package = payload_package.unwrap_or_else(|| { derive_local_agent_target_package( document_id, workspace_id, source_kind, root_uri, editor_target, run_target_snapshot, ) }); let allowed_files = local_agent_target_allowed_files(&target_package); let mut next = target_package.as_object().cloned().unwrap_or_default(); normalize_local_target_resource_kind(&mut next); next.insert( "allowedFiles".to_string(), Value::Array(allowed_files.into_iter().map(Value::String).collect()), ); Value::Object(next) } fn normalize_local_target_resource_kind(target: &mut serde_json::Map) { let fallback_identity = target .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); normalize_local_target_object_identity(target, fallback_identity.as_deref()); if matches!( target.get("resourceKind").and_then(Value::as_str), Some("page") | None ) { target.insert( "resourceKind".to_string(), Value::String("markdown_page".to_string()), ); } for key in ["workspacePath", "currentFile"] { if let Some(Value::Object(object)) = target.get_mut(key) { normalize_local_target_object_identity(object, fallback_identity.as_deref()); if matches!( object.get("resourceKind").and_then(Value::as_str), Some("page") | None ) { object.insert( "resourceKind".to_string(), Value::String("markdown_page".to_string()), ); } } } } fn normalize_local_target_object_identity( target: &mut serde_json::Map, fallback_identity: Option<&str>, ) { let current = target .get("objectIdentity") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if (current.is_empty() || current == "page:primary") && fallback_identity.is_some() { target.insert( "objectIdentity".to_string(), Value::String(fallback_identity.unwrap().to_string()), ); } } fn sanitize_local_agent_target_package(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "schema", "source", "frozenAt", "primaryTargetId", "workspaceId", "sourceKind", "rootUri", "documentId", "objectIdentity", "resourceKind", "assetId", "onlyofficeSessionId", "bridgeSessionId", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if let Some(workspace_path) = source.get("workspacePath") { let sanitized_path = sanitize_workspace_path(workspace_path); if !sanitized_path.is_null() { sanitized.insert("workspacePath".to_string(), sanitized_path); } } if let Some(current_file) = source.get("currentFile").and_then(Value::as_object) { let mut file = serde_json::Map::new(); for key in [ "rootUri", "relativePath", "documentId", "objectIdentity", "resourceKind", "assetId", "onlyofficeSessionId", "bridgeSessionId", ] { if let Some(value) = current_file.get(key) { file.insert(key.to_string(), value.clone()); } } if !file.is_empty() { sanitized.insert("currentFile".to_string(), Value::Object(file)); } } if let Some(files) = source.get("allowedFiles").and_then(Value::as_array) { let allowed = files .iter() .filter_map(Value::as_str) .filter_map(sanitize_relative_path) .map(Value::String) .collect::>(); sanitized.insert("allowedFiles".to_string(), Value::Array(allowed)); } if let Some(targets) = source.get("targets").and_then(Value::as_array) { let sanitized_targets = targets .iter() .map(sanitize_local_agent_target_entry) .filter(|value| !value.is_null()) .collect::>(); if !sanitized_targets.is_empty() { sanitized.insert("targets".to_string(), Value::Array(sanitized_targets)); } } if sanitized.is_empty() { Value::Null } else { sanitized .entry("schema".to_string()) .or_insert_with(|| Value::String("mnote.agent_target_package.v1".to_string())); Value::Object(sanitized) } } fn sanitize_local_agent_target_entry(value: &Value) -> Value { let Some(source) = value.as_object() else { return Value::Null; }; let mut sanitized = serde_json::Map::new(); for key in [ "targetId", "objectIdentity", "documentId", "workspaceId", "sourceKind", "rootUri", "relativePath", "resourceKind", "assetId", "paneRole", "title", "onlyofficeSessionId", "bridgeSessionId", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if let Some(workspace_path) = source.get("workspacePath") { let sanitized_path = sanitize_workspace_path(workspace_path); if !sanitized_path.is_null() { sanitized.insert("workspacePath".to_string(), sanitized_path); } } if sanitized.is_empty() { Value::Null } else { Value::Object(sanitized) } } fn derive_local_agent_target_package( document_id: &str, workspace_id: &str, source_kind: &str, root_uri: Option<&str>, editor_target: &Value, run_target_snapshot: &Value, ) -> Value { let target = if !editor_target.is_null() { editor_target } else { run_target_snapshot .get("editorTarget") .unwrap_or(&Value::Null) }; let workspace_path = target_workspace_path(target) .map(sanitize_workspace_path) .filter(|value| !value.is_null()) .unwrap_or_else(|| { let relative_path = local_resource_id_to_relative_path(document_id).unwrap_or_default(); json!({ "schema": "mnote.workspace_path.v1", "workspaceId": workspace_id, "sourceKind": source_kind, "rootUri": root_uri.map(Value::from).unwrap_or(Value::Null), "relativePath": relative_path, "documentId": document_id, "objectIdentity": document_id, "assetId": "", "resourceKind": "markdown_page" }) }); let relative_path = target_relative_path(&workspace_path) .or_else(|| target_relative_path(target)) .or_else(|| local_resource_id_to_relative_path(document_id)); let object_identity = workspace_path .get("objectIdentity") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(document_id) .to_string(); json!({ "schema": "mnote.agent_target_package.v1", "source": "server_derived", "workspaceId": workspace_id, "sourceKind": source_kind, "rootUri": root_uri.map(Value::from).unwrap_or(Value::Null), "documentId": document_id, "objectIdentity": object_identity, "resourceKind": workspace_path.get("resourceKind").cloned().unwrap_or_else(|| Value::String("markdown_page".to_string())), "workspacePath": workspace_path, "currentFile": relative_path.as_ref().map(|relative_path| json!({ "rootUri": root_uri.unwrap_or_default(), "relativePath": relative_path, "documentId": document_id, "objectIdentity": object_identity, "resourceKind": "markdown_page" })).unwrap_or(Value::Null), "allowedFiles": relative_path.into_iter().collect::>() }) } fn sanitize_payload_allowed_roots(value: Option<&Value>) -> Value { let roots = value .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let source = item.as_object()?; let mut sanitized = serde_json::Map::new(); for key in [ "rootUri", "permission", "recursive", "source", "grantId", "grantIds", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); } } if sanitized.is_empty() { None } else { Some(Value::Object(sanitized)) } }) .collect::>() }) .unwrap_or_default(); Value::Array(roots) } fn build_agent_run_allowed_roots( payload: &Value, root_uri: Option<&str>, is_local_source: bool, ) -> Value { if is_local_source { let Some(root_uri) = root_uri else { return Value::Array(Vec::new()); }; let permission_level = local_ai_payload_permission_level(payload); return json!([{ "rootUri": root_uri, "permission": if matches!(permission_level, "read_only" | "shared_read") { "read" } else { "write" }, "recursive": true, "source": if matches!(permission_level, "shared_read" | "shared_write") { "share_grant" } else { "local_ai_access_scope" }, "permissionLevel": permission_level }]); } sanitize_payload_allowed_roots(payload.get("allowedRoots")) } fn build_agent_run_primary_target( document_id: &str, workspace_id: &str, source_kind: &str, root_uri: Option<&str>, editor_target: &Value, ) -> Value { if !editor_target.is_null() { return editor_target.clone(); } json!({ "documentId": document_id, "workspaceId": workspace_id, "sourceKind": source_kind, "rootUri": root_uri.map(Value::from).unwrap_or(Value::Null) }) } #[allow(clippy::too_many_arguments)] fn build_agent_run_envelope( payload: &Value, document_id: &str, workspace_id: &str, source_kind: &str, root_uri: Option<&str>, profile: &str, actor_id: &str, actor_type: &str, session_id: &str, run_id: &str, trace_id: &str, editor_target: &Value, run_target_snapshot: &Value, agent_target_package: Option<&Value>, is_local_source: bool, ) -> Value { let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(profile); let acp_runtime = payload .get("acpRuntime") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(profile); let target_package = agent_target_package.cloned().unwrap_or(Value::Null); json!({ "schema": "mnote.agent_run_envelope.v1", "agentId": agent_id, "acpRuntime": acp_runtime, "profile": profile, "actorId": actor_id, "actorType": actor_type, "sessionId": session_id, "runId": run_id, "traceId": trace_id, "workspaceId": workspace_id, "documentId": document_id, "sourceKind": source_kind, "rootUri": root_uri.map(Value::from).unwrap_or(Value::Null), "contextRefs": sanitize_context_refs(payload.get("contextRefs")), "allowedRoots": build_agent_run_allowed_roots(payload, root_uri, is_local_source), "allowedFiles": target_package .get("allowedFiles") .cloned() .unwrap_or_else(|| Value::Array(Vec::new())), "targetPackage": target_package, "primaryTarget": build_agent_run_primary_target( document_id, workspace_id, source_kind, root_uri, editor_target, ), "runTargetSnapshot": run_target_snapshot, "resultPolicy": { "receiptSchema": "mnote.agent_run_receipt.v1", "changedFiles": if is_local_source { "required" } else { "best_effort" }, "refresh": "watcher_or_explicit_resync", "agentLoop": "owned_by_selected_agent" } }) } fn local_agent_audit_touches_current_file(payload: &Value, changed_files: &Value) -> bool { let current_relative_path = payload .get("documentId") .and_then(Value::as_str) .and_then(local_resource_id_to_relative_path); let Some(current_relative_path) = current_relative_path else { return false; }; changed_files .as_array() .map(|files| { files.iter().any(|file| { file.get("path") .and_then(Value::as_str) .map(|path| path == current_relative_path) .unwrap_or(false) }) }) .unwrap_or(false) } fn build_agent_run_receipt( payload: &Value, run_id: &str, acp_runtime: &str, status: &str, permission: &str, changed_files: Value, audit_scope: Value, write_attempt_rejected: bool, ) -> Value { let touches_current_file = local_agent_audit_touches_current_file(payload, &changed_files); json!({ "schema": "mnote.agent_run_receipt.v1", "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": payload.get("rootUri").cloned().unwrap_or(Value::Null), "agentKind": acp_runtime, "status": if write_attempt_rejected { "read_only_write_rejected" } else { status }, "permission": permission, "writeAttemptRejected": write_attempt_rejected, "changedFiles": changed_files, "auditScope": audit_scope, "refresh": { "touchesCurrentFile": touches_current_file, "currentDocumentId": payload.get("documentId").cloned().unwrap_or(Value::Null), "strategy": if touches_current_file { "refresh_current_file" } else { "refresh_changed_files" } } }) } 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 api_chat_profile_for_payload( payload: &Value, profile: &str, ) -> Option { if !crate::api_chat::payload_uses_api_chat_profile(payload, profile) { return None; } [ Some(profile), payload.get("profile").and_then(Value::as_str), payload.get("profileId").and_then(Value::as_str), payload.get("profile_id").and_then(Value::as_str), payload .get("agentProfileRef") .and_then(|value| value.get("profileId")) .and_then(Value::as_str), payload .get("agentProfileRef") .and_then(|value| value.get("isolatedProfile")) .and_then(Value::as_str), payload .get("agentProfileRef") .and_then(|value| value.get("baseProfile")) .and_then(Value::as_str), ] .into_iter() .flatten() .find_map(|candidate| crate::api_chat::resolve_api_chat_profile(candidate).ok()) } fn register_api_chat_runtime( registration: &HermesRunRegistration, run_id: &str, profile: &crate::api_chat::ResolvedApiChatProfile, ) -> 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: "api_chat_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 mut json = runtime_state_to_json(&state); json["transport"] = Value::String("api-chat".into()); json["providerKind"] = Value::String(profile.provider_kind.clone()); json["model"] = Value::String(profile.model.clone()); json["baseUrl"] = Value::String(profile.base_url.clone()); 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()) } #[derive(Debug, Clone, Copy)] struct ChatonlyProviderConfig { provider: &'static str, schema: &'static str, profiles: &'static [&'static str], base_profiles: &'static [&'static str], isolated_profiles: &'static [&'static str], profile_ids: &'static [&'static str], delete_url_env: &'static str, lookup_url_env: &'static str, default_delete_url: &'static str, default_lookup_url: &'static str, } const CHATONLY_PROVIDER_CONFIGS: &[ChatonlyProviderConfig] = &[ ChatonlyProviderConfig { provider: "doubao-web", schema: "mnote.provider_chat_context.v1", profiles: &["openclaw-doubao-chat", "doubao-chat"], base_profiles: &["doubao-chat"], isolated_profiles: &["openclaw-doubao-chat"], profile_ids: &["shared_doubao_chat"], delete_url_env: ENV_DOUBAO_CONVERSATION_DELETE_URL, lookup_url_env: ENV_DOUBAO_CONVERSATION_LOOKUP_URL, default_delete_url: DEFAULT_DOUBAO_CONVERSATION_DELETE_URL, default_lookup_url: DEFAULT_DOUBAO_CONVERSATION_LOOKUP_URL, }, ChatonlyProviderConfig { provider: "deepseek-web", schema: "mnote.provider_chat_context.v1", profiles: &["openclaw-deepseek-chat", "deepseek-chat"], base_profiles: &["deepseek-chat"], isolated_profiles: &["openclaw-deepseek-chat"], profile_ids: &["shared_deepseek_chat"], delete_url_env: ENV_DEEPSEEK_CONVERSATION_DELETE_URL, lookup_url_env: ENV_DEEPSEEK_CONVERSATION_LOOKUP_URL, default_delete_url: DEFAULT_DEEPSEEK_CONVERSATION_DELETE_URL, default_lookup_url: DEFAULT_DEEPSEEK_CONVERSATION_LOOKUP_URL, }, ChatonlyProviderConfig { provider: "gemini-web", schema: "mnote.provider_chat_context.v1", profiles: &["openclaw-gemini-chat", "gemini-chat"], base_profiles: &["gemini-chat"], isolated_profiles: &["openclaw-gemini-chat"], profile_ids: &["shared_gemini_chat"], delete_url_env: ENV_GEMINI_CONVERSATION_DELETE_URL, lookup_url_env: ENV_GEMINI_CONVERSATION_LOOKUP_URL, default_delete_url: DEFAULT_GEMINI_CONVERSATION_DELETE_URL, default_lookup_url: DEFAULT_GEMINI_CONVERSATION_LOOKUP_URL, }, ]; fn chatonly_provider_by_name(provider: &str) -> Option<&'static ChatonlyProviderConfig> { let provider = provider.trim(); CHATONLY_PROVIDER_CONFIGS .iter() .find(|config| config.provider == provider) } fn chatonly_profile_matches(value: Option<&str>, candidates: &[&str]) -> bool { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return false; }; candidates.iter().any(|candidate| *candidate == value) } fn chatonly_provider_for_run( payload: &Value, registration: &HermesRunRegistration, ) -> Option<&'static ChatonlyProviderConfig> { let agent_id = payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if agent_id != "chat_only" { return None; } let profile = registration.profile.trim(); let payload_profile = payload.get("profile").and_then(Value::as_str); let agent_profile = payload.get("agentProfileRef"); let base_profile = agent_profile .and_then(|value| value.get("baseProfile")) .and_then(Value::as_str); let isolated_profile = agent_profile .and_then(|value| value.get("isolatedProfile")) .and_then(Value::as_str); let profile_id = payload.get("profileId").and_then(Value::as_str); CHATONLY_PROVIDER_CONFIGS.iter().find(|config| { chatonly_profile_matches(Some(profile), config.profiles) || chatonly_profile_matches(payload_profile, config.profiles) || chatonly_profile_matches(base_profile, config.base_profiles) || chatonly_profile_matches(isolated_profile, config.isolated_profiles) || chatonly_profile_matches(profile_id, config.profile_ids) }) } fn provider_default_remote_url(provider: &str, remote_conversation_id: &str) -> Option { let id = remote_conversation_id.trim(); if id.is_empty() { return None; } if id.starts_with("http://") || id.starts_with("https://") { return Some(id.to_string()); } match provider { "doubao-web" => Some(format!("https://www.doubao.com/chat/{id}")), "deepseek-web" => Some(format!("https://chat.deepseek.com/a/chat/s/{id}")), "gemini-web" => Some(format!("https://gemini.google.com/app/{id}")), _ => None, } } fn chatonly_provider_prompt_text( input: &str, payload: &Value, registration: &HermesRunRegistration, mnote_session_id: &str, run_id: &str, ) -> String { let Some(config) = chatonly_provider_for_run(payload, registration) else { return input.to_string(); }; let metadata = json!({ "schema": config.schema, "provider": config.provider, "mnoteSessionId": mnote_session_id, "mnoteRunId": run_id, "actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or_default(), "workspaceId": payload.get("workspaceId").and_then(Value::as_str).unwrap_or_default(), "providerConversation": payload.get("providerConversation").cloned().unwrap_or(Value::Null) }); format!( "Conversation info (untrusted metadata):\n```json\n{}\n```\n\n{}", json_string(&metadata), input ) } fn payload_has_remote_provider_conversation(payload: &Value) -> bool { payload .get("providerConversation") .and_then(|value| value.get("remoteConversationId")) .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()) } fn inject_provider_conversation_binding_for_run( store: &dyn control_plane::ControlPlaneStore, context: &RequestContext, registration: &HermesRunRegistration, payload: &mut Value, ) -> Result<(), WebError> { let Some(config) = chatonly_provider_for_run(payload, registration) else { return Ok(()); }; if payload_has_remote_provider_conversation(payload) { return Ok(()); } let user_id = runtime_store_user_id(context, payload); let workspace_id = payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or(context.workspace.workspace_id.as_deref()); let Some(binding) = store .find_ai_external_conversation_binding( &user_id, workspace_id, ®istration.session_id, config.provider, ) .map_err(|error| { WebError::internal(format!("SQLite provider 会话绑定读取失败: {error}")) .with_context(context) })? else { return Ok(()); }; if binding.status != "active" || binding.remote_conversation_id.trim().is_empty() { return Ok(()); } payload["providerConversation"] = json!({ "provider": binding.provider, "remoteConversationId": binding.remote_conversation_id, "remoteUrl": binding.remote_url }); Ok(()) } fn persist_provider_conversation_event( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, event_type: &str, event_payload: &Value, run_payload: &Value, ) -> Result<(), WebError> { if event_type != "provider.conversation.bound" { return Ok(()); } let payload = event_payload.get("data").unwrap_or(event_payload); let provider = payload .get("provider") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| { chatonly_provider_for_run(run_payload, registration).map(|config| config.provider) }) .unwrap_or("doubao-web"); if chatonly_provider_by_name(provider).is_none() { return Ok(()); } let Some(remote_conversation_id) = payload .get("remoteConversationId") .or_else(|| payload.get("remote_conversation_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; let user_id = runtime_store_user_id(context, run_payload); 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 remote_url = payload .get("remoteUrl") .or_else(|| payload.get("remote_url")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| provider_default_remote_url(provider, remote_conversation_id)); let acp_session_id = payload .get("acpSessionId") .or_else(|| payload.get("acp_session_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let agent_id = run_payload .get("agentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("chat_only") .to_string(); state .control_plane() .upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput { id: None, user_id, workspace_id, mnote_session_id: registration.session_id.clone(), acp_session_id, agent_id, profile: registration.profile.clone(), provider: provider.to_string(), remote_conversation_id: remote_conversation_id.to_string(), remote_url, status: "active".to_string(), metadata_json: json_string(&json!({ "source": event_type, "payload": event_payload })), }) .map(|_| ()) .map_err(|error| { WebError::internal(format!( "SQLite provider conversation 绑定写入失败: {error}" )) .with_context(context) }) } fn mark_provider_conversation_local_deleted_for_session( store: &dyn control_plane::ControlPlaneStore, user_id: &str, workspace_id: Option<&str>, session_id: &str, ) -> Result { let metadata = json_string(&json!({ "reason": "mnote_session_deleted", "updatedAt": now_ms() })); let mut changed = 0; for config in CHATONLY_PROVIDER_CONFIGS { changed += store.mark_ai_external_conversation_binding_status( user_id, workspace_id, session_id, config.provider, "local_deleted", Some(&metadata), )?; } Ok(changed) } fn find_provider_conversation_binding_for_session( store: &dyn control_plane::ControlPlaneStore, user_id: &str, workspace_id: Option<&str>, session_id: &str, ) -> Result, control_plane::ControlPlaneError> { for config in CHATONLY_PROVIDER_CONFIGS { let binding = store.find_ai_external_conversation_binding( user_id, workspace_id, session_id, config.provider, )?; if binding.is_some() { return Ok(binding); } } Ok(None) } fn provider_conversation_delete_url( config: &ChatonlyProviderConfig, conversation_id: &str, ) -> String { let template = env_or_dotenv(config.delete_url_env) .unwrap_or_else(|| config.default_delete_url.to_string()); if template.contains("{conversationId}") { return template.replace("{conversationId}", conversation_id); } format!( "{}/{}", template.trim_end_matches('/'), conversation_id.trim_start_matches('/') ) } fn provider_conversation_lookup_url(config: &ChatonlyProviderConfig, session_id: &str) -> String { let template = env_or_dotenv(config.lookup_url_env) .unwrap_or_else(|| config.default_lookup_url.to_string()); if template.contains("{sessionId}") { return template.replace("{sessionId}", session_id); } format!( "{}/{}", template.trim_end_matches('/'), session_id.trim_start_matches('/') ) } async fn request_provider_conversation_lookup( config: &ChatonlyProviderConfig, session_id: &str, ) -> Result, WebError> { if session_id.trim().is_empty() { return Ok(None); } let url = provider_conversation_lookup_url(config, session_id); let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|error| { WebError::internal(format!("Provider 会话绑定查询 client 创建失败: {error}")) })?; let response = client.get(&url).send().await.map_err(|error| { WebError::bad_gateway_code( "provider_conversation_lookup_failed", format!("Provider 会话绑定查询失败: {error}"), ) })?; if response.status() == StatusCode::NOT_FOUND { return Ok(None); } if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(WebError::bad_gateway_code( "provider_conversation_lookup_http_failed", format!( "Provider 会话绑定查询返回 HTTP {}: {}", status.as_u16(), body ), )); } let payload = response.json::().await.map_err(|error| { WebError::bad_gateway_code( "provider_conversation_lookup_json_failed", format!("Provider 会话绑定查询 JSON 解析失败: {error}"), ) })?; let has_remote_id = payload .get("remoteConversationId") .or_else(|| payload.get("remote_conversation_id")) .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()); Ok(has_remote_id.then_some(payload)) } async fn persist_provider_conversation_from_proxy( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_id: &str, acp_runtime: &str, acp_session_id: &str, run_payload: &Value, ) -> Result<(), WebError> { let Some(config) = chatonly_provider_for_run(run_payload, registration) else { return Ok(()); }; let Some(mut provider_payload) = request_provider_conversation_lookup(config, ®istration.session_id).await? else { return Ok(()); }; if provider_payload.get("provider").is_none() { provider_payload["provider"] = Value::String(config.provider.to_string()); } if provider_payload.get("acpSessionId").is_none() && !acp_session_id.trim().is_empty() { provider_payload["acpSessionId"] = Value::String(acp_session_id.to_string()); } persist_acp_runtime_event( state, context, registration, run_id, acp_runtime, "provider.conversation.bound", &provider_payload, run_payload, ) .await .map(|_| ()) } async fn request_provider_conversation_delete( binding: &AiExternalConversationBindingRecord, ) -> Value { let Some(config) = chatonly_provider_by_name(&binding.provider) else { return json!({ "attempted": false, "status": "remote_delete_failed", "provider": binding.provider, "remoteConversationId": binding.remote_conversation_id, "error": "不支持的 provider" }); }; let remote_conversation_id = binding.remote_conversation_id.trim(); if remote_conversation_id.is_empty() { return json!({ "attempted": false, "status": "remote_delete_failed", "provider": binding.provider, "error": "remoteConversationId 为空" }); } if binding.status != "active" { return json!({ "attempted": false, "status": binding.status, "provider": binding.provider, "remoteConversationId": remote_conversation_id }); } let url = provider_conversation_delete_url(config, remote_conversation_id); let client = match reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() { Ok(client) => client, Err(error) => { return json!({ "attempted": false, "status": "remote_delete_failed", "provider": binding.provider, "remoteConversationId": remote_conversation_id, "error": error.to_string() }); } }; match client .post(&url) .json(&json!({ "provider": binding.provider, "remoteConversationId": remote_conversation_id })) .send() .await { Ok(response) if response.status().is_success() => { let http_status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); let response_json = serde_json::from_str::(&body).unwrap_or_else(|_| { json!({ "raw": body }) }); json!({ "attempted": true, "status": "remote_deleted", "provider": binding.provider, "remoteConversationId": remote_conversation_id, "httpStatus": http_status, "url": url, "response": response_json }) } Ok(response) => { let status = response.status(); let body = response.text().await.unwrap_or_default(); json!({ "attempted": true, "status": "remote_delete_failed", "provider": binding.provider, "remoteConversationId": remote_conversation_id, "httpStatus": status.as_u16(), "url": url, "error": body }) } Err(error) => json!({ "attempted": true, "status": "remote_delete_failed", "provider": binding.provider, "remoteConversationId": remote_conversation_id, "url": url, "error": error.to_string() }), } } async fn delete_provider_conversation_for_session( store: &dyn control_plane::ControlPlaneStore, user_id: &str, workspace_id: Option<&str>, session_id: &str, binding: Option<&AiExternalConversationBindingRecord>, ) -> Result { let Some(binding) = binding else { return Ok(Value::Null); }; let result = request_provider_conversation_delete(binding).await; let status = result .get("status") .and_then(Value::as_str) .unwrap_or("remote_delete_failed"); if matches!(status, "remote_deleted" | "remote_delete_failed") { store.mark_ai_external_conversation_binding_status( user_id, workspace_id, session_id, &binding.provider, status, Some(&json_string(&json!({ "reason": "mnote_session_deleted", "providerDelete": result, "updatedAt": now_ms() }))), )?; } Ok(result) } 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); let provider_kind = ai_runtime_value_provider_kind(&runtime) .or_else(|| ai_runtime_value_provider_kind(&payload)); 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, "providerKind": provider_kind.unwrap_or(Value::Null), "runtime": runtime, "payload": payload, "createdAt": record.created_at, "updatedAt": record.updated_at, "persistence": ACP_RUNTIME_SQLITE_STORE, }) } fn ai_runtime_run_provider_kind(record: &control_plane::AiRuntimeRunRecord) -> Option { let runtime = serde_json::from_str::(&record.runtime_json).ok(); let payload = serde_json::from_str::(&record.payload_json).ok(); runtime .as_ref() .and_then(ai_runtime_value_provider_kind) .or_else(|| payload.as_ref().and_then(ai_runtime_value_provider_kind)) } fn ai_runtime_value_provider_kind(value: &Value) -> Option { value .get("providerKind") .and_then(Value::as_str) .map(str::trim) .filter(|provider| !provider.is_empty()) .map(|provider| Value::String(provider.to_string())) } fn ai_runtime_session_messages_from_runs( state: &AppState, context: &RequestContext, user_id: &str, runs: &[control_plane::AiRuntimeRunRecord], ) -> Result, WebError> { let mut messages = Vec::new(); for run in runs.iter().rev() { if run.status == "session.created" { continue; } if let Ok(payload) = serde_json::from_str::(&run.payload_json) { if let Some(user_message) = payload .get("message") .or_else(|| payload.get("input")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { messages.push(json!({"role": "user", "content": user_message})); } } let events = state .control_plane() .list_ai_runtime_events(user_id, &run.run_id, 200) .map_err(|error| { WebError::internal(format!("SQLite runtime session 消息事件读取失败: {error}")) .with_context(context) })?; let mut assistant = String::new(); let mut completed_output = None; for event in events { if let Ok(payload) = serde_json::from_str::(&event.payload_json) { match event.event_type.as_str() { "message.delta" => { if let Some(delta) = payload.get("delta").and_then(Value::as_str) { assistant.push_str(delta); } } "run.completed" => { completed_output = payload .get("output") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); } _ => {} } } } let assistant_content = if assistant.trim().is_empty() { completed_output.unwrap_or_default() } else { assistant }; if !assistant_content.trim().is_empty() { messages.push(json!({"role": "assistant", "content": assistant_content})); } } Ok(messages) } fn reasonix_cold_resume_context_packet( state: &AppState, context: &RequestContext, registration: &HermesRunRegistration, run_payload: &Value, current_run_id: &str, ) -> Result, WebError> { let user_id = runtime_store_user_id(context, run_payload); let workspace_id = run_payload .get("workspaceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| context.workspace.workspace_id.as_deref()); let document_id = run_payload .get("documentId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let runs = state .control_plane() .list_ai_runtime_runs( &user_id, workspace_id, document_id, Some(®istration.session_id), 8, ) .map_err(|error| { WebError::internal(format!("SQLite Reasonix cold resume 历史读取失败: {error}")) .with_context(context) })? .into_iter() .filter(|run| run.run_id != current_run_id) .take(5) .collect::>(); if runs.is_empty() { return Ok(None); } let messages = ai_runtime_session_messages_from_runs(state, context, &user_id, &runs)?; if messages.is_empty() { return Ok(None); } let mut lines = vec![ "MNote cold resume context packet".to_string(), "Reasonix native live session was not available; use this bounded MNote transcript to resolve references. Do not treat this as hidden user input.".to_string(), ]; for message in messages .into_iter() .rev() .take(10) .collect::>() .into_iter() .rev() { let role = message .get("role") .and_then(Value::as_str) .unwrap_or("message"); let content = message .get("content") .and_then(Value::as_str) .unwrap_or_default() .trim(); if content.is_empty() { continue; } let bounded = if content.chars().count() > 1_200 { content.chars().take(1_200).collect::() + "..." } else { content.to_string() }; lines.push(format!("[{role}] {bounded}")); } Ok(Some(lines.join("\n"))) } 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 page_ai_session_export_markdown(detail: &Value) -> String { let session_id = detail .get("sessionId") .and_then(Value::as_str) .or_else(|| detail.pointer("/session/sessionId").and_then(Value::as_str)) .unwrap_or("unknown"); let persistence = detail .get("persistence") .and_then(Value::as_str) .unwrap_or("unknown"); let mut lines = vec![ format!("# Page AI Session {session_id}"), String::new(), format!("- persistence: `{persistence}`"), ]; lines.push(String::new()); lines.push("## Messages".into()); let messages = detail .pointer("/session/messages") .and_then(Value::as_array) .cloned() .unwrap_or_default(); if messages.is_empty() { lines.push(String::new()); lines.push("_No projected messages._".into()); } else { for message in messages { let role = message .get("role") .and_then(Value::as_str) .unwrap_or("message"); let content = message .get("content") .and_then(Value::as_str) .unwrap_or_default() .trim(); if content.is_empty() { continue; } lines.push(String::new()); lines.push(format!("### {role}")); lines.push(content.to_string()); } } lines.push(String::new()); lines.push("## Runs".into()); for run in detail .pointer("/session/runs") .and_then(Value::as_array) .into_iter() .flatten() { let run_id = run .get("runId") .or_else(|| run.get("run_id")) .and_then(Value::as_str) .unwrap_or("unknown"); let status = run .get("status") .and_then(Value::as_str) .unwrap_or("unknown"); let runtime = run .get("acpRuntime") .or_else(|| run.get("acp_runtime")) .and_then(Value::as_str) .unwrap_or("unknown"); lines.push(format!("- `{run_id}`: {runtime} / {status}")); } lines.join("\n") } fn page_ai_session_export_jsonl(detail: &Value) -> String { let events = detail .get("events") .and_then(Value::as_array) .cloned() .unwrap_or_default(); if !events.is_empty() { return events .into_iter() .filter_map(|event| serde_json::to_string(&event).ok()) .collect::>() .join("\n"); } detail .pointer("/session/messages") .and_then(Value::as_array) .cloned() .unwrap_or_default() .into_iter() .filter_map(|message| serde_json::to_string(&message).ok()) .collect::>() .join("\n") } fn page_ai_run_to_journal_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!({ "schema": "mnote.ai_run.v1", "hostRunId": record.run_id, "requestId": payload .get("requestId") .or_else(|| payload.get("request_id")) .cloned() .unwrap_or(Value::Null), "agentId": payload .get("agentId") .or_else(|| payload.get("agent")) .cloned() .unwrap_or_else(|| Value::String(record.profile.clone())), "provider": page_ai_run_provider(record), "providerRunId": runtime .get("runId") .or_else(|| runtime.get("run_id")) .cloned() .unwrap_or_else(|| Value::String(record.run_id.clone())), "sessionId": record.session_id, "actorId": record.user_id, "workspaceId": record.workspace_id, "documentId": record.document_id, "profile": record.profile, "acpRuntime": record.acp_runtime, "status": record.status, "runtime": runtime, "payload": payload, "createdAt": record.created_at, "updatedAt": record.updated_at, "persistence": ACP_RUNTIME_SQLITE_STORE, }) } fn trimmed_query_value(query: &HashMap, key: &str) -> Option { query .get(key) .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn page_ai_default_acp_runtime_for_status(payload: &Value, profile: &str) -> String { if crate::api_chat::payload_uses_api_chat_profile(payload, profile) || crate::api_chat::api_chat_profile_by_id(profile).is_some() { return "api-chat".into(); } payload .get("agentId") .or_else(|| payload.get("agent")) .and_then(Value::as_str) .map(page_ai_default_acp_runtime) .unwrap_or_else(|| { if profile == "reasonix" { "reasonix" } else if profile == "chat_only" { "chat_only" } else { "hermes" } }) .to_string() } fn page_ai_runtime_kind_for_status(payload: &Value, profile: &str, acp_runtime: &str) -> String { if crate::api_chat::payload_uses_api_chat_profile(payload, profile) || crate::api_chat::api_chat_profile_by_id(profile).is_some() || acp_runtime == "api-chat" { return "api-chat".into(); } if acp_runtime == "chat_only" || payload .get("agentId") .or_else(|| payload.get("agent")) .and_then(Value::as_str) .map(|agent| agent == "chat_only") .unwrap_or(false) { return "chat_only".into(); } if acp_runtime == "reasonix" || profile == "reasonix" { "reasonix".into() } else { "hermes".into() } } fn page_ai_runtime_mode_for_status( runtime: &str, _active_run: Option<&control_plane::AiRuntimeRunRecord>, _status_run: Option<&control_plane::AiRuntimeRunRecord>, payload: &Value, acp_session_id: Option<&str>, replay: &Value, reasonix_live_binding: &Value, ) -> &'static str { match runtime { "api-chat" => "server_transcript", "chat_only" => "provider_conversation", "reasonix" => { if let Some(mode) = payload .get("reasonixSessionMode") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) { return match mode { "native_live" => "native_live", "cold_resumed" => "cold_resumed", _ => "new_session", }; } if reasonix_live_binding.is_object() || acp_session_id.is_some() { "native_live" } else { "new_session" } } "hermes" => { if replay .get("replaySeen") .and_then(Value::as_bool) .unwrap_or(false) { "replay_seen" } else if acp_session_id.is_some() || payload.get("acpSessionId").is_some() { "loaded" } else { "new_session" } } _ => "new_session", } } fn page_ai_acp_session_id_for_status( run_id: Option<&str>, session_id: &str, payload: &Value, ) -> Option { if let Some(run_id) = run_id { if let Some(active) = ACP_ACTIVE_RUNS .lock() .expect("acp active runs") .get(run_id) .cloned() { return Some(active.acp_session_id); } } payload .get("acpSessionId") .or_else(|| payload.get("acp_session_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { ACP_LIVE_BINDINGS .lock() .expect("acp live bindings") .iter() .find(|(key, _binding)| key.mnote_session_id == session_id) .map(|(_key, binding)| binding.acp_session_id.clone()) }) } fn page_ai_reasonix_live_binding_for_status(session_id: &str, profile: &str) -> Value { ACP_LIVE_BINDINGS .lock() .expect("acp live bindings") .iter() .find(|(key, _binding)| { key.mnote_session_id == session_id && key.runtime == "reasonix" && (profile.is_empty() || key.profile == profile) }) .map(|(key, binding)| reasonix_live_binding_snapshot(key, binding, "native_live")) .unwrap_or(Value::Null) } fn page_ai_queue_status_for_session(session_id: &str) -> Value { let queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); let items = queue .get(session_id) .map(|items| { items .iter() .map(|item| { json!({ "queueId": item.queue_id, "sessionId": item.session_id, "profile": item.profile, "documentId": item.document_id, "traceId": item.trace_id, "queuedAt": item.queued_at, "preview": item.input.chars().take(80).collect::(), "cancellable": true, }) }) .collect::>() }) .unwrap_or_default(); json!({ "queuedCount": items.len(), "items": items, "cancellable": true, }) } fn page_ai_tools_status_for_profile(profile: &str) -> Value { let tools = mnote_tools_payload(profile); let enabled = tools .iter() .filter(|tool| { tool.get("enabled") .and_then(Value::as_bool) .unwrap_or(false) }) .count(); let disabled = tools.len().saturating_sub(enabled); json!({ "total": tools.len(), "enabled": enabled, "disabled": disabled, "items": tools, }) } fn page_ai_roots_status_for_payload(payload: &Value) -> Value { let roots = payload .get("allowedRoots") .and_then(Value::as_array) .cloned() .unwrap_or_else(|| { payload .get("rootUri") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(|root_uri| vec![json!({ "rootUri": root_uri, "permission": "write" })]) .unwrap_or_default() }); let write = roots .iter() .filter(|root| { !matches!( root.get("permission") .or_else(|| root.get("access")) .and_then(Value::as_str) .unwrap_or("write"), "read" | "readonly" | "read_only" ) }) .count(); let read_only = roots.len().saturating_sub(write); json!({ "write": write, "readOnly": read_only, "total": roots.len(), "items": roots, }) } fn page_ai_model_status_for_profile(profile: &str, runtime: &str) -> Value { if let Ok(api_profile) = crate::api_chat::resolve_api_chat_profile(profile) { return json!({ "profileId": api_profile.profile_id, "providerKind": api_profile.provider_kind, "resolvedModel": api_profile.model, "credentialStatus": if api_profile.api_key.is_some() { "configured" } else { "missing" }, "runtimeBinary": "api-chat", "configSource": "api-chat-profile", }); } let resolved_model = profile_config_value(profile, &["model", "name"]) .or_else(|| profile_config_value(profile, &["MODEL"])) .unwrap_or_default(); json!({ "profileId": profile, "providerKind": runtime, "resolvedModel": if resolved_model.trim().is_empty() { Value::Null } else { Value::String(resolved_model) }, "credentialStatus": "unknown", "runtimeBinary": runtime, "configSource": if profile_config_path(profile).exists() { "profile-file" } else { "runtime-default" }, }) } fn page_ai_usage_status_for_run( run: Option<&control_plane::AiRuntimeRunRecord>, runtime_json: &Value, ) -> Value { let runtime_usage = runtime_json.get("usage").cloned().unwrap_or(Value::Null); json!({ "lastRunTokens": runtime_usage .get("totalTokens") .or_else(|| runtime_usage.get("total_tokens")) .cloned() .unwrap_or(Value::Null), "sessionTokens": Value::Null, "cost": Value::Null, "cache": Value::Null, "source": if run.is_some() { "latest_run" } else { "none" }, "raw": runtime_usage, }) } fn page_ai_replay_status_for_run( state: &AppState, context: &RequestContext, user_id: &str, run: &control_plane::AiRuntimeRunRecord, ) -> Result { let events = state .control_plane() .list_ai_runtime_journal_events(user_id, &run.run_id, 0, 500) .map_err(|error| { WebError::internal(format!("SQLite Page AI replay 状态读取失败: {error}")) .with_context(context) })?; let mut count = 0usize; let mut last_seq = None; for event in events { let payload = serde_json::from_str::(&event.event.payload_json).unwrap_or(Value::Null); let replay = payload .get("replay") .and_then(Value::as_bool) .unwrap_or(false) || payload .get("source") .and_then(Value::as_str) .map(|source| source == "adapter_replay") .unwrap_or(false); if replay { count += 1; last_seq = Some(format_ai_run_seq(event.seq)); } } Ok(json!({ "replaySeen": count > 0, "replaySeq": last_seq, "replayMessageCount": count, })) } fn page_ai_logs_tail_for_run( state: &AppState, context: &RequestContext, user_id: &str, run: Option<&control_plane::AiRuntimeRunRecord>, ) -> Result { let Some(run) = run else { return Ok(json!({ "items": [] })); }; let events = state .control_plane() .list_ai_runtime_journal_events(user_id, &run.run_id, 0, 500) .map_err(|error| { WebError::internal(format!("SQLite Page AI logs tail 读取失败: {error}")) .with_context(context) })?; let mut items = events .into_iter() .filter(|event| { matches!( event.event.event_type.as_str(), "run.failed" | "tool.failed" | "permission.requested" | "permission.denied" | "session.info.updated" ) }) .map(|event| { json!({ "seq": format_ai_run_seq(event.seq), "kind": event.event.event_type, "payload": serde_json::from_str::(&event.event.payload_json).unwrap_or(Value::Null), "createdAt": event.event.created_at, }) }) .collect::>(); if items.len() > 8 { items = items.split_off(items.len() - 8); } Ok(json!({ "items": items })) } fn page_ai_journal_event_to_json(record: &control_plane::AiRuntimeJournalEventRecord) -> Value { let event = &record.event; let payload = serde_json::from_str::(&event.payload_json).unwrap_or(Value::Null); json!({ "schema": "mnote.ai_run_event.v1", "hostRunId": event.run_id, "seq": format_ai_run_seq(record.seq), "kind": event.event_type, "source": page_ai_run_event_source(event), "eventId": event.id, "sessionId": event.session_id, "workspaceId": event.workspace_id, "documentId": event.document_id, "profile": event.profile, "acpRuntime": event.acp_runtime, "payload": payload, "createdAt": event.created_at, "persistence": ACP_RUNTIME_SQLITE_STORE, }) } fn page_ai_synthetic_terminal_event_to_json( run: &control_plane::AiRuntimeRunRecord, kind: &str, seq: i64, ) -> Value { json!({ "schema": "mnote.ai_run_event.v1", "hostRunId": run.run_id.clone(), "seq": format_ai_run_seq(seq), "kind": kind, "source": "mnote", "eventId": format!("synthetic:{}:{}", run.run_id, kind), "sessionId": run.session_id.clone(), "workspaceId": run.workspace_id.clone(), "documentId": run.document_id.clone(), "profile": run.profile.clone(), "acpRuntime": run.acp_runtime.clone(), "payload": { "schema": "mnote.ai_run_terminal_reconciliation.v1", "status": run.status.clone(), "hostRunId": run.run_id.clone(), "reason": "terminal_status_without_terminal_event" }, "createdAt": run.updated_at.clone(), "persistence": ACP_RUNTIME_SQLITE_STORE, "synthetic": true, }) } fn page_ai_run_provider(record: &control_plane::AiRuntimeRunRecord) -> &'static str { match record.acp_runtime.as_str() { "reasonix" => "acp_reasonix", "chat_only" | "api-chat" => "chat_only", _ => "hermes_client", } } fn page_ai_run_event_source(record: &control_plane::AiRuntimeEventRecord) -> &'static str { if record.event_type.starts_with("tool.") { return "tool"; } match record.acp_runtime.as_str() { "reasonix" => "reasonix", "chat_only" | "api-chat" => "chat_only", _ => "hermes", } } fn page_ai_terminal_event_kind(status_or_event_type: &str) -> Option<&'static str> { match status_or_event_type { "completed" | "run.completed" => Some("run.completed"), "failed" | "run.failed" => Some("run.failed"), "cancelled" | "canceled" | "run.cancelled" | "run.canceled" => Some("run.cancelled"), "aborted" | "run.aborted" => Some("run.aborted"), _ => None, } } fn page_ai_terminal_status_for_event(event_type: &str) -> Option<&'static str> { match event_type { "run.completed" | "response.completed" | "completed" => Some("completed"), "run.failed" | "response.failed" | "failed" => Some("failed"), "run.aborted" | "run.cancelled" | "run.canceled" | "abort.completed" | "aborted" => { Some("aborted") } _ => None, } } fn page_ai_run_status_is_active(status: &str) -> bool { matches!( status, "pending" | "running" | "interrupted" | "tool_calling" | "queued" | "acp_pending" ) } fn parse_ai_run_seq(value: &str) -> Option { value .trim() .trim_start_matches('0') .parse::() .ok() .or_else(|| { if value.trim().chars().all(|ch| ch == '0') { Some(0) } else { None } }) } fn format_ai_run_seq(seq: i64) -> String { format!("{:018}", seq.max(0)) } 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_snapshot( root_uri: &str, files: BTreeMap, scope: &str, truncated_reason: Option, elapsed_ms: u128, ) -> LocalAgentAuditSnapshot { let total_bytes = files.values().map(|entry| entry.size).sum(); let file_count = files.len(); LocalAgentAuditSnapshot { root_uri: root_uri.to_string(), files, scope: scope.to_string(), truncated: truncated_reason.is_some(), truncated_reason, file_count, total_bytes, elapsed_ms, } } fn local_agent_audit_empty_snapshot(root_uri: &str, scope: &str) -> LocalAgentAuditSnapshot { local_agent_audit_snapshot(root_uri, BTreeMap::new(), scope, None, 0) } fn local_agent_audit_scope_value(snapshot: Option<&LocalAgentAuditSnapshot>) -> Value { let Some(snapshot) = snapshot else { return Value::Null; }; json!({ "scope": snapshot.scope.clone(), "truncated": snapshot.truncated, "truncatedReason": snapshot.truncated_reason.clone(), "fileCount": snapshot.file_count, "totalBytes": snapshot.total_bytes, "elapsedMs": snapshot.elapsed_ms, "limits": { "maxFiles": LOCAL_AGENT_AUDIT_MAX_FILES, "maxBytes": LOCAL_AGENT_AUDIT_MAX_BYTES, "maxElapsedMs": LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS } }) } fn local_agent_audit_effective_scope_value( source_snapshot: Option<&LocalAgentAuditSnapshot>, target_snapshot: Option<&LocalAgentAuditSnapshot>, ) -> Value { local_agent_audit_scope_value(target_snapshot.or(source_snapshot)) } fn local_agent_audit_collect_snapshot(root_uri: &str) -> Result { let started = Instant::now(); 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 = BTreeMap::new(); let mut truncated_reason = None; let mut stack = vec![canonical_root.clone()]; while let Some(dir) = stack.pop() { if truncated_reason.is_some() { break; } if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS { truncated_reason = Some("max_elapsed_ms".to_string()); break; } 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; } if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES { truncated_reason = Some("max_files".to_string()); break; } let relative_path = path .strip_prefix(&canonical_root) .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); if relative_path.is_empty() { continue; } let snapshot_entry = local_agent_audit_snapshot_entry(&path)?; let next_total = files.values().map(|entry| entry.size).sum::() + snapshot_entry.size; if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES { truncated_reason = Some("max_bytes".to_string()); break; } files.insert(relative_path, snapshot_entry); } } Ok(local_agent_audit_snapshot( root_uri, files, "full_root", truncated_reason, started.elapsed().as_millis(), )) } fn local_agent_audit_context_ref_requires_full_snapshot(ref_value: &Value) -> bool { ref_value.get("kind").and_then(Value::as_str).map(str::trim) == Some("folder") } fn local_agent_audit_push_relative_path(paths: &mut Vec, value: Option<&Value>) { let Some(relative_path) = value .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let normalized = relative_path.replace('\\', "/"); if normalized.starts_with('/') || normalized.split('/').any(|part| part == "..") { return; } paths.push(normalized); } fn local_agent_audit_push_allowed_files(paths: &mut Vec, value: Option<&Value>) { let Some(value) = value else { return; }; for relative_path in local_agent_target_allowed_files(value) { local_agent_audit_push_relative_path(paths, Some(&Value::String(relative_path))); } } fn local_agent_audit_relative_paths_from_payload(payload: &Value) -> Option> { let mut paths = Vec::new(); local_agent_audit_push_allowed_files(&mut paths, payload.get("targetPackage")); local_agent_audit_push_allowed_files( &mut paths, payload .get("pageContext") .and_then(|page_context| page_context.get("aiContext")) .and_then(|ai_context| ai_context.get("agentTargetPackage")), ); local_agent_audit_push_allowed_files( &mut paths, payload .get("agentRunEnvelope") .and_then(|envelope| envelope.get("targetPackage")), ); if let Some(items) = payload.get("allowedFiles").and_then(Value::as_array) { for item in items { local_agent_audit_push_relative_path(&mut paths, Some(item)); } } if !paths.is_empty() { paths.sort(); paths.dedup(); return Some(paths); } if let Some(items) = payload.get("contextRefs").and_then(Value::as_array) { if items .iter() .any(local_agent_audit_context_ref_requires_full_snapshot) { return None; } for item in items { local_agent_audit_push_relative_path(&mut paths, item.get("relativePath")); } } if let Some(relative_path) = payload .get("documentId") .and_then(Value::as_str) .and_then(local_resource_id_to_relative_path) { local_agent_audit_push_relative_path(&mut paths, Some(&Value::String(relative_path))); } for target in [ payload.get("editorTarget"), payload .get("runTargetSnapshot") .and_then(|snapshot| snapshot.get("editorTarget")), ] .into_iter() .flatten() { local_agent_audit_push_relative_path( &mut paths, target .get("workspacePath") .and_then(|workspace_path| workspace_path.get("relativePath")), ); } paths.sort(); paths.dedup(); Some(paths) } fn local_agent_audit_collect_snapshot_for_paths( root_uri: &str, relative_paths: &[String], ) -> Result { let started = Instant::now(); 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 = BTreeMap::new(); let mut truncated_reason = None; for relative_path in relative_paths { if started.elapsed().as_millis() > LOCAL_AGENT_AUDIT_MAX_ELAPSED_MS { truncated_reason = Some("max_elapsed_ms".to_string()); break; } if files.len() >= LOCAL_AGENT_AUDIT_MAX_FILES { truncated_reason = Some("max_files".to_string()); break; } let normalized = relative_path.replace('\\', "/"); if normalized.is_empty() || normalized.starts_with('/') || normalized.split('/').any(|part| part == "..") { continue; } let path = canonical_root.join(&normalized); if local_agent_audit_should_skip(&path) || !path.exists() { continue; } let canonical_path = path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_ai_audit_snapshot_failed", format!("无法读取本地文件快照路径: {error}"), ) })?; if !canonical_path.starts_with(&canonical_root) || !canonical_path.is_file() { continue; } let snapshot_entry = local_agent_audit_snapshot_entry(&canonical_path)?; let next_total = files.values().map(|entry| entry.size).sum::() + snapshot_entry.size; if next_total > LOCAL_AGENT_AUDIT_MAX_BYTES { truncated_reason = Some("max_bytes".to_string()); break; } files.insert(normalized, snapshot_entry); } Ok(local_agent_audit_snapshot( root_uri, files, "allowed_files", truncated_reason, started.elapsed().as_millis(), )) } fn local_agent_audit_collect_snapshot_for_payload( payload: &Value, previous: Option<&LocalAgentAuditSnapshot>, ) -> Result { let root_uri = payload .get("rootUri") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| previous.map(|snapshot| snapshot.root_uri.as_str())) .ok_or_else(|| { WebError::bad_request_code("local_ai_audit_root_required", "缺少本地审计 rootUri") })?; if let Some(previous) = previous { if !previous.files.is_empty() { let paths = previous.files.keys().cloned().collect::>(); return local_agent_audit_collect_snapshot_for_paths(root_uri, &paths); } } match local_agent_audit_relative_paths_from_payload(payload) { Some(paths) if !paths.is_empty() => { local_agent_audit_collect_snapshot_for_paths(root_uri, &paths) } _ => local_agent_audit_collect_snapshot(root_uri), } } 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(); let audit_scope = local_agent_audit_effective_scope_value(source_snapshot, target_snapshot); let agent_run_receipt = build_agent_run_receipt( payload, run_id, acp_runtime, status, permission, changed_files.clone(), audit_scope.clone(), write_attempt_rejected, ); 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, "auditScope": audit_scope, "agentRunReceipt": agent_run_receipt, "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_for_payload(payload, before.as_ref())) .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( &local_agent_audit_empty_snapshot(&after.root_uri, "empty"), after, ), (Some(before), None) => local_agent_audit_change_files( before, &local_agent_audit_empty_snapshot(&before.root_uri, "empty"), ), (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, ); let mut legacy_session_storage: Option = None; let mut legacy_persistence: Option<&'static str> = None; 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::local_folder_source::ensure_local_workspace_write_access_with_state( state, 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))?; legacy_persistence = Some("local_ai_session_jsonl"); legacy_session_storage = Some(if share_id.is_some() { "local_shared".to_string() } else { "local_private".to_string() }); } 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", "legacyPersistence": legacy_persistence, "legacySessionStorage": legacy_session_storage, "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, ); let mut legacy_session_storage: Option = None; let mut legacy_persistence: Option<&'static str> = None; 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::local_folder_source::ensure_local_workspace_write_access_with_state( state, 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))?; legacy_persistence = Some("local_ai_session_jsonl"); legacy_session_storage = Some(if share_id.is_some() { "local_shared".to_string() } else { "local_private".to_string() }); } let user_id = runtime_store_user_id(context, run_payload); let record = state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { id: None, user_id: user_id.clone(), 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) })?; let seq = state .control_plane() .list_ai_runtime_journal_events(&user_id, run_id, 0, 10_000) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime event seq 读取失败: {error}")) .with_context(context) })? .into_iter() .find(|item| item.event.id == record.id) .map(|item| format_ai_run_seq(item.seq)); persist_provider_conversation_event( state, context, registration, event_type, event_payload, run_payload, )?; if let Some(terminal_status) = page_ai_terminal_status_for_event(event_type) { reconcile_ai_runtime_run_terminal_status( state, context, &user_id, run_id, terminal_status, event_type, )?; update_runtime_by_run_id(run_id, terminal_status, Some(event_type), None); } Ok(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, "sessionStorage": "sqlite_control_plane", "legacyPersistence": legacy_persistence, "legacySessionStorage": legacy_session_storage, "sessionId": registration.session_id, "runId": run_id, "seq": seq })) } fn reconcile_ai_runtime_run_terminal_status( state: &AppState, context: &RequestContext, user_id: &str, run_id: &str, terminal_status: &str, reason: &str, ) -> Result, WebError> { let Some(run) = state .control_plane() .find_ai_runtime_run(user_id, run_id) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime run 终态读取失败: {error}")) .with_context(context) })? else { return Ok(None); }; if run.status == terminal_status { return Ok(Some(run)); } let mut runtime_json = serde_json::from_str::(&run.runtime_json).unwrap_or(Value::Null); if !runtime_json.is_object() { runtime_json = json!({}); } if let Value::Object(map) = &mut runtime_json { map.insert("status".into(), Value::String(terminal_status.to_string())); map.insert("lastEvent".into(), Value::String(reason.to_string())); map.insert("lastEventAt".into(), json!(now_ms())); map.insert("terminalReconciled".into(), Value::Bool(true)); } state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: Some(run.id.clone()), user_id: run.user_id.clone(), workspace_id: run.workspace_id.clone(), document_id: run.document_id.clone(), session_id: run.session_id.clone(), run_id: run.run_id.clone(), title: run.title.clone(), profile: run.profile.clone(), acp_runtime: run.acp_runtime.clone(), trace_id: run.trace_id.clone(), status: terminal_status.to_string(), runtime_json: json_string(&runtime_json), payload_json: run.payload_json.clone(), }) .map(Some) .map_err(|error| { WebError::internal(format!("SQLite ACP runtime run 终态补写失败: {error}")) .with_context(context) }) } fn reconcile_ai_runtime_run_from_terminal_events( state: &AppState, context: &RequestContext, user_id: &str, run: control_plane::AiRuntimeRunRecord, ) -> Result { if !page_ai_run_status_is_active(&run.status) { return Ok(run); } let events = state .control_plane() .list_ai_runtime_journal_events(user_id, &run.run_id, 0, 500) .map_err(|error| { WebError::internal(format!("SQLite Page AI active run 终态检查失败: {error}")) .with_context(context) })?; let Some((event_type, terminal_status)) = events.iter().find_map(|event| { page_ai_terminal_status_for_event(&event.event.event_type) .map(|status| (event.event.event_type.as_str(), status)) }) else { return Ok(run); }; reconcile_ai_runtime_run_terminal_status( state, context, user_id, &run.run_id, terminal_status, event_type, )? .ok_or_else(|| { WebError::internal("SQLite Page AI active run 终态补写后记录缺失").with_context(context) }) } 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::{ DirectoryGrantInput, UpsertAiExternalConversationBindingInput, UpsertAiRuntimeRunInput, UpsertUserInput, }; 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 test_request_context_with_user(user_id: &str) -> RequestContext { RequestContext { trace: crate::context::TraceContext { request_id: "req_test".into(), trace_id: "trace_test".into(), method: "POST".into(), path: "/api/hermes/client/runs".into(), }, auth: crate::context::AuthContext { authorization: None, cookie_header: None, actor_id: user_id.to_string(), actor_type: "user".into(), session_id: None, }, workspace: crate::context::WorkspaceContext { workspace_id: Some("ws_1".into()), tenant_id: None, deployment_id: None, project_id: None, }, source: crate::context::SourceContext { channel: "test".into(), client: "mnote-web-test".into(), idempotency_key: None, }, } } 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(); ACP_FINISHED_RUNS.lock().expect("acp finished runs").clear(); } #[tokio::test] async fn page_ai_agent_profiles_are_sqlite_user_scoped() { let response = app() .oneshot( Request::builder() .method("GET") .uri("/api/ai/agent-profiles?agentId=hermes") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .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"); let profiles = payload["profiles"].as_array().expect("profiles"); assert!(profiles.iter().any(|profile| profile["kind"] == "personal" && profile["canManageSkills"] == true && profile["ownerUserId"] == "user_1")); let shared = profiles .iter() .find(|profile| profile["profileId"] == "shared_lite") .expect("shared lite"); assert_eq!(shared["kind"], "shared"); assert_eq!(shared["canRun"], true); assert_eq!(shared["canManageSkills"], false); } #[tokio::test] async fn page_ai_skill_toggle_respects_builtin_user_policy_and_shared_readonly() { let _env_guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-ai-profile-policy-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); fs::create_dir_all(&hermes_home).expect("hermes home"); for skill in [ "global-search", "officecli", "vpn", "writer", "zhihu-search", ] { let skill_dir = hermes_home.join("skills").join(skill); fs::create_dir_all(&skill_dir).expect("skill dir"); fs::write( skill_dir.join("SKILL.md"), format!("---\ndescription: {skill}\n---\n"), ) .expect("skill"); } std::env::set_var("HERMES_HOME", &hermes_home); let app = build_app(test_state()); let disable_builtin = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/skills/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "skillKind": "mnote_builtin", "name": "mnote-current-page", "enabled": false }) .to_string(), )) .expect("request"), ) .await .expect("disable builtin"); assert_eq!(disable_builtin.status(), StatusCode::OK); let mnote_skills = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/skills?runtime=mnote&agentId=hermes") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("mnote skills"); assert_eq!(mnote_skills.status(), StatusCode::OK); let body = to_bytes(mnote_skills.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let current_page = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["skills"].as_array().into_iter().flatten()) .find(|skill| skill["id"] == "mnote-current-page") .expect("current page skill"); assert_eq!(current_page["enabled"], false); assert_eq!(current_page["configScope"], "user_sqlite"); let mindmap_skill = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["skills"].as_array().into_iter().flatten()) .find(|skill| skill["id"] == "mnote-mindmap") .expect("mindmap skill"); assert_eq!(mindmap_skill["enabled"], true); assert!(mindmap_skill["toolNames"] .as_array() .expect("mindmap tool names") .iter() .any(|name| name == "mnote.mindmap.create_from_outline")); let shared_toggle = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/skills/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "profileId": "shared_lite", "skillKind": "hermes_profile", "name": "writer", "enabled": false }) .to_string(), )) .expect("request"), ) .await .expect("shared toggle"); assert_eq!(shared_toggle.status(), StatusCode::FORBIDDEN); let profiles = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/ai/agent-profiles?agentId=hermes") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("profiles"); let body = to_bytes(profiles.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("profiles json"); let personal_id = payload["profiles"] .as_array() .expect("profiles") .iter() .find(|profile| profile["kind"] == "personal") .and_then(|profile| profile["profileId"].as_str()) .expect("personal profile") .to_string(); for legacy_profile in ["mnoteai", "%E6%88%91%E7%9A%84%20Hermes"] { let legacy_skills = app .clone() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/hermes/client/skills?runtime=hermes&profileId={}", legacy_profile )) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("legacy skills"); assert_eq!(legacy_skills.status(), StatusCode::OK); } let personal_skills = app .clone() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/hermes/client/skills?runtime=hermes&profileId={personal_id}" )) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("personal skills"); assert_eq!(personal_skills.status(), StatusCode::OK); let body = to_bytes(personal_skills.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("personal 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["enabled"].as_bool().unwrap_or(false), ) }) .collect::>(); assert_eq!(skills.get("global-search"), Some(&true)); assert_eq!(skills.get("vpn"), Some(&true)); assert_eq!(skills.get("zhihu-search"), Some(&true)); assert_eq!(skills.get("officecli"), None); assert_eq!(skills.get("writer"), None); assert_eq!(skills.len(), 3); let personal_config = fs::read_to_string( hermes_home .join("profiles") .join("mnote-u-user-1-default") .join("config.yaml"), ) .expect("personal config"); assert!(personal_config.contains(MNOTE_PERSONAL_SKILL_BASELINE_MARKER)); assert!( !personal_config.contains("writer"), "初始模板范围不应写入 skills.disabled" ); assert!( !personal_config.contains("officecli"), "初始模板范围不应写入 skills.disabled" ); let personal_skill_dir = hermes_home .join("profiles") .join("mnote-u-user-1-default") .join("skills"); assert!(personal_skill_dir .join("global-search") .join("SKILL.md") .exists()); assert!(personal_skill_dir.join("vpn").join("SKILL.md").exists()); assert!(personal_skill_dir .join("zhihu-search") .join("SKILL.md") .exists()); assert!(!personal_skill_dir.join("writer").join("SKILL.md").exists()); fs::create_dir_all(personal_skill_dir.join("writer")).expect("writer dir"); fs::write( personal_skill_dir.join("writer").join("SKILL.md"), "---\ndescription: writer\n---\n", ) .expect("profile writer skill"); let hidden_enable = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/skills/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "profileId": personal_id, "skillKind": "hermes_profile", "name": "writer", "enabled": true }) .to_string(), )) .expect("request"), ) .await .expect("hidden skill enable"); assert_eq!(hidden_enable.status(), StatusCode::OK); let customized_skills = app .oneshot( Request::builder() .method("GET") .uri(format!( "/api/hermes/client/skills?runtime=hermes&profileId={personal_id}" )) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("customized skills"); assert_eq!(customized_skills.status(), StatusCode::OK); let body = to_bytes(customized_skills.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("customized 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["enabled"].as_bool().unwrap_or(false), ) }) .collect::>(); assert_eq!(skills.get("writer"), Some(&true)); assert_eq!(skills.len(), 4); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn page_ai_capabilities_expose_knowledge_rag_and_toggle_tools() { let _env_guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-ai-capability-policy-{}", 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 app = build_app(test_state()); let response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("capabilities"); 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("capabilities json"); let all_capabilities = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .collect::>(); assert!( all_capabilities .iter() .all(|capability| capability["id"] != "mnote-chat-only"), "纯聊天是 agent 模式,不应作为 MNote 公共能力展示" ); assert!(!payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .any(|capability| capability["id"] == "mnote-local-index")); let knowledge_rag = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .find(|capability| capability["id"] == "mnote-knowledge-rag") .expect("knowledge rag capability"); assert_eq!(knowledge_rag["enabled"], true); assert_eq!(knowledge_rag["uiKind"], "ai_capability"); assert!(knowledge_rag["tools"] .as_array() .expect("knowledge rag tools") .iter() .any(|tool| tool["name"] == "mnote.knowledge_rag.query")); let toggle_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/capabilities/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "runtime": "mnote", "profile": "chemist", "id": "mnote-knowledge-rag", "enabled": false }) .to_string(), )) .expect("request"), ) .await .expect("toggle capability"); assert_eq!(toggle_response.status(), StatusCode::OK); let response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("capabilities after toggle"); 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("capabilities json"); let knowledge_rag = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["capabilities"].as_array().into_iter().flatten()) .find(|capability| capability["id"] == "mnote-knowledge-rag") .expect("knowledge rag capability"); assert_eq!(knowledge_rag["enabled"], false); assert_eq!(knowledge_rag["status"], "disabled"); assert!(knowledge_rag["tools"] .as_array() .expect("knowledge rag tools") .iter() .any(|tool| tool["name"] == "mnote.knowledge_rag.query" && tool["enabled"] == false)); let tools_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("tools after toggle"); assert_eq!(tools_response.status(), StatusCode::OK); let body = to_bytes(tools_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.knowledge_rag.query"]["enabled"], false); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn page_ai_agent_descriptors_merge_manifest_capabilities_and_preferences() { let _env_guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-ai-agent-descriptors-{}", 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 state = test_state(); for user_id in ["user_1", "user_2"] { state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.to_string()), email: Some(format!("{user_id}@example.com")), username: user_id.to_string(), display_name: user_id.to_string(), role: None, password_hash: None, }) .expect("seed user"); } state .control_plane() .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { id: None, user_id: "user_1".to_string(), workspace_id: None, source_kind: None, scope_kind: "page_ai".to_string(), scope_id: "common".to_string(), key: "ai.common.default_agent".to_string(), value_json: "\"reasonix\"".to_string(), }) .expect("seed user 1 preference"); state .control_plane() .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { id: None, user_id: "user_2".to_string(), workspace_id: None, source_kind: None, scope_kind: "page_ai".to_string(), scope_id: "common".to_string(), key: "ai.common.default_agent".to_string(), value_json: "\"hermes\"".to_string(), }) .expect("seed user 2 preference"); let app = build_app(state); let response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/agents/descriptors?profile=chemist") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("descriptors 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("descriptors json"); assert_eq!(payload["schema"], "mnote.ai_agent_descriptors.v1"); assert_eq!(payload["profile"], "chemist"); assert_eq!(payload["actorId"], "user_1"); assert_eq!( payload["preferenceValues"]["ai.common.default_agent"], "reasonix" ); let descriptors = payload["descriptors"] .as_array() .expect("descriptors") .iter() .map(|descriptor| { ( descriptor["agentId"] .as_str() .unwrap_or_default() .to_string(), descriptor.clone(), ) }) .collect::>(); assert!(descriptors.contains_key("hermes")); assert!(descriptors.contains_key("reasonix")); assert!(descriptors.contains_key("chat_only")); let reasonix = descriptors.get("reasonix").expect("reasonix descriptor"); let reasonix_fields = reasonix["fields"] .as_array() .expect("reasonix fields") .iter() .map(|field| field["key"].as_str().unwrap_or_default().to_string()) .collect::>(); assert!(reasonix_fields.contains(&"ai.agent.reasonix.model_id".to_string())); assert!(reasonix_fields.contains(&"ai.agent.reasonix.approval_mode".to_string())); assert!(reasonix_fields.contains(&"ai.agent.reasonix.plan_mode".to_string())); assert!(reasonix["capabilities"] .as_array() .expect("reasonix capabilities") .iter() .any(|capability| capability == "knowledge_rag")); assert!(reasonix["tools"] .as_array() .expect("reasonix tools") .iter() .any(|tool| tool["name"] == "mnote.knowledge_rag.query")); assert!(reasonix["capabilityPacks"] .as_array() .expect("reasonix capability packs") .iter() .any(|capability| capability["id"] == "mnote-knowledge-rag" && capability["enabled"] == true)); assert_eq!( reasonix["capabilityStates"]["knowledge_rag"]["enabled"], true ); let runtime_env = reasonix_runtime_env_for_payload(&json!({ "reasonixSettings": { "modelId": "mimo-pro", "approvalMode": "allow", "planMode": "auto" } })) .expect("reasonix env"); assert_eq!( runtime_env.get("REASONIX_MODEL").map(String::as_str), Some("mimo-pro") ); assert_eq!( runtime_env .get("MNOTE_REASONIX_APPROVAL_MODE") .map(String::as_str), Some("allow") ); assert_eq!( runtime_env .get("MNOTE_REASONIX_PLAN_MODE") .map(String::as_str), Some("auto") ); let chat_only = descriptors.get("chat_only").expect("chat-only descriptor"); assert_eq!(chat_only["canWriteFiles"], false); assert_eq!( chat_only["tools"] .as_array() .expect("chat-only tools") .len(), 0 ); assert!(chat_only["capabilities"] .as_array() .expect("chat-only capabilities") .iter() .any(|capability| capability == "chat")); let toggle_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/hermes/client/capabilities/toggle") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "runtime": "mnote", "profile": "chemist", "id": "mnote-knowledge-rag", "enabled": false }) .to_string(), )) .expect("toggle request"), ) .await .expect("toggle response"); assert_eq!(toggle_response.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/agents/descriptors?profile=chemist") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("descriptors after disable"); 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("descriptors json"); let reasonix = payload["descriptors"] .as_array() .expect("descriptors") .iter() .find(|descriptor| descriptor["agentId"] == "reasonix") .expect("reasonix descriptor after disable"); assert_eq!( reasonix["capabilityStates"]["knowledge_rag"]["enabled"], false ); assert!(reasonix["disabledCapabilities"] .as_array() .expect("disabled capabilities") .iter() .any(|capability| capability == "knowledge_rag")); assert!(!reasonix["capabilities"] .as_array() .expect("reasonix capabilities") .iter() .any(|capability| capability == "knowledge_rag")); let knowledge_tool = reasonix["tools"] .as_array() .expect("reasonix tools") .iter() .find(|tool| tool["name"] == "mnote.knowledge_rag.query") .expect("knowledge query tool after disable"); assert_eq!(knowledge_tool["enabled"], false); assert_eq!(knowledge_tool["status"], "disabled"); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } #[tokio::test] async fn page_ai_run_events_expose_journal_after_seq() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_1".to_string()), status: "completed".to_string(), runtime_json: "{\"runId\":\"provider_run_1\",\"status\":\"completed\"}".to_string(), payload_json: "{\"requestId\":\"request_1\",\"agentId\":\"reasonix\",\"message\":\"hello\"}" .to_string(), }) .expect("seed runtime run"); for (event_type, payload_json) in [ ("message.delta", "{\"delta\":\"hello\"}"), ("run.completed", "{\"output\":\"done\"}"), ] { state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { 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(), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), event_type: event_type.to_string(), payload_json: payload_json.to_string(), }) .expect("seed runtime event"); } let app = build_app(state); let run_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/runs/run_1") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("run response"); assert_eq!(run_response.status(), StatusCode::OK); let body = to_bytes(run_response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("run json"); assert_eq!(payload["schema"], "mnote.ai_run.v1"); assert_eq!(payload["run"]["hostRunId"], "run_1"); assert_eq!(payload["run"]["providerRunId"], "provider_run_1"); assert_eq!(payload["run"]["status"], "completed"); let events_response = app .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/runs/run_1/events?afterSeq=000000000000000001") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("events response"); assert_eq!(events_response.status(), StatusCode::OK); let body = to_bytes(events_response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("events json"); assert_eq!(payload["schema"], "mnote.ai_run_events.v1"); assert_eq!(payload["hostRunId"], "run_1"); assert_eq!(payload["afterSeq"], "000000000000000001"); assert_eq!(payload["nextSeq"], "000000000000000002"); let events = payload["events"].as_array().expect("events"); assert_eq!(events.len(), 1); assert_eq!(events[0]["schema"], "mnote.ai_run_event.v1"); assert_eq!(events[0]["seq"], "000000000000000002"); assert_eq!(events[0]["kind"], "run.completed"); assert_eq!(events[0]["source"], "reasonix"); assert_eq!(events[0]["payload"]["output"], "done"); } #[tokio::test] async fn page_ai_run_create_is_request_id_idempotent() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); let app = build_app(state); let request_payload = json!({ "requestId": "req_idempotent_1", "agentId": "reasonix", "profile": "reasonix", "acpRuntime": "reasonix", "workspaceId": "ws_1", "documentId": "doc_1", "sessionId": "sess_1", "message": "hello" }); let first_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/page-ai/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from(request_payload.to_string())) .expect("request"), ) .await .expect("first response"); assert_eq!(first_response.status(), StatusCode::OK); let body = to_bytes(first_response.into_body(), usize::MAX) .await .expect("body"); let first_payload: Value = serde_json::from_slice(&body).expect("first json"); assert_eq!(first_payload["schema"], "mnote.ai_run_receipt.v1"); assert_eq!(first_payload["created"], true); assert_eq!(first_payload["idempotent"], false); assert_eq!(first_payload["requestId"], "req_idempotent_1"); assert_eq!(first_payload["run"]["status"], "pending"); let host_run_id = first_payload["hostRunId"] .as_str() .expect("host run id") .to_string(); let second_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/page-ai/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::from(request_payload.to_string())) .expect("request"), ) .await .expect("second response"); assert_eq!(second_response.status(), StatusCode::OK); let body = to_bytes(second_response.into_body(), usize::MAX) .await .expect("body"); let second_payload: Value = serde_json::from_slice(&body).expect("second json"); assert_eq!(second_payload["created"], false); assert_eq!(second_payload["idempotent"], true); assert_eq!(second_payload["hostRunId"], host_run_id); let events_response = app .oneshot( Request::builder() .method("GET") .uri(format!("/api/page-ai/runs/{host_run_id}/events?afterSeq=0")) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("events response"); assert_eq!(events_response.status(), StatusCode::OK); let body = to_bytes(events_response.into_body(), usize::MAX) .await .expect("body"); let events_payload: Value = serde_json::from_slice(&body).expect("events json"); let events = events_payload["events"].as_array().expect("events"); assert_eq!(events.len(), 1); assert_eq!(events[0]["seq"], "000000000000000001"); assert_eq!(events[0]["kind"], "run.created"); assert_eq!(events[0]["payload"]["requestId"], "req_idempotent_1"); } #[tokio::test] async fn page_ai_run_events_reconcile_missing_terminal_event() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_terminal_missing".to_string(), title: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_1".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), payload_json: "{\"requestId\":\"request_terminal\",\"agentId\":\"reasonix\"}" .to_string(), }) .expect("seed runtime run"); state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { 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_terminal_missing".to_string(), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), event_type: "message.delta".to_string(), payload_json: "{\"delta\":\"hello\"}".to_string(), }) .expect("seed runtime event"); let response = build_app(state) .oneshot( Request::builder() .method("GET") .uri( "/api/page-ai/runs/run_terminal_missing/events?afterSeq=000000000000000001", ) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("events 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("events json"); assert_eq!(payload["nextSeq"], "000000000000000002"); let events = payload["events"].as_array().expect("events"); assert_eq!(events.len(), 1); assert_eq!(events[0]["seq"], "000000000000000002"); assert_eq!(events[0]["kind"], "run.completed"); assert_eq!(events[0]["source"], "mnote"); assert_eq!(events[0]["synthetic"], true); assert_eq!( events[0]["payload"]["reason"], "terminal_status_without_terminal_event" ); } #[tokio::test] async fn page_ai_session_active_run_returns_non_terminal_run() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); for (run_id, status, request_id) in [ ("run_completed", "completed", "request_completed"), ("run_running", "running", "request_running"), ] { 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_id.to_string(), title: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some(format!("trace_{run_id}")), status: status.to_string(), runtime_json: format!("{{\"status\":\"{status}\"}}"), payload_json: format!( "{{\"requestId\":\"{request_id}\",\"agentId\":\"reasonix\"}}" ), }) .expect("seed runtime run"); } let response = build_app(state) .oneshot( Request::builder() .method("GET") .uri( "/api/page-ai/sessions/sess_1/active-run?workspaceId=ws_1&documentId=doc_1", ) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("active run 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("active run json"); assert_eq!(payload["schema"], "mnote.ai_active_run.v1"); assert_eq!(payload["sessionId"], "sess_1"); assert_eq!(payload["active"], true); assert_eq!(payload["run"]["hostRunId"], "run_running"); assert_eq!(payload["run"]["status"], "running"); assert_eq!(payload["run"]["requestId"], "request_running"); } #[tokio::test] async fn page_ai_session_active_run_reconciles_terminal_event() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_stale".to_string(), title: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_run_stale".to_string()), status: "acp_pending".to_string(), runtime_json: "{\"status\":\"acp_pending\"}".to_string(), payload_json: "{\"requestId\":\"request_stale\",\"agentId\":\"reasonix\"}" .to_string(), }) .expect("seed stale runtime run"); state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { 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_stale".to_string(), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), event_type: "run.completed".to_string(), payload_json: "{\"stopReason\":\"EndTurn\"}".to_string(), }) .expect("seed terminal event"); let response = build_app(state.clone()) .oneshot( Request::builder() .method("GET") .uri( "/api/page-ai/sessions/sess_1/active-run?workspaceId=ws_1&documentId=doc_1", ) .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("active run 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("active run json"); assert_eq!(payload["active"], false); assert!(payload["run"].is_null()); let run = state .control_plane() .find_ai_runtime_run("user_1", "run_stale") .expect("find run") .expect("run exists"); assert_eq!(run.status, "completed"); let runtime: Value = serde_json::from_str(&run.runtime_json).expect("runtime json"); assert_eq!(runtime["status"], "completed"); assert_eq!(runtime["terminalReconciled"], true); } #[tokio::test] async fn page_ai_runtime_status_reports_reasonix_native_live() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_reasonix".to_string(), run_id: "run_reasonix".to_string(), title: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_reasonix".to_string()), status: "running".to_string(), runtime_json: "{\"status\":\"running\",\"runId\":\"run_reasonix\"}".to_string(), payload_json: json!({ "requestId": "request_reasonix", "agentId": "reasonix", "acpRuntime": "reasonix", "acpSessionId": "acp_reasonix_1", "reasonixSessionMode": "native_live", "rootUri": "file:///tmp/mnote-reasonix", "allowedRoots": [{"rootUri": "file:///tmp/mnote-reasonix", "permission": "write"}] }) .to_string(), }) .expect("seed runtime run"); let response = build_app(state) .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/runtime/status?sessionId=sess_reasonix&workspaceId=ws_1&documentId=doc_1&profile=reasonix") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("runtime status 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("runtime status json"); assert_eq!(payload["schema"], "mnote.page_ai_runtime_status.v1"); assert_eq!(payload["runtime"], "reasonix"); assert_eq!(payload["mode"], "native_live"); assert_eq!(payload["activeRun"]["hostRunId"], "run_reasonix"); assert_eq!(payload["acp"]["acpSessionId"], "acp_reasonix_1"); assert_eq!(payload["acp"]["supportsSessionLoad"], false); assert_eq!(payload["roots"]["write"], 1); assert!(payload["tools"]["enabled"].as_u64().unwrap_or(0) > 0); } #[tokio::test] async fn page_ai_runtime_status_reports_hermes_replay_without_current_answer() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_hermes".to_string(), run_id: "run_hermes".to_string(), title: Some("Page AI run".to_string()), profile: "hermes".to_string(), acp_runtime: "hermes".to_string(), trace_id: Some("trace_hermes".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\",\"runId\":\"run_hermes\"}".to_string(), payload_json: json!({ "requestId": "request_hermes", "agentId": "hermes", "acpRuntime": "hermes", "acpSessionId": "acp_hermes_1" }) .to_string(), }) .expect("seed runtime run"); state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { 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_hermes".to_string(), run_id: "run_hermes".to_string(), profile: "hermes".to_string(), acp_runtime: "hermes".to_string(), event_type: "message.delta".to_string(), payload_json: "{\"source\":\"adapter_replay\",\"replay\":true,\"delta\":\"历史 replay\"}" .to_string(), }) .expect("seed replay event"); let response = build_app(state) .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/runtime/status?sessionId=sess_hermes&workspaceId=ws_1&documentId=doc_1&profile=hermes") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("runtime status 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("runtime status json"); assert_eq!(payload["runtime"], "hermes"); assert_eq!(payload["mode"], "replay_seen"); assert_eq!(payload["activeRun"], Value::Null); assert_eq!(payload["acp"]["supportsSessionLoad"], true); assert_eq!(payload["acp"]["replaySeen"], true); assert_eq!(payload["acp"]["replayMessageCount"], 1); } #[tokio::test] async fn page_ai_runtime_status_keeps_api_chat_out_of_acp() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("api_chat_user".to_string()), email: Some("api_chat_user@example.com".to_string()), username: "api_chat_user".to_string(), display_name: "api_chat_user".to_string(), role: None, password_hash: None, }) .expect("seed user"); state .control_plane() .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { id: None, user_id: "api_chat_user".to_string(), workspace_id: Some("ws_api_chat".to_string()), document_id: Some("doc_api_chat".to_string()), session_id: "sess_api_chat".to_string(), run_id: "run_api_chat".to_string(), title: Some("API Chat".to_string()), profile: "shared_api_gpt_chat".to_string(), acp_runtime: "api-chat".to_string(), trace_id: Some("trace_api_chat".to_string()), status: "running".to_string(), runtime_json: "{\"status\":\"running\",\"transport\":\"api-chat\"}".to_string(), payload_json: json!({ "agentId": "chat_only", "profile": "shared_api_gpt_chat", "providerKind": "api-chat" }) .to_string(), }) .expect("seed api chat run"); let response = build_app(state) .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/runtime/status?sessionId=sess_api_chat&workspaceId=ws_api_chat&documentId=doc_api_chat&profile=shared_api_gpt_chat") .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("runtime status 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("runtime status json"); assert_eq!(payload["runtime"], "api-chat"); assert_eq!(payload["mode"], "server_transcript"); assert_eq!(payload["acp"]["supportsSessionLoad"], false); assert!(payload["acp"]["acpSessionId"].is_null()); assert_eq!(payload["model"]["providerKind"], "api-chat"); } #[tokio::test] async fn acp_runtime_event_persistence_reconciles_terminal_status() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_terminal".to_string(), title: Some("Page AI run".to_string()), profile: "reasonix".to_string(), acp_runtime: "reasonix".to_string(), trace_id: Some("trace_run_terminal".to_string()), status: "acp_pending".to_string(), runtime_json: "{\"status\":\"acp_pending\"}".to_string(), payload_json: "{\"requestId\":\"request_terminal\",\"agentId\":\"reasonix\"}" .to_string(), }) .expect("seed runtime run"); let registration = HermesRunRegistration { session_id: "sess_1".to_string(), profile: "reasonix".to_string(), document_id: "doc_1".to_string(), trace_id: "trace_run_terminal".to_string(), }; let context = test_request_context_with_user("user_1"); let run_payload = json!({ "workspaceId": "ws_1", "documentId": "doc_1", "actorId": "user_1", "actorType": "user", "requestId": "request_terminal" }); let persistence = persist_acp_runtime_event( &state, &context, ®istration, "run_terminal", "reasonix", "run.completed", &json!({"stopReason": "EndTurn"}), &run_payload, ) .await .expect("persist terminal event"); assert_eq!(persistence["ok"], true); let run = state .control_plane() .find_ai_runtime_run("user_1", "run_terminal") .expect("find run") .expect("run exists"); assert_eq!(run.status, "completed"); let runtime: Value = serde_json::from_str(&run.runtime_json).expect("runtime json"); assert_eq!(runtime["lastEvent"], "run.completed"); assert_eq!(runtime["terminalReconciled"], true); } #[tokio::test] async fn page_ai_capabilities_group_onlyoffice_live_bridge() { let _env_guard = env_lock().lock().expect("env lock"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-ai-onlyoffice-capability-{}", 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 = build_app(test_state()) .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist") .header("x-mnote-actor-id", "user_1") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("capabilities"); 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("capabilities json"); let office_category = payload["categories"] .as_array() .expect("categories") .iter() .find(|category| category["name"] == "office") .expect("office category"); assert_eq!(office_category["title"], "Office / ONLYOFFICE"); let onlyoffice = office_category["capabilities"] .as_array() .expect("office capabilities") .iter() .find(|capability| capability["id"] == "mnote-onlyoffice-live") .expect("onlyoffice capability"); assert_eq!(onlyoffice["title"], "ONLYOFFICE 实时编辑"); assert_eq!(onlyoffice["categoryTitle"], "Office / ONLYOFFICE"); assert_eq!(onlyoffice["enabled"], true); assert!(onlyoffice["tools"] .as_array() .expect("onlyoffice tools") .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.session.current")); assert!(onlyoffice["tools"] .as_array() .expect("onlyoffice tools") .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values")); assert!(onlyoffice["tools"] .as_array() .expect("onlyoffice tools") .iter() .any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_shape")); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); } #[test] fn reasonix_memory_policy_defaults_off_and_reads_user_preference() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: None, username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("user"); let context = RequestContext { trace: crate::context::TraceContext { request_id: "req_reasonix_memory".into(), trace_id: "trace_reasonix_memory".into(), method: "POST".into(), path: "/api/hermes/client/runs".into(), }, auth: crate::context::AuthContext { authorization: None, cookie_header: None, actor_id: "user_1".into(), actor_type: "user".into(), session_id: None, }, workspace: crate::context::WorkspaceContext { workspace_id: None, tenant_id: None, deployment_id: None, project_id: None, }, source: crate::context::SourceContext { channel: "test".into(), client: "mnote-web-test".into(), idempotency_key: None, }, }; let payload = json!({"actorId": "user_1", "agentId": "reasonix"}); let off_env = reasonix_memory_env_for_payload(&state, &context, &payload) .expect("default memory env") .expect("env"); assert_eq!( off_env.get("REASONIX_MEMORY").map(String::as_str), Some("off") ); state .control_plane() .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { id: None, user_id: "user_1".to_string(), workspace_id: None, source_kind: None, scope_kind: "page_ai_agent".to_string(), scope_id: "reasonix".to_string(), key: "ai.agent.reasonix.memory_enabled".to_string(), value_json: "true".to_string(), }) .expect("memory preference"); let on_env = reasonix_memory_env_for_payload(&state, &context, &payload) .expect("enabled memory env") .expect("env"); assert_eq!( on_env.get("REASONIX_MEMORY").map(String::as_str), Some("on") ); } #[test] fn page_ai_capability_policy_exposes_selected_context_without_prompt_classifying() { let payload = json!({ "agentId": "reasonix", "workspaceId": "ws_local", "documentId": "local-md:Current.md", "profile": "reasonix", "sourceKind": "local_folder", "contextRefs": ["current_page", "active_editor"], "rootUri": "file:///mnt/Data1T/mnote", "allowedRoots": [{ "rootUri": "file:///mnt/Data1T/mnote", "permission": "write" }], "skillPreferences": { "mnote": { "mnote-chat-only": false } } }); for message in ["收到请回复收到", "你好", "谢谢", "翻译 hello", "总结当前页"] { let policy = page_ai_capability_policy(&payload, message); assert!( policy.attach_mnote_capabilities, "selected context refs expose MNote capabilities; agent decides tool use: {message}" ); let json = policy.to_json(); assert_eq!(json["intentClass"], "agent_decides"); assert_eq!(json["workspaceId"], "ws_local"); assert_eq!(json["documentId"], "local-md:Current.md"); assert_eq!(json["sourceKind"], "local_folder"); assert_eq!(json["rootUri"], "file:///mnt/Data1T/mnote"); assert_eq!(json["profile"], "reasonix"); assert_eq!( json["aiAccessScope"]["allowedRoots"][0]["rootUri"], "file:///mnt/Data1T/mnote" ); assert_eq!( json["agentRunEnvelope"]["schema"], "mnote.agent_run_envelope.v1" ); assert_eq!( json["agentRunEnvelope"]["primaryTarget"]["documentId"], "local-md:Current.md" ); assert_eq!( json["agentRunEnvelope"]["resultPolicy"]["receiptSchema"], "mnote.agent_run_receipt.v1" ); assert!(json["availableSkills"] .as_array() .unwrap() .iter() .all(|skill| skill["id"] != "mnote-chat-only")); } } #[test] fn page_ai_capability_policy_does_not_attach_without_context_refs() { let payload = json!({ "agentId": "reasonix", "sourceKind": "local_folder", "contextRefs": [], "rootUri": "file:///mnt/Data1T/mnote" }); let policy = page_ai_capability_policy(&payload, "总结当前页"); assert!(!policy.attach_mnote_capabilities); let json = policy.to_json(); assert_eq!(json["reason"], "no_context_refs"); assert!(json["availableSkills"] .as_array() .unwrap() .iter() .all(|skill| skill["id"] == "mnote-chat-only")); } #[test] fn page_ai_capability_policy_never_attaches_for_chat_only() { let payload = json!({ "agentId": "chat_only", "sourceKind": "local_folder", "contextRefs": ["current_page", "folder"], "rootUri": "file:///mnt/Data1T/mnote" }); let policy = page_ai_capability_policy(&payload, "总结当前页"); assert!(!policy.attach_mnote_capabilities); let json = policy.to_json(); assert_eq!(json["reason"], "chat_only_agent"); assert!(json["availableSkills"] .as_array() .unwrap() .iter() .all(|skill| skill["id"] == "mnote-chat-only")); } #[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); } #[test] fn local_agent_audit_current_page_context_uses_scoped_snapshot() { let root = std::env::temp_dir().join(format!( "mnote-local-agent-audit-scoped-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create root"); std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a"); std::fs::write(root.join("b.md"), "# B\nold\n").expect("write b"); let root_uri = format!("file://{}", root.display()); let payload = json!({ "documentId": "local-md:a.md", "rootUri": root_uri, "contextRefs": [{ "kind": "current_page", "rootUri": root_uri, "relativePath": "a.md" }] }); let before = local_agent_audit_collect_snapshot_for_payload(&payload, None) .expect("before scoped snapshot"); assert!(before.files.contains_key("a.md")); assert!(!before.files.contains_key("b.md")); std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a"); std::fs::write(root.join("b.md"), "# B\nnew\n").expect("modify b"); let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before)) .expect("after scoped snapshot"); let changed = local_agent_audit_change_files(&before, &after); let files = changed.as_array().expect("changed files"); assert_eq!(files.len(), 1); assert_eq!(files[0]["path"], "a.md"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_agent_audit_allowed_files_override_folder_context() { let root = std::env::temp_dir().join(format!( "mnote-local-agent-audit-allowed-files-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create root"); std::fs::write(root.join("a.md"), "# A\nold\n").expect("write a"); std::fs::write(root.join("b.md"), "# B\nold\n").expect("write b"); let root_uri = format!("file://{}", root.display()); let payload = json!({ "documentId": "local-md:a.md", "rootUri": root_uri, "contextRefs": [{ "kind": "folder", "rootUri": root_uri, "relativePath": "" }], "targetPackage": { "schema": "mnote.agent_target_package.v1", "allowedFiles": ["a.md"], "currentFile": { "relativePath": "a.md" } } }); let before = local_agent_audit_collect_snapshot_for_payload(&payload, None) .expect("before scoped snapshot"); assert!(before.files.contains_key("a.md")); assert!(!before.files.contains_key("b.md")); std::fs::write(root.join("a.md"), "# A\nnew\n").expect("modify a"); std::fs::write(root.join("b.md"), "# B\nnew\n").expect("modify b"); let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before)) .expect("after scoped snapshot"); let changed = local_agent_audit_change_files(&before, &after); let files = changed.as_array().expect("changed files"); assert_eq!(files.len(), 1); assert_eq!(files[0]["path"], "a.md"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_agent_audit_reads_allowed_files_from_agent_run_envelope() { let payload = json!({ "contextRefs": [{ "kind": "folder", "relativePath": "" }], "agentRunEnvelope": { "targetPackage": { "allowedFiles": ["nested/a.md", "../blocked.md", "/abs.md", ""], "currentFile": { "relativePath": "nested/a.md" } } } }); let paths = local_agent_audit_relative_paths_from_payload(&payload) .expect("allowed files should avoid full snapshot"); assert_eq!(paths, vec!["abs.md".to_string(), "nested/a.md".to_string()]); } #[test] fn local_agent_audit_full_snapshot_truncates_and_receipt_exposes_scope() { let root = std::env::temp_dir().join(format!( "mnote-local-agent-audit-truncated-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create root"); for index in 0..(LOCAL_AGENT_AUDIT_MAX_FILES + 1) { std::fs::write( root.join(format!("file-{index}.md")), format!("# {index}\n"), ) .expect("write file"); } let root_uri = format!("file://{}", root.display()); let snapshot = local_agent_audit_collect_snapshot(&root_uri).expect("snapshot"); assert!(snapshot.truncated); assert_eq!(snapshot.truncated_reason.as_deref(), Some("max_files")); assert_eq!(snapshot.file_count, LOCAL_AGENT_AUDIT_MAX_FILES); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &HeaderMap::new(), ); let event = local_agent_audit_event( &context, &json!({ "workspaceId": "local-workspace-1", "documentId": "local-md:file-0.md", "sessionId": "sess_local_1", "rootUri": root_uri, }), "run_truncated", "reasonix", "completed", json!([]), Some(&snapshot), Some(&snapshot), false, ); assert_eq!(event["auditScope"]["truncated"], true); assert_eq!(event["auditScope"]["truncatedReason"], "max_files"); assert_eq!(event["agentRunReceipt"]["auditScope"]["truncated"], true); assert_eq!( event["agentRunReceipt"]["auditScope"]["limits"]["maxFiles"], LOCAL_AGENT_AUDIT_MAX_FILES ); 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)) } #[test] fn chatonly_doubao_run_payload_injects_existing_provider_conversation() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); state .control_plane() .upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput { id: None, user_id: "user_1".to_string(), workspace_id: Some("ws_1".to_string()), mnote_session_id: "sess_doubao".to_string(), acp_session_id: Some("acp_sess_doubao".to_string()), agent_id: "chat_only".to_string(), profile: "openclaw-doubao-chat".to_string(), provider: "doubao-web".to_string(), remote_conversation_id: "38428454119180290".to_string(), remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()), status: "active".to_string(), metadata_json: "{}".to_string(), }) .expect("seed binding"); let headers = HeaderMap::from_iter([( "x-mnote-actor-id".parse().expect("header name"), "user_1".parse().expect("header value"), )]); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &headers, ); let mut payload = json!({ "agentId": "chat_only", "profile": "openclaw-doubao-chat", "sessionId": "sess_doubao", "workspaceId": "ws_1", "message": "继续刚才的话题" }); let registration = run_registration_from_payload(&context, &payload); inject_provider_conversation_binding_for_run( state.control_plane(), &context, ®istration, &mut payload, ) .expect("inject provider conversation"); assert_eq!(payload["providerConversation"]["provider"], "doubao-web"); assert_eq!( payload["providerConversation"]["remoteConversationId"], "38428454119180290" ); assert_eq!( payload["providerConversation"]["remoteUrl"], "https://www.doubao.com/chat/38428454119180290" ); } #[test] fn chatonly_doubao_prompt_includes_strip_compatible_mnote_context() { let payload = json!({ "actorId": "user_1", "agentId": "chat_only", "profile": "openclaw-doubao-chat", "profileId": "shared_doubao_chat", "sessionId": "sess_doubao", "workspaceId": "ws_1", "providerConversation": { "provider": "doubao-web", "remoteConversationId": "38428454119180290" } }); let registration = HermesRunRegistration { session_id: "sess_doubao".to_string(), profile: "openclaw-doubao-chat".to_string(), document_id: "current".to_string(), trace_id: "trace_1".to_string(), }; let prompt = chatonly_provider_prompt_text( "请只回复:MNOTE_DOUBAO", &payload, ®istration, "sess_doubao", "run_1", ); assert!(prompt.starts_with("Conversation info (untrusted metadata):\n```json\n")); assert!(prompt.contains("\"mnoteSessionId\":\"sess_doubao\"")); assert!(prompt.contains("\"remoteConversationId\":\"38428454119180290\"")); assert!(prompt.ends_with("请只回复:MNOTE_DOUBAO")); } #[test] fn chatonly_provider_prompt_supports_deepseek_and_gemini_context() { let cases = [ ( "openclaw-deepseek-chat", "shared_deepseek_chat", "deepseek-web", "ds-session-1", ), ( "openclaw-gemini-chat", "shared_gemini_chat", "gemini-web", "https://gemini.google.com/app/gem-session-1", ), ]; for (profile, profile_id, provider, remote_conversation_id) in cases { let payload = json!({ "actorId": "user_1", "agentId": "chat_only", "profile": profile, "profileId": profile_id, "sessionId": "sess_provider", "workspaceId": "ws_1", "providerConversation": { "provider": provider, "remoteConversationId": remote_conversation_id } }); let registration = HermesRunRegistration { session_id: "sess_provider".to_string(), profile: profile.to_string(), document_id: "current".to_string(), trace_id: "trace_1".to_string(), }; let prompt = chatonly_provider_prompt_text( "请只回复:MNOTE_PROVIDER", &payload, ®istration, "sess_provider", "run_1", ); assert!(prompt.contains("\"schema\":\"mnote.provider_chat_context.v1\"")); assert!(prompt.contains(&format!("\"provider\":\"{provider}\""))); assert!(prompt.contains("\"mnoteSessionId\":\"sess_provider\"")); assert!(prompt.contains(remote_conversation_id)); assert!(prompt.ends_with("请只回复:MNOTE_PROVIDER")); } } #[test] fn api_chat_profiles_do_not_match_web_provider_binding() { let api_profile = crate::api_chat::api_chat_profile_by_id("shared_api_gpt_chat") .expect("api chat profile"); assert_eq!(api_profile.provider_kind, "api-chat"); let headers = HeaderMap::from_iter([( "x-mnote-actor-id".parse().expect("header name"), "api_chat_user".parse().expect("header value"), )]); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs".parse().expect("uri"), &headers, ); let payload = json!({ "agentId": "chat_only", "profileId": "shared_api_gpt_chat", "profile": api_profile.isolated_profile, "agentProfileRef": { "profileId": "shared_api_gpt_chat", "baseProfile": api_profile.base_profile, "isolatedProfile": api_profile.isolated_profile }, "sessionId": "api-chat-session", "documentId": "doc-api-chat", "traceId": "trace-api-chat" }); let registration = run_registration_from_payload(&context, &payload); assert!(crate::api_chat::payload_uses_api_chat_profile( &payload, ®istration.profile )); assert!( chatonly_provider_for_run(&payload, ®istration).is_none(), "api-chat profiles must not enter web provider conversation binding/delete" ); } #[test] fn api_chat_openai_sse_chunks_map_to_runtime_events() { let events = crate::api_chat::runtime_events_from_openai_sse_chunk( "run_api_chat_1", "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\",MNote\"}}]}\n\n\ data: [DONE]\n\n", ) .expect("parse openai sse"); assert_eq!(events.len(), 3); assert_eq!(events[0].event, "message.delta"); assert_eq!(events[0].data["delta"], "你好"); assert_eq!(events[1].event, "message.delta"); assert_eq!(events[1].data["delta"], ",MNote"); assert_eq!(events[2].event, "run.completed"); assert_eq!(events[2].data["output"], "你好,MNote"); } #[tokio::test] async fn api_chat_create_run_registers_without_acp_payload() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_acp_run_payloads(); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "agentId": "chat_only", "profileId": "shared_api_gpt_chat", "profile": "shared_api_gpt_chat", "sessionId": "mnote_api_chat_session", "workspaceId": "ws_api_chat", "documentId": "doc_api_chat", "traceId": "trace_api_chat", "message": "请只回复 API_CHAT_SMOKE" }) .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"); let run_id = payload["runId"].as_str().expect("run id"); assert_eq!(payload["runtime"]["transport"], "api-chat"); assert_eq!(payload["profile"], "api-gpt-chat"); assert!( !ACP_RUN_PAYLOADS .lock() .expect("acp run payloads") .contains_key(run_id), "api-chat run must not be stored as ACP/OpenClaw payload" ); } #[tokio::test] async fn api_chat_create_session_persists_sqlite_without_acp_session() { let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_acp_run_payloads(); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "ws_api_chat", "documentId": "doc_api_chat", "traceId": "trace_api_chat_session", "title": "API Chat", "profile": "shared_api_gpt_chat" }) .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!(payload["sessionStorage"], "sqlite_control_plane"); assert_eq!(payload["providerKind"], "api-chat"); assert_eq!(payload["profile"], "api-gpt-chat"); } #[tokio::test] async fn api_chat_events_stream_from_openai_compatible_upstream() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_acp_run_payloads(); let captured_body = Arc::new(Mutex::new(Value::Null)); let captured_for_route = Arc::clone(&captured_body); let mock = axum::Router::new().route( "/v1/chat/completions", post(move |Json(body): Json| { let captured = Arc::clone(&captured_for_route); async move { *captured.lock().expect("captured api body") = body; ( StatusCode::OK, [("content-type", "text/event-stream; charset=utf-8")], "data: {\"choices\":[{\"delta\":{\"content\":\"API\"}}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\" Chat\"}}]}\n\n\ data: [DONE]\n\n", ) } }), ); 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 api chat"); }); std::env::set_var( "MNOTE_API_CHAT_GPT_CHAT_BASE_URL", &format!("{upstream}/v1"), ); std::env::set_var("MNOTE_API_CHAT_GPT_CHAT_API_KEY", "test-api-chat-key"); let app = app(); let run_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "agentId": "chat_only", "profileId": "shared_api_gpt_chat", "profile": "shared_api_gpt_chat", "sessionId": "mnote_api_chat_stream", "workspaceId": "ws_api_chat", "documentId": "doc_api_chat", "traceId": "trace_api_chat_stream", "message": "请只回复 API Chat" }) .to_string(), )) .expect("request"), ) .await .expect("run response"); assert_eq!(run_response.status(), StatusCode::OK); let body = to_bytes(run_response.into_body(), usize::MAX) .await .expect("run body"); let payload: Value = serde_json::from_slice(&body).expect("run json"); let run_id = payload["runId"].as_str().expect("run id").to_string(); let events_response = app .clone() .oneshot( Request::builder() .method("GET") .uri(format!("/api/hermes/client/events/{run_id}")) .header("accept", "text/event-stream") .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("events request"), ) .await .expect("events response"); assert_eq!(events_response.status(), StatusCode::OK); let body = to_bytes(events_response.into_body(), usize::MAX) .await .expect("events body"); let text = String::from_utf8(body.to_vec()).expect("events utf8"); assert!(text.contains("event: message.delta"), "{text}"); assert!(text.contains("\"delta\":\"API\""), "{text}"); assert!(text.contains("\"delta\":\" Chat\""), "{text}"); assert!(text.contains("\"seq\":\"000000000000000001\""), "{text}"); assert!(text.contains("\"seq\":\"000000000000000002\""), "{text}"); assert!(text.contains("event: run.completed"), "{text}"); assert_eq!( captured_body.lock().expect("captured api body")["model"], "aisz-chat/gpt-5.5-extra-high-fast" ); let journal_response = app .oneshot( Request::builder() .method("GET") .uri(format!("/api/page-ai/runs/{run_id}/events?afterSeq=0")) .header("x-mnote-actor-id", "api_chat_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("journal request"), ) .await .expect("journal response"); assert_eq!(journal_response.status(), StatusCode::OK); let body = to_bytes(journal_response.into_body(), usize::MAX) .await .expect("journal body"); let journal: Value = serde_json::from_slice(&body).expect("journal json"); let journal_events = journal["events"].as_array().expect("journal events"); assert!(journal_events .iter() .any(|event| event["kind"] == "message.delta" && event["source"] == "chat_only")); assert!(journal_events .iter() .any(|event| event["kind"] == "run.completed" && event["source"] == "chat_only")); std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_BASE_URL"); std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_API_KEY"); } #[tokio::test] async fn api_chat_session_detail_restores_messages_from_sqlite_events() { let _env_guard = env_lock().lock().expect("env lock"); let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_acp_run_payloads(); let mock = axum::Router::new().route( "/v1/chat/completions", post(|| async { ( StatusCode::OK, [("content-type", "text/event-stream; charset=utf-8")], "data: {\"choices\":[{\"delta\":{\"content\":\"历史\"}}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\"恢复\"}}]}\n\n\ data: [DONE]\n\n", ) }), ); 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 api chat"); }); std::env::set_var( "MNOTE_API_CHAT_GPT_CHAT_BASE_URL", &format!("{upstream}/v1"), ); std::env::set_var("MNOTE_API_CHAT_GPT_CHAT_API_KEY", "test-api-chat-key"); let app = app(); let run_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/runs") .header("content-type", "application/json") .header("x-mnote-actor-id", "api_chat_history_user") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "agentId": "chat_only", "profileId": "shared_api_gpt_chat", "profile": "shared_api_gpt_chat", "sessionId": "mnote_api_chat_history", "workspaceId": "ws_api_chat_history", "documentId": "doc_api_chat_history", "traceId": "trace_api_chat_history", "message": "请恢复历史" }) .to_string(), )) .expect("request"), ) .await .expect("run response"); assert_eq!(run_response.status(), StatusCode::OK); let body = to_bytes(run_response.into_body(), usize::MAX) .await .expect("run body"); let payload: Value = serde_json::from_slice(&body).expect("run json"); let run_id = payload["runId"].as_str().expect("run id").to_string(); let events_response = app .clone() .oneshot( Request::builder() .method("GET") .uri(format!("/api/hermes/client/events/{run_id}")) .header("accept", "text/event-stream") .header("x-mnote-actor-id", "api_chat_history_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("events request"), ) .await .expect("events response"); assert_eq!(events_response.status(), StatusCode::OK); let _ = to_bytes(events_response.into_body(), usize::MAX) .await .expect("events body"); let detail_response = app .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions/mnote_api_chat_history?source=acp&workspaceId=ws_api_chat_history&documentId=doc_api_chat_history") .header("x-mnote-actor-id", "api_chat_history_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("detail request"), ) .await .expect("detail response"); assert_eq!(detail_response.status(), StatusCode::OK); let body = to_bytes(detail_response.into_body(), usize::MAX) .await .expect("detail body"); let detail: Value = serde_json::from_slice(&body).expect("detail json"); assert_eq!(detail["session"]["providerKind"], "api-chat"); assert_eq!(detail["session"]["messages"][0]["role"], "user"); assert_eq!(detail["session"]["messages"][0]["content"], "请恢复历史"); assert_eq!(detail["session"]["messages"][1]["role"], "assistant"); assert_eq!(detail["session"]["messages"][1]["content"], "历史恢复"); std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_BASE_URL"); std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_API_KEY"); } #[tokio::test] async fn api_chat_delete_session_skips_remote_provider_delete() { let _runtime_guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_acp_run_payloads(); let app = app(); let create_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "api_chat_delete_user") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "ws_api_chat_delete", "documentId": "doc_api_chat_delete", "traceId": "trace_api_chat_delete", "title": "API Chat Delete", "profile": "shared_api_gpt_chat" }) .to_string(), )) .expect("create request"), ) .await .expect("create response"); assert_eq!(create_response.status(), StatusCode::OK); let body = to_bytes(create_response.into_body(), usize::MAX) .await .expect("create body"); let create_payload: Value = serde_json::from_slice(&body).expect("create json"); let session_id = create_payload["sessionId"].as_str().expect("session id"); let delete_response = app .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/api/hermes/client/sessions/{session_id}?source=acp&workspaceId=ws_api_chat_delete&documentId=doc_api_chat_delete")) .header("x-mnote-actor-id", "api_chat_delete_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("delete request"), ) .await .expect("delete response"); assert_eq!(delete_response.status(), StatusCode::OK); let body = to_bytes(delete_response.into_body(), usize::MAX) .await .expect("delete body"); let payload: Value = serde_json::from_slice(&body).expect("delete json"); assert_eq!(payload["result"]["remoteDelete"]["attempted"], false); assert_eq!( payload["result"]["remoteDelete"]["reason"], "api_chat_has_no_remote_conversation" ); let list_response = app .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions?source=acp&workspaceId=ws_api_chat_delete&documentId=doc_api_chat_delete") .header("x-mnote-actor-id", "api_chat_delete_user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("list request"), ) .await .expect("list response"); assert_eq!(list_response.status(), StatusCode::OK); let body = to_bytes(list_response.into_body(), usize::MAX) .await .expect("list body"); let list: Value = serde_json::from_slice(&body).expect("list json"); assert!( list["sessions"] .as_array() .expect("sessions") .iter() .all(|session| session["sessionId"] != session_id), "{list}" ); } #[tokio::test] async fn provider_conversation_bound_event_persists_doubao_binding() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); let headers = HeaderMap::from_iter([( "x-mnote-actor-id".parse().expect("header name"), "user_1".parse().expect("header value"), )]); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs/run_1/events".parse().expect("uri"), &headers, ); let registration = HermesRunRegistration { session_id: "sess_doubao".to_string(), profile: "openclaw-doubao-chat".to_string(), document_id: "current".to_string(), trace_id: "trace_1".to_string(), }; let run_payload = json!({ "actorId": "user_1", "agentId": "chat_only", "workspaceId": "ws_1", "sessionId": "sess_doubao", "profile": "openclaw-doubao-chat" }); persist_acp_runtime_event( &state, &context, ®istration, "run_1", "reasonix", "provider.conversation.bound", &json!({ "provider": "doubao-web", "remoteConversationId": "38428454119180290", "remoteUrl": "https://www.doubao.com/chat/38428454119180290", "acpSessionId": "acp_sess_doubao" }), &run_payload, ) .await .expect("persist event"); let binding = state .control_plane() .find_ai_external_conversation_binding( "user_1", Some("ws_1"), "sess_doubao", "doubao-web", ) .expect("find binding") .expect("binding exists"); assert_eq!(binding.status, "active"); assert_eq!(binding.remote_conversation_id, "38428454119180290"); assert_eq!(binding.acp_session_id.as_deref(), Some("acp_sess_doubao")); } #[tokio::test] async fn provider_conversation_bound_event_persists_gemini_binding() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); let headers = HeaderMap::from_iter([( "x-mnote-actor-id".parse().expect("header name"), "user_1".parse().expect("header value"), )]); let context = RequestContext::from_http_parts( &axum::http::Method::POST, &"/api/hermes/client/runs/run_1/events".parse().expect("uri"), &headers, ); let registration = HermesRunRegistration { session_id: "sess_gemini".to_string(), profile: "openclaw-gemini-chat".to_string(), document_id: "current".to_string(), trace_id: "trace_1".to_string(), }; let run_payload = json!({ "actorId": "user_1", "agentId": "chat_only", "workspaceId": "ws_1", "sessionId": "sess_gemini", "profile": "openclaw-gemini-chat" }); persist_acp_runtime_event( &state, &context, ®istration, "run_1", "hermes", "provider.conversation.bound", &json!({ "provider": "gemini-web", "remoteConversationId": "https://gemini.google.com/app/gem-session-1", "remoteUrl": "https://gemini.google.com/app/gem-session-1", "acpSessionId": "acp_sess_gemini" }), &run_payload, ) .await .expect("persist event"); let binding = state .control_plane() .find_ai_external_conversation_binding( "user_1", Some("ws_1"), "sess_gemini", "gemini-web", ) .expect("find binding") .expect("binding exists"); assert_eq!(binding.status, "active"); assert_eq!( binding.remote_conversation_id, "https://gemini.google.com/app/gem-session-1" ); assert_eq!(binding.acp_session_id.as_deref(), Some("acp_sess_gemini")); } #[test] fn chatonly_doubao_session_delete_marks_binding_local_deleted() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); state .control_plane() .upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput { id: None, user_id: "user_1".to_string(), workspace_id: Some("ws_1".to_string()), mnote_session_id: "sess_doubao".to_string(), acp_session_id: None, agent_id: "chat_only".to_string(), profile: "openclaw-doubao-chat".to_string(), provider: "doubao-web".to_string(), remote_conversation_id: "38428454119180290".to_string(), remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()), status: "active".to_string(), metadata_json: "{}".to_string(), }) .expect("seed binding"); let changed = mark_provider_conversation_local_deleted_for_session( state.control_plane(), "user_1", Some("ws_1"), "sess_doubao", ) .expect("mark local deleted"); assert_eq!(changed, 1); let binding = state .control_plane() .find_ai_external_conversation_binding( "user_1", Some("ws_1"), "sess_doubao", "doubao-web", ) .expect("find binding") .expect("binding exists"); assert_eq!(binding.status, "local_deleted"); assert!(binding.deleted_at.is_some()); } #[tokio::test] async fn chatonly_doubao_session_delete_calls_provider_and_marks_remote_deleted() { let _env_guard = env_lock().lock().expect("env lock"); let calls = Arc::new(Mutex::new(Vec::::new())); let mock_calls = Arc::clone(&calls); let mock = axum::Router::new().route( "/delete/{conversation_id}", post(move |Path(conversation_id): Path| { let mock_calls = Arc::clone(&mock_calls); async move { mock_calls .lock() .expect("mock delete calls") .push(conversation_id); Json(json!({ "ok": true })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind mock delete endpoint"); let addr = listener.local_addr().expect("mock addr"); tokio::spawn(async move { axum::serve(listener, mock) .await .expect("mock delete endpoint"); }); std::env::set_var( "MNOTE_WEB_DOUBAO_CONVERSATION_DELETE_URL", format!("http://{addr}/delete/{{conversationId}}"), ); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".to_string()), email: Some("user_1@example.com".to_string()), username: "user_1".to_string(), display_name: "user_1".to_string(), role: None, password_hash: None, }) .expect("seed user"); 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_doubao".to_string(), run_id: "run_1".to_string(), title: None, profile: "openclaw-doubao-chat".to_string(), acp_runtime: "hermes".to_string(), trace_id: Some("trace_1".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), payload_json: "{\"message\":\"hello\"}".to_string(), }) .expect("seed runtime run"); state .control_plane() .upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput { id: None, user_id: "user_1".to_string(), workspace_id: Some("ws_1".to_string()), mnote_session_id: "sess_doubao".to_string(), acp_session_id: None, agent_id: "chat_only".to_string(), profile: "openclaw-doubao-chat".to_string(), provider: "doubao-web".to_string(), remote_conversation_id: "38428454119180290".to_string(), remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()), status: "active".to_string(), metadata_json: "{}".to_string(), }) .expect("seed binding"); let response = build_app(state.clone()) .oneshot( Request::builder() .method("DELETE") .uri("/api/hermes/client/sessions/sess_doubao?workspaceId=ws_1&deleteExternalProviderSession=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body: Value = serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) .expect("json body"); assert_eq!( body["result"]["providerConversationDelete"]["status"], "remote_deleted" ); assert_eq!( calls.lock().expect("mock calls").as_slice(), ["38428454119180290"] ); let binding = state .control_plane() .find_ai_external_conversation_binding( "user_1", Some("ws_1"), "sess_doubao", "doubao-web", ) .expect("find binding") .expect("binding exists"); assert_eq!(binding.status, "remote_deleted"); std::env::remove_var("MNOTE_WEB_DOUBAO_CONVERSATION_DELETE_URL"); } 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_sqlite_and_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 state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".into()), email: Some("user_1@example.com".into()), username: "user_1".into(), display_name: "user_1".into(), role: None, password_hash: None, }) .expect("user"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "user_1".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root.display().to_string(), permission: "write".into(), recursive: true, capabilities: vec!["local_files".into(), "ai_sessions".into()], source: "test".into(), created_by: Some("user_1".into()), }) .expect("grant write access"); let app = build_app(state); let response = app .clone() .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"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["sessionStorage"], "sqlite_control_plane"); assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl"); assert_eq!(payload["legacySessionStorage"], "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 .clone() .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"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(list_payload["sessionStorage"], "sqlite_control_plane"); 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 .clone() .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"], ACP_RUNTIME_SQLITE_STORE); assert_eq!( detail_payload["session"]["runs"][0]["sessionId"], session_id ); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn hermes_client_local_acp_run_writes_sqlite_and_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 state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_1".into()), email: Some("user_1@example.com".into()), username: "user_1".into(), display_name: "user_1".into(), role: None, password_hash: None, }) .expect("user"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "user_1".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root.display().to_string(), permission: "write".into(), recursive: true, capabilities: vec!["local_files".into(), "ai_sessions".into()], source: "test".into(), created_by: Some("user_1".into()), }) .expect("grant write access"); let response = build_app(state) .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"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["sessionStorage"], "sqlite_control_plane"); assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl"); assert_eq!(payload["legacySessionStorage"], "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_acp_session_create_accepts_sqlite_directory_grant() { let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-sqlite-grant-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).expect("root"); let root_uri = format!("file://{}", root.display()); let state = test_state(); for user_id in ["user_owner", "user_target"] { state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.into()), email: Some(format!("{user_id}@example.com")), username: user_id.into(), display_name: user_id.into(), role: None, password_hash: None, }) .expect("upsert user"); } state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "user_target".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root.display().to_string(), permission: "write".into(), recursive: true, capabilities: vec!["local_files".into(), "ai_sessions".into()], source: "test".into(), created_by: Some("user_owner".into()), }) .expect("grant write access"); let response = build_app(state) .oneshot( Request::builder() .method("POST") .uri("/api/hermes/client/sessions") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_target") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-granted", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": root_uri, "traceId": "trace_sqlite_grant_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"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["sessionStorage"], "sqlite_control_plane"); assert_eq!(payload["legacyPersistence"], "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"], "sqlite_control_plane"); assert_eq!(payload["legacySessionStorage"], "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::SERVICE_UNAVAILABLE); 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["code"], "convex_retired"); let query_body = captured_body.lock().expect("captured convex body").clone(); assert_eq!(query_body, Value::Null); } #[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::SERVICE_UNAVAILABLE); 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["code"], "convex_retired"); let bodies = captured_bodies.lock().expect("captured convex bodies"); assert!(bodies.is_empty()); } #[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::SERVICE_UNAVAILABLE); 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["code"], "convex_retired"); } #[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_export_uses_sqlite_store() { let response = build_app(seeded_acp_state()) .oneshot( Request::builder() .method("GET") .uri("/api/hermes/client/sessions/sess_1/export?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["ok"], true); assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!( payload["export"]["schema"], "mnote.page_ai_session_export.v1" ); assert!(payload["export"]["markdown"] .as_str() .unwrap_or_default() .contains("Page AI Session sess_1")); assert!(payload["export"]["formats"] .as_array() .unwrap_or(&Vec::new()) .iter() .any(|format| format.as_str() == Some("jsonl"))); assert!(payload["export"]["jsonl"] .as_str() .unwrap_or_default() .contains("\"role\"")); assert_eq!(payload["export"]["externalDelete"]["attempted"], false); } #[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_legacy_convex_returns_retired_guard() { 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::SERVICE_UNAVAILABLE); 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["code"], "convex_retired"); let query_body = captured_body.lock().expect("captured convex body").clone(); assert_eq!(query_body, Value::Null); } #[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": "选中的句子", "editorTarget": { "schema": "mnote.ai_editor_target.v1", "source": "open_editors_snapshot", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "workspaceId": "local-workspace-1", "editorKind": "page", "pageText": "恶意 editorTarget 正文不应进入 instructions", "workspacePath": { "schema": "mnote.workspace_path.v1", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "README.md", "documentId": "local-md:README.md", "objectIdentity": "page:primary", "assetId": "", "resourceKind": "page", "pageText": "恶意 workspacePath 正文不应进入 instructions" } }, "runTargetSnapshot": { "schema": "mnote.page_ai_run_target_snapshot.v1", "source": "open_editors_snapshot", "frozenAt": 12345, "workspaceId": "local-workspace-1", "documentId": "local-md:README.md", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "contextScope": "page", "promptPreview": "把选中的句子改短", "pageText": "恶意 runTargetSnapshot 正文不应进入 instructions", "editorTarget": { "schema": "mnote.ai_editor_target.v1", "source": "open_editors_snapshot", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "workspaceId": "local-workspace-1", "editorKind": "page", "pageText": "恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions", "workspacePath": { "schema": "mnote.workspace_path.v1", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "README.md", "documentId": "local-md:README.md", "objectIdentity": "page:primary", "resourceKind": "page", "pageText": "恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions" } } }, "pageContext": { "node": {"documentId": "local-md:README.md", "title": "README"}, "aiContext": { "schema": "mnote.page_ai_context.v1", "activeEditorTarget": { "schema": "mnote.ai_editor_target.v1", "source": "open_editors_snapshot", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "pageText": "恶意 activeEditorTarget 正文不应进入 instructions" }, "openEditorsSnapshot": { "schema": "mnote.open_editors_snapshot.v1", "activeObjectIdentity": "page:primary", "activeEditor": { "schema": "mnote.ai_editor_target.v1", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "pageText": "恶意 openEditorsSnapshot 正文不应进入 instructions" }, "groups": { "primary": { "paneRole": "primary", "activeObjectIdentity": "page:primary", "editors": [{ "schema": "mnote.ai_editor_target.v1", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "pageText": "恶意 group editor 正文不应进入 instructions" }], "resourceEditors": [] } } }, "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("\"allowedFiles\":[\"README.md\"]")); assert!(instructions.contains( "\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md\"]" )); assert!(instructions.contains("\"agentTargetPackage\"")); assert!(instructions.contains("\"targetPackage\"")); assert!(instructions.contains("\"resourceKind\":\"markdown_page\"")); assert!(instructions.contains("\"editorTarget\"")); assert!(instructions.contains("\"runTargetSnapshot\"")); assert!(instructions.contains("\"schema\":\"mnote.page_ai_run_target_snapshot.v1\"")); assert!(instructions.contains("\"source\":\"open_editors_snapshot\"")); assert!(instructions.contains("\"relativePath\":\"README.md\"")); assert!(instructions.contains("本地文件夹上下文")); assert!(instructions.contains("agent 自身文件读取/编辑能力")); assert!(instructions .contains("不要调用 mnote_doc_markdown_edit 或 mnote_page_save 处理 local-first 普通 Markdown 编辑")); assert!(instructions.contains( "do not use mnote_doc_markdown_edit or mnote_page_save for ordinary local Markdown edits" )); assert!(instructions .contains("\"forOrdinaryLocalMarkdown\":\"forbidden_use_agent_native_file_patch\"")); assert!(!instructions.contains("mnote_doc_fetch")); assert!(instructions.contains("\"selectedText\":\"选中的句子\"")); assert!(!instructions.contains("完整页面正文不应进入本地 agent instructions")); assert!(!instructions.contains("完整页面 XML 不应进入本地 agent instructions")); assert!(!instructions.contains("完整块内容不应进入本地 agent instructions")); assert!(!instructions.contains("恶意 editorTarget 正文不应进入 instructions")); assert!(!instructions.contains("恶意 workspacePath 正文不应进入 instructions")); assert!(!instructions.contains("恶意 activeEditorTarget 正文不应进入 instructions")); assert!(!instructions.contains("恶意 openEditorsSnapshot 正文不应进入 instructions")); assert!(!instructions.contains("恶意 group editor 正文不应进入 instructions")); assert!(!instructions.contains("恶意 runTargetSnapshot 正文不应进入 instructions")); assert!( !instructions.contains("恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions") ); assert!(!instructions .contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions")); assert!(!instructions.contains("\"contextBlocks\"")); assert!(!instructions.contains("\"pageXml\"")); assert!(!instructions.contains("\"pageText\"")); } #[test] fn hermes_client_run_body_carries_agent_run_envelope() { 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", "runId": "run_local_1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "agentId": "reasonix", "acpRuntime": "reasonix", "contextRefs": [ { "kind": "active_editor", "documentId": "local-md:README.md", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "README.md", "pageText": "contextRef 正文不应进入 envelope" }, { "kind": "folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "" } ], "allowedRoots": [{ "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "permission": "write", "recursive": true, "source": "sqlite_directory_grant", "absolutePath": "/mnt/Data1T/Mnote_data/users/user_1/我的空间" }], "editorTarget": { "schema": "mnote.ai_editor_target.v1", "source": "open_editors_snapshot", "objectIdentity": "page:primary", "documentId": "local-md:README.md", "workspaceId": "local-workspace-1", "editorKind": "page", "workspacePath": { "schema": "mnote.workspace_path.v1", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "README.md", "documentId": "local-md:README.md", "objectIdentity": "page:primary", "resourceKind": "page" } }, "message": "编辑当前文件", "traceId": "trace_local_1" }), ) .expect("body"); let instructions_text = body["instructions"].as_str().expect("instructions"); assert!(instructions_text.contains("onlyofficeSessionId 参数")); assert!(instructions_text.contains("不得改用最近活跃 Office session")); let instructions: Value = serde_json::from_str(instructions_text).expect("instructions json"); let envelope = &instructions["agentRunEnvelope"]; assert_eq!(envelope["schema"], "mnote.agent_run_envelope.v1"); assert_eq!(envelope["agentId"], "reasonix"); assert_eq!(envelope["acpRuntime"], "reasonix"); assert_eq!(envelope["runId"], "run_local_1"); assert_eq!(envelope["sessionId"], "sess_local_1"); assert_eq!( envelope["primaryTarget"]["documentId"], "local-md:README.md" ); assert_eq!(envelope["contextRefs"][0]["kind"], "active_editor"); assert_eq!(envelope["contextRefs"][0]["relativePath"], "README.md"); assert_eq!(envelope["allowedRoots"][0]["permission"], "write"); assert_eq!(envelope["allowedFiles"][0], "README.md"); assert_eq!( envelope["targetPackage"]["currentFile"]["relativePath"], "README.md" ); assert_eq!( envelope["targetPackage"]["workspacePath"]["resourceKind"], "markdown_page" ); assert_eq!( envelope["resultPolicy"]["receiptSchema"], "mnote.agent_run_receipt.v1" ); assert!(!envelope .to_string() .contains("contextRef 正文不应进入 envelope")); assert!(!envelope.to_string().contains("absolutePath")); } #[test] fn hermes_client_run_body_preserves_onlyoffice_target_scope() { 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_office", "runId": "run_local_office", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "agentId": "reasonix", "acpRuntime": "reasonix", "allowedRoots": [{ "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "permission": "write", "recursive": true }], "contextRefs": [{"kind": "active_editor"}], "editorTarget": { "schema": "mnote.ai_editor_target.v1", "targetId": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "objectIdentity": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "documentId": "local-md:README.md", "workspaceId": "local-workspace-1", "editorKind": "office", "resourceKind": "only_office", "assetId": "local-file:office/report.docx", "onlyofficeSessionId": "mnote-oo-session-a", "workspacePath": { "schema": "mnote.workspace_path.v1", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "office/report.docx", "documentId": "local-md:README.md", "objectIdentity": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "assetId": "local-file:office/report.docx", "resourceKind": "only_office" } }, "targetPackage": { "schema": "mnote.agent_target_package.v1", "primaryTargetId": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "documentId": "local-md:README.md", "objectIdentity": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "resourceKind": "only_office", "onlyofficeSessionId": "mnote-oo-session-a", "workspacePath": { "schema": "mnote.workspace_path.v1", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "office/report.docx", "documentId": "local-md:README.md", "objectIdentity": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "assetId": "local-file:office/report.docx", "resourceKind": "only_office" }, "targets": [{ "targetId": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "objectIdentity": "resource:onlyoffice:local-md:README.md:local-file:office/report.docx", "documentId": "local-md:README.md", "workspaceId": "local-workspace-1", "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间", "relativePath": "office/report.docx", "resourceKind": "only_office", "assetId": "local-file:office/report.docx", "onlyofficeSessionId": "mnote-oo-session-a" }] }, "message": "编辑 Office 资源", "traceId": "trace_local_office" }), ) .expect("body"); let instructions: Value = serde_json::from_str(body["instructions"].as_str().expect("instructions")) .expect("instructions json"); let scope = &instructions["aiAccessScope"]; assert!(scope["allowedResourceIds"] .as_array() .expect("allowed resources") .iter() .any(|value| value == "local-file:office/report.docx")); assert!(scope["allowedResourceIds"] .as_array() .expect("allowed resources") .iter() .any(|value| value == "resource:onlyoffice:local-md:README.md:local-file:office/report.docx")); assert!(scope["allowedResourceIds"] .as_array() .expect("allowed resources") .iter() .any(|value| value == "mnote-oo-session-a")); assert_eq!( instructions["agentTargetPackage"]["targets"][0]["onlyofficeSessionId"], "mnote-oo-session-a" ); assert_eq!( instructions["agentRunEnvelope"]["targetPackage"]["targets"][0]["assetId"], "local-file:office/report.docx" ); } #[test] fn capability_policy_does_not_text_classify_ack_prompt() { assert!( page_ai_capability_policy( &json!({"agentId": "reasonix", "contextRefs": ["current_page"]}), "收到请回复收到" ) .attach_mnote_capabilities ); assert!( !page_ai_capability_policy(&json!({"agentId": "chat_only"}), "帮我解释当前页面") .attach_mnote_capabilities ); } #[test] fn local_agent_audit_event_carries_agent_run_receipt() { 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 root = std::env::temp_dir().join(format!( "mnote-local-agent-run-receipt-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create root"); std::fs::write(root.join("README.md"), "# Readme\nold\n").expect("write readme"); std::fs::write(root.join("Other.md"), "# Other\nold\n").expect("write other"); let root_uri = format!("file://{}", root.display()); let payload = json!({ "workspaceId": "local-workspace-1", "documentId": "local-md:README.md", "sessionId": "sess_local_1", "rootUri": root_uri, "actorId": "user_1", "contextRefs": [{ "kind": "folder", "rootUri": root_uri, "relativePath": "" }], "targetPackage": { "schema": "mnote.agent_target_package.v1", "allowedFiles": ["README.md"], "currentFile": { "relativePath": "README.md" } } }); let before = local_agent_audit_collect_snapshot_for_payload(&payload, None) .expect("before allowed-files snapshot"); std::fs::write(root.join("README.md"), "# Readme\nnew\n").expect("modify readme"); std::fs::write(root.join("Other.md"), "# Other\nnew\n").expect("modify other"); let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before)) .expect("after allowed-files snapshot"); let changed_files = json!([ { "path": "README.md", "changeType": "modified", "summary": "1 line changed" } ]); let event = local_agent_audit_event( &context, &payload, "run_local_1", "reasonix", "completed", changed_files, Some(&before), Some(&after), false, ); let receipt = &event["agentRunReceipt"]; assert_eq!(receipt["schema"], "mnote.agent_run_receipt.v1"); assert_eq!(receipt["runId"], "run_local_1"); assert_eq!(receipt["sessionId"], "sess_local_1"); assert_eq!(receipt["agentKind"], "reasonix"); assert_eq!(receipt["status"], "completed"); assert_eq!(receipt["changedFiles"][0]["path"], "README.md"); assert_eq!(receipt["refresh"]["touchesCurrentFile"], true); assert_eq!(event["auditScope"]["scope"], "allowed_files"); assert_eq!(event["auditScope"]["fileCount"], 1); assert_eq!(receipt["auditScope"]["scope"], "allowed_files"); assert_eq!(receipt["auditScope"]["fileCount"], 1); let _ = std::fs::remove_dir_all(root); } #[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 local_ai_run_access_rewrites_allowed_roots_from_sqlite_grant() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("alice".to_string()), email: None, username: "alice".to_string(), display_name: "Alice".to_string(), role: None, password_hash: None, }) .expect("alice user"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "alice".to_string(), workspace_id: None, root_uri: "file:///tmp/mnote-ai-allowed".to_string(), root_path: "/tmp/mnote-ai-allowed".to_string(), permission: "write".to_string(), recursive: true, capabilities: vec!["ai".to_string()], source: "user".to_string(), created_by: Some("alice".to_string()), }) .expect("alice grant"); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "alice".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 mut payload = json!({ "workspaceId": "local-ws:alice:test", "documentId": "local-md:README.md", "sessionId": "sess_sqlite_roots", "sourceKind": "local_folder", "rootUri": "file:///tmp/mnote-ai-allowed", "allowedRoots": [{"rootUri": "file:///tmp/evil", "permission": "write", "source": "client"}] }); enforce_local_ai_run_access(&state, &context, "alice", &mut payload) .expect("alice allowed"); assert_eq!(payload["permissionLevel"], "read_write"); assert_eq!( payload["allowedRoots"][0]["rootUri"], "file:///tmp/mnote-ai-allowed" ); assert_eq!(payload["allowedRoots"][0]["permission"], "write"); assert_eq!( payload["allowedRoots"][0]["source"], "sqlite_directory_grant" ); assert!(payload["allowedRoots"][0]["grantIds"] .as_array() .map(|items| !items.is_empty()) .unwrap_or(false)); assert!(!payload.to_string().contains("file:///tmp/evil")); } #[test] fn local_ai_run_access_rejects_other_user_without_sqlite_grant() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("alice".to_string()), email: None, username: "alice".to_string(), display_name: "Alice".to_string(), role: None, password_hash: None, }) .expect("alice user"); state .control_plane() .upsert_user(UpsertUserInput { id: Some("bob".to_string()), email: None, username: "bob".to_string(), display_name: "Bob".to_string(), role: None, password_hash: None, }) .expect("bob user"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "alice".to_string(), workspace_id: None, root_uri: "file:///tmp/mnote-ai-alice-only".to_string(), root_path: "/tmp/mnote-ai-alice-only".to_string(), permission: "write".to_string(), recursive: true, capabilities: vec!["ai".to_string()], source: "user".to_string(), created_by: Some("alice".to_string()), }) .expect("alice grant"); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "bob".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 mut payload = json!({ "workspaceId": "local-ws:alice:test", "documentId": "local-md:README.md", "sessionId": "sess_bob_denied", "sourceKind": "local_folder", "rootUri": "file:///tmp/mnote-ai-alice-only" }); let error = enforce_local_ai_run_access(&state, &context, "bob", &mut payload) .expect_err("bob must be denied"); assert_eq!(error.status(), StatusCode::FORBIDDEN); assert_eq!(error.code(), "local_ai_workspace_write_access_denied"); } #[test] fn local_ai_run_access_downgrades_chat_only_to_read_scope() { let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("chat_user".to_string()), email: None, username: "chat_user".to_string(), display_name: "Chat User".to_string(), role: None, password_hash: None, }) .expect("chat user"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "chat_user".to_string(), workspace_id: None, root_uri: "file:///tmp/mnote-ai-chat-only".to_string(), root_path: "/tmp/mnote-ai-chat-only".to_string(), permission: "write".to_string(), recursive: true, capabilities: vec!["ai".to_string()], source: "user".to_string(), created_by: Some("chat_user".to_string()), }) .expect("chat grant"); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "chat_user".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 mut payload = json!({ "workspaceId": "local-ws:chat:test", "documentId": "local-md:README.md", "sessionId": "sess_chat_only", "sourceKind": "local_folder", "rootUri": "file:///tmp/mnote-ai-chat-only", "agentId": "chat_only" }); enforce_local_ai_run_access(&state, &context, "chat_user", &mut payload) .expect("chat only allowed as read"); assert_eq!(payload["permissionLevel"], "read_only"); assert_eq!(payload["allowedRoots"][0]["permission"], "read"); } #[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_only_for_remote_compat_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("远端 / cloud / compat 普通正文 search/replace、局部段落替换或全文 markdown 替换,应优先调用 mnote_doc_markdown_edit")); assert!(instructions.contains( "\"mnote_doc_markdown_edit for remote/cloud/compat 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", 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") .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!(tools.contains_key("mnote.doc.markdown_edit")); assert!(tools.contains_key("mnote.page.save")); assert_eq!(tools["mnote.doc.fetch"]["enabled"], true); assert!(tools["mnote.doc.markdown_edit"]["description"] .as_str() .unwrap_or_default() .contains("local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具")); assert!(tools["mnote.page.save"]["description"] .as_str() .unwrap_or_default() .contains("local-first 本地 Markdown 普通编辑禁止使用该工具")); 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); } }