Files
mnote/rust/crates/mnote-web/src/routes/hermes_client.rs
T

3408 lines
119 KiB
Rust
Raw Normal View History

2026-05-16 12:34:48 +08:00
use crate::app::AppState;
2026-05-14 15:10:33 +08:00
use crate::context::RequestContext;
use crate::error::WebError;
2026-05-16 12:34:48 +08:00
use crate::transport::convex::execute_convex_query_by_name;
2026-05-14 15:10:33 +08:00
use axum::body::Body;
2026-05-16 12:34:48 +08:00
use axum::extract::{Extension, Path, Query, State};
2026-05-14 15:10:33 +08:00
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};
2026-05-15 23:20:25 +08:00
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
2026-05-15 23:20:25 +08:00
use std::path::{Path as FsPath, PathBuf};
2026-05-14 15:10:33 +08:00
use std::process::Command;
2026-05-15 23:20:25 +08:00
use std::sync::{LazyLock, Mutex};
2026-05-14 15:10:33 +08:00
use std::time::Duration;
2026-05-15 23:20:25 +08:00
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
2026-05-14 15:10:33 +08:00
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
2026-05-15 23:20:25 +08:00
static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun>>>> =
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<String>,
last_tool_name: Option<String>,
last_tool_call_id: Option<String>,
last_audit_id: Option<String>,
}
#[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,
2026-05-16 12:34:48 +08:00
actor_id: String,
actor_type: String,
2026-05-15 23:20:25 +08:00
input: String,
context_summary: Value,
queued_at: u128,
}
2026-05-14 15:10:33 +08:00
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
workspace_id: Option<String>,
document_id: Option<String>,
trace_id: Option<String>,
title: Option<String>,
2026-05-14 17:04:17 +08:00
profile: Option<String>,
2026-05-14 15:10:33 +08:00
}
pub async fn list_sessions(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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::<Vec<_>>()
.join("&");
path.push('?');
path.push_str(&params);
}
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
}
pub async fn create_session(
Extension(context): Extension<RequestContext>,
Json(payload): Json<CreateSessionRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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);
2026-05-14 17:04:17 +08:00
let profile = payload
.profile
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
2026-05-14 15:10:33 +08:00
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"sessionId": session_id,
"workspaceId": payload.workspace_id,
"documentId": payload.document_id,
2026-05-14 17:04:17 +08:00
"profile": profile,
2026-05-14 15:10:33 +08:00
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": "hermes_on_first_run"
})),
))
}
2026-05-14 17:04:17 +08:00
pub async fn list_profiles(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(list_profiles_payload()),
))
}
pub async fn gateway_health(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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)
})),
))
2026-05-14 17:04:17 +08:00
}
pub async fn get_profile(
Extension(context): Extension<RequestContext>,
Path(profile_name): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"profile": profile_detail_payload(&profile_name)
})),
))
2026-05-14 17:04:17 +08:00
}
pub async fn switch_active_profile(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
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)
})),
))
2026-05-14 17:04:17 +08:00
}
pub async fn get_profile_memory(
Extension(context): Extension<RequestContext>,
2026-05-15 23:20:25 +08:00
Query(query): Query<HashMap<String, String>>,
2026-05-14 17:04:17 +08:00
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
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)),
))
2026-05-14 17:04:17 +08:00
}
pub async fn save_profile_memory(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
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})),
))
2026-05-14 17:04:17 +08:00
}
pub async fn list_skills(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
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)),
))
2026-05-14 17:04:17 +08:00
}
pub async fn toggle_skill(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
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})),
))
2026-05-14 17:04:17 +08:00
}
2026-05-14 15:10:33 +08:00
pub async fn get_session(
Extension(context): Extension<RequestContext>,
Path(session_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-15 23:20:25 +08:00
let runtime = runtime_state_for_session(&session_id);
2026-05-14 15:10:33 +08:00
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,
2026-05-15 23:20:25 +08:00
"session": session,
"runtime": runtime
2026-05-14 15:10:33 +08:00
})),
));
}
2026-05-15 23:20:25 +08:00
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<RequestContext>,
Path(session_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
get_session(Extension(context), Path(session_id)).await
2026-05-14 15:10:33 +08:00
}
async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
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::<Value>(line).ok())
})
.await
.ok()
.flatten()
}
pub async fn create_run(
2026-05-16 12:34:48 +08:00
State(state): State<AppState>,
2026-05-14 15:10:33 +08:00
Extension(context): Extension<RequestContext>,
2026-05-16 12:34:48 +08:00
Json(mut payload): Json<Value>,
2026-05-14 15:10:33 +08:00
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
2026-05-16 12:34:48 +08:00
let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
stamp_run_actor(&mut payload, &actor_id, &actor_type);
2026-05-15 23:20:25 +08:00
let registration = run_registration_from_payload(&context, &payload);
if session_has_active_run(&registration.session_id) {
let queued = enqueue_run(&context, &registration, &payload)?;
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
}
2026-05-14 15:10:33 +08:00
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
let upstream_body = build_run_upstream_body(&context, payload)?;
2026-05-15 23:20:25 +08:00
let result = proxy_json(
2026-05-14 15:10:33 +08:00
&context,
reqwest::Method::POST,
&upstream,
"/v1/runs",
Some(upstream_body),
)
2026-05-15 23:20:25 +08:00
.await?;
if let Some(runtime) = register_runtime_from_create_run_response(&registration, &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<RequestContext>,
Path((session_id, queue_id)): Path<(String, String)>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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)
})),
))
2026-05-14 15:10:33 +08:00
}
pub async fn stream_events(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Response, WebError> {
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));
}
2026-05-15 23:20:25 +08:00
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}"),
)
});
2026-05-14 15:10:33 +08:00
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<RequestContext>,
Path(run_id): Path<String>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
2026-05-15 23:20:25 +08:00
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(
2026-05-14 15:10:33 +08:00
&context,
reqwest::Method::POST,
&upstream,
&format!("/v1/runs/{}/stop", url_escape(&run_id)),
Some(payload),
)
2026-05-15 23:20:25 +08:00
.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)
}
}
2026-05-14 15:10:33 +08:00
}
pub async fn list_models(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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",
2026-05-14 15:10:33 +08:00
"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"
2026-05-14 15:10:33 +08:00
}
]
})),
))
}
2026-05-15 23:20:25 +08:00
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<String> {
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<Value> {
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::<Vec<_>>();
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<Value> {
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::<Vec<_>>();
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<i128> {
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<String> {
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<String>,
hub_installed: HashSet<String>,
usage: HashMap<String, Value>,
}
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<String> {
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<String> {
let Ok(content) = fs::read_to_string(path) else {
return HashSet::new();
};
let Ok(payload) = serde_json::from_str::<Value>(&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<String, Value> {
let Ok(content) = fs::read_to_string(path) else {
return HashMap::new();
};
serde_json::from_str::<Value>(&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<Value> {
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::<Vec<_>>();
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::<Vec<_>>();
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<Value>) -> Vec<Value> {
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)
}
2026-05-14 15:10:33 +08:00
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<String> {
[
"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())
2026-05-14 15:10:33 +08:00
}
fn configured_api_key() -> Option<String> {
[
"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())
}
2026-05-15 23:20:25 +08:00
#[derive(Debug)]
struct GatewayProbe {
ok: bool,
status: String,
http_status: Option<u64>,
path: Option<String>,
message: Option<String>,
}
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<String> {
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
2026-05-14 15:10:33 +08:00
}
2026-05-15 23:20:25 +08:00
fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
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::<Vec<_>>();
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<Value>) -> Vec<Value> {
let mut seen = HashSet::new();
values
.into_iter()
.filter_map(|value| value.as_str().map(str::trim).map(ToOwned::to_owned))
2026-05-14 15:10:33 +08:00
.filter(|value| !value.is_empty())
2026-05-15 23:20:25 +08:00
.filter(|value| seen.insert(value.clone()))
.map(Value::String)
.collect()
}
fn hermes_settings_suggestions(status: Option<u64>, message: Option<&str>) -> Vec<String> {
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<Value, WebError> {
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")
})?;
2026-05-14 15:10:33 +08:00
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);
2026-05-14 17:04:17 +08:00
let profile = payload
.get("profile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
2026-05-14 15:10:33 +08:00
let instructions = json!({
"role": "mnote_page_ai_context",
"workspaceId": workspace_id,
"documentId": document_id,
2026-05-14 17:04:17 +08:00
"profile": profile.unwrap_or("default"),
2026-05-16 12:34:48 +08:00
"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),
2026-05-14 15:10:33 +08:00
"sessionId": session_id,
"traceId": trace_id,
2026-05-16 12:34:48 +08:00
"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 猜测。",
2026-05-14 15:10:33 +08:00
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
2026-05-16 12:34:48 +08:00
"pageContext": sanitize_run_page_context(page_context)
2026-05-14 15:10:33 +08:00
})
.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());
}
2026-05-14 17:04:17 +08:00
if let Some(profile) = profile {
body["profile"] = Value::String(profile.to_string());
}
2026-05-14 15:10:33 +08:00
Ok(body)
}
2026-05-16 12:34:48 +08:00
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<Option<String>, 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)
}
2026-05-15 23:20:25 +08:00
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<Value> {
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<Value> {
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<String>, Option<String>, Option<String>)>,
) {
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<String>) {
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<String> {
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<String> {
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<Value> {
chunk
.split("\n\n")
.filter_map(|block| {
let data = block
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim)
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() || data == "[DONE]" {
return None;
}
serde_json::from_str::<Value>(&data).ok()
})
.collect()
}
2026-05-14 15:10:33 +08:00
async fn proxy_json(
context: &RequestContext,
method: reqwest::Method,
upstream: &str,
path: &str,
body: Option<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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::<Value>(&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)),
))
}
2026-05-15 23:20:25 +08:00
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<Value, WebError> {
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(&registration.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(),
2026-05-16 12:34:48 +08:00
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(),
2026-05-15 23:20:25 +08:00
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(&registration, &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,
2026-05-16 12:34:48 +08:00
"actorId": queued.actor_id,
"actorType": queued.actor_type,
2026-05-15 23:20:25 +08:00
"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<HermesQueuedRun> {
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<String> {
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::<Vec<_>>()).unwrap_or_default()
})
}
2026-05-14 15:10:33 +08:00
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"),
};
2026-05-15 23:20:25 +08:00
let suggestions = hermes_settings_suggestions(Some(status.as_u16() as u64), Some(&text));
2026-05-14 15:10:33 +08:00
WebError::new(
response_status,
code,
format!(
2026-05-15 23:20:25 +08:00
"Hermes upstream 返回 HTTP {}: {}。建议:{}",
2026-05-14 15:10:33 +08:00
status.as_u16(),
2026-05-15 23:20:25 +08:00
text.chars().take(600).collect::<String>(),
suggestions.join("")
2026-05-14 15:10:33 +08:00
),
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
2026-05-15 23:20:25 +08:00
.with_header("x-hermes-client-suggestion", suggestions.join(" | "))
2026-05-14 15:10:33 +08:00
}
fn hermes_unconfigured(
context: &RequestContext,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<String, WebError> {
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::<String>();
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;
2026-05-15 23:20:25 +08:00
use axum::routing::{get, post};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
2026-05-14 15:10:33 +08:00
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
2026-05-15 23:20:25 +08:00
fn runtime_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = 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();
}
2026-05-14 15:10:33 +08:00
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(),
}))
}
2026-05-16 12:34:48 +08:00
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(),
})
}
2026-05-15 23:20:25 +08:00
#[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");
}
2026-05-14 15:10:33 +08:00
#[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());
}
2026-05-14 17:04:17 +08:00
#[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());
}
2026-05-15 23:20:25 +08:00
#[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(
&registration,
&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(
&registration,
&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,
&registration,
&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,
&registration,
&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<Value>| {
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(
&registration,
&json!({
"ok": true,
"upstream": {"run_id": "run_active_auto"}
}),
)
.expect("runtime");
enqueue_run(
&context,
&registration,
&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<Value>| {
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(
&registration,
&json!({
"ok": true,
"upstream": {"run_id": "run_abort_1"}
}),
)
.expect("runtime");
enqueue_run(
&context,
&registration,
&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(
&registration,
&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(
&registration,
&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());
}
2026-05-14 15:10:33 +08:00
#[test]
2026-05-16 12:34:48 +08:00
fn hermes_client_run_body_carries_minimal_page_context_into_run_input() {
2026-05-14 15:10:33 +08:00
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": "概括当前页面",
2026-05-16 12:34:48 +08:00
"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"
},
2026-05-14 15:10:33 +08:00
"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\""));
2026-05-16 12:34:48 +08:00
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\""));
2026-05-14 15:10:33 +08:00
}
2026-05-14 17:04:17 +08:00
#[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]
2026-05-15 23:20:25 +08:00
async fn hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream() {
2026-05-14 17:04:17 +08:00
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");
2026-05-15 23:20:25 +08:00
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__");
2026-05-14 17:04:17 +08:00
let cases = [
("GET", "/api/hermes/client/profiles", None),
("GET", "/api/hermes/client/profiles/chemist", None),
2026-05-15 23:20:25 +08:00
(
"GET",
"/api/hermes/client/profile-memory?profile=chemist",
None,
),
2026-05-14 17:04:17 +08:00
("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")
2026-05-15 23:20:25 +08:00
.body(Body::from(body.unwrap_or_else(|| json!({})).to_string()))
2026-05-14 17:04:17 +08:00
.expect("request");
let response = app().oneshot(request).await.expect("response");
2026-05-15 23:20:25 +08:00
assert_eq!(response.status(), StatusCode::OK, "{uri}");
2026-05-14 17:04:17 +08:00
}
2026-05-15 23:20:25 +08:00
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::<HashMap<_, _>>();
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);
2026-05-14 17:04:17 +08:00
}
2026-05-14 15:10:33 +08:00
}