feat: expand page ai hermes control surface
This commit is contained in:
@@ -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(¶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
|
||||
}
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user