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
+371
View File
@@ -0,0 +1,371 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const EVIDENCE_LOCATOR_SCHEMA: &str = "mnote.evidence_locator.v1";
pub const RESOURCE_SOURCE_MAP_SCHEMA: &str = "mnote.resource_source_map.v1";
pub const PARSED_RESOURCE_ARTIFACT_SCHEMA: &str = "mnote.parsed_resource_artifact.v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceResourceKind {
Markdown,
Pdf,
Image,
Office,
Mindmap,
RawFile,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceBBox {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceRange {
pub start: u64,
pub end: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceOpenAction {
pub action_type: String,
pub url: String,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub params: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceLocator {
pub schema: String,
pub root_uri: String,
pub owner_document_id: String,
pub owner_document_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_path: Option<String>,
pub resource_kind: EvidenceResourceKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox: Option<EvidenceBBox>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub section_path: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_range: Option<EvidenceRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub char_range: Option<EvidenceRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_map_path: Option<String>,
pub open_action: EvidenceOpenAction,
}
impl EvidenceLocator {
pub fn new(
root_uri: impl Into<String>,
owner_document_id: impl Into<String>,
owner_document_path: impl Into<String>,
resource_kind: EvidenceResourceKind,
open_action: EvidenceOpenAction,
) -> Self {
Self {
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
root_uri: root_uri.into(),
owner_document_id: owner_document_id.into(),
owner_document_path: owner_document_path.into(),
resource_path: None,
resource_kind,
page: None,
bbox: None,
section_path: Vec::new(),
line_range: None,
char_range: None,
block_id: None,
source_map_path: None,
open_action,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceMapTextItem {
pub id: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox: Option<EvidenceBBox>,
#[serde(skip_serializing_if = "Option::is_none")]
pub char_range: Option<EvidenceRange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceMapBlockKind {
Text,
Heading,
Paragraph,
Table,
Figure,
Image,
List,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceMapBlock {
pub id: String,
pub block_type: SourceMapBlockKind,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bbox: Option<EvidenceBBox>,
#[serde(skip_serializing_if = "Option::is_none")]
pub char_range: Option<EvidenceRange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceMapPage {
pub page: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<f64>,
#[serde(default)]
pub text_items: Vec<SourceMapTextItem>,
#[serde(default)]
pub blocks: Vec<SourceMapBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SourceMapSection {
pub id: String,
pub title: String,
#[serde(default)]
pub path: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_start: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_end: Option<u32>,
#[serde(default)]
pub block_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResourceSourceMap {
pub schema: String,
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_version: Option<String>,
pub owner_document_path: String,
pub source_root_relative_path: String,
pub source_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_count: Option<u32>,
#[serde(default)]
pub pages: Vec<SourceMapPage>,
#[serde(default)]
pub sections: Vec<SourceMapSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ParsedResourceArtifact {
pub schema: String,
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_version: Option<String>,
pub owner_document_id: String,
pub owner_document_path: String,
pub source_root_relative_path: String,
pub source_hash: String,
pub artifact_root_relative_path: String,
pub source_map_root_relative_path: String,
pub updated_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceSearchMode {
Keyword,
Tree,
Hybrid,
Graph,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceSearchScope {
pub workspace_id: String,
pub root_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_document_id: Option<String>,
#[serde(default)]
pub include_resources: bool,
#[serde(default)]
pub include_ocr: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceSearchRequest {
pub query: String,
pub scope: EvidenceSearchScope,
#[serde(default = "default_evidence_search_mode")]
pub mode: EvidenceSearchMode,
#[serde(default = "default_evidence_top_k")]
pub top_k: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceSearchResult {
pub evidence_id: String,
pub quote: String,
pub score: f64,
pub source: EvidenceLocator,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceSearchResponse {
pub ok: bool,
#[serde(default)]
pub results: Vec<EvidenceSearchResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceReadContext {
pub before_blocks: u32,
pub after_blocks: u32,
pub include_section_summary: bool,
}
impl Default for EvidenceReadContext {
fn default() -> Self {
Self {
before_blocks: 3,
after_blocks: 3,
include_section_summary: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceReadRequest {
pub locator: EvidenceLocator,
#[serde(default)]
pub context: EvidenceReadContext,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceReadResponse {
pub ok: bool,
pub locator: EvidenceLocator,
pub quote: String,
#[serde(default)]
pub context_blocks: Vec<EvidenceSearchResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceOpenRequest {
pub locator: EvidenceLocator,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceEdge {
pub edge_id: String,
pub from_id: String,
pub to_id: String,
pub edge_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_evidence_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_locator: Option<EvidenceLocator>,
pub confidence: f64,
pub created_by: String,
}
fn default_evidence_search_mode() -> EvidenceSearchMode {
EvidenceSearchMode::Hybrid
}
fn default_evidence_top_k() -> u32 {
8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evidence_locator_serializes_canonical_schema() {
let locator = EvidenceLocator::new(
"file:///workspace",
"local-md:docs~2FPage.md",
"docs/Page.md",
EvidenceResourceKind::Pdf,
EvidenceOpenAction {
action_type: "mnote.open_resource_locator".into(),
url: "/documents/local-md:docs~2FPage.md?resource=docs%2Fspec.pdf".into(),
params: Value::Null,
},
);
let value = serde_json::to_value(locator).expect("locator should serialize");
assert_eq!(value["schema"], EVIDENCE_LOCATOR_SCHEMA);
assert_eq!(value["resourceKind"], "pdf");
assert!(value.get("bbox").is_none());
assert!(value["openAction"]["params"].is_null());
}
#[test]
fn source_map_keeps_single_provider_contract() {
let source_map = ResourceSourceMap {
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
provider: "liteparse".into(),
model_version: Some("2.0.5".into()),
owner_document_path: "docs/Page.md".into(),
source_root_relative_path: "docs/Page.assets/spec.pdf".into(),
source_hash: "sha256:demo".into(),
page_count: Some(1),
pages: vec![SourceMapPage {
page: 1,
width: Some(595.0),
height: Some(842.0),
text_items: vec![SourceMapTextItem {
id: "p1_t1".into(),
text: "Revenue".into(),
bbox: Some(EvidenceBBox {
x0: 72.0,
y0: 124.0,
x1: 160.0,
y1: 140.0,
}),
char_range: Some(EvidenceRange { start: 0, end: 7 }),
}],
blocks: vec![],
}],
sections: vec![],
};
let value = serde_json::to_value(source_map).expect("source map should serialize");
assert_eq!(value["schema"], RESOURCE_SOURCE_MAP_SCHEMA);
assert_eq!(value["pages"][0]["textItems"][0]["bbox"]["x0"], 72.0);
}
}
+25
View File
@@ -2,6 +2,7 @@ pub mod ai;
pub mod command;
pub mod common;
pub mod editor;
pub mod evidence;
pub mod governance;
pub mod kernel;
pub mod mindmap;
@@ -37,6 +38,14 @@ pub use editor::{
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
pub use evidence::{
EvidenceBBox, EvidenceEdge, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest,
EvidenceRange, EvidenceReadContext, EvidenceReadRequest, EvidenceReadResponse,
EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse,
EvidenceSearchResult, EvidenceSearchScope, ParsedResourceArtifact, ResourceSourceMap,
SourceMapBlock, SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
EVIDENCE_LOCATOR_SCHEMA, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
};
pub use kernel::{
DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem,
DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType,
@@ -78,6 +87,7 @@ pub use tool::{
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
EVIDENCE_TOOLSET_READ, EVIDENCE_TOOL_OPEN, EVIDENCE_TOOL_READ, EVIDENCE_TOOL_SEARCH,
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
@@ -131,6 +141,9 @@ mod tests {
"doc_find",
"docs_search",
"docs_read",
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
"image_read",
"doc_insert_blocks",
"doc_replace_range",
@@ -195,6 +208,18 @@ mod tests {
.expect("docs_read toolset should exist");
assert!(!docs_read.write_toolset);
assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]);
let evidence_read = registry
.toolset("toolset.evidence_read")
.expect("evidence toolset should exist");
assert!(!evidence_read.write_toolset);
assert_eq!(
evidence_read.tool_names,
&[
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
]
);
let doc_write = registry
.toolset("toolset.doc_write")
.expect("doc_write toolset should exist");
+49
View File
@@ -140,6 +140,39 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
};
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
name: "mnote.evidence.search",
display_name: "证据搜索",
description: "在工作区内搜索可回跳原文的证据块,返回 quote、locator 与 openAction。",
toolset_id: "toolset.evidence_read",
invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read,
requires_confirmation: false,
input_schema_json: r#"{"type":"object","required":["query","scope"],"properties":{"query":{"type":"string"},"scope":{"type":"object","required":["workspaceId","rootUri"],"properties":{"workspaceId":{"type":"string"},"rootUri":{"type":"string"},"targetDocumentId":{"type":"string"},"includeResources":{"type":"boolean"},"includeOcr":{"type":"boolean"}}},"mode":{"enum":["keyword","tree","hybrid","graph"]},"topK":{"type":"integer","minimum":1,"maximum":30}}}"#,
};
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
name: "mnote.evidence.read",
display_name: "证据读回",
description: "按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用。",
toolset_id: "toolset.evidence_read",
invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read,
requires_confirmation: false,
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"},"context":{"type":"object","properties":{"beforeBlocks":{"type":"integer","minimum":0,"maximum":20},"afterBlocks":{"type":"integer","minimum":0,"maximum":20},"includeSectionSummary":{"type":"boolean"}}}}}"#,
};
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
name: "mnote.evidence.open",
display_name: "证据打开",
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
toolset_id: "toolset.evidence_read",
invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read,
requires_confirmation: false,
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"}}}"#,
};
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
name: "image_read",
display_name: "读取图片",
@@ -387,6 +420,18 @@ pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
tool_names: &["docs_search", "docs_read"],
};
pub const EVIDENCE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
id: "toolset.evidence_read",
display_name: "证据读取",
description: "搜索、读回和打开可定位原文证据的只读工具集合。",
write_toolset: false,
tool_names: &[
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
],
};
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
id: "toolset.readonly",
display_name: "只读工具",
@@ -481,6 +526,7 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
MEDIA_TOOLSET,
SLASH_TOOLSET_WRITE,
DOCS_TOOLSET_READ,
EVIDENCE_TOOLSET_READ,
DOC_TOOLSET_READ,
DOC_TOOLSET_WRITE,
MINDMAP_TOOLSET_READ,
@@ -495,6 +541,9 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
DOC_TOOL_FIND,
DOCS_TOOL_SEARCH,
DOCS_TOOL_READ,
EVIDENCE_TOOL_SEARCH,
EVIDENCE_TOOL_READ,
EVIDENCE_TOOL_OPEN,
IMAGE_READ_TOOL,
DOC_TOOL_INSERT_BLOCKS,
DOC_TOOL_REPLACE_RANGE,