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
@@ -0,0 +1,185 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde_json::{json, Value};
pub async fn create_summary(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
create_artifact_node(
state,
context,
input,
"summary",
"mnote.artifact.create_summary",
)
.await
}
pub async fn create_ai_note(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
create_artifact_node(
state,
context,
input,
"ai_note",
"mnote.artifact.create_ai_note",
)
.await
}
async fn create_artifact_node(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
node_type: &str,
tool_name: &str,
) -> Result<Value, WebError> {
ensure_write_contract(context, input)?;
let document_id = input.effective_document_id().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少 documentId")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
let content = input
.arg_string("summary")
.or_else(|| input.arg_string("content"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少内容")
.with_context(context)
})?;
let idempotency_key =
input.idempotency_key_or_default(&format!("{tool_name}_{}", context.trace.request_id));
let command_id = format!(
"{}_{}",
tool_name.replace('.', "_"),
context.trace.request_id
);
let artifact_document_id = if node_type == "summary" {
format!("summary_{}", document_id)
} else {
format!("ai_note_{}_{}", document_id, context.trace.request_id)
};
if input.dry_run.unwrap_or(false) {
return Ok(json!({
"dryRun": true,
"commandName": "tree.node.create",
"commandId": command_id,
"artifactType": node_type,
"artifactDocumentId": artifact_document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"diff": [{"op": "create_artifact", "artifactType": node_type}]
}));
}
let command = RuntimeCommandEnvelopeWire {
name: "tree.node.create".into(),
command_id: command_id.clone(),
idempotency_key: Some(idempotency_key),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: input
.session_id
.clone()
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
capabilities: input.capability_scope.clone().unwrap_or_default(),
},
target: Some(RuntimeTargetWire {
workspace_id: workspace_id.clone(),
page_id: Some(document_id.clone()),
block_id: None,
}),
payload: json!({
"workspaceId": workspace_id,
"parentId": document_id,
"documentId": artifact_document_id,
"accessScope": "private",
"nodeType": node_type,
"title": if node_type == "summary" { "AI Summary" } else { "AI Note" },
"content": [
{
"id": format!("{}_body", node_type),
"type": "paragraph",
"content": [{"type": "text", "text": content}]
}
],
"artifact": {
"kind": node_type,
"sourceDocumentId": document_id,
"source": "hermes",
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": input.tool_call_id,
"traceId": input.effective_trace_id(&context.trace.trace_id)
},
"referenceEdge": {
"from": document_id,
"kind": "ai_artifact_reference"
}
}),
preflight_data: None,
reason: Some(tool_name.into()),
refs: vec![tool_name.into(), "hermes-tool-call".into()],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
context,
workspace_id.as_deref(),
command,
)
.await?;
Ok(json!({
"commandName": "tree.node.create",
"commandId": command_id,
"artifactType": node_type,
"artifactDocumentId": artifact_document_id,
"referenceEdge": {
"from": document_id,
"to": artifact_document_id,
"kind": "ai_artifact_reference"
},
"result": execution.result,
"artifacts": execution.artifacts,
"artifactError": execution.artifact_error
}))
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
Ok(())
}
@@ -0,0 +1,58 @@
use serde_json::{json, Value};
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
pub fn manifest() -> Value {
json!({
"schemaVersion": MANIFEST_SCHEMA_VERSION,
"plugin": {
"name": "mnote",
"description": "mnote 页面、树、artifact 与 edge 工具",
"runtimeOwner": "mnote-web",
"writeOwner": "rust-runtime-kernel"
},
"tools": [
page_get_tool(),
planned_tool("mnote.page.save", ["page.write"]),
planned_tool("mnote.page.update_title", ["page.write"]),
planned_tool("mnote.page.update_options", ["page.write"]),
planned_tool("mnote.artifact.create_summary", ["artifact.write"]),
planned_tool("mnote.artifact.create_ai_note", ["artifact.write"])
]
})
}
fn page_get_tool() -> Value {
json!({
"name": "mnote.page.get",
"description": "读取当前页面 Page Aggregate 摘要",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.read"],
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"],
"properties": {
"workspaceId": { "type": "string" },
"documentId": { "type": "string" },
"sessionId": { "type": "string" },
"runId": { "type": "string" },
"toolCallId": { "type": "string" },
"traceId": { "type": "string" },
"includeBody": { "type": "boolean", "default": true },
"includeOptions": { "type": "boolean", "default": true },
"includeBlocks": { "type": "boolean", "default": true }
}
}
})
}
fn planned_tool(name: &str, scope: impl IntoIterator<Item = &'static str>) -> Value {
json!({
"name": name,
"description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
"status": "planned"
})
}
@@ -0,0 +1,91 @@
pub mod artifact;
pub mod manifest;
pub mod page;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallInput {
pub tool_name: String,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
pub actor_id: Option<String>,
pub session_id: Option<String>,
pub run_id: Option<String>,
pub tool_call_id: Option<String>,
pub trace_id: Option<String>,
pub idempotency_key: Option<String>,
pub dry_run: Option<bool>,
pub capability_scope: Option<Vec<String>>,
pub args: Option<Value>,
}
impl ToolCallInput {
pub fn arg_string(&self, key: &str) -> Option<String> {
self.args
.as_ref()
.and_then(|args| args.get(key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn arg_value(&self, key: &str) -> Option<Value> {
self.args.as_ref().and_then(|args| args.get(key)).cloned()
}
pub fn effective_workspace_id(&self) -> Option<String> {
self.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| self.arg_string("workspaceId"))
}
pub fn effective_document_id(&self) -> Option<String> {
self.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| self.arg_string("documentId"))
}
pub fn effective_tool_call_id(&self) -> String {
self.tool_call_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("tool_call_missing")
.to_string()
}
pub fn effective_trace_id<'a>(&'a self, fallback: &'a str) -> &'a str {
self.trace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback)
}
pub fn idempotency_key_or_default(&self, fallback: &str) -> String {
self.idempotency_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback)
.to_string()
}
pub fn has_idempotency_key(&self) -> bool {
self.idempotency_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
}
}
@@ -0,0 +1,341 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde_json::{json, Value};
pub async fn page_get(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let document_id = input.effective_document_id().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
)
.await?;
let aggregate_value =
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))?;
let title = aggregate_value
.pointer("/head/title")
.and_then(Value::as_str)
.unwrap_or("无标题");
let content = aggregate_value
.pointer("/body/content")
.cloned()
.unwrap_or(Value::Null);
let page_options = aggregate_value
.pointer("/layout/pageOptions")
.or_else(|| aggregate_value.pointer("/layout/page_options"))
.cloned()
.unwrap_or_else(|| json!({}));
let blocks = summarize_blocks(&content);
let body_summary = blocks
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.filter(|text| !text.trim().is_empty())
.take(8)
.collect::<Vec<_>>()
.join("\n");
Ok(json!({
"documentId": document_id,
"workspaceId": workspace_id,
"title": title,
"bodySummary": body_summary,
"pageOptions": page_options,
"blocks": blocks,
"aggregateSchema": aggregate_value.get("schema").cloned().unwrap_or(Value::Null),
"aggregateSource": aggregate_value.get("source").cloned().unwrap_or(Value::Null)
}))
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
Ok(())
}
pub async fn page_save(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let content = input.arg_value("content").ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content")
.with_context(context)
})?;
page_command(
state,
context,
input,
"page.body.save",
json!({
"content": content,
"mode": input.arg_string("mode").unwrap_or_else(|| "replace".into())
}),
None,
)
.await
}
pub async fn update_title(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let title = input.arg_string("title").ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.page.update_title 缺少 title",
)
.with_context(context)
})?;
page_command(
state,
context,
input,
"page.head.updateTitle",
json!({ "title": title }),
None,
)
.await
}
pub async fn update_options(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let options = input.arg_value("options").ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.page.update_options 缺少 options",
)
.with_context(context)
})?;
let (wired_options, ignored_options, warnings) = filter_wired_page_options(options);
page_command(
state,
context,
input,
"page.layout.updateOptions",
json!({ "options": wired_options }),
Some(json!({
"ignoredOptions": ignored_options,
"warnings": warnings
})),
)
.await
}
async fn page_command(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
command_name: &str,
payload_patch: Value,
result_extra: Option<Value>,
) -> Result<Value, WebError> {
ensure_write_contract(context, input)?;
let document_id = input.effective_document_id().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
let command_id = format!(
"{}_{}",
command_name.replace('.', "_"),
context.trace.request_id
);
let idempotency_key = input.idempotency_key_or_default(&command_id);
let payload = merge_page_payload(&document_id, workspace_id.as_deref(), payload_patch);
if input.dry_run.unwrap_or(false) {
let mut result = json!({
"dryRun": true,
"commandName": command_name,
"commandId": command_id,
"documentId": document_id,
"workspaceId": workspace_id,
"diff": [{"op": command_name, "payload": payload}]
});
merge_result_extra(&mut result, result_extra);
return Ok(result);
}
let command = RuntimeCommandEnvelopeWire {
name: command_name.into(),
command_id: command_id.clone(),
idempotency_key: Some(idempotency_key),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: input
.session_id
.clone()
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
capabilities: input.capability_scope.clone().unwrap_or_default(),
},
target: Some(RuntimeTargetWire {
workspace_id: workspace_id.clone(),
page_id: Some(document_id.clone()),
block_id: None,
}),
payload,
preflight_data: None,
reason: Some(format!("Hermes tool {command_name}")),
refs: vec![command_name.into(), "hermes-tool-call".into()],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
context,
workspace_id.as_deref(),
command,
)
.await?;
let mut result = json!({
"commandName": command_name,
"commandId": command_id,
"result": execution.result,
"artifacts": execution.artifacts,
"artifactError": execution.artifact_error
});
merge_result_extra(&mut result, result_extra);
Ok(result)
}
fn merge_result_extra(result: &mut Value, extra: Option<Value>) {
if let (Value::Object(result_map), Some(Value::Object(extra_map))) = (result, extra) {
for (key, value) in extra_map {
result_map.insert(key, value);
}
}
}
fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Value) -> Value {
let mut payload = json!({
"documentId": document_id,
"workspaceId": workspace_id
});
if let (Value::Object(base), Value::Object(extra)) = (&mut payload, patch) {
for (key, value) in extra {
base.insert(key, value);
}
}
payload
}
fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"];
let mut out = serde_json::Map::new();
let mut ignored = Vec::new();
let mut warnings = Vec::new();
if let Value::Object(map) = options {
for (key, value) in map {
if allowed.contains(&key.as_str()) {
out.insert(key, value);
} else {
warnings.push(json!({
"code": "page_option_not_wired",
"field": key.clone(),
"message": "页面设置字段尚未接入 Page Aggregate command,已忽略"
}));
ignored.push(key);
}
}
}
(Value::Object(out), ignored, warnings)
}
fn summarize_blocks(content: &Value) -> Vec<Value> {
let mut out = Vec::new();
collect_blocks(content, &mut out);
out.into_iter().take(40).collect()
}
fn collect_blocks(value: &Value, out: &mut Vec<Value>) {
match value {
Value::Array(items) => {
for item in items {
collect_blocks(item, out);
}
}
Value::Object(map) => {
let block_id = map
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let block_type = map
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let text = collect_text(value);
if !block_id.is_empty() || !text.is_empty() {
out.push(json!({
"id": block_id,
"type": block_type,
"text": text
}));
}
if let Some(children) = map.get("children") {
collect_blocks(children, out);
}
}
_ => {}
}
}
fn collect_text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Array(items) => items.iter().map(collect_text).collect::<Vec<_>>().join(""),
Value::Object(map) => {
if let Some(text) = map.get("text").and_then(Value::as_str) {
return text.to_string();
}
if let Some(content) = map.get("content") {
return collect_text(content);
}
String::new()
}
_ => String::new(),
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod app;
pub mod context;
pub mod error;
pub mod hermes_tools;
pub mod local_folder_watcher_registry;
pub mod middleware;
pub mod page_aggregate;
+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\")"));
+176 -74
View File
@@ -3014,7 +3014,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function pageAiStorageKey() {
return 'doc_ai_sessions:' + currentDocumentId();
return 'hermes_page_ai_session:' + currentDocumentId();
}
function pageAiNewSession(title) {
@@ -3046,46 +3046,90 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function pageAiLoadSessions() {
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
try {
var raw = window.localStorage.getItem(pageAiStorageKey());
if (!raw) {
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
return;
}
var parsed = JSON.parse(raw);
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []);
if (!sessions.length) {
var fallback = pageAiNewSession();
pageUiState.pageAiSessions = [fallback];
pageUiState.pageAiActiveSessionId = fallback.id;
pageUiState.pageAiMessages = [];
return;
}
pageUiState.pageAiSessions = sessions;
var parsed = raw ? JSON.parse(raw) : null;
var activeId = String(parsed && parsed.activeSessionId || '').trim();
var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
pageUiState.pageAiActiveSessionId = active.id;
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : [];
} catch (_) {
var reset = pageAiNewSession();
pageUiState.pageAiSessions = [reset];
pageUiState.pageAiActiveSessionId = reset.id;
pageUiState.pageAiMessages = [];
}
if (activeId) {
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
pageUiState.pageAiSessions[0].id = activeId;
pageUiState.pageAiActiveSessionId = activeId;
pageUiState.pageAiMessages = [];
return;
}
} catch (_) {}
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
}
function pageAiPersistSessions() {
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
try {
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
activeSessionId: pageUiState.pageAiActiveSessionId,
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
activeSessionId: pageUiState.pageAiActiveSessionId
}));
} catch (_) {}
}
async function pageAiEnsureHermesSession() {
pageAiLoadSessions();
var current = pageAiCurrentSession();
if (current && String(current.id || '').startsWith('mnote_')) return current;
var response = await fetch('/api/hermes/client/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
documentId: currentDocumentId(),
traceId: 'page-ai-' + Date.now().toString(36),
title: current && current.title ? current.title : '当前页问答'
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
var code = payload && payload.code ? payload.code : 'hermes_session_failed_' + response.status;
throw new Error(code);
}
var session = {
id: String(payload.sessionId || '').trim(),
title: String(payload.title || '当前页问答'),
createdAt: Date.now(),
updatedAt: Date.now(),
messages: pageUiState.pageAiMessages.slice()
};
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
pageUiState.pageAiActiveSessionId = session.id;
pageAiPersistSessions();
renderPageAiConversation();
return session;
}
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id), {
headers: { 'accept': 'application/json' }
});
if (!response.ok) return;
var payload = await response.json().catch(function(){ return null; });
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
var messages = session && Array.isArray(session.messages) ? session.messages : [];
if (!messages.length) return;
pageUiState.pageAiMessages = messages.slice(-40).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
});
current.messages = pageUiState.pageAiMessages.slice();
current.updatedAt = Date.now();
renderPageAiConversation();
}
function pageAiCurrentSession() {
return pageUiState.pageAiSessions.find(function(session) {
return session.id === pageUiState.pageAiActiveSessionId;
@@ -3146,7 +3190,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var args = normalized && normalized.args ? normalized.args : {};
var documentId = searchText(args.documentId || args.pageId || currentDocumentId());
var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get');
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 mnote-cli 建立页面级工具调用,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
} catch (_) {
return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。';
}
@@ -3175,7 +3219,11 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span>推荐问题</span>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
'<div class="wolai-page-ai-intents">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'</div>' +
@@ -3187,8 +3235,6 @@ const SIDEBAR_TREE_JS: &str = r##"
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
'<div class="wolai-page-ai-provider-group">' +
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="codex" aria-pressed="false">Codex</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="claudecode" aria-pressed="false">ClaudeCode</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
@@ -3240,9 +3286,10 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
'</div>';
}).join('');
@@ -3258,6 +3305,15 @@ const SIDEBAR_TREE_JS: &str = r##"
drawer.hidden = false;
pageUiState.pageAiOpen = true;
updatePageAiTriggerState();
pageAiEnsureHermesSession().then(function() {
return pageAiRestoreHermesSession();
}).catch(function(error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error))
});
renderPageAiConversation();
});
}
function closePageAiDrawer() {
@@ -3290,7 +3346,14 @@ const SIDEBAR_TREE_JS: &str = r##"
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
if (eventName) onEvent(eventName, dataLines.join('\n'));
var payloadText = dataLines.join('\n');
if (!eventName && payloadText) {
try {
var parsed = JSON.parse(payloadText);
eventName = parsed && parsed.event ? String(parsed.event) : '';
} catch (_) {}
}
if (eventName) onEvent(eventName, payloadText);
});
}
}
@@ -3299,39 +3362,35 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
pageAiLoadSessions();
var contextSnapshot = currentPageAiContextSnapshot();
var aggregate = contextSnapshot.aggregate || {};
var body = contextSnapshot.body || {};
var subtree = contextSnapshot.subtree || null;
var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
var currentSession = pageAiCurrentSession();
if (currentSession) {
if (currentSession.title === '新会话') {
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
}
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
renderPageAiConversation();
var currentSession = null;
try {
var response = await fetch('/api/ai-agent/run', {
await pageAiEnsureHermesSession();
var contextSnapshot = currentPageAiContextSnapshot();
var aggregate = contextSnapshot.aggregate || {};
var body = contextSnapshot.body || {};
var subtree = contextSnapshot.subtree || null;
var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
currentSession = pageAiCurrentSession();
if (currentSession) {
if (currentSession.title === '新会话') {
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
}
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
renderPageAiConversation();
var response = await fetch('/api/hermes/client/runs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
stream: true,
maxSteps: 8,
scope: 'document',
messages: [{ role: 'user', content: prompt }],
toolChoice: {
mode: 'auto',
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
},
context: {
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
documentId: currentDocumentId(),
sessionId: pageUiState.pageAiActiveSessionId,
message: prompt,
model: 'hermes-agent',
pageContext: {
documentBlocks: body.content || null,
node: {
documentId: currentDocumentId(),
@@ -3343,23 +3402,54 @@ const SIDEBAR_TREE_JS: &str = r##"
evidence: null,
pageOptions: currentPageOptions()
},
options: {
searxng: true,
ai: { provider: pageUiState.pageAiProvider }
}
selectedBlockId: null,
selectedText: null,
traceId: 'page-ai-run-' + Date.now().toString(36)
})
});
if (!response.ok) {
throw new Error('page_ai_failed_' + response.status);
var errorPayload = await response.json().catch(function(){ return null; });
throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status);
}
var runPayload = await response.json().catch(function(){ return null; });
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
var runId = upstream && (upstream.run_id || upstream.runId);
if (!runId) throw new Error('hermes_run_missing_run_id');
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
headers: { 'accept': 'text/event-stream' }
});
if (!eventResponse.ok) {
var eventError = await eventResponse.json().catch(function(){ return null; });
throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status);
}
var assistantText = '';
await streamPageAiResponse(response, function(eventName, payloadText) {
if (eventName === 'assistant_message') {
await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
if (eventName === 'assistant_message' || eventName === 'message.delta') {
try {
var payload = JSON.parse(payloadText || 'null');
assistantText = searchText(payload && payload.text);
assistantText += searchText((payload && (payload.text || payload.delta)) || '');
} catch (_) {
assistantText += searchText(payloadText);
}
}
if (eventName === 'run.completed') {
try {
var completed = JSON.parse(payloadText || 'null');
if (completed && completed.output) assistantText = searchText(completed.output);
} catch (_) {}
}
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
try {
var toolEvent = JSON.parse(payloadText || 'null');
pageUiState.pageAiMessages.push({
role: 'tool',
content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName)
});
} catch (_) {
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
}
renderPageAiConversation();
}
});
pageUiState.pageAiMessages.push({
role: 'assistant',
@@ -3370,18 +3460,16 @@ const SIDEBAR_TREE_JS: &str = r##"
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
} catch (error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error))
content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
});
currentSession = pageAiCurrentSession();
if (currentSession) {
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
@@ -3937,6 +4025,20 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
if (pageAiIntent) {
e.preventDefault();
var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
if (intentName === 'create-summary') {
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
return;
}
if (intentName === 'create-ai-note') {
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
return;
}
}
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
if (pageAiProvider) {
e.preventDefault();
+7
View File
@@ -2690,6 +2690,13 @@ body {
gap: 8px;
}
.wolai-page-ai-intents {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.wolai-page-ai-suggestion-list,
.wolai-page-ai-toolbar {
display: flex;