feat: expand page ai hermes control surface

This commit is contained in:
lix-2026
2026-05-14 17:04:17 +08:00
parent d1d19f755c
commit f257b2a014
6 changed files with 1447 additions and 50 deletions
@@ -22,6 +22,7 @@ pub struct CreateSessionRequest {
document_id: Option<String>,
trace_id: Option<String>,
title: Option<String>,
profile: Option<String>,
}
pub async fn list_sessions(
@@ -60,6 +61,12 @@ pub async fn create_session(
.filter(|value| !value.trim().is_empty())
.unwrap_or("current");
let session_id = stable_session_id(document_id, &trace_id);
let profile = payload
.profile
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
Ok((
StatusCode::OK,
stamp_client_headers(),
@@ -68,6 +75,7 @@ pub async fn create_session(
"sessionId": session_id,
"workspaceId": payload.workspace_id,
"documentId": payload.document_id,
"profile": profile,
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": "hermes_on_first_run"
@@ -75,6 +83,133 @@ pub async fn create_session(
))
}
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(&params);
}
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
}
pub async fn get_session(
Extension(context): Extension<RequestContext>,
Path(session_id): Path<String>,
@@ -335,10 +470,16 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
let profile = payload
.get("profile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let instructions = json!({
"role": "mnote_page_ai_context",
"workspaceId": workspace_id,
"documentId": document_id,
"profile": profile.unwrap_or("default"),
"actorId": context.auth.actor_id,
"actorType": context.auth.actor_type,
"sessionId": session_id,
@@ -362,6 +503,9 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
{
body["model"] = Value::String(model.to_string());
}
if let Some(profile) = profile {
body["profile"] = Value::String(profile.to_string());
}
Ok(body)
}
@@ -664,6 +808,39 @@ mod tests {
assert!(payload.get("messages").is_none());
}
#[tokio::test]
async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/sessions")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_1",
"documentId": "doc_1",
"traceId": "trace_1",
"title": "当前页问答",
"profile": "chemist"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
assert_eq!(payload["profile"], "chemist");
assert!(payload.get("messages").is_none());
}
#[test]
fn hermes_client_run_body_carries_page_context_into_run_input() {
let context = RequestContext::from_http_parts(
@@ -693,4 +870,77 @@ mod tests {
assert!(instructions.contains("\"title\":\"页面标题\""));
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
}
#[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}"
);
}
}
}
+23 -1
View File
@@ -27,7 +27,7 @@ pub(crate) mod web_shell;
mod ws;
use crate::app::AppState;
use axum::routing::{any, get, post};
use axum::routing::{any, get, post, put};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
@@ -145,6 +145,28 @@ pub fn build_router(state: AppState) -> Router {
"/client/sessions/{session_id}",
get(hermes_client::get_session),
)
.route("/client/profiles", get(hermes_client::list_profiles))
.route(
"/client/profiles/active",
put(hermes_client::switch_active_profile),
)
.route(
"/client/profiles/{profile_name}",
get(hermes_client::get_profile),
)
.route(
"/client/profile-memory",
get(hermes_client::get_profile_memory)
.post(hermes_client::save_profile_memory),
)
.route(
"/client/skills",
get(hermes_client::list_skills),
)
.route(
"/client/skills/toggle",
put(hermes_client::toggle_skill),
)
.route("/client/runs", post(hermes_client::create_run))
.route("/client/events/{run_id}", get(hermes_client::stream_events))
.route(