2026-05-14 15:10:33 +08:00
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use axum::body::Body;
|
|
|
|
|
use axum::extract::{Extension, Path, Query};
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
|
|
|
use axum::response::Response;
|
|
|
|
|
use axum::Json;
|
|
|
|
|
use futures_util::TryStreamExt;
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
|
|
|
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct CreateSessionRequest {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: Option<String>,
|
|
|
|
|
trace_id: Option<String>,
|
|
|
|
|
title: Option<String>,
|
2026-05-14 17:04:17 +08:00
|
|
|
profile: Option<String>,
|
2026-05-14 15:10:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_sessions(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<HashMap<String, String>>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
let mut path = "/api/hermes/sessions".to_string();
|
|
|
|
|
if !query.is_empty() {
|
|
|
|
|
let params = query
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("&");
|
|
|
|
|
path.push('?');
|
|
|
|
|
path.push_str(¶ms);
|
|
|
|
|
}
|
|
|
|
|
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_session(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<CreateSessionRequest>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let trace_id = payload
|
|
|
|
|
.trace_id
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.unwrap_or_else(|| context.trace.trace_id.clone());
|
|
|
|
|
let document_id = payload
|
|
|
|
|
.document_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.unwrap_or("current");
|
|
|
|
|
let session_id = stable_session_id(document_id, &trace_id);
|
2026-05-14 17:04:17 +08:00
|
|
|
let profile = payload
|
|
|
|
|
.profile
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("default");
|
2026-05-14 15:10:33 +08:00
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
"workspaceId": payload.workspace_id,
|
|
|
|
|
"documentId": payload.document_id,
|
2026-05-14 17:04:17 +08:00
|
|
|
"profile": profile,
|
2026-05-14 15:10:33 +08:00
|
|
|
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
|
|
|
|
"traceId": trace_id,
|
|
|
|
|
"persistence": "hermes_on_first_run"
|
|
|
|
|
})),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:04:17 +08:00
|
|
|
pub async fn list_profiles(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::GET,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/api/hermes/profiles",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_profile(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Path(profile_name): Path<String>,
|
|
|
|
|
) -> 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,
|
|
|
|
|
&format!("/api/hermes/profiles/{}", url_escape(&profile_name)),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn switch_active_profile(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<Value>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::PUT,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/api/hermes/profiles/active",
|
|
|
|
|
Some(payload),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_profile_memory(
|
|
|
|
|
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,
|
|
|
|
|
"/api/hermes/memory",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn save_profile_memory(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<Value>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::POST,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/api/hermes/memory",
|
|
|
|
|
Some(payload),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
let mut path = "/api/hermes/skills".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).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn toggle_skill(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<Value>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::PUT,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/api/hermes/skills/toggle",
|
|
|
|
|
Some(payload),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:10:33 +08:00
|
|
|
pub async fn get_session(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Path(session_id): Path<String>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
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
|
|
|
|
|
})),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::GET,
|
|
|
|
|
&upstream,
|
|
|
|
|
&format!("/api/hermes/sessions/{}", url_escape(&session_id)),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<Value>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
let upstream_body = build_run_upstream_body(&context, payload)?;
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::POST,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/v1/runs",
|
|
|
|
|
Some(upstream_body),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn stream_events(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Path(run_id): Path<String>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return Err(hermes_unconfigured_error(&context));
|
|
|
|
|
};
|
|
|
|
|
let url = upstream_url(
|
|
|
|
|
&upstream,
|
|
|
|
|
&format!("/v1/runs/{}/events", url_escape(&run_id)),
|
|
|
|
|
)?;
|
|
|
|
|
let mut request = reqwest::Client::builder()
|
|
|
|
|
.timeout(Duration::from_secs(1800))
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
|
|
|
|
|
})?
|
|
|
|
|
.get(url);
|
|
|
|
|
if let Some(api_key) = configured_api_key() {
|
|
|
|
|
request = request.bearer_auth(api_key);
|
|
|
|
|
}
|
|
|
|
|
let upstream_response = request.send().await.map_err(|error| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"hermes_client_upstream_unavailable",
|
|
|
|
|
format!("Hermes events upstream 连接失败: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
|
|
|
|
})?;
|
|
|
|
|
if !upstream_response.status().is_success() {
|
|
|
|
|
let status = upstream_response.status();
|
|
|
|
|
let text = upstream_response.text().await.unwrap_or_default();
|
|
|
|
|
return Err(upstream_error(&context, status, text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stream = upstream_response.bytes_stream().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 Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::POST,
|
|
|
|
|
&upstream,
|
|
|
|
|
&format!("/v1/runs/{}/stop", url_escape(&run_id)),
|
|
|
|
|
Some(payload),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_models(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let Some(upstream) = configured_upstream() else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
|
proxy_json(
|
|
|
|
|
&context,
|
|
|
|
|
reqwest::Method::GET,
|
|
|
|
|
&upstream,
|
|
|
|
|
"/v1/models",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_tools(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"tools": [
|
|
|
|
|
{
|
|
|
|
|
"name": "mnote.page.get",
|
|
|
|
|
"scope": "page.read",
|
|
|
|
|
"schemaVersion": "mnote.hermes_tool.v1",
|
|
|
|
|
"status": "planned_by_task_e"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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> {
|
|
|
|
|
std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL")
|
|
|
|
|
.ok()
|
|
|
|
|
.or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok())
|
|
|
|
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn configured_api_key() -> Option<String> {
|
|
|
|
|
std::env::var("MNOTE_WEB_HERMES_API_KEY")
|
|
|
|
|
.ok()
|
|
|
|
|
.or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok())
|
|
|
|
|
.or_else(|| std::env::var("API_SERVER_KEY").ok())
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<Value, WebError> {
|
|
|
|
|
let message = 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)
|
|
|
|
|
})
|
|
|
|
|
.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);
|
2026-05-14 17:04:17 +08:00
|
|
|
let profile = payload
|
|
|
|
|
.get("profile")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
2026-05-14 15:10:33 +08:00
|
|
|
let instructions = json!({
|
|
|
|
|
"role": "mnote_page_ai_context",
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"documentId": document_id,
|
2026-05-14 17:04:17 +08:00
|
|
|
"profile": profile.unwrap_or("default"),
|
2026-05-14 15:10:33 +08:00
|
|
|
"actorId": context.auth.actor_id,
|
|
|
|
|
"actorType": context.auth.actor_type,
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
"traceId": trace_id,
|
|
|
|
|
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。",
|
|
|
|
|
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
|
|
|
|
|
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
|
|
|
|
|
"pageContext": 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());
|
|
|
|
|
}
|
2026-05-14 17:04:17 +08:00
|
|
|
if let Some(profile) = profile {
|
|
|
|
|
body["profile"] = Value::String(profile.to_string());
|
|
|
|
|
}
|
2026-05-14 15:10:33 +08:00
|
|
|
Ok(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn proxy_json(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
method: reqwest::Method,
|
|
|
|
|
upstream: &str,
|
|
|
|
|
path: &str,
|
|
|
|
|
body: Option<Value>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
let url = upstream_url(upstream, path)?;
|
|
|
|
|
let client = reqwest::Client::builder()
|
|
|
|
|
.timeout(Duration::from_secs(1800))
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
|
|
|
|
|
})?;
|
|
|
|
|
let mut request = client.request(method, url);
|
|
|
|
|
if let Some(api_key) = configured_api_key() {
|
|
|
|
|
request = request.bearer_auth(api_key);
|
|
|
|
|
}
|
|
|
|
|
if let Some(body) = body {
|
|
|
|
|
request = request.json(&body);
|
|
|
|
|
}
|
|
|
|
|
let response = request.send().await.map_err(|error| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"hermes_client_upstream_unavailable",
|
|
|
|
|
format!("Hermes upstream 连接失败: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
|
|
|
|
})?;
|
|
|
|
|
let status = response.status();
|
|
|
|
|
let text = response.text().await.unwrap_or_default();
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
return Err(upstream_error(context, status, text));
|
|
|
|
|
}
|
|
|
|
|
let payload = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| {
|
|
|
|
|
json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"raw": text
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(normalize_success_payload(context, payload)),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
};
|
|
|
|
|
WebError::new(
|
|
|
|
|
response_status,
|
|
|
|
|
code,
|
|
|
|
|
format!(
|
|
|
|
|
"Hermes upstream 返回 HTTP {}: {}",
|
|
|
|
|
status.as_u16(),
|
|
|
|
|
text.chars().take(600).collect::<String>()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 std::sync::{Mutex, OnceLock};
|
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
|
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: true,
|
|
|
|
|
enable_debug_shell_routes: false,
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: None,
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn hermes_client_unauthenticated_requests_return_401() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/hermes/client/sessions")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(json!({"documentId":"doc_1"}).to_string()))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-error-code")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("hermes_client_unauthorized")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn hermes_client_unconfigured_run_returns_stable_error() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
|
|
|
|
std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/hermes/client/runs")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("x-mnote-actor-id", "user_1")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": "ws_1",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"sessionId": "sess_1",
|
|
|
|
|
"message": "ping",
|
|
|
|
|
"traceId": "trace_1"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-error-code")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("hermes_client_unconfigured")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn hermes_client_session_create_does_not_require_upstream_or_store_chat() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/hermes/client/sessions")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("x-mnote-actor-id", "user_1")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": "ws_1",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"traceId": "trace_1",
|
|
|
|
|
"title": "当前页问答"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["ok"], true);
|
|
|
|
|
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
|
|
|
|
assert_eq!(payload["persistence"], "hermes_on_first_run");
|
|
|
|
|
assert!(payload.get("messages").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:04:17 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/hermes/client/sessions")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("x-mnote-actor-id", "user_1")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": "ws_1",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"traceId": "trace_1",
|
|
|
|
|
"title": "当前页问答",
|
|
|
|
|
"profile": "chemist"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
|
|
|
|
assert_eq!(payload["profile"], "chemist");
|
|
|
|
|
assert!(payload.get("messages").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:10:33 +08:00
|
|
|
#[test]
|
|
|
|
|
fn hermes_client_run_body_carries_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": {"title": "页面标题"},
|
|
|
|
|
"selectedBlockId": "block_1",
|
|
|
|
|
"selectedText": "选中文本",
|
|
|
|
|
"traceId": "trace_1"
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(body["input"], "概括当前页面");
|
|
|
|
|
assert_eq!(body["session_id"], "sess_1");
|
|
|
|
|
let instructions = body["instructions"].as_str().expect("instructions");
|
|
|
|
|
assert!(instructions.contains("\"workspaceId\":\"ws_1\""));
|
|
|
|
|
assert!(instructions.contains("\"documentId\":\"doc_1\""));
|
|
|
|
|
assert!(instructions.contains("\"title\":\"页面标题\""));
|
|
|
|
|
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
|
|
|
|
|
}
|
2026-05-14 17:04:17 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn hermes_client_run_body_carries_agent_profile() {
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&axum::http::Method::POST,
|
|
|
|
|
&"/api/hermes/client/runs".parse().expect("uri"),
|
|
|
|
|
&HeaderMap::new(),
|
|
|
|
|
);
|
|
|
|
|
let body = build_run_upstream_body(
|
|
|
|
|
&context,
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": "ws_1",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"sessionId": "sess_1",
|
|
|
|
|
"message": "用当前 agent 回答",
|
|
|
|
|
"profile": "chemist",
|
|
|
|
|
"traceId": "trace_1"
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(body["profile"], "chemist");
|
|
|
|
|
let instructions = body["instructions"].as_str().expect("instructions");
|
|
|
|
|
assert!(instructions.contains("\"profile\":\"chemist\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn hermes_client_profile_skill_and_memory_routes_exist_with_stable_unconfigured_error() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
|
|
|
|
std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
|
|
|
|
|
let 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::SERVICE_UNAVAILABLE, "{uri}");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-error-code")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("hermes_client_unconfigured"),
|
|
|
|
|
"{uri}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 15:10:33 +08:00
|
|
|
}
|