use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::execute_convex_query_by_name; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; use axum::Json; use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path as FsPath, PathBuf}; use std::process::Command; use std::sync::{LazyLock, Mutex}; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::warn; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; static HERMES_RUNTIME_REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static HERMES_RUN_QUEUE: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); #[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(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { workspace_id: Option, document_id: Option, trace_id: Option, title: Option, profile: Option, } pub async fn list_sessions( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let Some(upstream) = configured_upstream() 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).await } pub async fn create_session( 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"); 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": "hermes_on_first_run" })), )) } 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 upstream = configured_upstream(); let profile_status = profile_gateway_status(&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).await; gateway["ok"] = Value::Bool(probe.ok); gateway["status"] = Value::String(probe.status); gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null); gateway["path"] = probe.path.map(Value::from).unwrap_or(Value::Null); gateway["message"] = probe.message.map(Value::from).unwrap_or(Value::Null); if !probe.ok { suggestions.extend( hermes_settings_suggestions( gateway["httpStatus"].as_u64(), gateway["message"].as_str(), ) .into_iter() .map(Value::String), ); } } let ok = gateway["ok"].as_bool().unwrap_or(false) && profile_status["modelConfigured"].as_bool().unwrap_or(false) && profile_status["apiKeyConfigured"] .as_bool() .unwrap_or(false); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": ok, "traceId": context.trace.trace_id, "profile": profile_status, "gateway": gateway, "suggestions": unique_string_values(suggestions) })), )) } pub async fn get_profile( Extension(context): Extension, Path(profile_name): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "profile": profile_detail_payload(&profile_name) })), )) } pub async fn switch_active_profile( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let name = payload .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 profile name") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; switch_active_profile_local(name).map_err(|error| { WebError::bad_gateway_code( "hermes_client_profile_switch_failed", format!("切换 Hermes profile 失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "profile": profile_detail_payload(name) })), )) } pub async fn get_profile_memory( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = query .get("profile") .map(String::as_str) .unwrap_or(fallback_profile.as_str()); Ok(( StatusCode::OK, stamp_client_headers(), Json(profile_memory_payload(profile)), )) } pub async fn save_profile_memory( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .unwrap_or(fallback_profile.as_str()); let section = payload .get("section") .and_then(Value::as_str) .map(str::trim) .filter(|value| matches!(*value, "memory" | "user" | "soul")) .ok_or_else(|| { WebError::bad_request_code( "hermes_client_bad_request", "section 必须是 memory、user 或 soul", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let content = payload .get("content") .and_then(Value::as_str) .unwrap_or_default(); save_profile_memory_local(profile, section, content).map_err(|error| { WebError::bad_gateway_code( "hermes_client_memory_save_failed", format!("保存 Hermes profile memory 失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true})), )) } pub async fn list_skills( Extension(context): Extension, Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = query .get("profile") .map(String::as_str) .unwrap_or(fallback_profile.as_str()); Ok(( StatusCode::OK, stamp_client_headers(), Json(skills_payload(profile)), )) } pub async fn toggle_skill( Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let name = payload .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 skill name") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let enabled = payload .get("enabled") .and_then(Value::as_bool) .ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); let profile = payload .get("profile") .and_then(Value::as_str) .unwrap_or(fallback_profile.as_str()); set_skill_enabled(profile, name, enabled).map_err(|error| { WebError::bad_gateway_code( "hermes_client_skill_toggle_failed", format!("更新 Hermes skill 设置失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({"ok": true})), )) } pub async fn get_session( Extension(context): Extension, Path(session_id): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; 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( Extension(context): Extension, Path(session_id): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { get_session(Extension(context), Path(session_id)).await } async fn load_session_from_hermes_cli(session_id: &str) -> Option { let session_id = session_id.to_string(); tokio::task::spawn_blocking(move || { let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "hermes".into()); let output = Command::new(hermes_bin) .args(["sessions", "export", "--session-id", &session_id, "-"]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8(output.stdout).ok()?; stdout .lines() .find_map(|line| serde_json::from_str::(line).ok()) }) .await .ok() .flatten() } pub async fn create_run( State(state): State, Extension(context): Extension, Json(mut payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; let (actor_id, actor_type) = resolve_run_actor(&state, &context).await; stamp_run_actor(&mut payload, &actor_id, &actor_type); let registration = run_registration_from_payload(&context, &payload); 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() 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), ) .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) })), )) } pub async fn stream_events( Extension(context): Extension, Path(run_id): Path, ) -> Result { ensure_authenticated(&context)?; let Some(upstream) = configured_upstream() 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() { 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 Some(upstream) = configured_upstream() 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), ) .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) } } } 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, ) .await } pub async fn list_tools( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, "tools": [ { "name": "mnote.page.get", "scope": "page.read", "kind": "read", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "读取当前页面正文、标题、设置与结构快照" }, { "name": "mnote.page.save", "scope": "page.write", "kind": "write", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "保存当前页面正文" }, { "name": "mnote.page.update_title", "scope": "page.write", "kind": "write", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "更新当前页面标题" }, { "name": "mnote.page.update_options", "scope": "page.write", "kind": "write", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "更新当前页面设置" }, { "name": "mnote.artifact.create_summary", "scope": "artifact.write", "kind": "write", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "为当前页面创建或更新 AI Summary" }, { "name": "mnote.artifact.create_ai_note", "scope": "artifact.write", "kind": "write", "schemaVersion": "mnote.hermes_tool.v1", "status": "available", "description": "基于当前页面创建新的 AI Note" } ] })), )) } 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")) } fn active_profile_name() -> Option { fs::read_to_string(hermes_home().join("active_profile")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn profile_home(profile: &str) -> PathBuf { let home = hermes_home(); let profile = profile.trim(); if profile.is_empty() || profile == "default" { return home; } let candidate = home.join("profiles").join(profile); if candidate.exists() { candidate } else { home } } fn profile_config_path(profile: &str) -> PathBuf { profile_home(profile).join("config.yaml") } fn parse_profile_list(stdout: &str) -> Vec { let active = active_profile_name().unwrap_or_else(|| "default".into()); stdout .replace("\r\n", "\n") .replace('\r', "\n") .lines() .filter_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("Profile") || trimmed.starts_with('─') { return None; } let is_active = trimmed.starts_with('◆'); let without_marker = trimmed.trim_start_matches('◆').trim(); let parts = without_marker .split_whitespace() .map(str::to_string) .collect::>(); if parts.is_empty() { return None; } let name = parts[0].clone(); let model = parts .get(1) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); let gateway = parts .get(2) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); let alias = parts .get(3) .filter(|value| value.as_str() != "—") .cloned() .unwrap_or_default(); Some(json!({ "name": name, "active": name == active || (active == "default" && is_active), "model": model, "gateway": gateway, "alias": alias })) }) .collect() } fn fallback_profiles() -> Vec { let active = active_profile_name().unwrap_or_else(|| "default".into()); let default_status = profile_gateway_status("default"); let mut profiles = vec![json!({ "name": "default", "active": active == "default", "model": default_status["modelDefault"].as_str().unwrap_or_default(), "gateway": default_status["gateway"].as_str().unwrap_or_default(), "provider": default_status["provider"].as_str().unwrap_or_default(), "alias": "" })]; let profiles_dir = hermes_home().join("profiles"); if let Ok(entries) = fs::read_dir(profiles_dir) { let mut names = entries .filter_map(Result::ok) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|entry| entry.file_name().into_string().ok()) .collect::>(); names.sort(); profiles.extend(names.into_iter().map(|name| { let status = profile_gateway_status(&name); json!({ "name": name, "active": name == active, "model": status["modelDefault"].as_str().unwrap_or_default(), "gateway": status["gateway"].as_str().unwrap_or_default(), "provider": status["provider"].as_str().unwrap_or_default(), "alias": "" }) })); } profiles } fn list_profiles_payload() -> Value { let profiles = Command::new(hermes_bin()) .args(["profile", "list"]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|stdout| parse_profile_list(&stdout)) .filter(|profiles| !profiles.is_empty()) .unwrap_or_else(fallback_profiles); json!({ "ok": true, "profiles": profiles }) } 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 extract_skill_description(markdown: &str) -> String { markdown .lines() .find_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { return None; } Some( trimmed .trim_start_matches("Description:") .trim() .to_string(), ) }) .unwrap_or_default() } struct SkillCatalogMeta { bundled: HashSet, hub_installed: HashSet, usage: HashMap, } impl SkillCatalogMeta { fn read(skills_dir: &FsPath) -> Self { Self { bundled: read_bundled_skill_names(&skills_dir.join(".bundled_manifest")), hub_installed: read_hub_installed_skill_names( &skills_dir.join(".hub").join("lock.json"), ), usage: read_skill_usage(&skills_dir.join(".usage.json")), } } fn usage_for(&self, name: &str) -> Option<&Value> { self.usage.get(name) } } fn read_bundled_skill_names(path: &FsPath) -> HashSet { fs::read_to_string(path) .ok() .map(|content| { content .lines() .filter_map(|line| { line.split_once(':') .map(|(name, _)| name.trim().to_string()) }) .filter(|name| !name.is_empty()) .collect() }) .unwrap_or_default() } fn read_hub_installed_skill_names(path: &FsPath) -> HashSet { let Ok(content) = fs::read_to_string(path) else { return HashSet::new(); }; let Ok(payload) = serde_json::from_str::(&content) else { return HashSet::new(); }; payload .get("installed") .and_then(Value::as_object) .map(|installed| { installed .iter() .flat_map(|(name, entry)| { let mut names = vec![name.to_string()]; if let Some(path) = entry.get("install_path").and_then(Value::as_str) { if let Some(last) = path.rsplit('/').find(|part| !part.trim().is_empty()) { names.push(last.to_string()); } } names }) .collect() }) .unwrap_or_default() } fn read_skill_usage(path: &FsPath) -> HashMap { let Ok(content) = fs::read_to_string(path) else { return HashMap::new(); }; serde_json::from_str::(&content) .ok() .and_then(|payload| payload.as_object().cloned()) .map(|items| items.into_iter().collect()) .unwrap_or_default() } fn skill_source(name: &str, meta: &SkillCatalogMeta) -> &'static str { if meta.bundled.contains(name) { "builtin" } else if meta.hub_installed.contains(name) { "hub" } else { "local" } } fn skill_is_agent_generated(usage: Option<&Value>) -> bool { usage .and_then(Value::as_object) .map(|record| { record .get("created_by") .and_then(Value::as_str) .map(|value| value == "agent") .unwrap_or(false) || record .get("agent_created") .and_then(Value::as_bool) .unwrap_or(false) }) .unwrap_or(false) } fn skill_origin(source: &str, usage: Option<&Value>) -> &'static str { if skill_is_agent_generated(usage) { "generated" } else if source == "builtin" { "builtin" } else if source == "hub" { "installed" } else { "copied" } } fn skill_entry( name: String, category: String, path: PathBuf, disabled: &[String], meta: &SkillCatalogMeta, ) -> Option { let content = fs::read_to_string(path).ok()?; let source = skill_source(&name, meta); let usage = meta.usage_for(&name); Some(json!({ "name": name, "description": extract_skill_description(&content), "enabled": !disabled.iter().any(|item| item == &name), "source": source, "origin": skill_origin(source, usage), "createdBy": usage.and_then(|record| record.get("created_by")).and_then(Value::as_str), "createdAt": usage.and_then(|record| record.get("created_at")).and_then(Value::as_str), "patchCount": usage.and_then(|record| record.get("patch_count")).and_then(Value::as_i64), "category": category })) } fn skills_payload(profile: &str) -> Value { let skills_dir = hermes_home().join("skills"); let disabled = disabled_skills(profile); let meta = SkillCatalogMeta::read(&skills_dir); let mut categories = Vec::new(); let Ok(entries) = fs::read_dir(&skills_dir) else { return json!({"ok": true, "categories": [], "archived": []}); }; let mut dirs = entries .filter_map(Result::ok) .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) .collect::>(); dirs.sort_by_key(|entry| entry.file_name()); for entry in dirs { let name = entry.file_name().to_string_lossy().to_string(); let dir = entry.path(); let direct_skill = dir.join("SKILL.md"); if direct_skill.exists() { if let Some(skill) = skill_entry(name, "misc".into(), direct_skill, &disabled, &meta) { categories.push(json!({ "name": "misc", "description": "misc", "skills": [skill] })); } continue; } let mut skills = fs::read_dir(&dir) .ok() .into_iter() .flat_map(|entries| entries.filter_map(Result::ok)) .filter(|child| child.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) .filter_map(|child| { let skill_name = child.file_name().to_string_lossy().to_string(); skill_entry( skill_name, name.clone(), child.path().join("SKILL.md"), &disabled, &meta, ) }) .collect::>(); if !skills.is_empty() { skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); categories.push(json!({ "name": name, "description": "", "skills": skills })); } } json!({ "ok": true, "categories": merge_misc_categories(categories), "archived": [] }) } fn merge_misc_categories(categories: Vec) -> Vec { let mut merged = Vec::new(); let mut misc = Vec::new(); for category in categories { if category["name"].as_str() == Some("misc") { if let Some(skills) = category["skills"].as_array() { misc.extend(skills.iter().cloned()); } } else { merged.push(category); } } if !misc.is_empty() { misc.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); merged.insert( 0, json!({ "name": "misc", "description": "misc", "skills": misc }), ); } merged } fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> { let path = profile_config_path(profile); let mut disabled = disabled_skills(profile); if enabled { disabled.retain(|item| item != name); } else if !disabled.iter().any(|item| item == name) { disabled.push(name.to_string()); } disabled.sort(); let existing = fs::read_to_string(&path).unwrap_or_default(); let mut kept = Vec::new(); let mut in_skills = false; let mut in_disabled = false; for line in existing.lines() { let trimmed = line.trim(); if !line.starts_with(' ') && !trimmed.is_empty() { in_skills = trimmed == "skills:"; in_disabled = false; } if in_skills && trimmed == "disabled:" { in_disabled = true; continue; } if in_disabled { if trimmed.starts_with("- ") || trimmed.is_empty() { continue; } in_disabled = false; } kept.push(line.to_string()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut output = kept.join("\n"); if !output.ends_with('\n') && !output.is_empty() { output.push('\n'); } output.push_str("skills:\n disabled:\n"); for skill in disabled { output.push_str(" - "); output.push_str(&skill); output.push('\n'); } fs::write(path, output) } fn 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 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()) } #[derive(Debug)] struct GatewayProbe { ok: bool, status: String, http_status: Option, path: Option, message: Option, } async fn probe_gateway_health(upstream: &str) -> 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) = configured_api_key() { request = request.bearer_auth(api_key); } match request.send().await { Ok(response) if response.status().is_success() => { return GatewayProbe { ok: true, status: "ok".into(), http_status: Some(response.status().as_u16() as u64), path: Some(path.into()), message: None, }; } Ok(response) => { let status = response.status(); let text = response.text().await.unwrap_or_default(); if path == "/v1/models" || status != reqwest::StatusCode::NOT_FOUND { return GatewayProbe { ok: false, status: "upstream_error".into(), http_status: Some(status.as_u16() as u64), path: Some(path.into()), message: Some(text.chars().take(600).collect()), }; } } Err(error) => { return GatewayProbe { ok: false, status: "unreachable".into(), http_status: None, path: Some(path.into()), message: Some(error.to_string()), }; } } } GatewayProbe { ok: false, status: "unknown".into(), http_status: None, path: None, message: Some("Hermes gateway health 探测未返回有效结果".into()), } } fn env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } if cfg!(test) { return None; } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = fs::read_to_string(root).ok()?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } let Some((candidate_key, value)) = line.split_once('=') else { continue; }; if candidate_key.trim() != key { continue; } let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } fn yaml_path_value(content: &str, path: &[&str]) -> Option { let mut stack: Vec<(usize, String)> = Vec::new(); for raw_line in content.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') { continue; } let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } let Some((key, value)) = trimmed.split_once(':') else { continue; }; let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); stack.push((indent, key)); let keys = stack .iter() .map(|(_, key)| key.as_str()) .collect::>(); if keys == path && !value.is_empty() { return Some(value); } } None } fn profile_gateway_status(profile: &str) -> Value { let normalized_profile = if profile.trim().is_empty() { "default" } else { profile.trim() }; let config_path = profile_config_path(normalized_profile); let config = fs::read_to_string(&config_path).unwrap_or_default(); let model_default = yaml_path_value(&config, &["model", "default"]).unwrap_or_default(); let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default(); let gateway = yaml_path_value(&config, &["model", "gateway"]) .or_else(|| yaml_path_value(&config, &["model", "base_url"])) .unwrap_or_default(); let model_api_key = yaml_path_value(&config, &["model", "api_key"]); let model_key_env = yaml_path_value(&config, &["model", "key_env"]); let provider_api_key = if provider.is_empty() { None } else { yaml_path_value(&config, &["providers", &provider, "api_key"]) }; let provider_key_env = if provider.is_empty() { None } else { yaml_path_value(&config, &["providers", &provider, "key_env"]) }; let key_env_configured = model_key_env .as_deref() .or(provider_key_env.as_deref()) .and_then(env_or_dotenv) .is_some(); let api_key_configured = model_api_key .as_deref() .or(provider_api_key.as_deref()) .map(|value| !value.trim().is_empty()) .unwrap_or(false) || key_env_configured; let mut suggestions = Vec::new(); if model_default.is_empty() { suggestions .push("当前 Hermes profile 缺少 model.default,请在 Hermes 设置中选择默认模型。"); } if provider.is_empty() { suggestions .push("当前 Hermes profile 缺少 model.provider,请在 Hermes 设置中选择 provider。"); } if !api_key_configured { suggestions.push("当前 Hermes profile 未检测到 provider API key 或 key_env,请在 Hermes 设置中补齐 API key。"); } json!({ "name": normalized_profile, "path": profile_home(normalized_profile).to_string_lossy(), "configPath": config_path.to_string_lossy(), "configExists": config_path.exists(), "modelDefault": model_default, "provider": provider, "gateway": gateway, "modelConfigured": !model_default.is_empty(), "apiKeyConfigured": api_key_configured, "suggestions": suggestions }) } fn unique_string_values(values: Vec) -> Vec { let mut seen = HashSet::new(); values .into_iter() .filter_map(|value| value.as_str().map(str::trim).map(ToOwned::to_owned)) .filter(|value| !value.is_empty()) .filter(|value| seen.insert(value.clone())) .map(Value::String) .collect() } fn hermes_settings_suggestions(status: Option, message: Option<&str>) -> Vec { let lower = message.unwrap_or_default().to_lowercase(); let mut suggestions = Vec::new(); if lower.contains("model.default") || (lower.contains("model") && (lower.contains("default") || lower.contains("missing"))) { suggestions.push( "当前 Hermes profile 可能缺少 model.default,请在 Hermes 设置中选择默认模型。".into(), ); } if lower.contains("api key") || lower.contains("apikey") || lower.contains("unauthorized") || lower.contains("forbidden") || matches!(status, Some(401 | 403)) { suggestions.push("当前 Hermes provider 可能缺少 API key 或 key_env 未生效,请在 Hermes 设置中补齐 API key。".into()); } if lower.contains("profile") || lower.contains("provider") { suggestions .push("请检查当前页面 AI 选择的 Hermes profile、provider 与 gateway 是否匹配。".into()); } if suggestions.is_empty() { suggestions.push("请检查 Hermes gateway 是否正在运行,并确认当前 profile 的 model.default、provider 与 API key 已配置。".into()); } suggestions } fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { let message = extract_run_message(&payload).ok_or_else(|| { WebError::bad_request_code("hermes_client_bad_request", "缺少 message") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; let document_id = payload .get("documentId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or("current"); let trace_id = payload .get("traceId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&context.trace.trace_id); let session_id = payload .get("sessionId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| stable_session_id(document_id, trace_id)); let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null); let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null); let profile = payload .get("profile") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let instructions = json!({ "role": "mnote_page_ai_context", "workspaceId": workspace_id, "documentId": document_id, "profile": profile.unwrap_or("default"), "actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id), "actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type), "sessionId": session_id, "traceId": trace_id, "toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。", "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null), "pageContext": sanitize_run_page_context(page_context) }) .to_string(); let mut body = json!({ "input": message, "session_id": session_id, "instructions": instructions }); if let Some(model) = payload .get("model") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) { body["model"] = Value::String(model.to_string()); } if let Some(profile) = profile { body["profile"] = Value::String(profile.to_string()); } Ok(body) } fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return (actor_id.to_string(), context.auth.actor_type.clone()); } if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return (state.config().dev_user_id.clone(), "devFallback".into()); } ("anonymous".into(), "anonymous".into()) } async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return (actor_id.to_string(), context.auth.actor_type.clone()); } if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() { return ("anonymous".into(), "anonymous".into()); } if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await { return (user_id, "user".into()); } effective_run_actor(state, context) } async fn resolve_current_convex_user_id( state: &AppState, context: &RequestContext, ) -> Result, WebError> { let payload = execute_convex_query_by_name( state.config(), context, "users:currentUser", json!({}), None, "hermes_run_current_user", ) .await?; Ok(payload .get("_id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned)) } 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", ] { 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.page.get".into())), ); Value::Object(sanitized) } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default() } fn runtime_state_to_json(state: &HermesRuntimeState) -> Value { json!({ "sessionId": state.session_id, "runId": state.run_id, "profile": state.profile, "documentId": state.document_id, "traceId": state.trace_id, "status": state.status, "startedAt": state.started_at, "lastEventAt": state.last_event_at, "lastEvent": state.last_event, "lastToolName": state.last_tool_name, "lastToolCallId": state.last_tool_call_id, "lastAuditId": state.last_audit_id }) } fn runtime_state_for_session(session_id: &str) -> Value { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .get(session_id) .map(runtime_state_to_json) .unwrap_or(Value::Null) } fn runtime_state_for_run(run_id: &str) -> Option { let registry = HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry"); registry .values() .find(|state| state.run_id == run_id) .map(runtime_state_to_json) } fn 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, } } 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 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, ) -> 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) = 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() 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), ) .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 std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tower::util::ServiceExt; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn runtime_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn clear_runtime_registry() { HERMES_RUNTIME_REGISTRY .lock() .expect("hermes runtime registry") .clear(); } fn clear_run_queue() { HERMES_RUN_QUEUE.lock().expect("hermes run queue").clear(); } fn app() -> axum::Router { build_app(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, 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 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, 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(), }) } #[tokio::test] async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() { let _env_guard = env_lock().lock().expect("env lock"); 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"); 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"); 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"); 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"); 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"); } #[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::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") ); } #[tokio::test] async fn hermes_client_session_create_does_not_require_upstream_or_store_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"], "hermes_on_first_run"); assert!(payload.get("messages").is_none()); } #[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_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_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(); 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"); } #[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" }, "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("不应进入 Hermes instructions")); assert!(!instructions.contains("\"documentBlocks\"")); assert!(!instructions.contains("\"subtree\"")); assert!(!instructions.contains("\"outline\"")); } #[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\"")); } #[tokio::test] async fn hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream() { let _guard = env_lock().lock().expect("env lock"); std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); let hermes_home = std::env::temp_dir().join(format!( "mnote-web-hermes-client-test-{}", std::process::id() )); let _ = fs::remove_dir_all(&hermes_home); for name in ["draft", "installed-demo", "builtin-demo"] { fs::create_dir_all(hermes_home.join("skills").join("writing").join(name)) .expect("test skill dir"); fs::write( hermes_home .join("skills") .join("writing") .join(name) .join("SKILL.md"), format!("# {name}\n\n写作助手"), ) .expect("test skill"); } fs::create_dir_all(hermes_home.join("skills").join("copied-demo")) .expect("test copied skill dir"); fs::write( hermes_home .join("skills") .join("copied-demo") .join("SKILL.md"), "# copied-demo\n\n复制技能", ) .expect("test copied skill"); fs::write( hermes_home.join("skills").join(".bundled_manifest"), "builtin-demo:abc123\n", ) .expect("test bundled manifest"); fs::create_dir_all(hermes_home.join("skills").join(".hub")).expect("test hub dir"); fs::write( hermes_home.join("skills").join(".hub").join("lock.json"), json!({ "installed": { "installed-demo": { "source": "skills.sh", "install_path": "writing/installed-demo" } } }) .to_string(), ) .expect("test hub lock"); fs::write( hermes_home.join("skills").join(".usage.json"), json!({ "draft": { "created_by": "agent", "created_at": "2026-05-14T00:00:00Z", "patch_count": 2 } }) .to_string(), ) .expect("test usage"); std::env::set_var("HERMES_HOME", &hermes_home); std::env::set_var("MNOTE_WEB_HERMES_BIN", "__missing_hermes_for_test__"); let cases = [ ("GET", "/api/hermes/client/profiles", None), ("GET", "/api/hermes/client/profiles/chemist", None), ( "GET", "/api/hermes/client/profile-memory?profile=chemist", None, ), ("GET", "/api/hermes/client/skills?profile=chemist", None), ( "PUT", "/api/hermes/client/profiles/active", Some(json!({"name": "chemist"})), ), ( "POST", "/api/hermes/client/profile-memory", Some(json!({"profile": "chemist", "section": "soul", "content": "test"})), ), ( "PUT", "/api/hermes/client/skills/toggle", Some(json!({"name": "chem", "enabled": true})), ), ]; for (method, uri, body) in cases { let request = Request::builder() .method(method) .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") .body(Body::from(body.unwrap_or_else(|| json!({})).to_string())) .expect("request"); let response = app().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK, "{uri}"); } assert_eq!( fs::read_to_string(hermes_home.join("active_profile")) .expect("active profile") .trim(), "chemist" ); assert_eq!( fs::read_to_string(hermes_home.join("profiles").join("chemist").join("SOUL.md")) .expect("soul"), "test" ); let request = Request::builder() .method("GET") .uri("/api/hermes/client/skills?profile=chemist") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"); let response = app().oneshot(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("skills json"); let skills = payload["categories"] .as_array() .expect("categories") .iter() .flat_map(|category| category["skills"].as_array().into_iter().flatten()) .map(|skill| { ( skill["name"].as_str().unwrap_or_default().to_string(), skill.clone(), ) }) .collect::>(); assert_eq!(skills["draft"]["origin"], "generated"); assert_eq!(skills["draft"]["createdBy"], "agent"); assert_eq!(skills["installed-demo"]["source"], "hub"); assert_eq!(skills["installed-demo"]["origin"], "installed"); assert_eq!(skills["builtin-demo"]["source"], "builtin"); assert_eq!(skills["copied-demo"]["origin"], "copied"); std::env::remove_var("HERMES_HOME"); std::env::remove_var("MNOTE_WEB_HERMES_BIN"); let _ = fs::remove_dir_all(&hermes_home); } }