- 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
8492 lines
308 KiB
Rust
8492 lines
308 KiB
Rust
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(¶ms);
|
||
}
|
||
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,
|
||
®istration,
|
||
&session_index_run_id,
|
||
&acp_runtime,
|
||
&runtime_state,
|
||
&runtime_payload,
|
||
)
|
||
.await?;
|
||
persistence = ACP_RUNTIME_SQLITE_STORE;
|
||
}
|
||
Ok((
|
||
StatusCode::OK,
|
||
stamp_client_headers(),
|
||
Json(json!({
|
||
"ok": true,
|
||
"sessionId": session_id,
|
||
"workspaceId": payload.workspace_id,
|
||
"documentId": payload.document_id,
|
||
"profile": profile,
|
||
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
||
"traceId": trace_id,
|
||
"persistence": persistence
|
||
})),
|
||
))
|
||
}
|
||
|
||
pub async fn list_profiles(
|
||
Extension(context): Extension<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 runtime,Hermes HTTP gateway 已退役。"
|
||
},
|
||
"suggestions": []
|
||
})),
|
||
));
|
||
}
|
||
let upstream = configured_upstream_for_profile(&profile);
|
||
let mut suggestions = profile_status
|
||
.get("suggestions")
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
if upstream.is_none() {
|
||
suggestions.push(Value::String(
|
||
"未配置 MNOTE_WEB_HERMES_UPSTREAM_URL;请先启动 Hermes gateway,并让 mnote-web 指向该 gateway。"
|
||
.into(),
|
||
));
|
||
}
|
||
|
||
let mut gateway = json!({
|
||
"configured": upstream.is_some(),
|
||
"upstream": upstream,
|
||
"ok": false,
|
||
"status": if upstream.is_some() { "checking" } else { "unconfigured" }
|
||
});
|
||
if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) {
|
||
let probe =
|
||
probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await;
|
||
gateway["ok"] = Value::Bool(probe.ok);
|
||
gateway["status"] = Value::String(probe.status);
|
||
gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null);
|
||
gateway["path"] = probe.path.map(Value::from).unwrap_or(Value::Null);
|
||
gateway["message"] = probe.message.map(Value::from).unwrap_or(Value::Null);
|
||
if !probe.ok {
|
||
suggestions.extend(
|
||
hermes_settings_suggestions(
|
||
gateway["httpStatus"].as_u64(),
|
||
gateway["message"].as_str(),
|
||
)
|
||
.into_iter()
|
||
.map(Value::String),
|
||
);
|
||
}
|
||
}
|
||
let ok = gateway["ok"].as_bool().unwrap_or(false)
|
||
&& profile_status["modelConfigured"].as_bool().unwrap_or(false)
|
||
&& profile_status["apiKeyConfigured"]
|
||
.as_bool()
|
||
.unwrap_or(false);
|
||
Ok((
|
||
StatusCode::OK,
|
||
stamp_client_headers(),
|
||
Json(json!({
|
||
"ok": ok,
|
||
"traceId": context.trace.trace_id,
|
||
"profile": profile_status,
|
||
"gateway": gateway,
|
||
"suggestions": unique_string_values(suggestions)
|
||
})),
|
||
))
|
||
}
|
||
|
||
pub async fn get_profile(
|
||
Extension(context): Extension<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, ®istration.profile) {
|
||
let run_id = payload
|
||
.get("runId")
|
||
.and_then(Value::as_str)
|
||
.filter(|value| !value.trim().is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.unwrap_or_else(|| new_acp_run_id(®istration));
|
||
payload["runId"] = Value::String(run_id.clone());
|
||
let runtime_state = register_acp_runtime(®istration, &run_id);
|
||
// Store payload for stream_events to use
|
||
ACP_RUN_PAYLOADS
|
||
.lock()
|
||
.expect("acp run payloads")
|
||
.insert(run_id.clone(), payload.clone());
|
||
let persistence_result = persist_acp_runtime_run(
|
||
&state,
|
||
&context,
|
||
®istration,
|
||
&run_id,
|
||
&acp_runtime,
|
||
&runtime_state,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
if payload
|
||
.get("sourceKind")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
== Some("local_folder")
|
||
{
|
||
if let Some(root_uri) = payload.get("rootUri").and_then(Value::as_str) {
|
||
match local_agent_audit_collect_snapshot(root_uri) {
|
||
Ok(snapshot) => local_agent_audit_store_snapshot(&run_id, snapshot),
|
||
Err(error) => {
|
||
warn!(error = ?error, run_id = %run_id, "本地 agent audit 初始快照采集失败");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let response = json!({
|
||
"ok": true,
|
||
"runId": run_id,
|
||
"sessionId": registration.session_id,
|
||
"profile": registration.profile,
|
||
"traceId": context.trace.trace_id,
|
||
"runtime": runtime_state,
|
||
"persistence": persistence_result
|
||
.get("persistence")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
|
||
"sessionStorage": persistence_result
|
||
.get("sessionStorage")
|
||
.cloned()
|
||
.unwrap_or(Value::Null),
|
||
});
|
||
return Ok((StatusCode::OK, stamp_client_headers(), Json(response)));
|
||
}
|
||
|
||
if session_has_active_run(®istration.session_id) {
|
||
let queued = enqueue_run(&context, ®istration, &payload)?;
|
||
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
|
||
}
|
||
let Some(upstream) = configured_upstream_for_profile(®istration.profile) else {
|
||
return hermes_unconfigured(&context);
|
||
};
|
||
let upstream_body = build_run_upstream_body(&context, payload)?;
|
||
let result = proxy_json(
|
||
&context,
|
||
reqwest::Method::POST,
|
||
&upstream,
|
||
"/v1/runs",
|
||
Some(upstream_body),
|
||
Some(®istration.profile),
|
||
)
|
||
.await?;
|
||
if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) {
|
||
let mut payload = result.2 .0;
|
||
payload["runtime"] = runtime;
|
||
Ok((result.0, result.1, Json(payload)))
|
||
} else {
|
||
Ok(result)
|
||
}
|
||
}
|
||
|
||
pub async fn cancel_queued_run(
|
||
Extension(context): Extension<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 协议直连 Reasonix(DeepSeek 缓存优先)",
|
||
"model": reasonix_model,
|
||
"preset": reasonix_preset,
|
||
"apiKeyConfigured": reasonix_has_key,
|
||
"version": "0.43.0"
|
||
}
|
||
])
|
||
})
|
||
}
|
||
|
||
fn profile_detail_payload(profile: &str) -> Value {
|
||
let dir = profile_home(profile);
|
||
let gateway_status = profile_gateway_status(profile);
|
||
json!({
|
||
"name": if profile.trim().is_empty() { "default" } else { profile.trim() },
|
||
"path": dir.to_string_lossy(),
|
||
"model": gateway_status["modelDefault"].as_str().unwrap_or_default(),
|
||
"provider": gateway_status["provider"].as_str().unwrap_or_default(),
|
||
"gateway": gateway_status["gateway"].as_str().unwrap_or_default(),
|
||
"modelConfigured": gateway_status["modelConfigured"].as_bool().unwrap_or(false),
|
||
"apiKeyConfigured": gateway_status["apiKeyConfigured"].as_bool().unwrap_or(false),
|
||
"suggestions": gateway_status["suggestions"].clone(),
|
||
"skills": skills_payload(profile)["categories"].as_array().map(Vec::len).unwrap_or_default(),
|
||
"hasEnv": dir.join(".env").exists(),
|
||
"hasSoulMd": dir.join("SOUL.md").exists()
|
||
})
|
||
}
|
||
|
||
fn switch_active_profile_local(profile: &str) -> std::io::Result<()> {
|
||
let command_result = Command::new(hermes_bin())
|
||
.args(["profile", "use", profile])
|
||
.output();
|
||
if command_result
|
||
.as_ref()
|
||
.map(|output| output.status.success())
|
||
.unwrap_or(false)
|
||
{
|
||
return Ok(());
|
||
}
|
||
let home = hermes_home();
|
||
fs::create_dir_all(&home)?;
|
||
if profile != "default" {
|
||
fs::create_dir_all(home.join("profiles").join(profile))?;
|
||
}
|
||
fs::write(home.join("active_profile"), profile.as_bytes())
|
||
}
|
||
|
||
fn file_text(path: &FsPath) -> String {
|
||
fs::read_to_string(path).unwrap_or_default()
|
||
}
|
||
|
||
fn file_mtime_ms(path: &FsPath) -> Option<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,
|
||
®istration.session_id,
|
||
&event,
|
||
share_id.as_deref(),
|
||
)
|
||
.map_err(|error| error.with_context(context))?;
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"persistence": "local_ai_session_jsonl",
|
||
"sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
|
||
"sessionId": registration.session_id,
|
||
"runId": run_id
|
||
}));
|
||
}
|
||
let workspace_id = args
|
||
.get("workspaceId")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned);
|
||
let document_id = args
|
||
.get("documentId")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned);
|
||
let title = args
|
||
.get("title")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned);
|
||
let status = args
|
||
.get("status")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("acp_pending")
|
||
.to_string();
|
||
let record = state
|
||
.control_plane()
|
||
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
|
||
id: None,
|
||
user_id: runtime_store_user_id(context, payload),
|
||
workspace_id,
|
||
document_id,
|
||
session_id: registration.session_id.clone(),
|
||
run_id: run_id.to_string(),
|
||
title,
|
||
profile: registration.profile.clone(),
|
||
acp_runtime: acp_runtime.to_string(),
|
||
trace_id: Some(registration.trace_id.clone()),
|
||
status,
|
||
runtime_json: json_string(runtime_state),
|
||
payload_json: json_string(payload),
|
||
})
|
||
.map_err(|error| {
|
||
WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}"))
|
||
.with_context(context)
|
||
})?;
|
||
Ok(json!({
|
||
"ok": true,
|
||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||
"sessionStorage": "sqlite_control_plane",
|
||
"sessionId": record.session_id,
|
||
"runId": record.run_id
|
||
}))
|
||
}
|
||
|
||
fn acp_runtime_event_store_args(
|
||
context: &RequestContext,
|
||
registration: &HermesRunRegistration,
|
||
run_id: &str,
|
||
acp_runtime: &str,
|
||
event_type: &str,
|
||
event_payload: &Value,
|
||
run_payload: &Value,
|
||
) -> Value {
|
||
let workspace_id = run_payload
|
||
.get("workspaceId")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.or_else(|| context.workspace.workspace_id.clone());
|
||
let created_at = now_ms();
|
||
json!({
|
||
"schema": "mnote.acp_runtime_event.v1",
|
||
"source": "acp",
|
||
"eventId": format!(
|
||
"acp_event:{}:{}:{}",
|
||
sanitize_id_part(run_id),
|
||
created_at,
|
||
sanitize_id_part(event_type)
|
||
),
|
||
"userId": runtime_store_user_id(context, run_payload),
|
||
"workspaceId": workspace_id,
|
||
"documentId": registration.document_id,
|
||
"sessionId": registration.session_id,
|
||
"runId": run_id,
|
||
"profile": registration.profile,
|
||
"acpRuntime": acp_runtime,
|
||
"eventType": event_type,
|
||
"payload": event_payload,
|
||
"createdAt": created_at
|
||
})
|
||
}
|
||
|
||
async fn persist_acp_runtime_event(
|
||
state: &AppState,
|
||
context: &RequestContext,
|
||
registration: &HermesRunRegistration,
|
||
run_id: &str,
|
||
acp_runtime: &str,
|
||
event_type: &str,
|
||
event_payload: &Value,
|
||
run_payload: &Value,
|
||
) -> Result<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,
|
||
®istration.session_id,
|
||
&event,
|
||
share_id.as_deref(),
|
||
)
|
||
.map_err(|error| error.with_context(context))?;
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"persistence": "local_ai_session_jsonl",
|
||
"sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
|
||
"sessionId": registration.session_id,
|
||
"runId": run_id
|
||
}));
|
||
}
|
||
state
|
||
.control_plane()
|
||
.append_ai_runtime_event(AppendAiRuntimeEventInput {
|
||
id: None,
|
||
user_id: runtime_store_user_id(context, run_payload),
|
||
workspace_id: args
|
||
.get("workspaceId")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
document_id: args
|
||
.get("documentId")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
session_id: registration.session_id.clone(),
|
||
run_id: run_id.to_string(),
|
||
profile: registration.profile.clone(),
|
||
acp_runtime: acp_runtime.to_string(),
|
||
event_type: event_type.to_string(),
|
||
payload_json: json_string(event_payload),
|
||
})
|
||
.map_err(|error| {
|
||
WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}"))
|
||
.with_context(context)
|
||
})?;
|
||
Ok(json!({
|
||
"ok": true,
|
||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||
"sessionStorage": "sqlite_control_plane",
|
||
"sessionId": registration.session_id,
|
||
"runId": run_id
|
||
}))
|
||
}
|
||
|
||
fn register_runtime_from_create_run_response(
|
||
registration: &HermesRunRegistration,
|
||
payload: &Value,
|
||
) -> Option<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(®istration.trace_id),
|
||
now_ms()
|
||
),
|
||
session_id: registration.session_id.clone(),
|
||
profile: registration.profile.clone(),
|
||
document_id: registration.document_id.clone(),
|
||
trace_id: registration.trace_id.clone(),
|
||
actor_id: payload
|
||
.get("actorId")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("anonymous")
|
||
.to_string(),
|
||
actor_type: payload
|
||
.get("actorType")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("anonymous")
|
||
.to_string(),
|
||
input,
|
||
context_summary: queue_context_summary(payload),
|
||
queued_at: now_ms(),
|
||
};
|
||
let queue_length = {
|
||
let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue");
|
||
let entries = queue.entry(registration.session_id.clone()).or_default();
|
||
entries.push_back(queued.clone());
|
||
entries.len()
|
||
};
|
||
Ok(queued_run_to_json(&queued, queue_length))
|
||
}
|
||
|
||
async fn start_next_queued_run(context: RequestContext, session_id: String) {
|
||
if session_has_active_run(&session_id) {
|
||
return;
|
||
}
|
||
let Some(queued) = pop_next_queued_run(&session_id) else {
|
||
return;
|
||
};
|
||
let Some(upstream) = configured_upstream_for_profile(&queued.profile) else {
|
||
warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run");
|
||
return;
|
||
};
|
||
let payload = queued_run_payload(&queued);
|
||
let upstream_body = match build_run_upstream_body(&context, payload) {
|
||
Ok(body) => body,
|
||
Err(error) => {
|
||
warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 构造 run body 失败");
|
||
return;
|
||
}
|
||
};
|
||
let registration = HermesRunRegistration {
|
||
session_id: queued.session_id.clone(),
|
||
profile: queued.profile.clone(),
|
||
document_id: queued.document_id.clone(),
|
||
trace_id: queued.trace_id.clone(),
|
||
};
|
||
match proxy_json(
|
||
&context,
|
||
reqwest::Method::POST,
|
||
&upstream,
|
||
"/v1/runs",
|
||
Some(upstream_body),
|
||
Some(&queued.profile),
|
||
)
|
||
.await
|
||
{
|
||
Ok((_, _, Json(payload))) => {
|
||
let _ = register_runtime_from_create_run_response(®istration, &payload);
|
||
}
|
||
Err(error) => {
|
||
warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 自动启动下一条 run 失败");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn queued_run_payload(queued: &HermesQueuedRun) -> Value {
|
||
json!({
|
||
"documentId": queued.document_id,
|
||
"sessionId": queued.session_id,
|
||
"profile": queued.profile,
|
||
"message": queued.input,
|
||
"traceId": queued.trace_id,
|
||
"actorId": queued.actor_id,
|
||
"actorType": queued.actor_type,
|
||
"contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null)
|
||
})
|
||
}
|
||
|
||
fn remove_queued_run(session_id: &str, queue_id: &str) -> bool {
|
||
let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue");
|
||
let Some(entries) = queue.get_mut(session_id) else {
|
||
return false;
|
||
};
|
||
let before = entries.len();
|
||
entries.retain(|item| item.queue_id != queue_id);
|
||
let removed = entries.len() != before;
|
||
if entries.is_empty() {
|
||
queue.remove(session_id);
|
||
}
|
||
removed
|
||
}
|
||
|
||
fn pop_next_queued_run(session_id: &str) -> Option<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(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"upstream": {"run_id": "run_1"}
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
assert_eq!(runtime["sessionId"], "mnote_doc_1_trace_1");
|
||
assert_eq!(runtime["runId"], "run_1");
|
||
assert!(runtime.get("messages").is_none());
|
||
|
||
let response = app()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/hermes/client/sessions/mnote_doc_1_trace_1/resume")
|
||
.header("content-type", "application/json")
|
||
.header("x-mnote-actor-id", "user_1")
|
||
.body(Body::empty())
|
||
.expect("request"),
|
||
)
|
||
.await
|
||
.expect("response");
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let body = to_bytes(response.into_body(), usize::MAX)
|
||
.await
|
||
.expect("body");
|
||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||
assert_eq!(payload["ok"], true);
|
||
assert_eq!(
|
||
payload["session"]["messages"].as_array().map(Vec::len),
|
||
Some(0)
|
||
);
|
||
assert_eq!(payload["runtime"]["sessionId"], "mnote_doc_1_trace_1");
|
||
assert_eq!(payload["runtime"]["runId"], "run_1");
|
||
assert_eq!(payload["runtime"]["status"], "running");
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_active_session_queues_second_run_without_chat_truth() {
|
||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
let context = RequestContext::from_http_parts(
|
||
&axum::http::Method::POST,
|
||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||
&HeaderMap::new(),
|
||
);
|
||
let registration = HermesRunRegistration {
|
||
session_id: "mnote_doc_queue_trace_1".into(),
|
||
profile: "chemist".into(),
|
||
document_id: "doc_queue".into(),
|
||
trace_id: "trace_queued_1".into(),
|
||
};
|
||
register_runtime_from_create_run_response(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"upstream": {"run_id": "run_active_1"}
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
assert!(session_has_active_run("mnote_doc_queue_trace_1"));
|
||
|
||
let queued = enqueue_run(
|
||
&context,
|
||
®istration,
|
||
&json!({
|
||
"workspaceId": "ws_1",
|
||
"documentId": "doc_queue",
|
||
"sessionId": "mnote_doc_queue_trace_1",
|
||
"profile": "chemist",
|
||
"message": "第二条问题",
|
||
"traceId": "trace_queued_1",
|
||
"selectedText": "选区",
|
||
"pageContext": {
|
||
"title": "页面标题",
|
||
"body": "正文不应进入 queue 响应",
|
||
"blocks": [{"id": "block_1"}]
|
||
},
|
||
"messages": [
|
||
{"role": "assistant", "content": "历史消息不应保存为 mnote 真相"}
|
||
]
|
||
}),
|
||
)
|
||
.expect("queued");
|
||
assert_eq!(queued["queued"], true);
|
||
assert_eq!(queued["queueLength"], 1);
|
||
assert_eq!(queued["sessionId"], "mnote_doc_queue_trace_1");
|
||
assert!(queued.get("messages").is_none());
|
||
assert!(queued.get("pageContext").is_none());
|
||
assert_eq!(queued["contextSummary"]["hasPageContext"], true);
|
||
assert_eq!(queued["contextSummary"]["hasSelectedText"], true);
|
||
|
||
let popped = pop_next_queued_run("mnote_doc_queue_trace_1").expect("queued run");
|
||
assert_eq!(popped.input, "第二条问题");
|
||
assert_eq!(popped.profile, "chemist");
|
||
assert!(popped.context_summary.get("pageContext").is_none());
|
||
assert_eq!(queue_len_for_session("mnote_doc_queue_trace_1"), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_acp_stream_payload_lookup_keeps_payload_for_reconnect() {
|
||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_acp_run_payloads();
|
||
ACP_RUN_PAYLOADS
|
||
.lock()
|
||
.expect("acp run payloads")
|
||
.insert("run_keep_1".into(), json!({"message": "hello"}));
|
||
|
||
let first = acp_run_payload_for_stream("run_keep_1").expect("first payload");
|
||
let second = acp_run_payload_for_stream("run_keep_1").expect("second payload");
|
||
|
||
assert_eq!(first["message"], "hello");
|
||
assert_eq!(second["message"], "hello");
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_acp_runtime_event_store_args_are_user_scoped() {
|
||
let mut headers = HeaderMap::new();
|
||
headers.insert("x-mnote-actor-id", "user_1".parse().unwrap());
|
||
headers.insert("x-mnote-workspace-id", "ws_header".parse().unwrap());
|
||
let context = RequestContext::from_http_parts(
|
||
&axum::http::Method::GET,
|
||
&"/api/hermes/client/events/run_1".parse().expect("uri"),
|
||
&headers,
|
||
);
|
||
let registration = HermesRunRegistration {
|
||
session_id: "sess_1".into(),
|
||
profile: "reasonix".into(),
|
||
document_id: "doc_1".into(),
|
||
trace_id: "trace_1".into(),
|
||
};
|
||
let args = acp_runtime_event_store_args(
|
||
&context,
|
||
®istration,
|
||
"run_1",
|
||
"reasonix",
|
||
"message.delta",
|
||
&json!({"text": "hello"}),
|
||
&json!({"workspaceId": "ws_1"}),
|
||
);
|
||
|
||
assert_eq!(args["schema"], "mnote.acp_runtime_event.v1");
|
||
assert_eq!(args["userId"], "user_1");
|
||
assert_eq!(args["workspaceId"], "ws_1");
|
||
assert_eq!(args["documentId"], "doc_1");
|
||
assert_eq!(args["sessionId"], "sess_1");
|
||
assert_eq!(args["runId"], "run_1");
|
||
assert_eq!(args["profile"], "reasonix");
|
||
assert_eq!(args["acpRuntime"], "reasonix");
|
||
assert_eq!(args["eventType"], "message.delta");
|
||
assert_eq!(args["payload"]["text"], "hello");
|
||
assert!(args.get("messages").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_cancel_queued_run_removes_pending_item() {
|
||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
let context = RequestContext::from_http_parts(
|
||
&axum::http::Method::POST,
|
||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||
&HeaderMap::new(),
|
||
);
|
||
let registration = HermesRunRegistration {
|
||
session_id: "mnote_doc_cancel_trace_1".into(),
|
||
profile: "default".into(),
|
||
document_id: "doc_cancel".into(),
|
||
trace_id: "trace_cancel_1".into(),
|
||
};
|
||
let queued = enqueue_run(
|
||
&context,
|
||
®istration,
|
||
&json!({
|
||
"documentId": "doc_cancel",
|
||
"sessionId": "mnote_doc_cancel_trace_1",
|
||
"message": "等待取消",
|
||
"traceId": "trace_cancel_1"
|
||
}),
|
||
)
|
||
.expect("queued");
|
||
let queue_id = queued["queueId"].as_str().expect("queue id");
|
||
assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 1);
|
||
assert!(remove_queued_run("mnote_doc_cancel_trace_1", queue_id));
|
||
assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 0);
|
||
assert!(pop_next_queued_run("mnote_doc_cancel_trace_1").is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn hermes_client_terminal_run_can_start_next_queued_run() {
|
||
let _env_guard = env_lock().lock().expect("env lock");
|
||
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
|
||
let hits = Arc::new(AtomicUsize::new(0));
|
||
let hits_for_route = Arc::clone(&hits);
|
||
let mock = axum::Router::new().route(
|
||
"/v1/runs",
|
||
post(move |Json(_body): Json<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(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"upstream": {"run_id": "run_active_auto"}
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
enqueue_run(
|
||
&context,
|
||
®istration,
|
||
&json!({
|
||
"workspaceId": "ws_1",
|
||
"documentId": "doc_auto",
|
||
"sessionId": "mnote_doc_auto_trace_1",
|
||
"profile": "chemist",
|
||
"message": "自动出队问题",
|
||
"traceId": "trace_auto_1",
|
||
"pageContext": {"title": "自动出队"}
|
||
}),
|
||
)
|
||
.expect("queued");
|
||
|
||
let (_, terminal_sessions) = normalize_sse_chunk(
|
||
"run_active_auto",
|
||
&format!(
|
||
"data: {}\n\n",
|
||
json!({
|
||
"event": "run.completed",
|
||
"run_id": "run_active_auto",
|
||
"session_id": "mnote_doc_auto_trace_1"
|
||
})
|
||
),
|
||
);
|
||
assert_eq!(
|
||
terminal_sessions,
|
||
vec!["mnote_doc_auto_trace_1".to_string()]
|
||
);
|
||
|
||
start_next_queued_run(context, "mnote_doc_auto_trace_1".into()).await;
|
||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||
assert_eq!(queue_len_for_session("mnote_doc_auto_trace_1"), 0);
|
||
let runtime = runtime_state_for_session("mnote_doc_auto_trace_1");
|
||
assert_eq!(runtime["runId"], "run_queued_next");
|
||
assert_eq!(runtime["status"], "running");
|
||
|
||
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn hermes_client_abort_returns_events_and_continues_queue() {
|
||
let _env_guard = env_lock().lock().expect("env lock");
|
||
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
std::env::set_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY", "1");
|
||
|
||
let stop_hits = Arc::new(AtomicUsize::new(0));
|
||
let run_hits = Arc::new(AtomicUsize::new(0));
|
||
let stop_hits_for_route = Arc::clone(&stop_hits);
|
||
let run_hits_for_route = Arc::clone(&run_hits);
|
||
let mock = axum::Router::new()
|
||
.route(
|
||
"/v1/runs/{run_id}/stop",
|
||
post(move || {
|
||
let stop_hits = Arc::clone(&stop_hits_for_route);
|
||
async move {
|
||
stop_hits.fetch_add(1, Ordering::SeqCst);
|
||
Json(json!({"ok": true, "status": "aborted"}))
|
||
}
|
||
}),
|
||
)
|
||
.route(
|
||
"/v1/runs",
|
||
post(move |Json(_body): Json<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(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"upstream": {"run_id": "run_abort_1"}
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
enqueue_run(
|
||
&context,
|
||
®istration,
|
||
&json!({
|
||
"documentId": "doc_abort",
|
||
"sessionId": "mnote_doc_abort_trace_1",
|
||
"message": "abort 后继续",
|
||
"traceId": "trace_abort_1"
|
||
}),
|
||
)
|
||
.expect("queued");
|
||
|
||
let response = app()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/hermes/client/runs/run_abort_1/abort")
|
||
.header("content-type", "application/json")
|
||
.header("x-mnote-actor-id", "user_1")
|
||
.body(Body::from(json!({"reason":"test_abort"}).to_string()))
|
||
.expect("request"),
|
||
)
|
||
.await
|
||
.expect("response");
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let body = to_bytes(response.into_body(), usize::MAX)
|
||
.await
|
||
.expect("body");
|
||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||
assert_eq!(payload["events"][0]["event"], "abort.started");
|
||
assert_eq!(payload["events"][1]["event"], "abort.completed");
|
||
assert_eq!(stop_hits.load(Ordering::SeqCst), 1);
|
||
assert_eq!(run_hits.load(Ordering::SeqCst), 1);
|
||
assert_eq!(queue_len_for_session("mnote_doc_abort_trace_1"), 0);
|
||
let runtime = runtime_state_for_session("mnote_doc_abort_trace_1");
|
||
assert_eq!(runtime["runId"], "run_after_abort");
|
||
assert_eq!(runtime["status"], "running");
|
||
|
||
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
||
std::env::remove_var("MNOTE_WEB_ENABLE_HERMES_HTTP_PROXY");
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_runtime_registry_tracks_tool_event_summary_only() {
|
||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
let registration = HermesRunRegistration {
|
||
session_id: "mnote_doc_2_trace_2".into(),
|
||
profile: "default".into(),
|
||
document_id: "doc_2".into(),
|
||
trace_id: "trace_2".into(),
|
||
};
|
||
register_runtime_from_create_run_response(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"runId": "run_2"
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
let _ = normalize_sse_chunk(
|
||
"run_2",
|
||
&format!(
|
||
"data: {}\n\n",
|
||
json!({
|
||
"event": "tool.started",
|
||
"run_id": "run_2",
|
||
"session_id": "mnote_doc_2_trace_2",
|
||
"name": "mnote.page.get",
|
||
"toolCallId": "call_1",
|
||
"arguments": {"includeBody": false}
|
||
})
|
||
),
|
||
);
|
||
let runtime = runtime_state_for_session("mnote_doc_2_trace_2");
|
||
assert_eq!(runtime["status"], "tool_calling");
|
||
assert_eq!(runtime["lastToolName"], "mnote.page.get");
|
||
assert_eq!(runtime["lastToolCallId"], "call_1");
|
||
assert!(runtime.get("arguments").is_none());
|
||
assert!(runtime.get("messages").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_normalizes_upstream_sse_events_to_runtime_contract() {
|
||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||
clear_runtime_registry();
|
||
clear_run_queue();
|
||
let registration = HermesRunRegistration {
|
||
session_id: "mnote_doc_norm_trace_1".into(),
|
||
profile: "default".into(),
|
||
document_id: "doc_norm".into(),
|
||
trace_id: "trace_norm_1".into(),
|
||
};
|
||
register_runtime_from_create_run_response(
|
||
®istration,
|
||
&json!({
|
||
"ok": true,
|
||
"runId": "run_norm_1"
|
||
}),
|
||
)
|
||
.expect("runtime");
|
||
|
||
let (normalized, terminal_sessions) = normalize_sse_chunk(
|
||
"run_norm_1",
|
||
&format!(
|
||
"data: {}\n\ndata: {}\n\n",
|
||
json!({
|
||
"event": "assistant_message",
|
||
"run_id": "run_norm_1",
|
||
"session_id": "mnote_doc_norm_trace_1",
|
||
"text": "hello"
|
||
}),
|
||
json!({
|
||
"event": "tool.failed",
|
||
"run_id": "run_norm_1",
|
||
"session_id": "mnote_doc_norm_trace_1",
|
||
"toolCallId": "call_failed_1",
|
||
"name": "mnote.page.save",
|
||
"code": "permission_denied",
|
||
"error": "写入被拒绝"
|
||
})
|
||
),
|
||
);
|
||
assert!(normalized.contains("event: message.delta"));
|
||
assert!(normalized.contains("\"schemaVersion\":\"mnote.hermes_runtime_event.v1\""));
|
||
assert!(normalized.contains("\"event\":\"tool.failed\""));
|
||
assert!(normalized.contains("\"toolCallId\":\"call_failed_1\""));
|
||
assert!(normalized.contains("\"code\":\"permission_denied\""));
|
||
assert!(terminal_sessions.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn hermes_client_run_body_carries_minimal_page_context_into_run_input() {
|
||
let context = RequestContext::from_http_parts(
|
||
&axum::http::Method::POST,
|
||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||
&HeaderMap::new(),
|
||
);
|
||
let body = build_run_upstream_body(
|
||
&context,
|
||
json!({
|
||
"workspaceId": "ws_1",
|
||
"documentId": "doc_1",
|
||
"sessionId": "sess_1",
|
||
"message": "概括当前页面",
|
||
"pageContext": {
|
||
"contextScope": "page",
|
||
"node": {"documentId": "doc_1", "title": "页面标题"},
|
||
"documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
|
||
"subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
|
||
"outline": [{"title": "不应进入 Hermes instructions"}],
|
||
"contentAccess": "mnote.page.get",
|
||
"aiContext": {
|
||
"schema": "mnote.page_ai_context.v1",
|
||
"contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}],
|
||
"pageXml": "<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);
|
||
}
|
||
}
|