2026-04-16 22:01:51 +08:00
|
|
|
use crate::app::AppState;
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use axum::extract::{Extension, State};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
|
|
|
use axum::Json;
|
2026-04-16 22:01:51 +08:00
|
|
|
use bridge_runtime::{
|
2026-04-29 12:24:44 +08:00
|
|
|
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
|
|
|
|
runtime_input_requests_result, RuntimeInput,
|
2026-04-16 22:01:51 +08:00
|
|
|
};
|
|
|
|
|
use serde::Serialize;
|
2026-04-29 12:24:44 +08:00
|
|
|
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";
|
2026-04-16 22:01:51 +08:00
|
|
|
|
|
|
|
|
#[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(runtime_input): Json<RuntimeInput>,
|
2026-04-29 12:24:44 +08:00
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
2026-04-16 22:01:51 +08:00
|
|
|
let payload = if runtime_input_requests_result(&runtime_input) {
|
|
|
|
|
match execute_runtime_query(runtime_input) {
|
|
|
|
|
Ok(result) => json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"bridge": "hermes_runtime_result",
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
2026-04-29 12:24:44 +08:00
|
|
|
"contract": ai_bridge_contract(),
|
2026-04-16 22:01:51 +08:00
|
|
|
"result": result,
|
|
|
|
|
}),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
let failure = build_failure_response(error);
|
|
|
|
|
return Ok((
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
2026-04-29 12:24:44 +08:00
|
|
|
stamp_ai_bridge_headers(),
|
2026-04-16 22:01:51 +08:00
|
|
|
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",
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
2026-04-29 12:24:44 +08:00
|
|
|
"contract": ai_bridge_contract(),
|
2026-04-16 22:01:51 +08:00
|
|
|
"plan": success.plan,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Err(error) => {
|
|
|
|
|
let failure = build_failure_response(error);
|
|
|
|
|
return Ok((
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
2026-04-29 12:24:44 +08:00
|
|
|
stamp_ai_bridge_headers(),
|
2026-04-16 22:01:51 +08:00
|
|
|
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ai_bridge_contract() -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"schema": "mnote.ai_bridge.v1",
|
|
|
|
|
"owner": "mnote-web",
|
|
|
|
|
"bridge": "hermes",
|
|
|
|
|
"sessionOwner": "rust-web-hermes",
|
|
|
|
|
"toolEventOwner": "rust-web-hermes",
|
|
|
|
|
"clientActionOwner": "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,
|
|
|
|
|
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": "docs_search",
|
|
|
|
|
"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");
|
|
|
|
|
}
|
2026-04-16 22:01:51 +08:00
|
|
|
}
|