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
@@ -166,6 +166,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,
@@ -72,6 +72,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,
@@ -110,6 +111,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
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,
@@ -165,6 +167,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:9".into()),
enable_legacy_next_compat: false,
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,
@@ -232,6 +235,7 @@ mod tests {
legacy_next_base_url: Some(format!("http://{}", addr)),
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,
@@ -947,6 +947,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,
@@ -1778,6 +1778,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1413,6 +1413,7 @@ mod tests {
legacy_next_base_url: Some(legacy_next_base_url),
enable_legacy_next_compat,
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,
@@ -165,6 +165,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,
+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);
}
}
+753 -10
View File
@@ -1,7 +1,8 @@
use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
@@ -75,7 +76,7 @@ pub async fn mnote_call(
let dry_run = input.dry_run.unwrap_or(false);
let effect = if dry_run {
"dry_run"
} else if input.tool_name == "mnote.page.get" {
} else if is_read_tool(&input.tool_name) {
"read"
} else {
"write"
@@ -98,6 +99,15 @@ pub async fn mnote_call(
dry_run,
"mnote Hermes tool call started"
);
let profile = input
.profile
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| input.arg_string("profile"))
.or_else(hermes_client::active_profile_name)
.unwrap_or_else(|| "default".into());
audit_push(json!({
"phase": "started",
"traceId": trace_id,
@@ -107,9 +117,34 @@ pub async fn mnote_call(
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"dryRun": dry_run
"actorId": input.actor_id,
"dryRun": dry_run
}));
if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) {
let error = WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_disabled",
"当前 Hermes profile 已关闭该 mnote tool",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
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,
"profile": profile,
"status": error.status().as_u16(),
"message": error.message()
}));
return Err(error);
}
if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
audit_push(json!({
"phase": "failed",
@@ -150,6 +185,15 @@ pub async fn mnote_call(
return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
}
let result = match input.tool_name.as_str() {
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
"mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await,
"mnote.block.delete" => block::block_delete(&state, &context, &input).await,
"mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
"mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
"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,
@@ -240,6 +284,13 @@ pub async fn mnote_call(
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
)
}
fn audit_log() -> &'static Mutex<Vec<Value>> {
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
LOG.get_or_init(|| Mutex::new(Vec::new()))
@@ -332,7 +383,7 @@ fn idempotency_cache_key(
document_id: Option<&str>,
dry_run: bool,
) -> Option<String> {
if dry_run || input.tool_name == "mnote.page.get" {
if dry_run || is_read_tool(&input.tool_name) {
return None;
}
let idempotency_key = input.idempotency_key.as_deref()?.trim();
@@ -359,7 +410,8 @@ fn idempotency_cache_put(key: String, response: Value) {
}
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
let has_actor = context.auth.actor_id.trim() != "anonymous";
let actor = context.auth.actor_id.trim();
let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty();
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
return Ok(());
}
@@ -377,24 +429,39 @@ fn authenticated_tool_context(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<RequestContext, WebError> {
if ensure_authenticated(context).is_ok() {
let context_actor = context.auth.actor_id.trim();
if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" {
return Ok(context.clone());
}
let has_cookie_or_auth =
context.auth.authorization.is_some() || context.auth.cookie_header.is_some();
let actor_id = input
.actor_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "anonymous")
.filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"mnote_tool_unauthorized",
"mnote Hermes tool 需要登录后访问",
"mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")
})?;
if has_cookie_or_auth {
let mut next = context.clone();
next.auth.actor_id = actor_id.to_string();
next.auth.actor_type = input
.arg_string("actorType")
.or_else(|| input.arg_string("actor_type"))
.unwrap_or_else(|| "user".into());
if next.auth.session_id.is_none() {
next.auth.session_id = input.session_id.clone();
}
return Ok(next);
}
let has_run_identity = input
.session_id
.as_deref()
@@ -487,8 +554,15 @@ mod tests {
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use std::fs;
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(),
@@ -498,6 +572,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,
@@ -523,6 +598,16 @@ mod tests {
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一" }]
},
{
"id": "p_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "第一段" }]
},
{
"id": "p_2",
"type": "paragraph",
"content": [{ "type": "text", "text": "第二段" }]
}
],
"revision": 7,
@@ -531,13 +616,63 @@ mod tests {
}"#
.into(),
),
mutation_fixtures_json: None,
mutation_fixtures_json: Some(
r#"{
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
async fn call_tool_ok(payload: Value) -> Value {
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(payload.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");
serde_json::from_slice(&body).expect("json")
}
async fn block_revision_ref(block_id: &str) -> String {
let payload = call_tool_ok(json!({
"toolName": "mnote.doc.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_ref",
"runId": "run_ref",
"toolCallId": format!("call_ref_{block_id}"),
"traceId": format!("trace_ref_{block_id}"),
"capabilityScope": ["page.read"],
"args": {"scope": "full", "detail": "with_ids"}
}))
.await;
payload["result"]["blocks"]
.as_array()
.expect("blocks")
.iter()
.find(|block| block["blockId"] == json!(block_id))
.and_then(|block| block["revisionRef"].as_str())
.expect("revisionRef")
.to_string()
}
#[tokio::test]
async fn hermes_tools_manifest_returns_first_batch_tools() {
let response = app()
@@ -558,6 +693,27 @@ mod tests {
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!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.replace"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.insert_after"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.delete"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.move_after"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
@@ -622,6 +778,59 @@ mod tests {
assert_eq!(payload["result"]["title"], "服务端页面");
}
#[tokio::test]
async fn hermes_tools_call_rejects_profile_disabled_tool() {
let _guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-hermes-tool-disabled-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("blocked");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
"mnote:\n tools:\n disabled:\n - mnote.page.get\n",
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
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_disabled",
"runId": "run_disabled",
"toolCallId": "call_disabled",
"traceId": "trace_disabled",
"profile": "blocked",
"capabilityScope": ["page.read"]
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
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["code"], "mnote_tool_disabled");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
#[tokio::test]
async fn hermes_tools_write_tools_require_auth() {
let response = app()
@@ -693,6 +902,540 @@ mod tests {
assert_eq!(payload["audit"]["effect"], "read");
}
#[tokio::test]
async fn hermes_tools_doc_fetch_returns_block_projection() {
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.doc.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_doc_fetch_1",
"traceId": "trace_doc_fetch_1",
"capabilityScope": ["page.read"],
"args": {"scope": "full", "detail": "with_ids"}
})
.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.doc.fetch");
assert_eq!(payload["audit"]["effect"], "read");
assert_eq!(payload["result"]["revision"], json!(7));
assert_eq!(
payload["result"]["blocks"][0]["blockId"],
json!("heading_1")
);
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
assert!(payload["result"]["blocks"][0]["revisionRef"]
.as_str()
.unwrap_or_default()
.starts_with("pageRev:7:block:heading_1:hash:"));
}
#[tokio::test]
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
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.doc.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_doc_fetch_selection_1",
"traceId": "trace_doc_fetch_selection_1",
"capabilityScope": ["page.read"],
"args": {
"scope": "selection",
"selectedBlockIds": ["heading_1"],
"format": "page_xml"
}
})
.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"]["schema"], "mnote.page_ai_context.v1");
assert_eq!(payload["result"]["scope"], "selection");
assert_eq!(payload["result"]["format"], "page_xml");
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
assert!(payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("<block id=\"heading_1\""));
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
}
#[tokio::test]
async fn hermes_tools_doc_find_and_block_fetch_use_block_projection() {
let find_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.doc.find",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_doc_find_1",
"traceId": "trace_doc_find_1",
"capabilityScope": ["page.read"],
"args": {"query": "章节一"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(find_response.status(), StatusCode::OK);
let find_body = to_bytes(find_response.into_body(), usize::MAX)
.await
.expect("body");
let find_payload: Value = serde_json::from_slice(&find_body).expect("json");
assert_eq!(
find_payload["result"]["matches"][0]["blockId"],
json!("heading_1")
);
let fetch_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.block.fetch",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_block_fetch_1",
"traceId": "trace_block_fetch_1",
"capabilityScope": ["block.read"],
"args": {"blockId": "heading_1", "contextBefore": 1, "contextAfter": 1}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(fetch_response.status(), StatusCode::OK);
let fetch_body = to_bytes(fetch_response.into_body(), usize::MAX)
.await
.expect("body");
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("json");
assert_eq!(
fetch_payload["result"]["block"]["blockId"],
json!("heading_1")
);
assert_eq!(fetch_payload["result"]["block"]["text"], json!("章节一"));
assert_eq!(fetch_payload["audit"]["effect"], "read");
}
#[tokio::test]
async fn hermes_tools_plan_update_and_block_move_after_are_dry_run_only() {
let heading_ref = block_revision_ref("heading_1").await;
let plan_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.doc.plan_update",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_plan_1",
"traceId": "trace_plan_1",
"idempotencyKey": "idem_plan_1",
"dryRun": true,
"capabilityScope": ["page.write"],
"args": {
"command": "block_replace",
"blockId": "heading_1",
"content": "替换标题"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(plan_response.status(), StatusCode::OK);
let plan_body = to_bytes(plan_response.into_body(), usize::MAX)
.await
.expect("body");
let plan_payload: Value = serde_json::from_slice(&plan_body).expect("json");
assert_eq!(plan_payload["audit"]["effect"], "dry_run");
assert_eq!(plan_payload["result"]["diff"][0]["op"], "replace");
let move_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.block.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_move_1",
"traceId": "trace_move_1",
"idempotencyKey": "idem_move_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "heading_1",
"anchorBlockId": "heading_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone(),
"anchorRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(move_response.status(), StatusCode::OK);
let move_body = to_bytes(move_response.into_body(), usize::MAX)
.await
.expect("body");
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
assert_eq!(move_payload["result"]["blocked"], true);
assert_eq!(
move_payload["result"]["warnings"][0]["code"],
"block_move_after_blocked"
);
}
#[tokio::test]
async fn hermes_tools_block_replace_and_insert_after_write_through_page_body_save() {
let heading_ref = block_revision_ref("heading_1").await;
let p1_ref = block_revision_ref("p_1").await;
let p2_ref = block_revision_ref("p_2").await;
let replace_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.block.replace",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_replace_1",
"traceId": "trace_replace_1",
"idempotencyKey": "idem_replace_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "heading_1",
"content": "替换后的章节",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(replace_response.status(), StatusCode::OK);
let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
.await
.expect("body");
let replace_payload: Value = serde_json::from_slice(&replace_body).expect("json");
assert_eq!(replace_payload["audit"]["effect"], "write");
assert_eq!(
replace_payload["result"]["changedBlocks"][0]["blockId"],
json!("heading_1")
);
assert_eq!(
replace_payload["result"]["commandName"],
json!("page.body.save")
);
let insert_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.block.insert_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_insert_1",
"traceId": "trace_insert_1",
"idempotencyKey": "idem_insert_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"anchorBlockId": "heading_1",
"content": "新增段落",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"anchorRevisionRef": heading_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(insert_response.status(), StatusCode::OK);
let insert_body = to_bytes(insert_response.into_body(), usize::MAX)
.await
.expect("body");
let insert_payload: Value = serde_json::from_slice(&insert_body).expect("json");
assert_eq!(insert_payload["audit"]["effect"], "write");
assert_eq!(
insert_payload["result"]["changedBlocks"][0]["op"],
json!("insert_after")
);
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str()
.unwrap_or_default()
.starts_with("ai_block_"));
let delete_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.block.delete",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_delete_1",
"traceId": "trace_delete_1",
"idempotencyKey": "idem_delete_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": p1_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
.await
.expect("body");
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("json");
assert_eq!(delete_payload["audit"]["effect"], "write");
assert_eq!(
delete_payload["result"]["changedBlocks"][0]["op"],
json!("delete")
);
assert_eq!(
delete_payload["result"]["changedBlocks"][0]["blockId"],
json!("p_1")
);
let move_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.block.move_after",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_move_write_1",
"traceId": "trace_move_write_1",
"idempotencyKey": "idem_move_write_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": heading_ref.clone(),
"anchorRevisionRef": p2_ref.clone()
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(move_response.status(), StatusCode::OK);
let move_body = to_bytes(move_response.into_body(), usize::MAX)
.await
.expect("body");
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
assert_eq!(move_payload["audit"]["effect"], "write");
assert_eq!(
move_payload["result"]["changedBlocks"][0]["op"],
json!("move_after")
);
}
#[tokio::test]
async fn hermes_tools_block_write_requires_fresh_revision_and_block_ref() {
let missing_revision_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.block.replace",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_missing_revision_1",
"traceId": "trace_missing_revision_1",
"idempotencyKey": "idem_missing_revision_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "heading_1",
"content": "不应写入"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(missing_revision_response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
missing_revision_response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_write_precondition_required")
);
let stale_ref_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.block.replace",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_stale_ref_1",
"traceId": "trace_stale_ref_1",
"idempotencyKey": "idem_stale_ref_1",
"dryRun": false,
"capabilityScope": ["block.write"],
"args": {
"blockId": "heading_1",
"content": "不应写入",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"blockRevisionRef": "pageRev:old:block:heading_1:hash:stale"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(stale_ref_response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
stale_ref_response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_conflict")
);
}
#[tokio::test]
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
let response = app()
@@ -203,6 +203,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,
@@ -8,6 +8,7 @@ use crate::routes::local_markdown_parser::{
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::http::StatusCode;
use bridge_runtime::project_legacy_content_to_block_document;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
@@ -306,6 +307,10 @@ pub fn resolve_local_markdown_page_aggregate(
.count() as u64;
let read_only = markdown_file.is_readonly;
let block_document =
project_legacy_content_to_block_document(document_id, &content, &Value::Number(0.into()))
.map_err(|error| WebError::internal(format!("{error:?}")))?;
Ok(PageAggregate {
schema: PageAggregate::SCHEMA.into(),
projection_version: PageAggregate::VERSION,
@@ -339,6 +344,9 @@ pub fn resolve_local_markdown_page_aggregate(
document_id,
&markdown_file.path,
)?),
block_document,
block_projection_version: 1,
projection_source: "local_markdown.content".into(),
},
tree: PageTree { page_subtree },
stats: PageStats {
@@ -334,6 +334,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,
@@ -283,6 +283,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,
@@ -350,6 +351,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,
+7
View File
@@ -16,6 +16,7 @@ mod media;
mod mindmap_api;
mod mindmap_shell;
mod onlyoffice;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
mod search;
@@ -86,6 +87,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
.route(
"/api/page-ai/block-edit-workflow",
post(page_ai_workflow::block_edit_workflow),
)
.route("/onlyoffice", get(onlyoffice::page))
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
@@ -196,6 +201,7 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/client/skills", get(hermes_client::list_skills))
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
.route("/client/runs", post(hermes_client::create_run))
.route(
"/client/sessions/{session_id}/queue/{queue_id}",
@@ -244,6 +250,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1132,6 +1132,7 @@ mod tests {
legacy_next_base_url,
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,
@@ -0,0 +1,531 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{block, ToolCallInput};
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tracing::info;
pub async fn block_edit_workflow(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let started = Instant::now();
let workspace_id = string_field(&payload, "workspaceId")
.or_else(|| context.workspace.workspace_id.clone())
.ok_or_else(|| {
WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 workspaceId")
.with_context(&context)
})?;
let document_id = string_field(&payload, "documentId").ok_or_else(|| {
WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 documentId")
.with_context(&context)
})?;
let message = string_field(&payload, "message").ok_or_else(|| {
WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 message")
.with_context(&context)
})?;
let trace_id =
string_field(&payload, "traceId").unwrap_or_else(|| context.trace.trace_id.clone());
let session_id = string_field(&payload, "sessionId")
.unwrap_or_else(|| format!("page_ai_fast_{}", context.trace.request_id));
let run_id = string_field(&payload, "runId").unwrap_or_else(|| session_id.clone());
info!(
trace_id = %trace_id,
run_id = %run_id,
workspace_id = %workspace_id,
document_id = %document_id,
"mnote page AI block workflow started"
);
if !looks_like_block_edit(&message) {
return Err(WebError::bad_request_code(
"page_ai_workflow_not_block_edit",
"当前请求不像块编辑任务,交给通用页面 AI",
)
.with_context(&context));
}
let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
let ai_context = page_context
.get("aiContext")
.cloned()
.or_else(|| payload.get("aiContext").cloned())
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_workflow_missing_context",
"块编辑快路径缺少 mnote.page_ai_context.v1",
)
.with_context(&context)
})?;
let profile = string_field(&payload, "profile").unwrap_or_else(|| "mnoteai".into());
let model_started = Instant::now();
let (operations, operation_source) = if let Some(operations) =
direct_block_edit_operations(&message)
{
(operations, "local_rule")
} else {
let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
(extract_operations_from_model_text(&model_output)?, "model")
};
info!(
trace_id = %trace_id,
run_id = %run_id,
operations = operations.len(),
operation_source = operation_source,
model_ms = model_started.elapsed().as_millis(),
"mnote page AI block workflow model completed"
);
if operations.is_empty() {
return Err(WebError::bad_request_code(
"page_ai_workflow_empty_operations",
"模型未返回块操作",
)
.with_context(&context));
}
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.cloned()
.unwrap_or_else(|| json!([]));
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 apply_input = ToolCallInput {
tool_name: "mnote.doc.apply_block_ops".into(),
workspace_id: Some(workspace_id.clone()),
document_id: Some(document_id.clone()),
actor_id: Some(actor_id),
profile: Some(profile),
session_id: Some(session_id),
run_id: Some(run_id.clone()),
tool_call_id: Some(format!("fast_apply_{}", context.trace.request_id)),
trace_id: Some(trace_id.clone()),
idempotency_key: Some(format!("page_ai_fast_apply_{}", context.trace.request_id)),
dry_run: Some(false),
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
args: Some(json!({
"operations": operations,
"allowedTargetBlockIds": allowed_target_block_ids
})),
};
let apply_started = Instant::now();
let apply_result = block::doc_apply_block_ops(&state, &context, &apply_input).await?;
let apply_ms = apply_started.elapsed().as_millis();
info!(
trace_id = %trace_id,
run_id = %run_id,
apply_ms = apply_ms,
total_ms = started.elapsed().as_millis(),
"mnote page AI block workflow completed"
);
Ok((
StatusCode::OK,
HeaderMap::new(),
Json(json!({
"ok": true,
"schema": "mnote.page_ai_block_edit_workflow.v1",
"fastPath": true,
"documentId": document_id,
"workspaceId": workspace_id,
"runId": run_id,
"traceId": trace_id,
"operationSource": operation_source,
"operations": apply_input.arg_value("operations").unwrap_or_else(|| json!([])),
"applyResult": apply_result,
"message": "已通过页面块编辑快路径完成写入。",
"timingsMs": {
"total": started.elapsed().as_millis(),
"apply": apply_ms
}
})),
))
}
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_operations_from_model_text(content);
}
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
return Ok(operations.clone());
}
if let Some(operations) = parsed
.get("arguments")
.and_then(|value| value.get("operations"))
.and_then(Value::as_array)
{
return Ok(operations.clone());
}
if let Some(operations) = parsed.as_array() {
return Ok(operations.clone());
}
Err(WebError::bad_request_code(
"page_ai_workflow_bad_model_output",
"模型输出未包含 operations",
))
}
fn parse_model_json(text: &str) -> Result<Value, WebError> {
let trimmed = strip_code_fence(text.trim());
if let Ok(value) = serde_json::from_str::<Value>(&trimmed) {
return Ok(value);
}
if let Some(slice) = first_json_slice(&trimmed) {
if let Ok(value) = serde_json::from_str::<Value>(slice) {
return Ok(value);
}
}
Err(WebError::bad_request_code(
"page_ai_workflow_bad_model_json",
"模型输出不是可解析 JSON",
))
}
fn strip_code_fence(text: &str) -> String {
let trimmed = text.trim();
if !trimmed.starts_with("```") {
return trimmed.to_string();
}
let without_open = trimmed.lines().skip(1).collect::<Vec<_>>().join("\n");
without_open
.trim()
.strip_suffix("```")
.unwrap_or(without_open.trim())
.trim()
.to_string()
}
fn first_json_slice(text: &str) -> Option<&str> {
let start = text.find('{').or_else(|| text.find('['))?;
let open = text.as_bytes()[start] as char;
let close = if open == '{' { '}' } else { ']' };
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, ch) in text[start..].char_indices() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
} else if ch == open {
depth += 1;
} else if ch == close {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(&text[start..start + offset + ch.len_utf8()]);
}
}
}
None
}
async fn call_block_edit_model(
context: &RequestContext,
profile: &str,
message: &str,
ai_context: &Value,
) -> Result<String, WebError> {
let model = workflow_model_config(profile);
let page_xml = ai_context
.get("pageXml")
.and_then(Value::as_str)
.unwrap_or_default();
let page_text = ai_context
.get("pageText")
.and_then(Value::as_str)
.unwrap_or_default();
let allowed = ai_context
.get("allowedTargetBlockIds")
.cloned()
.unwrap_or_else(|| json!([]));
let body = json!({
"model": model.model,
"temperature": 0,
"max_tokens": 900,
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": "你是 mnote 页面块编辑 workflow。只输出 JSON{\"operations\":[...] ,\"summary\":\"...\"}。operations 的 op 只能是 replace、insert_after、delete、move_after。优先使用 page_xml 中的 block id;禁止输出解释文字。"
},
{
"role": "user",
"content": format!(
"用户指令:{}\n\nallowedTargetBlockIds{}\n\npage_xml\n{}\n\npage_text\n{}",
message,
allowed,
page_xml,
page_text
)
}
]
});
let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/'));
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|error| {
WebError::internal(format!("页面 AI workflow HTTP client 构造失败: {error}"))
})?
.post(url)
.bearer_auth(model.api_key)
.json(&body)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_workflow_model_unavailable",
format!("页面 AI workflow 模型请求失败: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_workflow_model_failed",
format!("页面 AI workflow 模型返回失败: {status}"),
)
.with_context(context));
}
let payload = parse_model_json(&text)?;
payload
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| {
WebError::bad_gateway_code(
"page_ai_workflow_model_no_content",
"页面 AI workflow 模型响应缺少 message.content",
)
.with_context(context)
})
}
struct WorkflowModelConfig {
model: String,
base_url: String,
api_key: String,
}
fn workflow_model_config(profile: &str) -> WorkflowModelConfig {
let config = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
let provider =
yaml_path_value(&config, &["model", "provider"]).unwrap_or_else(|| "deepseek".into());
let model = yaml_path_value(&config, &["model", "default"])
.or_else(|| yaml_path_value(&config, &["providers", &provider, "model"]))
.unwrap_or_else(|| "deepseek-v4-flash".into());
let base_url = yaml_path_value(&config, &["model", "base_url"])
.or_else(|| yaml_path_value(&config, &["providers", &provider, "base_url"]))
.unwrap_or_else(|| "https://api.deepseek.com/v1".into());
let api_key = yaml_path_value(&config, &["model", "api_key"])
.or_else(|| yaml_path_value(&config, &["providers", &provider, "api_key"]))
.or_else(|| {
yaml_path_value(&config, &["model", "key_env"])
.or_else(|| yaml_path_value(&config, &["providers", &provider, "key_env"]))
.and_then(|env_key| std::env::var(env_key).ok())
})
.unwrap_or_default();
WorkflowModelConfig {
model,
base_url,
api_key,
}
}
fn string_field(payload: &Value, key: &str) -> Option<String> {
payload
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn looks_like_block_edit(message: &str) -> bool {
[
"新增", "添加", "插入", "删除", "删掉", "修改", "替换", "改成", "移动", "移到", "move",
"replace", "delete", "insert",
]
.iter()
.any(|needle| message.contains(needle))
}
fn direct_block_edit_operations(message: &str) -> Option<Vec<Value>> {
let mut operations = Vec::new();
for clause in message
.split(|ch| matches!(ch, '' | ';' | '\n'))
.map(str::trim)
.filter(|value| !value.is_empty())
{
let quoted = quoted_segments(clause);
if (clause.contains("替换") || clause.contains("改成")) && quoted.len() >= 2 {
operations.push(json!({
"op": "replace",
"matchText": quoted[0],
"content": quoted[1]
}));
} else if (clause.contains("插入") || clause.contains("新增") || clause.contains("添加"))
&& quoted.len() >= 2
{
operations.push(json!({
"op": "insert_after",
"matchText": quoted[0],
"content": quoted[1]
}));
} else if (clause.contains("删除") || clause.contains("删掉")) && !quoted.is_empty() {
operations.push(json!({
"op": "delete",
"matchText": quoted[0]
}));
}
}
if operations.is_empty() {
None
} else {
Some(operations)
}
}
fn quoted_segments(value: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut start: Option<char> = None;
let mut current = String::new();
for ch in value.chars() {
match (start, ch) {
(None, '「' | '“' | '"') => {
start = Some(ch);
current.clear();
}
(Some('「'), '」') | (Some('“'), '”') | (Some('"'), '"') => {
if !current.trim().is_empty() {
segments.push(current.trim().to_string());
}
current.clear();
start = None;
}
(Some(_), _) => current.push(ch),
(None, _) => {}
}
}
segments
}
fn hermes_home() -> PathBuf {
std::env::var("HERMES_HOME")
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".hermes"))
})
.unwrap_or_else(|| PathBuf::from(".hermes"))
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = hermes_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
}
let candidate = home.join("profiles").join(profile);
if candidate.exists() {
candidate.join("config.yaml")
} else {
home.join("config.yaml")
}
}
fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
let mut stack: Vec<(usize, String)> = 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('#') || !line.contains(':') {
continue;
}
let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
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();
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
stack.push((indent, key));
if stack.len() == path.len()
&& stack
.iter()
.zip(path.iter())
.all(|((_, key), expected)| key == expected)
&& !value.is_empty()
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::{direct_block_edit_operations, extract_operations_from_model_text};
#[test]
fn extracts_operations_from_fenced_model_json() {
let operations = extract_operations_from_model_text(
r#"```json
{"operations":[{"op":"replace","matchText":"旧文本","content":"新文本"}],"summary":"ok"}
```"#,
)
.expect("operations");
assert_eq!(operations.len(), 1);
assert_eq!(operations[0]["op"], "replace");
assert_eq!(operations[0]["matchText"], "旧文本");
}
#[test]
fn parses_direct_chinese_block_operations() {
let operations = direct_block_edit_operations(
"把「第二段」替换为「第二段已修改」;在「第一段」后插入「插入段」;删除「第三段」。只简短回复结果。",
)
.expect("operations");
assert_eq!(operations.len(), 3);
assert_eq!(operations[0]["op"], "replace");
assert_eq!(operations[0]["matchText"], "第二段");
assert_eq!(operations[0]["content"], "第二段已修改");
assert_eq!(operations[1]["op"], "insert_after");
assert_eq!(operations[1]["matchText"], "第一段");
assert_eq!(operations[1]["content"], "插入段");
assert_eq!(operations[2]["op"], "delete");
assert_eq!(operations[2]["matchText"], "第三段");
}
}
@@ -1056,6 +1056,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
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,
@@ -376,6 +376,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,
@@ -493,6 +494,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,
@@ -130,6 +130,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,
+49 -2
View File
@@ -19,6 +19,15 @@ pub async fn events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<StreamSnapshotQuery>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
events_with_block_delta(state, context, query, None).await
}
async fn events_with_block_delta(
state: AppState,
context: RequestContext,
query: StreamSnapshotQuery,
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
let initial_cursor = read_stream_cursor_from_payload(&initial_payload);
@@ -36,6 +45,7 @@ pub async fn events(
polls: 0,
initial_payload,
initial_emitted: false,
block_delta_rx,
}),
move |state| async move {
let mut state = state?;
@@ -48,6 +58,23 @@ pub async fn events(
));
}
// Phase C:在每次 poll 前先检查是否有 block.delta 可发送
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
state.block_delta_rx = None;
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
}
}
loop {
if let Some(max_polls) = max_polls {
if state.polls >= max_polls {
@@ -57,6 +84,23 @@ pub async fn events(
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
// 每次 poll 后也检查一下 delta
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
state.block_delta_rx = None;
}
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
}
}
let poll_query = live_poll_query(&state.query);
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
@@ -140,11 +184,11 @@ pub async fn tree_events(
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
let sse = events(State(state), Extension(context), Query(query)).await?;
let block_delta_rx = state.block_delta_tx.subscribe();
let sse = events_with_block_delta(state, context, query, Some(block_delta_rx)).await?;
Ok((headers, sse))
}
#[derive(Clone)]
struct StreamPollState {
app_state: AppState,
context: RequestContext,
@@ -153,6 +197,8 @@ struct StreamPollState {
polls: u32,
initial_payload: Value,
initial_emitted: bool,
#[allow(dead_code)]
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
}
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
@@ -203,6 +249,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,
@@ -1034,6 +1034,51 @@ mod tests {
);
}
#[test]
fn stream_change_preserves_remove_asset_delta_fields() {
let overview = json!({
"command_logs": [
{
"id": "clog_2",
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.resource.delete",
"payload": {
"streamDelta": {
"op": "remove_asset",
"assetId": "asset_1",
"documentId": "doc_target",
"updatedAt": "2026-04-25T10:00:02Z"
}
}
},
{
"id": "clog_1",
"command_id": "cmd_1",
"created_at": "2026-04-25T10:00:01Z"
}
],
"domain_events": []
});
let change = resolve_stream_change(
&overview,
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Delta);
assert_eq!(
change.delta,
Some(json!({
"op": "remove_asset",
"assetId": "asset_1",
"documentId": "doc_target",
"updatedAt": "2026-04-25T10:00:02Z"
}))
);
}
#[test]
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
let overview = json!({
@@ -1395,5 +1440,9 @@ mod tests {
{ "id": "asset_1" }
]
})));
assert!(delta_requires_projection_snapshot(&json!({
"op": "remove_asset",
"assetId": "asset_1"
})));
}
}
+10
View File
@@ -6940,6 +6940,7 @@ mod tests {
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -7896,6 +7897,7 @@ mod tests {
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
assert_eq!(
payload["result"]["execution"]["deletedCount"],
Value::from(1)
@@ -8002,10 +8004,18 @@ mod tests {
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
Value::String("move_document".into())
);
assert_eq!(
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["sortOrder"],
Value::from(1)
);
assert_eq!(
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"],
Value::String("move_document".into())
);
assert_eq!(
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["sortOrder"],
Value::from(1)
);
}
#[tokio::test]
@@ -2936,6 +2936,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,
@@ -2969,6 +2970,29 @@ mod tests {
}))
}
fn app_with_unreachable_convex_without_fixture() -> 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: None,
enable_legacy_next_compat: false,
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: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
allow_dev_fixtures: false,
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 document_shell_returns_page_aggregate_snapshot() {
let response = app()
@@ -3100,6 +3124,100 @@ mod tests {
assert_eq!(payload["result"]["body"]["revision"], 7);
}
#[tokio::test]
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
assert_eq!(
response
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
);
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"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(payload.get("schema").is_none());
assert!(payload.get("result").is_none());
}
#[tokio::test]
async fn document_shell_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(!text.contains("mnote.page_aggregate.v1"));
assert!(!text.contains("data-mnote-dev-fixture"));
assert!(!text.contains("data-page-aggregate-snapshot"));
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
@@ -3231,6 +3349,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,