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
+280 -14
View File
@@ -3,8 +3,8 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{
artifact, block, context_tools, doc, manifest, onlyoffice_live, page, resource, skill,
ToolCallInput,
artifact, block, context_tools, doc, evidence, manifest, onlyoffice_live, page, resource,
skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -360,6 +360,11 @@ pub(crate) async fn execute_mnote_tool_call(
}
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
"mnote.evidence.search" => evidence::evidence_search(&state, &context, &input).await,
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
"mnote.evidence.open" => evidence::evidence_open(&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,
@@ -529,10 +534,32 @@ pub(crate) async fn execute_mnote_tool_call(
"message": error.message()
}));
}
let result = result?;
let mut result = result?;
if !dry_run && !is_read_tool(&input.tool_name) {
record_local_agent_tool_write(&context, &input, &profile, &result);
}
let evidence_receipt = if is_evidence_receipt_tool(&input.tool_name) {
let evidence_ids = evidence_ids_for_result(&result);
let receipt = json!({
"schema": "mnote.agent_run_receipt.evidence.v1",
"traceId": trace_id.clone(),
"sessionId": input.session_id.clone(),
"runId": input.run_id.clone(),
"toolCallId": tool_call_id.clone(),
"toolName": input.tool_name.clone(),
"workspaceId": workspace_id.clone(),
"documentId": document_id.clone(),
"rootUri": input.effective_root_uri(),
"evidenceIds": evidence_ids,
});
if let Some(result_object) = result.as_object_mut() {
result_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
result_object.insert("runReceipt".into(), receipt.clone());
}
Some(receipt)
} else {
None
};
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
info!(
trace_id = %trace_id,
@@ -546,7 +573,23 @@ pub(crate) async fn execute_mnote_tool_call(
effect,
"mnote Hermes tool call completed"
);
let response_body = json!({
let mut audit = json!({
"effect": effect,
"commandId": command_id,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"dryRun": dry_run,
"idempotencyKey": input.idempotency_key,
"capabilityScope": input.capability_scope
});
if let Some(receipt) = &evidence_receipt {
if let Some(audit_object) = audit.as_object_mut() {
audit_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
audit_object.insert("runReceipt".into(), receipt.clone());
}
}
let mut response_body = json!({
"ok": true,
"toolName": input.tool_name,
"toolCallId": tool_call_id,
@@ -554,18 +597,15 @@ pub(crate) async fn execute_mnote_tool_call(
"sessionId": input.session_id,
"runId": input.run_id,
"result": result,
"audit": {
"effect": effect,
"commandId": command_id,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"dryRun": dry_run,
"idempotencyKey": input.idempotency_key,
"capabilityScope": input.capability_scope
},
"audit": audit,
"error": null
});
if let Some(receipt) = &evidence_receipt {
if let Some(response_object) = response_body.as_object_mut() {
response_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
response_object.insert("runReceipt".into(), receipt.clone());
}
}
if let Some(key) = idempotency_key {
idempotency_cache_put(key, response_body.clone());
}
@@ -659,6 +699,11 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.context.resolve_target"
| "mnote.doc.fetch"
| "mnote.doc.find"
| "docs_search"
| "docs_read"
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.block.fetch"
| "mnote.mindmap.fetch"
| "mnote.office.fetch_summary"
@@ -678,6 +723,50 @@ fn is_read_tool(tool_name: &str) -> bool {
)
}
fn is_evidence_receipt_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"docs_search"
| "docs_read"
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
)
}
fn evidence_ids_for_result(result: &Value) -> Vec<String> {
let mut ids = Vec::new();
collect_evidence_ids(result, &mut ids);
ids
}
fn collect_evidence_ids(value: &Value, ids: &mut Vec<String>) {
match value {
Value::Object(object) => {
if let Some(id) = object
.get("evidenceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
let id = id.to_string();
if !ids.iter().any(|existing| existing == &id) {
ids.push(id);
}
}
for child in object.values() {
collect_evidence_ids(child, ids);
}
}
Value::Array(items) => {
for item in items {
collect_evidence_ids(item, ids);
}
}
_ => {}
}
}
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
let direct = input
.arg_string("permissionLevel")
@@ -5280,6 +5369,183 @@ mod tests {
);
}
#[tokio::test]
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
let root =
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("README.md"),
"# Evidence Home\n\ncompat-evidence-token 正文\n",
)
.expect("markdown");
let root_uri = format!("file://{}", root.display());
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")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "docs_search",
"workspaceId": "local-ws-docs-search",
"sourceKind": "local_folder",
"rootUri": root_uri,
"sessionId": "sess_docs_search",
"runId": "run_docs_search",
"toolCallId": "call_docs_search",
"traceId": "trace_docs_search",
"args": {
"query": "compat-evidence-token",
"includeOcr": true,
"limit": 5
}
})
.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"]["compatTool"], "docs_search");
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
let result = payload["result"]["results"]
.as_array()
.and_then(|items| items.first())
.expect("evidence result");
assert_eq!(
result["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert_eq!(
result["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
assert_eq!(
payload["result"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert_eq!(
payload["result"]["runReceipt"]["schema"].as_str(),
Some("mnote.agent_run_receipt.evidence.v1")
);
assert_eq!(
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
assert_eq!(
payload["runReceipt"]["toolCallId"].as_str(),
Some("call_docs_search")
);
assert_eq!(
payload["audit"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
let completed_audit =
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
.into_iter()
.find(|event| event["phase"] == "completed")
.expect("completed audit");
assert_eq!(
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert!(payload["result"]["evidence"]
.as_array()
.expect("evidence")
.iter()
.any(|item| item["quote"]
.as_str()
.unwrap_or_default()
.contains("compat-evidence-token")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
let root =
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
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")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "docs_read",
"workspaceId": "local-ws-docs-read",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"sessionId": "sess_docs_read",
"runId": "run_docs_read",
"toolCallId": "call_docs_read",
"traceId": "trace_docs_read",
"args": {
"documentId": "local-md:README.md",
"includeContent": true
}
})
.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"]["compatTool"], "docs_read");
assert_eq!(
payload["result"]["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert_eq!(
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert!(payload["result"]["document"]
.to_string()
.contains("legacy docs read"));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
let response = app()