Improve local evidence search and AI capabilities

This commit is contained in:
lix-2026
2026-06-05 23:00:53 +08:00
parent a1dcfc9f76
commit 4dbd9a978b
52 changed files with 6415 additions and 527 deletions
@@ -1318,6 +1318,123 @@ pub async fn toggle_skill(
))
}
pub async fn list_capabilities(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let runtime = query.get("runtime").map(String::as_str).unwrap_or("mnote");
if runtime != "mnote" {
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"runtime": runtime,
"categories": [],
"archived": []
})),
));
}
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());
let payload = mnote_capabilities_payload(
&state,
&context,
query.get("agentId").map(String::as_str),
profile,
)?;
Ok((StatusCode::OK, stamp_client_headers(), Json(payload)))
}
pub async fn toggle_capability(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let capability_id = payload
.get("id")
.or_else(|| 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", "缺少 capability id")
.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 runtime = payload
.get("runtime")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("mnote");
if runtime != "mnote" {
return Err(WebError::bad_request_code(
"hermes_client_capability_runtime_unsupported",
"当前只支持 MNote 内置能力开关",
)
.with_context(&context));
}
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
let profile = payload
.get("profile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback_profile.as_str());
let skill = crate::hermes_tools::skill::find_skill(capability_id, None).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"mnote_capability_not_found",
"未知 MNote AI 能力",
)
.with_context(&context)
})?;
let actor_id = page_ai_actor_id(&state, &context)?;
ensure_page_ai_actor_user(&state, &actor_id, page_ai_actor_is_admin(&context))?;
set_mnote_builtin_capability_enabled(&state, &actor_id, capability_id, enabled, &context)?;
for tool_name in skill.tool_names {
if mnote_capability_tool_toggleable(tool_name) {
set_mnote_tool_enabled(profile, tool_name, enabled).map_err(|error| {
WebError::bad_gateway_code(
"hermes_client_capability_tool_toggle_failed",
format!("更新 MNote 能力工具设置失败: {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,
"runtime": "mnote",
"id": capability_id,
"enabled": enabled,
"profile": profile,
"configScope": "user_sqlite+profile_tool_policy"
})),
))
}
pub async fn toggle_tool(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
@@ -4068,6 +4185,217 @@ fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
}))
}
fn mnote_capabilities_payload(
state: &AppState,
context: &RequestContext,
agent_id: Option<&str>,
profile: &str,
) -> Result<Value, WebError> {
let mut skills_payload = mnote_builtin_skills_payload(agent_id);
stamp_mnote_builtin_skill_payload_policy(state, context, &mut skills_payload)?;
let tools_by_name = mnote_tools_payload(profile)
.into_iter()
.filter_map(|tool| {
let name = tool.get("name").and_then(Value::as_str)?.to_string();
Some((name, tool))
})
.collect::<BTreeMap<_, _>>();
let mut capability_categories: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for category in skills_payload
.get("categories")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let mut capabilities = Vec::new();
for skill in category
.get("skills")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(id) = skill.get("id").and_then(Value::as_str) else {
continue;
};
if id == "mnote-chat-only" {
continue;
}
let tool_names = skill
.get("toolNames")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.filter(|name| !name.trim().is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let tools = tool_names
.iter()
.filter_map(|name| tools_by_name.get(name).cloned())
.collect::<Vec<_>>();
let disabled_tool_count = tools
.iter()
.filter(|tool| tool.get("enabled").and_then(Value::as_bool) == Some(false))
.count();
let enabled = skill
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
let status = if !enabled {
"disabled"
} else if disabled_tool_count > 0 {
"partial"
} else {
"available"
};
let capability_category = skill
.get("category")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("mnote");
capabilities.push(json!({
"id": id,
"name": id,
"title": skill.get("title").cloned().unwrap_or_else(|| json!(id)),
"description": skill.get("description").cloned().unwrap_or(Value::Null),
"enabled": enabled,
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
"builtin": true,
"configurable": true,
"configScope": "user_sqlite+profile_tool_policy",
"skillKind": "mnote_capability",
"source": "mnote",
"origin": "builtin",
"category": capability_category,
"categoryTitle": mnote_capability_category_title(capability_category),
"capabilityId": id,
"capabilityKind": "mnote_builtin",
"uiKind": mnote_capability_ui_kind(capability_category),
"skillId": id,
"readOnly": skill.get("readOnly").cloned().unwrap_or(Value::Bool(false)),
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null),
"toolNames": tool_names,
"tools": tools,
"toolCount": tools.len(),
"disabledToolCount": disabled_tool_count,
"status": status,
}));
}
for capability in capabilities {
let category_name = capability
.get("category")
.and_then(Value::as_str)
.unwrap_or("mnote")
.to_string();
capability_categories
.entry(category_name)
.or_default()
.push(capability);
}
}
let categories = ordered_mnote_capability_categories(capability_categories)
.into_iter()
.map(|(name, capabilities)| {
json!({
"name": name,
"title": mnote_capability_category_title(&name),
"description": mnote_capability_category_description(&name),
"capabilities": capabilities.clone(),
"skills": capabilities
})
})
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"runtime": "mnote",
"profile": profile,
"categories": categories,
"archived": []
}))
}
fn ordered_mnote_capability_categories(
mut categories: BTreeMap<String, Vec<Value>>,
) -> Vec<(String, Vec<Value>)> {
let mut ordered = Vec::new();
for name in ["mnote", "knowledge", "file", "resource", "office", "chat"] {
if let Some(capabilities) = categories.remove(name) {
ordered.push((name.to_string(), capabilities));
}
}
ordered.extend(categories);
ordered
}
fn mnote_capability_category_title(category: &str) -> &'static str {
match category {
"knowledge" => "知识库与索引",
"file" => "本地文件",
"resource" => "资源编辑",
"office" => "Office / ONLYOFFICE",
"chat" => "聊天",
_ => "MNote",
}
}
fn mnote_capability_category_description(category: &str) -> &'static str {
match category {
"knowledge" => "本地索引、证据检索和资料范围管理。",
"file" => "授权目录内的本地 Markdown 文件读写。",
"resource" => "MNote 资源型编辑器能力,例如思维导图。",
"office" => "Office 摘要、建议和 ONLYOFFICE 实时编辑桥。",
"chat" => "不读取文档上下文的普通对话能力。",
_ => "MNote 页面上下文与基础能力。",
}
}
fn mnote_capability_ui_kind(category: &str) -> &'static str {
if category == "chat" {
"chat"
} else {
"ai_capability"
}
}
fn set_mnote_builtin_capability_enabled(
state: &AppState,
actor_id: &str,
capability_id: &str,
enabled: bool,
context: &RequestContext,
) -> Result<(), WebError> {
let key = format!("ai.agent.mnote_builtin.skill.{capability_id}.enabled");
state
.control_plane()
.upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput {
id: None,
user_id: actor_id.to_string(),
workspace_id: None,
source_kind: None,
scope_kind: "page_ai_capability".to_string(),
scope_id: "mnote_builtin".to_string(),
key,
value_json: Value::Bool(enabled).to_string(),
})
.map(|_| ())
.map_err(|error| {
WebError::internal(format!("SQLite MNote AI 能力偏好写入失败: {error}"))
.with_context(context)
})
}
fn mnote_capability_tool_toggleable(tool_name: &str) -> bool {
!matches!(
tool_name,
"mnote.skill.read" | "mnote.context.snapshot" | "mnote.context.resolve_target"
)
}
fn extract_skill_description(markdown: &str) -> String {
markdown
.lines()
@@ -4451,6 +4779,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
"skillKind": "mnote_builtin",
"source": "mnote",
"origin": "builtin",
"category": skill.get("category").cloned().unwrap_or_else(|| json!("mnote")),
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
"toolNames": skill.get("toolNames").cloned().unwrap_or(Value::Null),
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null)
@@ -10144,6 +10473,223 @@ mod tests {
let _ = fs::remove_dir_all(&hermes_home);
}
#[tokio::test]
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
let _env_guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-ai-capability-policy-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
fs::create_dir_all(&hermes_home).expect("hermes home");
std::env::set_var("HERMES_HOME", &hermes_home);
let app = build_app(test_state());
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("capabilities");
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("capabilities json");
let all_capabilities = payload["categories"]
.as_array()
.expect("categories")
.iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.collect::<Vec<_>>();
assert!(
all_capabilities
.iter()
.all(|capability| capability["id"] != "mnote-chat-only"),
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
);
let local_index = payload["categories"]
.as_array()
.expect("categories")
.iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index")
.expect("local index capability");
assert_eq!(local_index["enabled"], true);
assert_eq!(local_index["uiKind"], "ai_capability");
assert!(local_index["tools"]
.as_array()
.expect("local index tools")
.iter()
.any(|tool| tool["name"] == "mnote.index.status"));
assert!(local_index["tools"]
.as_array()
.expect("local index tools")
.iter()
.any(|tool| tool["name"] == "mnote.index.update_settings"));
let toggle_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/hermes/client/capabilities/toggle")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"runtime": "mnote",
"profile": "chemist",
"id": "mnote-local-index",
"enabled": false
})
.to_string(),
))
.expect("request"),
)
.await
.expect("toggle capability");
assert_eq!(toggle_response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("capabilities after toggle");
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("capabilities json");
let local_index = payload["categories"]
.as_array()
.expect("categories")
.iter()
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
.find(|capability| capability["id"] == "mnote-local-index")
.expect("local index capability");
assert_eq!(local_index["enabled"], false);
assert_eq!(local_index["status"], "disabled");
assert!(local_index["tools"]
.as_array()
.expect("local index tools")
.iter()
.any(|tool| tool["name"] == "mnote.index.status" && tool["enabled"] == false));
let tools_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("tools after toggle");
assert_eq!(tools_response.status(), StatusCode::OK);
let body = to_bytes(tools_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.index.status"]["enabled"], false);
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
#[tokio::test]
async fn page_ai_capabilities_group_onlyoffice_live_bridge() {
let _env_guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-ai-onlyoffice-capability-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
fs::create_dir_all(&hermes_home).expect("hermes home");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = build_app(test_state())
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("capabilities");
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("capabilities json");
let office_category = payload["categories"]
.as_array()
.expect("categories")
.iter()
.find(|category| category["name"] == "office")
.expect("office category");
assert_eq!(office_category["title"], "Office / ONLYOFFICE");
let onlyoffice = office_category["capabilities"]
.as_array()
.expect("office capabilities")
.iter()
.find(|capability| capability["id"] == "mnote-onlyoffice-live")
.expect("onlyoffice capability");
assert_eq!(onlyoffice["title"], "ONLYOFFICE 实时编辑");
assert_eq!(onlyoffice["categoryTitle"], "Office / ONLYOFFICE");
assert_eq!(onlyoffice["enabled"], true);
assert!(onlyoffice["tools"]
.as_array()
.expect("onlyoffice tools")
.iter()
.any(|tool| tool["name"] == "mnote.onlyoffice.session.current"));
assert!(onlyoffice["tools"]
.as_array()
.expect("onlyoffice tools")
.iter()
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values"));
assert!(onlyoffice["tools"]
.as_array()
.expect("onlyoffice tools")
.iter()
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_shape"));
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
#[test]
fn reasonix_memory_policy_defaults_off_and_reads_user_preference() {
let state = test_state();