feat: add evidence search and stabilize pdf previews

- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes
- fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug
- keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable

Verification:
- node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
- node scripts/task-dev-hot-plan-test.js
- cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar
- cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot
- cargo build -p mnote-web --manifest-path rust/Cargo.toml
- browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
lix-2026
2026-06-04 18:51:16 +08:00
parent 9f4b5c4d48
commit 627213dc01
51 changed files with 15960 additions and 1844 deletions
@@ -0,0 +1,245 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::routes;
use axum::http::StatusCode;
use core_protocol::{
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
};
use serde_json::{json, Value};
pub async fn evidence_search(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let body = evidence_search_request(input, context)?;
routes::evidence::search_payload(state, context, body).await
}
pub async fn evidence_read(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_evidence_read_payload_invalid",
format!("Evidence read 参数无效: {error}"),
)
.with_context(context)
})?;
routes::evidence::read_payload(state, context, body).await
}
pub async fn evidence_open(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_evidence_open_payload_invalid",
format!("Evidence open 参数无效: {error}"),
)
.with_context(context)
})?;
routes::evidence::open_payload(state, context, body).await
}
pub async fn legacy_docs_search(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let payload = evidence_search(state, context, input).await?;
Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_search",
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
"source": "mnote.evidence.search",
}))
}
pub async fn legacy_docs_read(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if input.arg_value("locator").is_some() {
let payload = evidence_read(state, context, input).await?;
return Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_read",
"result": payload,
"source": "mnote.evidence.read",
}));
}
let document = doc::doc_fetch(state, context, input).await?;
let locator = legacy_document_locator(input);
Ok(json!({
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_read",
"documentId": input.effective_document_id(),
"document": document,
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
"source": {
"tool": "mnote.doc.fetch",
"locator": locator,
},
}))
}
fn evidence_search_request(
input: &ToolCallInput,
context: &RequestContext,
) -> Result<EvidenceSearchRequest, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("scope").is_none() {
let query = input.arg_string("query").ok_or_else(|| {
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
WebError::bad_request_code(
"mnote_evidence_workspace_required",
"Evidence 搜索缺少 workspaceId",
)
.with_context(context)
})?;
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
.with_context(context)
})?;
let include_resources = input
.args
.as_ref()
.and_then(|value| value.get("includeResources"))
.and_then(Value::as_bool)
.unwrap_or(true);
let include_ocr = input
.args
.as_ref()
.and_then(|value| value.get("includeOcr"))
.and_then(Value::as_bool)
.unwrap_or(true);
let target_document_id = input
.effective_document_id()
.or_else(|| input.arg_string("pageId"))
.or_else(|| input.arg_string("targetDocumentId"));
args = json!({
"query": query,
"scope": {
"workspaceId": workspace_id,
"rootUri": root_uri,
"targetDocumentId": target_document_id,
"includeResources": include_resources,
"includeOcr": include_ocr,
},
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
"topK": input
.args
.as_ref()
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
.and_then(Value::as_u64)
.unwrap_or(8),
});
}
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
WebError::new(
StatusCode::BAD_REQUEST,
"mnote_evidence_search_payload_invalid",
format!("Evidence search 参数无效: {error}"),
)
.with_context(context)
})
}
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
let root_uri = local_root_uri_for_evidence(input)?;
let document_id = input.effective_document_id()?;
let owner_document_path =
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
Some(EvidenceLocator {
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
root_uri: root_uri.clone(),
owner_document_id: document_id,
owner_document_path: owner_document_path.clone(),
resource_path: Some(owner_document_path.clone()),
resource_kind: EvidenceResourceKind::Markdown,
page: None,
bbox: None,
section_path: Vec::new(),
line_range: None,
char_range: None,
block_id: None,
source_map_path: None,
open_action: EvidenceOpenAction {
action_type: "mnote.open_resource_locator".into(),
url: "/".into(),
params: json!({
"rootUri": root_uri,
"ownerDocumentPath": owner_document_path,
}),
},
})
}
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
input.effective_root_uri().or_else(|| {
input
.arg_value("aiAccessScope")
.and_then(|scope| {
scope
.get("allowedRoots")
.or_else(|| scope.get("allowed_roots"))
.cloned()
})
.and_then(|allowed_roots| {
allowed_roots.as_array().and_then(|roots| {
roots
.iter()
.filter_map(|root| {
root.get("rootUri")
.or_else(|| root.get("root_uri"))
.and_then(Value::as_str)
})
.map(str::trim)
.find(|root_uri| !root_uri.is_empty())
.map(ToOwned::to_owned)
})
})
})
}
fn document_path_from_local_id(document_id: &str) -> Option<String> {
let encoded = document_id.trim().strip_prefix("local-md:")?;
decode_local_id_segment(encoded)
}
fn decode_local_id_segment(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'~' {
if index + 2 >= bytes.len() {
return None;
}
let hex = &value[index + 1..index + 3];
let byte = u8::from_str_radix(hex, 16).ok()?;
decoded.push(byte);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
@@ -19,6 +19,9 @@ pub fn manifest() -> Value {
context_resolve_target_tool(),
doc_fetch_tool(),
doc_find_tool(),
evidence_search_tool(),
evidence_read_tool(),
evidence_open_tool(),
block_fetch_tool(),
doc_plan_update_tool(),
block_replace_tool(),
@@ -249,6 +252,104 @@ fn doc_find_tool() -> Value {
})
}
fn evidence_search_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("query".into(), json!({ "type": "string" }));
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert(
"includeResources".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"includeOcr".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"mode".into(),
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
);
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
map.insert(
"scope".into(),
json!({
"type": "object",
"properties": {
"workspaceId": { "type": "string" },
"rootUri": { "type": "string" },
"targetDocumentId": { "type": "string" },
"includeResources": { "type": "boolean" },
"includeOcr": { "type": "boolean" }
}
}),
);
}
json!({
"name": "mnote.evidence.search",
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator 与 openAction。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn evidence_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("locator".into(), json!({ "type": "object" }));
map.insert(
"context".into(),
json!({
"type": "object",
"properties": {
"beforeBlocks": { "type": "integer", "default": 3 },
"afterBlocks": { "type": "integer", "default": 3 },
"includeSectionSummary": { "type": "boolean", "default": true }
}
}),
);
}
json!({
"name": "mnote.evidence.read",
"description": "按 EvidenceLocator 读取原文证据及周边上下文,供回答引用。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["locator"],
"properties": properties
}
})
}
fn evidence_open_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("locator".into(), json!({ "type": "object" }));
}
json!({
"name": "mnote.evidence.open",
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["locator"],
"properties": properties
}
})
}
fn block_fetch_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -2,6 +2,7 @@ pub mod artifact;
pub mod block;
pub mod context_tools;
pub mod doc;
pub mod evidence;
pub mod manifest;
pub mod onlyoffice_live;
pub mod page;
@@ -31,6 +31,22 @@ const SKILLS: &[MnoteSkill] = &[
],
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteSkill {
id: "mnote-document-evidence",
title: "MNote document evidence",
description: "Search local documents and resources with clickable evidence locators.",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
],
content: include_str!("../../../../../skills/mnote-document-evidence/SKILL.md"),
},
MnoteSkill {
id: "mnote-local-file",
title: "MNote local file editing",
@@ -279,6 +295,21 @@ mod tests {
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[test]
fn skill_registry_exposes_document_evidence_skill_to_agents() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = hermes_skills
.iter()
.find(|skill| skill["id"] == "mnote-document-evidence")
.expect("hermes should see document evidence skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.evidence.search"));
}
#[tokio::test]
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(