Files
mnote/rust/crates/mnote-web/src/routes/hermes_client.rs
T
lix-2026 47e224d419 chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs

- retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries

- add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
2026-05-22 17:45:22 +08:00

8492 lines
308 KiB
Rust
Raw Blame History

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