feat: EditorRuntimeActor - 三层缓存/delta/事件架构

Phase A — EditorRuntimeActor 内存缓存层
- 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init
- block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径
- editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启)
- bridge-runtime 三个核心函数公开化
- rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞)

Phase B — 编辑器增量 delta channel
- BlockDelta/DeltaOperation 类型 + actor.build_block_delta()
- leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch
- DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent
- 工具响应含 blockDelta 字段供前端消费

Phase C — 事件 stream delta
- broadcast channel 在 AppState/actor/SSE 三层贯通
- tree_events SSE 端点发 block.delta 事件
- 旧客户端降级兼容

环境修复
- rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出)
- run-convex-deploy.js(封装 Convex function 部署到本地后端 3210)

ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
lix-2026
2026-05-16 22:03:30 +08:00
parent d8bfaea306
commit f292c6710a
101 changed files with 13618 additions and 2416 deletions
+504 -66
View File
@@ -1,6 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::execute_convex_query_by_name;
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
@@ -80,7 +81,11 @@ pub async fn list_sessions(
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
let profile = query
.get("profile")
.map(String::as_str)
.unwrap_or("default");
let Some(upstream) = configured_upstream_for_profile(profile) else {
return hermes_unconfigured(&context);
};
let mut path = "/api/hermes/sessions".to_string();
@@ -93,7 +98,15 @@ pub async fn list_sessions(
path.push('?');
path.push_str(&params);
}
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
proxy_json(
&context,
reqwest::Method::GET,
&upstream,
&path,
None,
Some(profile),
)
.await
}
pub async fn create_session(
@@ -157,7 +170,7 @@ pub async fn gateway_health(
.map(ToOwned::to_owned)
.or_else(active_profile_name)
.unwrap_or_else(|| "default".into());
let upstream = configured_upstream();
let upstream = configured_upstream_for_profile(&profile);
let profile_status = profile_gateway_status(&profile);
let mut suggestions = profile_status
.get("suggestions")
@@ -178,7 +191,8 @@ pub async fn gateway_health(
"status": if upstream.is_some() { "checking" } else { "unconfigured" }
});
if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) {
let probe = probe_gateway_health(&upstream_url).await;
let probe =
probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await;
gateway["ok"] = Value::Bool(probe.ok);
gateway["status"] = Value::String(probe.status);
gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null);
@@ -387,6 +401,52 @@ pub async fn toggle_skill(
))
}
pub async fn toggle_tool(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let name = payload
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 tool name")
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
})?;
let enabled = payload
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
})?;
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
let profile = payload
.get("profile")
.and_then(Value::as_str)
.unwrap_or(fallback_profile.as_str());
set_mnote_tool_enabled(profile, name, enabled).map_err(|error| {
WebError::bad_gateway_code(
"hermes_client_tool_toggle_failed",
format!("更新 mnote tool 设置失败: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
})?;
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({"ok": true})),
))
}
pub async fn get_session(
Extension(context): Extension<RequestContext>,
Path(session_id): Path<String>,
@@ -466,7 +526,7 @@ pub async fn create_run(
let queued = enqueue_run(&context, &registration, &payload)?;
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
}
let Some(upstream) = configured_upstream() else {
let Some(upstream) = configured_upstream_for_profile(&registration.profile) else {
return hermes_unconfigured(&context);
};
let upstream_body = build_run_upstream_body(&context, payload)?;
@@ -476,6 +536,7 @@ pub async fn create_run(
&upstream,
"/v1/runs",
Some(upstream_body),
Some(&registration.profile),
)
.await?;
if let Some(runtime) = register_runtime_from_create_run_response(&registration, &result.2 .0) {
@@ -511,7 +572,8 @@ pub async fn stream_events(
Path(run_id): Path<String>,
) -> Result<Response, WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
let Some(upstream) = configured_upstream_for_profile(&profile) else {
return Err(hermes_unconfigured_error(&context));
};
let url = upstream_url(
@@ -525,7 +587,7 @@ pub async fn stream_events(
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
})?
.get(url);
if let Some(api_key) = configured_api_key() {
if let Some(api_key) = configured_api_key_for_profile(&profile) {
request = request.bearer_auth(api_key);
}
let upstream_response = request.send().await.map_err(|error| {
@@ -583,7 +645,8 @@ pub async fn abort_run(
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
let Some(upstream) = configured_upstream_for_profile(&profile) else {
return hermes_unconfigured(&context);
};
let queued_session_id = session_id_for_run(&run_id);
@@ -594,6 +657,7 @@ pub async fn abort_run(
&upstream,
&format!("/v1/runs/{}/stop", url_escape(&run_id)),
Some(payload),
Some(&profile),
)
.await;
match result {
@@ -629,70 +693,29 @@ pub async fn list_models(
&upstream,
"/v1/models",
None,
None,
)
.await
}
pub async fn list_tools(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
let profile = query
.get("profile")
.map(String::as_str)
.unwrap_or(fallback_profile.as_str());
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"tools": [
{
"name": "mnote.page.get",
"scope": "page.read",
"kind": "read",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "读取当前页面正文、标题、设置与结构快照"
},
{
"name": "mnote.page.save",
"scope": "page.write",
"kind": "write",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "保存当前页面正文"
},
{
"name": "mnote.page.update_title",
"scope": "page.write",
"kind": "write",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "更新当前页面标题"
},
{
"name": "mnote.page.update_options",
"scope": "page.write",
"kind": "write",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "更新当前页面设置"
},
{
"name": "mnote.artifact.create_summary",
"scope": "artifact.write",
"kind": "write",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "为当前页面创建或更新 AI Summary"
},
{
"name": "mnote.artifact.create_ai_note",
"scope": "artifact.write",
"kind": "write",
"schemaVersion": "mnote.hermes_tool.v1",
"status": "available",
"description": "基于当前页面创建新的 AI Note"
}
]
"profile": profile,
"tools": mnote_tools_payload(profile)
})),
))
}
@@ -717,7 +740,7 @@ fn hermes_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".hermes"))
}
fn active_profile_name() -> Option<String> {
pub(crate) fn active_profile_name() -> Option<String> {
fs::read_to_string(hermes_home().join("active_profile"))
.ok()
.map(|value| value.trim().to_string())
@@ -948,6 +971,105 @@ fn disabled_skills(profile: &str) -> Vec<String> {
disabled
}
fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec<String> {
let mut stack: Vec<(usize, String)> = Vec::new();
let mut values = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim_end_matches('\r');
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
if let Some(value) = trimmed.strip_prefix("- ") {
let keys = stack
.iter()
.map(|(_, key)| key.as_str())
.collect::<Vec<_>>();
if keys == path {
let normalized = value.trim().trim_matches('"').trim_matches('\'');
if !normalized.is_empty() {
values.push(normalized.to_string());
}
}
continue;
}
while stack
.last()
.map(|(level, _)| *level >= indent)
.unwrap_or(false)
{
stack.pop();
}
let Some((key, _value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
stack.push((indent, key));
}
values
}
pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec<String> {
let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
yaml_disabled_list(&content, &["mnote", "tools", "disabled"])
}
pub(crate) fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool {
disabled_mnote_tools(profile)
.iter()
.any(|item| item == name)
}
fn mnote_tools_payload(profile: &str) -> Vec<Value> {
let disabled = disabled_mnote_tools(profile);
manifest::manifest()
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|tool| mnote_tool_entry(tool, &disabled))
.collect()
}
fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
let name = tool.get("name").and_then(Value::as_str)?.trim();
if name.is_empty() {
return None;
}
let capability_scope = tool
.get("capabilityScope")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let disabled = disabled.iter().any(|item| item == name);
let status = if disabled {
"disabled"
} else {
tool.get("status")
.and_then(Value::as_str)
.unwrap_or("available")
};
Some(json!({
"name": name,
"description": tool.get("description").and_then(Value::as_str).unwrap_or_default(),
"scope": capability_scope.first().cloned().unwrap_or_else(|| "mnote".into()),
"capabilityScope": capability_scope,
"kind": if capability_scope.iter().any(|scope| scope.ends_with(".write")) { "write" } else { "read" },
"schemaVersion": tool.get("schemaVersion").and_then(Value::as_str).unwrap_or(manifest::TOOL_SCHEMA_VERSION),
"status": status,
"enabled": !disabled,
"unavailableReason": if disabled { "当前 Hermes profile 已关闭该 mnote tool" } else { "" }
}))
}
fn extract_skill_description(markdown: &str) -> String {
markdown
.lines()
@@ -1237,6 +1359,73 @@ fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Resul
fs::write(path, output)
}
fn set_mnote_tool_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> {
let path = profile_config_path(profile);
let mut disabled = disabled_mnote_tools(profile);
if enabled {
disabled.retain(|item| item != name);
} else if !disabled.iter().any(|item| item == name) {
disabled.push(name.to_string());
}
disabled.sort();
let existing = fs::read_to_string(&path).unwrap_or_default();
let mut kept = Vec::new();
let mut stack: Vec<(usize, String)> = Vec::new();
let mut skipping_disabled_list = false;
let mut disabled_indent = 0usize;
for line in existing.lines() {
let trimmed = line.trim();
let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
if skipping_disabled_list {
if trimmed.starts_with("- ") && indent > disabled_indent {
continue;
}
skipping_disabled_list = false;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
kept.push(line.to_string());
continue;
}
if !trimmed.starts_with("- ") {
while stack
.last()
.map(|(level, _)| *level >= indent)
.unwrap_or(false)
{
stack.pop();
}
if let Some((key, _value)) = trimmed.split_once(':') {
let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
stack.push((indent, key));
let keys = stack
.iter()
.map(|(_, key)| key.as_str())
.collect::<Vec<_>>();
if keys == ["mnote", "tools", "disabled"] {
skipping_disabled_list = true;
disabled_indent = indent;
continue;
}
}
}
kept.push(line.to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut output = kept.join("\n");
if !output.ends_with('\n') && !output.is_empty() {
output.push('\n');
}
output.push_str("mnote:\n tools:\n disabled:\n");
for tool in disabled {
output.push_str(" - ");
output.push_str(&tool);
output.push('\n');
}
fs::write(path, output)
}
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() {
@@ -1264,6 +1453,57 @@ fn configured_upstream() -> Option<String> {
.filter(|value| !value.is_empty())
}
fn profile_config_value(profile: &str, path: &[&str]) -> Option<String> {
let content = fs::read_to_string(profile_config_path(profile)).ok()?;
yaml_path_value(&content, path)
}
fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String {
let normalized = profile
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
format!("{prefix}_{normalized}_{suffix}")
}
fn configured_upstream_for_profile(profile: &str) -> Option<String> {
let profile = profile.trim();
if !profile.is_empty() && profile != "default" {
let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "UPSTREAM_URL");
if let Some(value) = env_or_dotenv(&env_key) {
return Some(value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty());
}
let enabled = profile_config_value(profile, &["API_SERVER_ENABLED"])
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes"
)
})
.unwrap_or(false);
let port = profile_config_value(profile, &["API_SERVER_PORT"]);
if enabled {
if let Some(port) = port
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let host = profile_config_value(profile, &["API_SERVER_HOST"])
.unwrap_or_else(|| "127.0.0.1".into());
return Some(format!("http://{}:{}", host.trim(), port));
}
}
}
configured_upstream()
}
fn configured_api_key() -> Option<String> {
[
"MNOTE_WEB_HERMES_API_KEY",
@@ -1277,6 +1517,23 @@ fn configured_api_key() -> Option<String> {
.filter(|value| !value.is_empty())
}
fn configured_api_key_for_profile(profile: &str) -> Option<String> {
let profile = profile.trim();
if !profile.is_empty() && profile != "default" {
let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "API_KEY");
if let Some(value) = env_or_dotenv(&env_key) {
return Some(value);
}
if let Some(value) = profile_config_value(profile, &["API_SERVER_KEY"]) {
let trimmed = value.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
configured_api_key()
}
#[derive(Debug)]
struct GatewayProbe {
ok: bool,
@@ -1286,7 +1543,7 @@ struct GatewayProbe {
message: Option<String>,
}
async fn probe_gateway_health(upstream: &str) -> GatewayProbe {
async fn probe_gateway_health(upstream: &str, api_key: Option<String>) -> GatewayProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
@@ -1307,7 +1564,7 @@ async fn probe_gateway_health(upstream: &str) -> GatewayProbe {
continue;
};
let mut request = client.get(url);
if let Some(api_key) = configured_api_key() {
if let Some(api_key) = api_key.as_deref() {
request = request.bearer_auth(api_key);
}
match request.send().await {
@@ -1555,6 +1812,29 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let tool_guidance = concat!(
"每次调用 mnote 工具都必须显式透传 workspaceId、documentId、actorId、sessionId、runId、traceId",
"actorId 必须使用本 instructions 中的 actorId,不允许省略或填 hermes/anonymous。",
"凡用户要求新增、删除、修改、替换、移动正文段落或块,必须走块级链路。",
"本 instructions 的 pageContext.aiContext 是本次 run 开始时冻结的 mnote.page_ai_context.v1",
"其中 contextBlocks/pageXml/pageText 可直接作为 tiptapRead 风格的已读上下文使用;",
"如果 aiContext 已包含足够唯一的目标文本或 blockId,应直接调用 mnote_doc_apply_block_ops",
"不要为了同一上下文再先调用 mnote_doc_fetch。",
"scope=selection 时只能修改 allowedTargetBlockIds 内的块;",
"简单小段落编辑或同一页内多个普通叶子块操作,应优先用 mnote_doc_apply_block_ops 一次提交 operations",
"该快路径等价于 Tiptap tiptapEdit 风格的批量编辑工具,可用唯一 matchText/anchorText 或已知 blockId/anchorBlockId 定位,",
"避免 fetch、plan、多个单步写入造成多轮模型往返。",
"只有当文本不唯一、目标不明确、涉及复杂块/子块/表格/资源块,或 apply_block_ops 返回歧义/不支持时,",
"再降级为 mnote_doc_fetch(scope=full 或 keyword, detail=with_ids) 定位块,",
"mnote_block_fetch 读取目标块 revisionRef/context",
"mnote_doc_plan_update(dryRun=true) 生成并检查 diff",
"最后调用 mnote_block_replace、mnote_block_insert_after、mnote_block_delete 或 mnote_block_move_after。",
"写入后再 mnote_doc_fetch 或 mnote_block_fetch 回读验证。",
"mnote_page_get 只用于读取页面标题、页面设置或粗略摘要;",
"mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级工具无法表达时作为高风险兜底,",
"不能作为正文块新增、删除、修改、移动的首选工具。",
"不要依据非 aiContext 的页面摘要猜测正文内容或块 id。"
);
let instructions = json!({
"role": "mnote_page_ai_context",
"workspaceId": workspace_id,
@@ -1563,8 +1843,26 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
"actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id),
"actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type),
"sessionId": session_id,
"runId": payload
.get("runId")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(&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。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。",
"toolGuidance": tool_guidance,
"blockEditingToolOrder": [
"mnote_doc_apply_block_ops for simple small paragraph edits or multiple leaf-block operations",
"mnote_doc_fetch | mnote_doc_find only when target text is ambiguous or structure is complex",
"mnote_doc_fetch",
"mnote_block_fetch",
"mnote_doc_plan_update(dryRun=true)",
"mnote_block_replace | mnote_block_insert_after | mnote_block_delete | mnote_block_move_after",
"mnote_doc_fetch | mnote_block_fetch"
],
"pageSavePolicy": {
"mnote_page_save": "fallback_only_for_explicit_whole_page_write",
"forBlockEditing": "forbidden_as_first_choice"
},
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
"pageContext": sanitize_run_page_context(page_context)
@@ -1655,6 +1953,7 @@ fn sanitize_run_page_context(page_context: Value) -> Value {
"evidence",
"pageOptions",
"contentAccess",
"aiContext",
] {
if let Some(value) = source.get(key) {
sanitized.insert(key.to_string(), value.clone());
@@ -1665,7 +1964,7 @@ fn sanitize_run_page_context(page_context: Value) -> Value {
sanitized
.get("contentAccess")
.cloned()
.unwrap_or_else(|| Value::String("mnote.page.get".into())),
.unwrap_or_else(|| Value::String("mnote.doc.fetch".into())),
);
Value::Object(sanitized)
}
@@ -1989,6 +2288,16 @@ fn session_id_for_run(run_id: &str) -> Option<String> {
.map(|state| state.session_id.clone())
}
fn profile_for_run(run_id: &str) -> Option<String> {
let registry = HERMES_RUNTIME_REGISTRY
.lock()
.expect("hermes runtime registry");
registry
.values()
.find(|state| state.run_id == run_id)
.map(|state| state.profile.clone())
}
fn sse_json_events(chunk: &str) -> Vec<Value> {
chunk
.split("\n\n")
@@ -2013,6 +2322,7 @@ async fn proxy_json(
upstream: &str,
path: &str,
body: Option<Value>,
profile: Option<&str>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let url = upstream_url(upstream, path)?;
let client = reqwest::Client::builder()
@@ -2022,7 +2332,10 @@ async fn proxy_json(
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
})?;
let mut request = client.request(method, url);
if let Some(api_key) = configured_api_key() {
if let Some(api_key) = profile
.and_then(configured_api_key_for_profile)
.or_else(configured_api_key)
{
request = request.bearer_auth(api_key);
}
if let Some(body) = body {
@@ -2147,7 +2460,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) {
let Some(queued) = pop_next_queued_run(&session_id) else {
return;
};
let Some(upstream) = configured_upstream() else {
let Some(upstream) = configured_upstream_for_profile(&queued.profile) else {
warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run");
return;
};
@@ -2171,6 +2484,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) {
&upstream,
"/v1/runs",
Some(upstream_body),
Some(&queued.profile),
)
.await
{
@@ -2417,6 +2731,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -2439,6 +2754,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3189,7 +3505,13 @@ mod tests {
"documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
"subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
"outline": [{"title": "不应进入 Hermes instructions"}],
"contentAccess": "mnote.page.get"
"contentAccess": "mnote.page.get",
"aiContext": {
"schema": "mnote.page_ai_context.v1",
"contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}],
"pageXml": "<page><block id=\"block_1\">允许进入 Hermes instructions</block></page>",
"allowedTargetBlockIds": ["block_1"]
}
},
"selectedBlockId": "block_1",
"selectedText": "选中文本",
@@ -3205,6 +3527,8 @@ mod tests {
assert!(instructions.contains("\"title\":\"页面标题\""));
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\""));
assert!(instructions.contains("\"schema\":\"mnote.page_ai_context.v1\""));
assert!(instructions.contains("允许进入 Hermes instructions"));
assert!(!instructions.contains("不应进入 Hermes instructions"));
assert!(!instructions.contains("\"documentBlocks\""));
assert!(!instructions.contains("\"subtree\""));
@@ -3404,4 +3728,118 @@ mod tests {
std::env::remove_var("MNOTE_WEB_HERMES_BIN");
let _ = fs::remove_dir_all(&hermes_home);
}
#[tokio::test]
async fn hermes_client_tools_uses_manifest_and_profile_disabled_state() {
let _guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-hermes-tools-local-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("chemist");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
"mnote:\n tools:\n disabled:\n - mnote.block.replace\n",
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = app()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
.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("tools json");
let tools = payload["tools"]
.as_array()
.expect("tools")
.iter()
.map(|tool| {
(
tool["name"].as_str().unwrap_or_default().to_string(),
tool.clone(),
)
})
.collect::<HashMap<_, _>>();
assert!(tools.contains_key("mnote.doc.fetch"));
assert!(tools.contains_key("mnote.block.fetch"));
assert!(tools.contains_key("mnote.block.replace"));
assert!(tools.contains_key("mnote.block.insert_after"));
assert!(tools.contains_key("mnote.block.delete"));
assert!(tools.contains_key("mnote.block.move_after"));
assert!(tools.contains_key("mnote.doc.apply_block_ops"));
assert_eq!(tools["mnote.doc.fetch"]["enabled"], true);
assert_eq!(tools["mnote.block.replace"]["enabled"], false);
assert_eq!(tools["mnote.block.replace"]["status"], "disabled");
assert_eq!(
tools["mnote.block.replace"]["unavailableReason"],
"当前 Hermes profile 已关闭该 mnote tool"
);
let toggle_response = app()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/hermes/client/tools/toggle")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"profile": "chemist",
"name": "mnote.block.replace",
"enabled": true
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(toggle_response.status(), StatusCode::OK);
let response = app()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
.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("tools json");
let tools = payload["tools"]
.as_array()
.expect("tools")
.iter()
.map(|tool| {
(
tool["name"].as_str().unwrap_or_default().to_string(),
tool.clone(),
)
})
.collect::<HashMap<_, _>>();
assert_eq!(tools["mnote.block.replace"]["enabled"], true);
assert_eq!(tools["mnote.block.replace"]["status"], "available");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
}