feat(ai): switch page ai to hermes panel

This commit is contained in:
lix-2026
2026-05-14 15:10:33 +08:00
parent e9188716e6
commit 9816035491
48 changed files with 6353 additions and 412 deletions
+32 -229
View File
@@ -3,16 +3,12 @@ use crate::context::RequestContext;
use crate::error::WebError;
use axum::body::Body;
use axum::extract::{Extension, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
use std::env;
use std::path::PathBuf;
use std::process::Command;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use serde_json::Value;
pub async fn next_ai_agent_run(
State(state): State<AppState>,
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
@@ -29,19 +25,17 @@ pub async fn next_ai_agent_run(
.with_header("x-mnote-web-owner", "mnote-web")
})?;
if let Some(provider) = explicit_agent_provider(&payload) {
return Err(WebError::bad_gateway_code(
"ai_provider_bridge_unavailable",
format!(
"{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级。"
),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
}
run_local_mnote_cli_ai_host(&state, &context, &payload).await
let provider = explicit_agent_provider(&payload).unwrap_or("legacy");
Err(WebError::new(
StatusCode::GONE,
"legacy_ai_agent_run_retired",
format!(
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin。"
),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "legacy-ai-agent-run-retired"))
}
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
@@ -60,205 +54,6 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
}
}
fn resolve_repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
}
fn build_mnote_cli_args(context: &RequestContext, payload: &Value) -> Vec<String> {
let ai = payload
.get("options")
.and_then(|value| value.get("ai"))
.cloned()
.unwrap_or(Value::Null);
let runtime_context = payload.get("context").cloned().unwrap_or(Value::Null);
let document_id = runtime_context
.get("documentId")
.and_then(Value::as_str)
.unwrap_or("current");
let workspace_id = runtime_context
.get("workspaceId")
.and_then(Value::as_str)
.or(context.workspace.workspace_id.as_deref());
let session_id = ai
.get("sessionId")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("ai-{}", context.trace.request_id));
let args_json = json!({
"pageId": document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"provider": ai.get("provider").cloned().unwrap_or(Value::Null),
"modelKey": ai.get("modelKey").cloned().unwrap_or(Value::Null),
"profileId": ai.get("profileId").cloned().unwrap_or(Value::Null),
"selectedUids": runtime_context.get("selectedUids").cloned().unwrap_or(Value::Null),
"pageOptions": runtime_context.get("pageOptions").cloned().unwrap_or(Value::Null),
})
.to_string();
vec![
"run".into(),
"--quiet".into(),
"--manifest-path".into(),
resolve_repo_root()
.join("rust")
.join("Cargo.toml")
.to_string_lossy()
.to_string(),
"-p".into(),
"mnote-cli".into(),
"--".into(),
"--json".into(),
"--validate-only".into(),
"--dry-run".into(),
"--actor-id".into(),
context.auth.actor_id.clone(),
"--actor-type".into(),
context.auth.actor_type.clone(),
"--session-id".into(),
session_id,
"--reason".into(),
"ai-agent-run:mnote-web-rust-host".into(),
"tool".into(),
"run".into(),
"--tool-name".into(),
"doc_get".into(),
"--kind".into(),
"query".into(),
"--mode".into(),
"explain-plan".into(),
"--args-json".into(),
args_json,
]
}
async fn run_local_mnote_cli_ai_host(
state: &AppState,
context: &RequestContext,
payload: &Value,
) -> Result<Response, WebError> {
let stream = payload
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(true);
let args = build_mnote_cli_args(context, payload);
let repo_root = resolve_repo_root();
let actor_id =
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
state.config().dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
let actor_type =
if context.auth.actor_type.trim().is_empty() || context.auth.actor_type == "anonymous" {
"user".to_string()
} else {
context.auth.actor_type.clone()
};
let dev_email = state.config().dev_user_email.clone();
let dev_name = state.config().dev_user_name.clone();
let output = tokio::task::spawn_blocking(move || {
Command::new("cargo")
.args(args)
.current_dir(repo_root)
.env("CARGO_TERM_COLOR", "never")
.env(
"RUSTUP_TOOLCHAIN",
env::var("RUSTUP_TOOLCHAIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "1.89.0".into()),
)
.env("DEV_USER_ID", actor_id)
.env("DEV_USER_EMAIL", dev_email)
.env("DEV_USER_NAME", dev_name)
.env("MNOTE_CLI_ALLOW_CREATE_PAGE", "1")
.env("MNOTE_CLI_ALLOW_EDIT", "1")
.env("MNOTE_ACTOR_TYPE", actor_type)
.output()
})
.await
.map_err(|error| {
WebError::bad_gateway_code(
"mnote_cli_host_join_error",
format!("mnote-cli host join 失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
})?
.map_err(|error| {
WebError::bad_gateway_code(
"mnote_cli_host_spawn_error",
format!("mnote-cli host 启动失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
})?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stream {
if !output.status.success() {
return Err(WebError::bad_gateway_code(
"mnote_cli_host_failed",
if stderr.is_empty() {
"mnote-cli 执行失败".into()
} else {
stderr
},
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web"));
}
let mut response = Json(json!({
"ok": true,
"bridgeOwner": "mnote-cli",
"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout },
}))
.into_response();
stamp_owner_header(response.headers_mut());
response.headers_mut().insert(
HeaderName::from_static("x-mnote-ai-execution-owner"),
HeaderValue::from_static("mnote-cli"),
);
return Ok(response);
}
let body = if output.status.success() {
format!(
"event: ready\ndata: {}\n\nevent: assistant_message\ndata: {}\n\nevent: completion\ndata: {}\n\n",
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
json!({"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }}).to_string(),
json!({"ok": true, "text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }, "steps": 1}).to_string(),
)
} else {
format!(
"event: ready\ndata: {}\n\nevent: error\ndata: {}\n\n",
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
json!({"ok": false, "message": if stderr.is_empty() { "mnote-cli 执行失败" } else { &stderr }}).to_string(),
)
};
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(header::CONNECTION, "keep-alive")
.header("x-accel-buffering", "no")
.header("x-mnote-ai-execution-owner", "mnote-cli")
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("mnote-cli SSE 响应构造失败: {error}")))?;
stamp_owner_header(response.headers_mut());
Ok(response)
}
fn stamp_owner_header(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
@@ -306,7 +101,7 @@ mod tests {
}
#[tokio::test]
async fn direct_ai_agent_run_is_owned_by_rust_web_when_next_compat_disabled() {
async fn direct_ai_agent_run_returns_legacy_retired_guard() {
let response = build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -337,7 +132,7 @@ mod tests {
.await
.expect("response");
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(response.status(), StatusCode::GONE);
assert_eq!(
response
.headers()
@@ -345,15 +140,23 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("legacy-ai-agent-run-retired")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(!text.contains("legacy_next_compat_disabled"));
assert!(text.contains("legacy_ai_agent_run_retired"));
assert!(text.contains("Hermes client proxy"));
}
#[tokio::test]
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
async fn explicit_agent_provider_returns_legacy_retired_guard() {
let response = build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -384,20 +187,20 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(response.status(), StatusCode::GONE);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("provider-bridge-unavailable")
Some("legacy-ai-agent-run-retired")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("codex"));
assert!(text.contains("provider 直连链路已退场"));
assert!(text.contains("legacy_ai_agent_run_retired"));
}
#[tokio::test]
@@ -451,13 +254,13 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(response.status(), StatusCode::GONE);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("provider-bridge-unavailable")
Some("legacy-ai-agent-run-retired")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
@@ -0,0 +1,696 @@
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>,
}
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(&params);
}
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);
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"sessionId": session_id,
"workspaceId": payload.workspace_id,
"documentId": payload.document_id,
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": "hermes_on_first_run"
})),
))
}
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);
let instructions = json!({
"role": "mnote_page_ai_context",
"workspaceId": workspace_id,
"documentId": document_id,
"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());
}
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());
}
#[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\""));
}
}
@@ -0,0 +1,787 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";
pub async fn mnote_audit(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let trace_id = query.get("traceId").map(String::as_str);
let tool_call_id = query.get("toolCallId").map(String::as_str);
let persisted_only = query
.get("persistedOnly")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);
let events = if persisted_only {
audit_persisted_events(trace_id, tool_call_id)
} else {
audit_events(trace_id, tool_call_id)
};
Ok((
StatusCode::OK,
stamp_tool_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"auditStore": if persisted_only { "jsonl" } else { "memory" },
"events": events
})),
))
}
pub async fn mnote_manifest(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
Ok((
StatusCode::OK,
stamp_tool_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"manifest": manifest::manifest()
})),
))
}
pub async fn mnote_call(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let trace_id = input
.effective_trace_id(&context.trace.trace_id)
.to_string();
let tool_call_id = input.effective_tool_call_id();
let workspace_id = input.effective_workspace_id();
let document_id = input.effective_document_id();
let dry_run = input.dry_run.unwrap_or(false);
let effect = if dry_run {
"dry_run"
} else if input.tool_name == "mnote.page.get" {
"read"
} else {
"write"
};
let idempotency_key = idempotency_cache_key(
&input,
workspace_id.as_deref(),
document_id.as_deref(),
dry_run,
);
info!(
trace_id = %trace_id,
session_id = input.session_id.as_deref().unwrap_or(""),
run_id = input.run_id.as_deref().unwrap_or(""),
tool_call_id = %tool_call_id,
tool_name = %input.tool_name,
workspace_id = workspace_id.as_deref().unwrap_or(""),
document_id = document_id.as_deref().unwrap_or(""),
actor_id = input.actor_id.as_deref().unwrap_or(""),
dry_run,
"mnote Hermes tool call started"
);
audit_push(json!({
"phase": "started",
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": tool_call_id,
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"dryRun": dry_run
}));
if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
audit_push(json!({
"phase": "failed",
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": tool_call_id,
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"status": error.status().as_u16(),
"message": error.message()
}));
return Err(error);
}
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
info!(
trace_id = %trace_id,
session_id = input.session_id.as_deref().unwrap_or(""),
run_id = input.run_id.as_deref().unwrap_or(""),
tool_call_id = %tool_call_id,
tool_name = %input.tool_name,
workspace_id = workspace_id.as_deref().unwrap_or(""),
document_id = document_id.as_deref().unwrap_or(""),
actor_id = input.actor_id.as_deref().unwrap_or(""),
"mnote Hermes tool call idempotency replay"
);
audit_push(json!({
"phase": "idempotency_replay",
"traceId": trace_id,
"sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null),
"runId": cached.get("runId").cloned().unwrap_or(Value::Null),
"toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null),
"toolName": cached.get("toolName").cloned().unwrap_or(Value::Null),
"audit": cached.get("audit").cloned().unwrap_or(Value::Null)
}));
return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
}
let result = match input.tool_name.as_str() {
"mnote.page.get" => page::page_get(&state, &context, &input).await,
"mnote.page.save" => page::page_save(&state, &context, &input).await,
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
_ => Err(
WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool")
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"),
),
};
if let Err(error) = &result {
warn!(
trace_id = %trace_id,
session_id = input.session_id.as_deref().unwrap_or(""),
run_id = input.run_id.as_deref().unwrap_or(""),
tool_call_id = %tool_call_id,
tool_name = %input.tool_name,
workspace_id = workspace_id.as_deref().unwrap_or(""),
document_id = document_id.as_deref().unwrap_or(""),
actor_id = input.actor_id.as_deref().unwrap_or(""),
status = %error.status(),
message = %error.message(),
"mnote Hermes tool call failed"
);
audit_push(json!({
"phase": "failed",
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": tool_call_id,
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"status": error.status().as_u16(),
"message": error.message()
}));
}
let result = result?;
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
info!(
trace_id = %trace_id,
session_id = input.session_id.as_deref().unwrap_or(""),
run_id = input.run_id.as_deref().unwrap_or(""),
tool_call_id = %tool_call_id,
tool_name = %input.tool_name,
workspace_id = workspace_id.as_deref().unwrap_or(""),
document_id = document_id.as_deref().unwrap_or(""),
actor_id = input.actor_id.as_deref().unwrap_or(""),
effect,
"mnote Hermes tool call completed"
);
let response_body = json!({
"ok": true,
"toolName": input.tool_name,
"toolCallId": tool_call_id,
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"result": result,
"audit": {
"effect": effect,
"commandId": command_id,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"dryRun": dry_run,
"idempotencyKey": input.idempotency_key,
"capabilityScope": input.capability_scope
},
"error": null
});
if let Some(key) = idempotency_key {
idempotency_cache_put(key, response_body.clone());
}
audit_push(json!({
"phase": "completed",
"traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null),
"sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null),
"runId": response_body.get("runId").cloned().unwrap_or(Value::Null),
"toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null),
"toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null),
"audit": response_body.get("audit").cloned().unwrap_or(Value::Null)
}));
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}
fn audit_log() -> &'static Mutex<Vec<Value>> {
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
LOG.get_or_init(|| Mutex::new(Vec::new()))
}
fn audit_push(event: Value) {
if let Ok(mut log) = audit_log().lock() {
log.push(event.clone());
let overflow = log.len().saturating_sub(500);
if overflow > 0 {
log.drain(0..overflow);
}
}
if let Err(error) = audit_append_persistent(&event) {
warn!(message = %error, "mnote Hermes tool audit 持久化失败");
}
}
fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
let Ok(log) = audit_log().lock() else {
return Vec::new();
};
log.iter()
.filter(|event| {
trace_id
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
.unwrap_or(true)
})
.filter(|event| {
tool_call_id
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
.unwrap_or(true)
})
.cloned()
.collect()
}
fn audit_log_path() -> PathBuf {
env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl"))
}
fn audit_append_persistent(event: &Value) -> Result<(), String> {
let path = audit_log_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| error.to_string())?;
let line = serde_json::to_string(event).map_err(|error| error.to_string())?;
writeln!(file, "{line}").map_err(|error| error.to_string())
}
fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
let path = audit_log_path();
let Ok(file) = File::open(path) else {
return Vec::new();
};
BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter_map(|line| serde_json::from_str::<Value>(&line).ok())
.filter(|event| {
trace_id
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
.unwrap_or(true)
})
.filter(|event| {
tool_call_id
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
.unwrap_or(true)
})
.collect()
}
fn idempotency_cache() -> &'static Mutex<HashMap<String, Value>> {
static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn idempotency_cache_key(
input: &ToolCallInput,
workspace_id: Option<&str>,
document_id: Option<&str>,
dry_run: bool,
) -> Option<String> {
if dry_run || input.tool_name == "mnote.page.get" {
return None;
}
let idempotency_key = input.idempotency_key.as_deref()?.trim();
if idempotency_key.is_empty() {
return None;
}
Some(format!(
"{}|{}|{}|{}",
input.tool_name,
workspace_id.unwrap_or(""),
document_id.unwrap_or(""),
idempotency_key
))
}
fn idempotency_cache_get(key: &str) -> Option<Value> {
idempotency_cache().lock().ok()?.get(key).cloned()
}
fn idempotency_cache_put(key: String, response: Value) {
if let Ok(mut cache) = idempotency_cache().lock() {
cache.insert(key, response);
}
}
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,
"mnote_tool_unauthorized",
"mnote Hermes tool 需要登录后访问",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn ensure_workspace_context(
context: &RequestContext,
input_workspace_id: Option<&str>,
) -> Result<(), WebError> {
let Some(input_workspace_id) = input_workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let Some(header_workspace_id) = context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
if input_workspace_id == header_workspace_id {
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"workspace_context_conflict",
"mnote Hermes tool 请求的 workspaceId 与请求上下文不一致",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn stamp_tool_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_HERMES_TOOL_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools"));
}
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: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"can_edit": true,
"wide_layout": false,
"use_small_text": false,
"show_toc": true,
"block_count": 1
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
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_tools_manifest_returns_first_batch_tools() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/hermes/tools/mnote/manifest")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.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");
let tools = payload["manifest"]["tools"].as_array().expect("tools");
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
assert_eq!(
payload["manifest"]["schemaVersion"],
"mnote.hermes_tool_manifest.v1"
);
}
#[tokio::test]
async fn hermes_tools_page_get_requires_auth() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.body(Body::from(
json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hermes_tools_write_tools_require_auth() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_1",
"dryRun": false,
"args": {"content": []}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hermes_tools_page_get_returns_page_aggregate_summary() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.get",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"capabilityScope": ["page.read"]
})
.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["toolName"], "mnote.page.get");
assert_eq!(payload["toolCallId"], "call_1");
assert_eq!(payload["result"]["title"], "服务端页面");
assert!(payload["result"]["bodySummary"]
.as_str()
.unwrap_or_default()
.contains("章节一"));
assert_eq!(payload["audit"]["effect"], "read");
}
#[tokio::test]
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-workspace-id", "ws_other")
.body(Body::from(
json!({
"toolName": "mnote.page.get",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"capabilityScope": ["page.read"]
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("workspace_context_conflict")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(!text.contains("服务端页面"));
assert!(!text.contains("章节一"));
}
#[tokio::test]
async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"args": {"content": []}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_idempotency_required")
);
}
#[tokio::test]
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_save_1",
"dryRun": true,
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
})
.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["audit"]["effect"], "dry_run");
assert_eq!(payload["result"]["dryRun"], true);
assert_eq!(payload["result"]["commandName"], "page.body.save");
}
#[tokio::test]
async fn hermes_tools_update_options_dry_run_filters_unwired_fields() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.update_options",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_options_1",
"dryRun": true,
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
})
.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");
let options = &payload["result"]["diff"][0]["payload"]["options"];
assert_eq!(options["wideLayout"], true);
assert!(options.get("pageFont").is_none());
assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont");
assert_eq!(
payload["result"]["warnings"][0]["code"],
"page_option_not_wired"
);
}
#[tokio::test]
async fn hermes_tools_artifact_dry_run_returns_artifact_plan() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.artifact.create_summary",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_summary_1",
"dryRun": true,
"args": {"summary": "摘要内容"}
})
.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["result"]["dryRun"], true);
assert_eq!(payload["result"]["artifactType"], "summary");
}
}
@@ -364,9 +364,7 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-mnote-object-editor=\"mindmap\""));
assert!(html.contains(
"data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""
));
assert!(html.contains("data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""));
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
assert!(html.contains("mindmap.command.apply"));
+28 -3
View File
@@ -1,11 +1,13 @@
mod bridge;
mod command_support;
pub(crate) mod command_support;
mod compat;
mod documents;
mod editor;
mod gateway;
mod health;
mod hermes;
mod hermes_client;
mod hermes_tools;
mod kernel;
mod local_folder_events;
mod local_folder_source;
@@ -21,7 +23,7 @@ mod snapshot_support;
mod sse;
mod stream_support;
mod tree;
mod web_shell;
pub(crate) mod web_shell;
mod ws;
use crate::app::AppState;
@@ -134,7 +136,30 @@ pub fn build_router(state: AppState) -> Router {
&hermes_base_path,
Router::new()
.route("/health", get(hermes::health))
.route("/bridge", post(hermes::bridge_runtime)),
.route("/bridge", post(hermes::bridge_runtime))
.route(
"/client/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/client/sessions/{session_id}",
get(hermes_client::get_session),
)
.route("/client/runs", post(hermes_client::create_run))
.route("/client/events/{run_id}", get(hermes_client::stream_events))
.route(
"/client/runs/{run_id}/abort",
post(hermes_client::abort_run),
)
.route("/client/models", get(hermes_client::list_models))
.route("/client/tools", get(hermes_client::list_tools)),
)
.nest(
"/api/hermes/tools",
Router::new()
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
.route("/mnote/call", post(hermes_tools::mnote_call))
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
);
if enable_debug_shell_routes {
+16 -15
View File
@@ -1,5 +1,5 @@
use crate::app::AppState;
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use axum::body::{Body, Bytes};
@@ -796,7 +796,9 @@ pub async fn forcesave(
return WebError::new(
axum::http::StatusCode::NOT_IMPLEMENTED,
"onlyoffice_legacy_writeback_unavailable",
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
format!(
"OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"
),
);
}
error
@@ -831,7 +833,9 @@ async fn proxy_legacy_onlyoffice_json(
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| {
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
WebError::internal(format!(
"OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"
))
})?;
let mut request = client
.post(target)
@@ -861,7 +865,9 @@ async fn proxy_legacy_onlyoffice_json(
})?;
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
response
.headers_mut()
.insert(header::CONTENT_TYPE, content_type);
Ok(response)
}
@@ -1142,9 +1148,7 @@ mod tests {
async fn spawn_legacy_json_server(
response_body: &'static str,
) -> (String, oneshot::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
@@ -1216,9 +1220,8 @@ mod tests {
let request = captured.await.expect("captured");
assert_eq!(payload["error"], 0);
assert!(request.starts_with(
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
));
assert!(request
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
assert!(request.contains(r#""status":2"#));
assert!(request.contains(r#""key":"doc_key""#));
}
@@ -1252,8 +1255,7 @@ mod tests {
#[tokio::test]
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
let (base_url, captured) =
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
.await;
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await;
let response = forcesave(
State(test_state(Some(base_url))),
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
@@ -1274,8 +1276,7 @@ mod tests {
assert_eq!(payload["ok"], true);
assert_eq!(payload["via"], "forcesave");
assert!(request.starts_with(
"POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"
));
assert!(request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
}
}
+10 -8
View File
@@ -4439,13 +4439,6 @@ fn build_tree_shell_html(
target: { documentId },
payload: { documentId },
});
if (documentId) {
postToHost("tree.navigate", {
documentId,
target: { documentId },
payload: { documentId },
});
}
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
if (mode === "filetree") {
beginInlineRename("filetree", `doc:${documentId}`);
@@ -4454,6 +4447,13 @@ fn build_tree_shell_html(
}
return;
}
if (documentId) {
postToHost("tree.navigate", {
documentId,
target: { documentId },
payload: { documentId },
});
}
scheduleRefresh({
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
});
@@ -6810,7 +6810,9 @@ mod tests {
assert!(html.contains("applyCreatedDocumentLocally"));
assert!(html.contains("applyRemovedDocumentLocally"));
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
assert!(
html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
);
assert!(html.contains("application/x-mnote-page-tree-node"));
assert!(html.contains("页面已拖放到"));
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));