Files
mnote/rust/crates/mnote-web/src/routes/hermes.rs
T

298 lines
11 KiB
Rust

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesHealthResponse {
pub ok: bool,
pub service: String,
pub bridge: &'static str,
pub request_id: String,
pub trace_id: String,
}
pub async fn health(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<HermesHealthResponse> {
Json(HermesHealthResponse {
ok: true,
service: state.config().service_name.clone(),
bridge: "hermes",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
})
}
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let session_id = format!("hermes_{}", context.trace.request_id);
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
Ok(runtime_input) => runtime_input,
Err(_) => {
return Ok((
StatusCode::OK,
stamp_ai_bridge_headers(),
Json(json!({
"ok": true,
"bridge": "hermes_session",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"structuredWrite": {
"owner": "rust-web-hermes",
"allowedCommands": [
"page.body.save",
"tree.node.create",
"kernel.edge.attach"
]
},
"compatPayload": payload,
})),
));
}
};
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
"ok": true,
"bridge": "hermes_runtime_result",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"result": result,
}),
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
} else {
match execute_runtime_input(runtime_input) {
Ok(plan) => {
let success = build_success_response(plan);
json!({
"ok": success.ok,
"bridge": "hermes_runtime_plan",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"plan": success.plan,
})
}
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
};
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
}
fn ai_bridge_contract(session_id: &str) -> Value {
json!({
"schema": "mnote.ai_bridge.v1",
"owner": "mnote-web",
"bridge": "hermes",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
"canonicalRoute": "/api/hermes/bridge",
"sessionOwner": "rust-web-hermes",
"toolEventOwner": "rust-web-hermes",
"clientActionOwner": "rust-web-hermes",
"structuredWriteOwner": "rust-web-hermes"
})
}
fn stamp_ai_bridge_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
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_AI_BRIDGE_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
}
headers
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
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,
enable_editor_actor: true,
enable_page_ai_pi_lab: 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 ai_bridge_route_returns_hermes_owner_contract() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"kind": "tool",
"context": {
"deploymentId": null,
"projectId": null,
"workspaceId": "ws_demo",
"requestId": "req_1",
"traceId": "trace_1",
"actor": {
"actorType": "user",
"actorId": "user_1",
"sessionId": null
},
"source": {
"channel": "rust-web",
"client": "mnote-web"
},
"tenantId": null,
"authToken": null,
"idempotencyKey": null,
"validateOnly": false,
"dryRun": false
},
"tool": {
"tool": "search_web",
"kind": "query",
"mode": "plan",
"argsJson": {"query": "Rust Web"},
"target": null,
"reason": "owner gate",
"refs": []
},
"data": null
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-ai-bridge-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-web-hermes")
);
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["contract"]["schema"], "mnote.ai_bridge.v1");
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/"));
}
#[tokio::test]
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"stream": true,
"scope": "document",
"messages": [{"role": "user", "content": "生成摘要"}],
"context": {"documentId": "doc_1"}
})
.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["bridge"], "hermes_session");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert_eq!(
payload["contract"]["structuredWriteOwner"],
"rust-web-hermes"
);
}
}