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:
@@ -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(¶ms);
|
||||
}
|
||||
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, ®istration, &payload)?;
|
||||
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
|
||||
}
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let Some(upstream) = configured_upstream_for_profile(®istration.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(®istration.profile),
|
||||
)
|
||||
.await?;
|
||||
if let Some(runtime) = register_runtime_from_create_run_response(®istration, &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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user