feat(ai): switch page ai to hermes panel
This commit is contained in:
@@ -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(¶ms);
|
||||
}
|
||||
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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")"));
|
||||
|
||||
Reference in New Issue
Block a user