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
File diff suppressed because it is too large Load Diff
+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()
@@ -14,7 +14,9 @@ use futures_util::stream;
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
use std::convert::Infallible;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -299,13 +301,16 @@ fn build_local_folder_watch_batch_payload(
workspace_id: &str,
watcher_payloads: Vec<Value>,
) -> Option<Value> {
let revision = local_folder_watch_revision(root_uri).ok()?;
let mut changed_paths = Vec::new();
let mut affected_parents = Vec::new();
let mut event_kinds = Vec::new();
let mut seen_paths = std::collections::BTreeSet::new();
let mut seen_parents = std::collections::BTreeSet::new();
let mut seen_kinds = std::collections::BTreeSet::new();
let mut latest_modified_ms = 0u128;
let mut hasher = DefaultHasher::new();
root_uri.hash(&mut hasher);
workspace_id.hash(&mut hasher);
for payload in watcher_payloads {
let relative_path = payload
.get("relativePath")
@@ -318,10 +323,18 @@ fn build_local_folder_watch_batch_payload(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown");
let event_revision = watch_event_revision_ms(&payload);
if event_revision > latest_modified_ms {
latest_modified_ms = event_revision;
}
relative_path.hash(&mut hasher);
event_kind.hash(&mut hasher);
event_revision.hash(&mut hasher);
if seen_paths.insert(relative_path.to_string()) {
changed_paths.push(json!({
"relativePath": relative_path,
"kind": event_kind,
"revision": event_revision,
}));
}
if seen_kinds.insert(event_kind.to_string()) {
@@ -338,14 +351,21 @@ fn build_local_folder_watch_batch_payload(
if changed_paths.is_empty() {
return None;
}
let revision = format!("{:016x}", hasher.finish());
Some(json!({
"schema": "mnote.local_folder_watch_batch.v1",
"kind": "watch_batch",
"sourceKind": "local_folder",
"rootUri": root_uri,
"workspaceId": workspace_id,
"revision": revision.revision,
"watchRevision": revision,
"revision": revision.clone(),
"watchRevision": {
"rootUri": root_uri,
"revision": revision,
"entryCount": changed_paths.len(),
"latestModifiedMs": latest_modified_ms,
"scope": "changed_paths",
},
"changedPaths": changed_paths,
"affectedParents": affected_parents,
"eventKinds": event_kinds,
@@ -353,6 +373,18 @@ fn build_local_folder_watch_batch_payload(
}))
}
fn watch_event_revision_ms(payload: &Value) -> u128 {
payload
.get("revision")
.and_then(|value| {
value
.as_u64()
.map(u128::from)
.or_else(|| value.as_str().and_then(|text| text.parse::<u128>().ok()))
})
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
}
fn build_tree_live_error_payload(
root_uri: &str,
workspace_id: &str,
@@ -684,6 +716,8 @@ mod tests {
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
assert_eq!(payload["kind"], "watch_batch");
assert_eq!(payload["fallbackResync"], false);
assert_eq!(payload["watchRevision"]["scope"], "changed_paths");
assert_eq!(payload["watchRevision"]["entryCount"], 2);
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
assert!(
payload["affectedParents"]
@@ -1,4 +1,4 @@
use crate::app::AppState;
use crate::app::{open_control_plane_store, AppState};
use crate::context::RequestContext;
use crate::error::WebError;
use crate::page_aggregate::{
@@ -2680,6 +2680,21 @@ pub fn load_local_folder_file_tree_children_snapshot(
load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None)
}
pub fn load_local_folder_file_tree_children_snapshot_with_reveal(
root_uri: &str,
parent_relative_path: &str,
reveal_document_id: Option<&str>,
) -> Result<ProjectionSnapshot, WebError> {
let reveal_relative_path = reveal_document_id
.and_then(local_markdown_relative_path_from_document_id)
.map(|path| path.replace('\\', "/"));
load_local_folder_file_tree_scope_snapshot(
root_uri,
Some(parent_relative_path),
reveal_relative_path.as_deref(),
)
}
fn load_local_folder_file_tree_scope_snapshot(
root_uri: &str,
parent_relative_path: Option<&str>,
@@ -2732,16 +2747,15 @@ fn load_local_folder_file_tree_scope_snapshot(
&workspace_id,
&metadata,
)?;
if parent_relative_path.is_empty() {
append_file_tree_reveal_rows(
&canonical_root,
reveal_relative_path,
&root_source_uri,
&workspace_id,
&metadata,
&mut scan_result.rows,
)?;
}
append_file_tree_reveal_rows(
&canonical_root,
parent_relative_path,
reveal_relative_path,
&root_source_uri,
&workspace_id,
&metadata,
&mut scan_result.rows,
)?;
let items = scan_result
.rows
@@ -3207,7 +3221,13 @@ fn save_local_markdown_page_inner(
}
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id);
let control_plane = open_control_plane_store();
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
&control_plane,
root,
root_uri,
workspace_id,
);
}
fn local_markdown_conflict_error(
@@ -6842,6 +6862,7 @@ fn ancestor_directories_for_relative_path(relative_path: &str) -> Vec<String> {
fn append_file_tree_reveal_rows(
root: &Path,
parent_relative_path: &str,
reveal_relative_path: Option<&str>,
root_source_uri: &str,
workspace_id: &str,
@@ -6854,10 +6875,30 @@ fn append_file_tree_reveal_rows(
else {
return Ok(());
};
let mut ancestors = ancestor_directories_for_relative_path(reveal_relative_path);
if let Some(bundle_parent) = same_name_markdown_bundle_parent(reveal_relative_path) {
let parent_relative_path = parent_relative_path
.trim()
.trim_matches('/')
.replace('\\', "/");
let reveal_relative_path = reveal_relative_path
.trim()
.trim_matches('/')
.replace('\\', "/");
if !parent_relative_path.is_empty()
&& reveal_relative_path != parent_relative_path
&& !reveal_relative_path.starts_with(&format!("{parent_relative_path}/"))
{
return Ok(());
}
let mut ancestors = ancestor_directories_for_relative_path(&reveal_relative_path);
if let Some(bundle_parent) = same_name_markdown_bundle_parent(&reveal_relative_path) {
ancestors.retain(|ancestor| ancestor != &bundle_parent);
}
if !parent_relative_path.is_empty() {
ancestors.retain(|ancestor| {
ancestor != &parent_relative_path
&& ancestor.starts_with(&format!("{parent_relative_path}/"))
});
}
if ancestors.is_empty() {
return Ok(());
}
@@ -7047,7 +7088,38 @@ fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result<Pa
}
fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
if matches!(file_name, ".git" | "node_modules" | ".mnote") {
let lower_name = file_name.to_ascii_lowercase();
if matches!(
lower_name.as_str(),
".git"
| ".mnote"
| ".codegraph"
| ".codex"
| ".claw"
| ".gemini"
| ".reasonix"
| ".venv"
| "__pycache__"
| "node_modules"
| ".next"
| ".turbo"
| ".pnpm-store"
| ".convex-tmp"
| "target"
| "dist"
| "build"
| "tmp"
| "temp"
| "artifacts"
| "test-results"
| "pw-tests"
| "recycle"
| "reference-code"
| "services"
| "cankao"
| "ai-sessions"
) || lower_name.starts_with("onlyoffice-")
{
return true;
}
relative_path == ".mnote/trash"
@@ -9732,6 +9804,18 @@ mod tests {
std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored");
let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision");
assert_eq!(initial.revision, ignored.revision);
std::fs::create_dir_all(root.join("target")).expect("create target");
std::fs::write(root.join("target").join("ignored.md"), "# Ignored\n")
.expect("write ignored target md");
std::fs::create_dir_all(root.join("reference-code")).expect("create reference-code");
std::fs::write(
root.join("reference-code").join("ignored.md"),
"# Ignored\n",
)
.expect("write ignored reference md");
let ignored_generated =
local_folder_watch_revision(&root_uri).expect("ignored generated revision");
assert_eq!(initial.revision, ignored_generated.revision);
std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md");
let updated = local_folder_watch_revision(&root_uri).expect("updated revision");
assert_ne!(initial.revision, updated.revision);
+690 -32
View File
@@ -8,6 +8,10 @@ use crate::routes::local_folder_source::{
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use core_protocol::{
EvidenceBBox, EvidenceRange, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind,
SourceMapPage, SourceMapSection, SourceMapTextItem, RESOURCE_SOURCE_MAP_SCHEMA,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
@@ -125,6 +129,7 @@ struct MineruZipAsset {
struct MineruZipExtraction {
markdown: String,
assets: Vec<MineruZipAsset>,
source_map_input: Option<Value>,
}
#[derive(Debug, Clone)]
@@ -164,26 +169,6 @@ pub(crate) async fn create_job(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PROVIDER);
let token = if provider != "mock" {
Some(mineru_token().ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"mineru_token_missing",
"缺少 MinerU API token",
)
.with_context(&context)
})?)
} else {
None
};
if provider != "mock" && token.is_none() {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"mineru_token_missing",
"缺少 MinerU API token",
)
.with_context(&context));
}
let source_relative = body.source_root_relative_path.trim().replace('\\', "/");
let _source_path_hint = body
.source_path
@@ -199,6 +184,30 @@ pub(crate) async fn create_job(
DEFAULT_MODEL_VERSION,
body.force,
)?;
if !body.force {
if let Some(entry) = reusable_existing_ocr_entry(&state, &root, root_uri, &plan)? {
return Ok(ok_json(
&context,
json!({
"ok": true,
"deduplicated": true,
"job": ocr_job_payload(&root, &entry),
}),
));
}
}
let token = if provider != "mock" {
Some(mineru_token().ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"mineru_token_missing",
"缺少 MinerU API token",
)
.with_context(&context)
})?)
} else {
None
};
let now = now_ms();
let mut entry = build_index_entry(&plan, "queued", now, "", None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
@@ -316,6 +325,10 @@ pub(crate) async fn delete_job(
let removed = index.entries.remove(&source);
if let Some(entry) = &removed {
let sidecar = root.join(&entry.ocr_root_relative_path);
let parse_sidecar = root.join(parse_sidecar_relative_path(&entry.ocr_root_relative_path));
let source_map_sidecar = root.join(source_map_sidecar_relative_path(
&entry.ocr_root_relative_path,
));
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
if sidecar.exists() {
fs::remove_file(&sidecar).map_err(|error| {
@@ -326,6 +339,30 @@ pub(crate) async fn delete_job(
.with_context(&context)
})?;
}
if parse_sidecar.exists() {
fs::remove_file(&parse_sidecar).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!(
"无法删除 OCR Parse Markdown {}: {error}",
parse_sidecar.display()
),
)
.with_context(&context)
})?;
}
if source_map_sidecar.exists() {
fs::remove_file(&source_map_sidecar).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!(
"无法删除 OCR source-map {}: {error}",
source_map_sidecar.display()
),
)
.with_context(&context)
})?;
}
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
}
write_ocr_index(&root, &index)?;
@@ -581,6 +618,10 @@ async fn run_mineru_ocr(
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
write_mineru_zip_assets(plan, &extraction.assets)?;
if let Some(source_map_input) = extraction.source_map_input.as_ref() {
let source_map = build_mineru_source_map(plan, source_map_input);
write_source_map_sidecar(plan, &source_map)?;
}
Ok(extraction.markdown)
}
@@ -777,6 +818,7 @@ fn extract_mineru_markdown_and_assets_from_zip(
)
})?;
let mut candidates = Vec::<(String, String)>::new();
let mut json_candidates = Vec::<(String, Value)>::new();
let mut assets = Vec::<MineruZipAsset>::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
@@ -800,6 +842,19 @@ fn extract_mineru_markdown_and_assets_from_zip(
candidates.push((name, markdown));
continue;
}
if name.to_ascii_lowercase().ends_with(".json") {
let mut json_text = String::new();
file.read_to_string(&mut json_text).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_json_read_failed",
format!("MinerU JSON 读取失败: {error}"),
)
})?;
if let Ok(value) = serde_json::from_str::<Value>(&json_text) {
json_candidates.push((name, value));
}
continue;
}
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
continue;
};
@@ -830,7 +885,241 @@ fn extract_mineru_markdown_and_assets_from_zip(
"MinerU 结果包中缺少 Markdown 文件",
)
})?;
Ok(MineruZipExtraction { markdown, assets })
let source_map_input = pick_mineru_source_map_input(json_candidates);
Ok(MineruZipExtraction {
markdown,
assets,
source_map_input,
})
}
fn pick_mineru_source_map_input(candidates: Vec<(String, Value)>) -> Option<Value> {
candidates
.into_iter()
.max_by_key(|(name, value)| {
let lower = name.to_ascii_lowercase();
let preferred = lower.ends_with("content_list.json")
|| lower.ends_with("_content_list.json")
|| lower.ends_with("middle.json");
let item_count = mineru_content_items(value)
.map(|items| items.len())
.unwrap_or_default();
(preferred, item_count)
})
.map(|(_, value)| value)
}
fn build_mineru_source_map(plan: &OcrSidecarPlan, value: &Value) -> ResourceSourceMap {
let mut pages = BTreeMap::<u32, SourceMapPage>::new();
let mut sections = Vec::new();
let mut section_stack: Vec<String> = Vec::new();
if let Some(items) = mineru_content_items(value) {
for (index, item) in items.iter().enumerate() {
let page = mineru_page_number(item).unwrap_or(1);
let text = mineru_item_text(item).unwrap_or_default();
if text.trim().is_empty() {
continue;
}
let bbox = mineru_item_bbox(item);
let block_id = format!("p{page}_b{}", index + 1);
let text_item_id = format!("p{page}_t{}", index + 1);
let block_type = mineru_block_kind(item);
if matches!(block_type, SourceMapBlockKind::Heading) {
let level = mineru_heading_level(item).unwrap_or(1).max(1);
section_stack.truncate(level.saturating_sub(1));
section_stack.push(text.clone());
sections.push(SourceMapSection {
id: format!(
"sec_{}",
short_hash(&format!(
"{}:{}:{}",
plan.source_root_relative_path,
page,
section_stack.join("/")
))
),
title: text.clone(),
path: section_stack.clone(),
page_start: Some(page),
page_end: Some(page),
block_ids: vec![block_id.clone()],
});
} else if let Some(section) = sections.last_mut() {
section.page_end = Some(page);
if !section.block_ids.iter().any(|id| id == &block_id) {
section.block_ids.push(block_id.clone());
}
}
let page_entry = pages.entry(page).or_insert_with(|| SourceMapPage {
page,
width: None,
height: None,
text_items: Vec::new(),
blocks: Vec::new(),
});
page_entry.text_items.push(SourceMapTextItem {
id: text_item_id,
text: text.clone(),
bbox: bbox.clone(),
char_range: Some(EvidenceRange {
start: 0,
end: text.chars().count() as u64,
}),
});
page_entry.blocks.push(SourceMapBlock {
id: block_id,
block_type,
text: text.clone(),
bbox,
char_range: Some(EvidenceRange {
start: 0,
end: text.chars().count() as u64,
}),
});
}
}
ResourceSourceMap {
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
provider: plan.provider.clone(),
model_version: Some(plan.model_version.clone()),
owner_document_path: plan.owner_document_path.clone(),
source_root_relative_path: plan.source_root_relative_path.clone(),
source_hash: format!("size:{}:mtime:{}", plan.source_size, plan.source_mtime_ms),
page_count: pages.keys().max().copied(),
pages: pages.into_values().collect(),
sections,
}
}
fn write_source_map_sidecar(
plan: &OcrSidecarPlan,
source_map: &ResourceSourceMap,
) -> Result<(), WebError> {
let source_map_path = source_map_path_for_ocr_plan(plan);
if let Some(parent) = source_map_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_source_map_create_failed",
format!("无法创建 source-map 目录 {}: {error}", parent.display()),
)
})?;
}
let content = serde_json::to_string_pretty(source_map).map_err(|error| {
WebError::bad_request_code(
"local_source_map_serialize_failed",
format!("source-map 序列化失败: {error}"),
)
})?;
fs::write(&source_map_path, content).map_err(|error| {
WebError::bad_request_code(
"local_source_map_write_failed",
format!("无法写入 source-map {}: {error}", source_map_path.display()),
)
})
}
fn source_map_path_for_ocr_plan(plan: &OcrSidecarPlan) -> PathBuf {
let source_map_relative = source_map_sidecar_relative_path(&plan.ocr_root_relative_path);
let file_name = Path::new(&source_map_relative)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("source.source-map.json"));
plan.ocr_path
.parent()
.unwrap_or_else(|| Path::new(""))
.join(file_name)
}
fn parse_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
ocr_root_relative_path
.strip_suffix(".ocr.md")
.map(|prefix| format!("{prefix}.parse.md"))
.unwrap_or_else(|| format!("{ocr_root_relative_path}.parse.md"))
}
fn source_map_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
ocr_root_relative_path
.strip_suffix(".ocr.md")
.map(|prefix| format!("{prefix}.source-map.json"))
.unwrap_or_else(|| format!("{ocr_root_relative_path}.source-map.json"))
}
fn mineru_content_items(value: &Value) -> Option<Vec<Value>> {
match value {
Value::Array(items) => Some(items.clone()),
Value::Object(map) => {
for key in ["content_list", "contentList", "items", "blocks", "pages"] {
if let Some(items) = map.get(key).and_then(mineru_content_items) {
return Some(items);
}
}
map.values().find_map(mineru_content_items)
}
_ => None,
}
}
fn mineru_item_text(value: &Value) -> Option<String> {
for key in ["text", "content", "markdown", "md"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn mineru_page_number(value: &Value) -> Option<u32> {
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
return u32::try_from(page_idx + 1).ok();
}
for key in ["page", "page_no", "pageNo", "page_number", "pageNumber"] {
if let Some(page) = value.get(key).and_then(Value::as_u64) {
return u32::try_from(page.max(1)).ok();
}
}
None
}
fn mineru_item_bbox(value: &Value) -> Option<EvidenceBBox> {
let bbox = value.get("bbox").and_then(Value::as_array)?;
if bbox.len() != 4 {
return None;
}
Some(EvidenceBBox {
x0: bbox[0].as_f64()?,
y0: bbox[1].as_f64()?,
x1: bbox[2].as_f64()?,
y1: bbox[3].as_f64()?,
})
}
fn mineru_block_kind(value: &Value) -> SourceMapBlockKind {
match value
.get("type")
.or_else(|| value.get("block_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"title" | "heading" => SourceMapBlockKind::Heading,
"table" => SourceMapBlockKind::Table,
"image" => SourceMapBlockKind::Image,
"figure" => SourceMapBlockKind::Figure,
"list" => SourceMapBlockKind::List,
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
_ => SourceMapBlockKind::Text,
}
}
fn mineru_heading_level(value: &Value) -> Option<usize> {
value
.get("level")
.or_else(|| value.pointer("/props/level"))
.and_then(Value::as_u64)
.map(|value| value as usize)
}
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
@@ -991,7 +1280,7 @@ fn plan_ocr_sidecar_path(
source_root_relative_path: &str,
provider: &str,
model_version: &str,
force: bool,
_force: bool,
) -> Result<OcrSidecarPlan, WebError> {
let owner_document_path = owner_document_path_from_id(document_id)?;
let source_root_relative_path = normalize_relative_path(source_root_relative_path)?;
@@ -1049,15 +1338,7 @@ fn plan_ocr_sidecar_path(
.and_then(|value| value.to_str())
.unwrap_or("source");
let base_file_name = format!("{source_leaf}.ocr.md");
let mut ocr_relative = ocr_dir.join(&base_file_name);
let default_path = root.join(&ocr_relative);
if !force && default_path.exists() {
let suffix = short_hash(&format!(
"{}:{}:{}",
source_root_relative_path, source_metadata.size, source_metadata.mtime_ms
));
ocr_relative = ocr_dir.join(format!("{source_leaf}-{suffix}.ocr.md"));
}
let ocr_relative = ocr_dir.join(&base_file_name);
let ocr_root_relative_path = ocr_relative.to_string_lossy().replace('\\', "/");
let ocr_path = root.join(&ocr_root_relative_path);
ensure_target_under_root(root, &ocr_path, "local_ocr_sidecar_root_escape")?;
@@ -1086,6 +1367,20 @@ fn write_ocr_sidecar(
)
})?;
}
let parse_relative = parse_sidecar_relative_path(&plan.ocr_root_relative_path);
let parse_file_name = Path::new(&parse_relative)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("source.parse.md"));
let parse_path = plan.ocr_path.with_file_name(parse_file_name);
fs::write(&parse_path, markdown_body.trim_end()).map_err(|error| {
WebError::bad_request_code(
"local_ocr_sidecar_write_failed",
format!(
"无法写入 OCR Parse Markdown {}: {error}",
parse_path.display()
),
)
})?;
let content = build_ocr_markdown(plan, markdown_body, "done", now);
fs::write(&plan.ocr_path, content).map_err(|error| {
WebError::bad_request_code(
@@ -1220,6 +1515,93 @@ fn write_ocr_index(root: &Path, index: &OcrIndex) -> Result<(), WebError> {
})
}
fn reusable_existing_ocr_entry(
state: &AppState,
root: &Path,
root_uri: &str,
plan: &OcrSidecarPlan,
) -> Result<Option<OcrIndexEntry>, WebError> {
let index = read_ocr_index(root)?;
if let Some(entry) = index.entries.get(&plan.source_root_relative_path) {
if entry.source_size == plan.source_size && entry.source_mtime_ms == plan.source_mtime_ms {
let status = entry.status.as_str();
if status == "done" {
let sidecar = root.join(&entry.ocr_root_relative_path);
if sidecar.is_file() && !source_is_stale(root, entry) {
return Ok(Some(entry.clone()));
}
}
if matches!(
status,
"queued" | "uploading" | "mineru_processing" | "downloading" | "writing_sidecar"
) {
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
if state
.local_ocr_active_jobs
.read()
.map(|jobs| jobs.contains_key(&key))
.unwrap_or(false)
&& !source_is_stale(root, entry)
{
return Ok(Some(entry.clone()));
}
}
}
}
let default_ocr_path = root.join(&plan.ocr_root_relative_path);
if default_ocr_path.is_file() {
let markdown = fs::read_to_string(&default_ocr_path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_read_failed",
format!(
"无法读取 OCR Markdown {}: {error}",
default_ocr_path.display()
),
)
})?;
if let Some(frontmatter) = parse_ocr_frontmatter(&markdown) {
if frontmatter.source_root_relative_path == plan.source_root_relative_path
&& frontmatter.source_size == plan.source_size
&& frontmatter.source_mtime_ms == plan.source_mtime_ms
&& frontmatter.status == "done"
{
let now = now_ms();
let entry = OcrIndexEntry {
job_id: format!(
"ocr_{}_{}",
now,
short_hash(&plan.source_root_relative_path)
),
owner_document_id: format!(
"local-md:{}",
encode_local_id_segment(&plan.owner_document_path)
),
owner_document_path: plan.owner_document_path.clone(),
source_root_relative_path: plan.source_root_relative_path.clone(),
ocr_root_relative_path: plan.ocr_root_relative_path.clone(),
provider: frontmatter.provider,
model_version: plan.model_version.clone(),
status: frontmatter.status,
source_size: plan.source_size,
source_mtime_ms: plan.source_mtime_ms,
created_at_ms: now,
updated_at_ms: now,
plain_text_preview: strip_ocr_frontmatter(&markdown)
.chars()
.take(240)
.collect(),
error: None,
};
upsert_ocr_index_entry(root, entry.clone())?;
return Ok(Some(entry));
}
}
}
Ok(None)
}
fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebError> {
let mut index = read_ocr_index(root)?;
index.version = OCR_INDEX_VERSION;
@@ -1787,6 +2169,11 @@ mod tests {
.join("Page.ocr")
.join("photo.png.ocr.md")
.is_file());
assert!(root
.join("docs")
.join("Page.ocr")
.join("photo.png.parse.md")
.is_file());
let escaped_root = query_escape(&root_uri);
let status_response = app()
@@ -1855,6 +2242,11 @@ mod tests {
.join("Page.ocr")
.join("photo.png.ocr.md")
.exists());
assert!(!root
.join("docs")
.join("Page.ocr")
.join("photo.png.parse.md")
.exists());
assert!(read_ocr_index(&root)
.expect("index after delete")
.entries
@@ -1862,6 +2254,235 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_reuses_existing_done_sidecar_without_reprocessing() {
let root = temp_root("mnote-local-ocr-dedup-done");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let create_payload = |markdown: &str| {
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": markdown
})
};
let first_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(create_payload("First OCR Token").to_string()))
.expect("first request"),
)
.await
.expect("first response");
assert_eq!(first_response.status(), StatusCode::OK);
let first_body = to_bytes(first_response.into_body(), usize::MAX)
.await
.expect("first body");
let first_payload: Value = serde_json::from_slice(&first_body).expect("first json");
assert_eq!(first_payload["job"]["status"].as_str(), Some("done"));
let second_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(create_payload("Second OCR Token").to_string()))
.expect("second request"),
)
.await
.expect("second response");
assert_eq!(second_response.status(), StatusCode::OK);
let second_body = to_bytes(second_response.into_body(), usize::MAX)
.await
.expect("second body");
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
assert_eq!(
second_payload["job"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
let sidecar =
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
.expect("sidecar");
assert!(sidecar.contains("First OCR Token"));
assert!(!sidecar.contains("Second OCR Token"));
assert!(!root
.join("docs")
.join("Page.ocr")
.join("photo.png-")
.exists());
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_recovers_existing_done_sidecar_when_index_is_missing() {
let root = temp_root("mnote-local-ocr-dedup-sidecar-recover");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let create_payload = |markdown: &str| {
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": markdown
})
};
let first_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
create_payload("Recovered OCR Token").to_string(),
))
.expect("first request"),
)
.await
.expect("first response");
assert_eq!(first_response.status(), StatusCode::OK);
fs::remove_file(ocr_index_path(&root)).expect("remove index");
let second_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
create_payload("Should Not Reprocess").to_string(),
))
.expect("second request"),
)
.await
.expect("second response");
assert_eq!(second_response.status(), StatusCode::OK);
let second_body = to_bytes(second_response.into_body(), usize::MAX)
.await
.expect("second body");
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
assert_eq!(
read_ocr_index(&root)
.expect("recovered index")
.entries
.len(),
1
);
let sidecar =
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
.expect("sidecar");
assert!(sidecar.contains("Recovered OCR Token"));
assert!(!sidecar.contains("Should Not Reprocess"));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_reuses_existing_done_before_mineru_token_check() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
std::env::remove_var("MNOTE_MINERU_API_TOKEN");
std::env::remove_var("MINERU_API_TOKEN");
let root = temp_root("mnote-local-ocr-dedup-before-token");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let first_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": "Existing OCR Token"
})
.to_string(),
))
.expect("first request"),
)
.await
.expect("first response");
assert_eq!(first_response.status(), StatusCode::OK);
let second_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mineru"
})
.to_string(),
))
.expect("second request"),
)
.await
.expect("second response");
if let Some(value) = old_mnote_token {
std::env::set_var("MNOTE_MINERU_API_TOKEN", value);
}
if let Some(value) = old_mineru_token {
std::env::set_var("MINERU_API_TOKEN", value);
}
assert_eq!(second_response.status(), StatusCode::OK);
let body = to_bytes(second_response.into_body(), usize::MAX)
.await
.expect("second body");
let payload: Value = serde_json::from_slice(&body).expect("second json");
assert_eq!(payload["deduplicated"].as_bool(), Some(true));
assert_eq!(payload["job"]["status"].as_str(), Some("done"));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
let root = temp_root("mnote-local-ocr-events");
@@ -1935,6 +2556,12 @@ mod tests {
let root = temp_root("mnote-local-ocr-token");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let (status, payload) = post_ocr_job(
&root,
@@ -1966,7 +2593,14 @@ mod tests {
let poll_count = Arc::new(AtomicUsize::new(0));
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
"# MinerU Result\n\n![OCR 图](images/ocr.png)\n\n识别文本",
&[("images/ocr.png", b"png-bytes")],
&[
("images/ocr.png", b"png-bytes"),
(
"content_list.json",
r#"[{"type":"title","level":1,"page_idx":0,"text":"MinerU Result","bbox":[0,0,100,18]},{"type":"text","page_idx":0,"text":"识别文本","bbox":[10,20,110,40]}]"#
.as_bytes(),
),
],
));
let mock_mineru = axum::Router::new()
@@ -2114,6 +2748,30 @@ mod tests {
assert!(sidecar.contains("provider: mineru"));
assert!(sidecar.contains("![OCR 图](images/ocr.png)"));
assert!(sidecar.contains("识别文本"));
let parse_markdown = fs::read_to_string(
root.join("docs")
.join("Page.ocr")
.join("photo.png.parse.md"),
)
.expect("parse markdown");
assert_eq!(
parse_markdown.trim(),
"# MinerU Result\n\n![OCR 图](images/ocr.png)\n\n识别文本"
);
let source_map = fs::read_to_string(
root.join("docs")
.join("Page.ocr")
.join("photo.png.source-map.json"),
)
.expect("source map");
let source_map_json: Value = serde_json::from_str(&source_map).expect("source map json");
assert_eq!(source_map_json["schema"], RESOURCE_SOURCE_MAP_SCHEMA);
assert_eq!(source_map_json["provider"], "mineru");
assert_eq!(source_map_json["pages"][0]["page"], 1);
assert_eq!(source_map_json["pages"][0]["blocks"][1]["text"], "识别文本");
assert_eq!(source_map_json["pages"][0]["blocks"][1]["bbox"]["x0"], 10.0);
assert_eq!(source_map_json["sections"][0]["path"][0], "MinerU Result");
assert_eq!(source_map_json["sections"][0]["blockIds"][1], "p1_b2");
assert_eq!(
fs::read(
root.join("docs")
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -4,6 +4,7 @@ mod compat;
pub(crate) mod dev_hot;
mod documents;
mod editor;
pub(crate) mod evidence;
mod gateway;
mod health;
mod hermes;
@@ -41,7 +42,12 @@ pub(crate) use local_folder_source::{
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
};
pub(crate) use local_search_index::refresh_local_search_index_for_path;
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
pub(crate) use local_search_index::{
refresh_local_search_index_for_change_path_with_store,
refresh_local_search_index_if_scheduled_due_with_store,
};
use crate::app::AppState;
use axum::extract::DefaultBodyLimit;
@@ -67,6 +73,9 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
.route(
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
@@ -302,6 +311,14 @@ pub fn build_router(state: AppState) -> Router {
"/api/search/local-index/refresh",
post(search::refresh_local_index),
)
.route(
"/api/search/local-index/status",
get(search::local_index_status),
)
.route(
"/api/search/local-index/settings",
put(search::update_local_index_settings),
)
.route(
"/api/search/local-index/backlinks",
get(search::local_index_backlinks),
@@ -846,7 +863,7 @@ mod tests {
let response = app(false)
.oneshot(
Request::builder()
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf")
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf&page=3&bbox=1,2,3,4&blockId=p3_b1")
.body(Body::empty())
.expect("request"),
)
@@ -858,6 +875,13 @@ mod tests {
.expect("body bytes");
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
assert!(html.contains("<title>report.pdf</title>"));
assert!(html.contains(r#"data-evidence-page="3""#));
assert!(html.contains(r#"data-evidence-bbox="1,2,3,4""#));
assert!(html.contains(r#"data-evidence-block-id="p3_b1""#));
assert!(html.contains("convertToViewportRectangle"));
assert!(html.contains("Math.max(2, window.devicePixelRatio"));
assert!(html.contains("disableWorker: true"));
assert!(html.contains("__mnotePdfPreviewDispose"));
assert!(!html.contains("mnote-pdf-toolbar"));
assert!(!html.contains("mnote-pdf-title"));
assert!(!html.contains("mnote-pdf-button"));
+571 -42
View File
@@ -1,18 +1,20 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{local_folder_source, local_search_index};
use crate::routes::{evidence, local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{EvidenceSearchMode, EvidenceSearchResult};
use serde::Deserialize;
use serde_json::{json, Value};
@@ -64,6 +66,18 @@ pub struct LocalSearchIndexRefreshRequest {
pub root_uri: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSearchIndexSettingsRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub include_paths: Vec<String>,
pub schedule_mode: Option<String>,
pub schedule_time: Option<String>,
pub schedule_date: Option<String>,
pub run_on_change: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSearchIndexQuery {
@@ -169,43 +183,71 @@ pub async fn documents(
None
};
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
local_search_index::query_local_search_index(
&root_path,
root_uri,
&effective_workspace_id,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?
};
let (result, evidence_results) =
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings = resolve_local_index_user_settings(
&state,
&context,
&effective_workspace_id,
&root_path,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let result = local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?;
let evidence_results = evidence::evidence_results_from_local_search(
&result,
&root_path,
root_uri,
EvidenceSearchMode::Hybrid,
&normalized_query,
);
(result, evidence_results)
} else {
let result = load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?;
(result, Vec::new())
};
let results = result
.get("results")
.cloned()
.unwrap_or(Value::Array(vec![]));
let results = attach_evidence_to_search_results(results, &evidence_results);
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -213,7 +255,8 @@ pub async fn documents(
StatusCode::OK,
headers,
Json(json!({
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
"results": results,
"evidence": evidence_results,
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
"meta": {
@@ -249,10 +292,16 @@ pub async fn refresh_local_index(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let refreshed = local_search_index::refresh_local_search_index(
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let refreshed = local_search_index::refresh_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -273,6 +322,180 @@ pub async fn refresh_local_index(
))
}
pub async fn local_index_status(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let root_uri = query.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_search_root_required",
"本地索引状态缺少 rootUri",
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings =
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let status = local_search_index::local_index_status_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&user_settings,
&effective_settings,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"result": status,
"meta": {
"owner": "mnote-web",
"projectionOwner": "rust-kernel",
"queryName": "search.local_index.status",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}
})),
))
}
pub async fn update_local_index_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<LocalSearchIndexSettingsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let root_uri = body.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_search_root_required",
"本地索引设置缺少 rootUri",
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_write_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let actor_id = current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"local_index_settings_auth_required",
"本地索引设置需要登录用户",
)
.with_context(&context)
})?;
let settings = local_search_index::write_user_local_index_settings(
state.control_plane(),
&actor_id,
&effective_workspace_id,
&root_path,
&body.include_paths,
body.schedule_mode.as_deref(),
body.schedule_time.as_deref(),
body.schedule_date.as_deref(),
body.run_on_change,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let status = local_search_index::local_index_status_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&settings,
&effective_settings,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"settings": settings,
"result": status,
"meta": {
"owner": "mnote-web",
"projectionOwner": "rust-kernel",
"queryName": "search.local_index.settings.update",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}
})),
))
}
fn attach_evidence_to_search_results(
results: Value,
evidence_results: &[EvidenceSearchResult],
) -> Value {
let Value::Array(items) = results else {
return results;
};
Value::Array(
items
.into_iter()
.enumerate()
.map(|(index, item)| {
let Some(evidence) = evidence_results.get(index) else {
return item;
};
let mut item = item;
if let Some(map) = item.as_object_mut() {
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
map.insert("evidence".into(), evidence_value);
let source = map.entry("source").or_insert_with(|| json!({}));
if let Some(source_map) = source.as_object_mut() {
source_map.insert(
"locator".into(),
serde_json::to_value(&evidence.source).unwrap_or(Value::Null),
);
}
}
item
})
.collect(),
)
}
fn resolve_local_index_user_settings(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
root_path: &std::path::Path,
) -> Result<local_search_index::LocalIndexSettings, WebError> {
if let Some(actor_id) = current_actor_id(state, context) {
return local_search_index::read_user_local_index_settings(
state.control_plane(),
&actor_id,
workspace_id,
root_path,
);
}
local_search_index::read_local_index_settings_or_default(root_path)
}
pub async fn local_index_backlinks(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -305,10 +528,19 @@ pub async fn local_index_backlinks(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let backlinks = local_search_index::query_local_backlinks(
let user_settings =
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let backlinks = local_search_index::query_local_backlinks_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
document_id,
)?;
let mut headers = HeaderMap::new();
@@ -350,7 +582,20 @@ pub async fn local_index_tags(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
let user_settings =
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let tags = local_search_index::query_local_tags_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
@@ -550,6 +795,7 @@ mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
@@ -789,6 +1035,15 @@ mod tests {
.expect("home result");
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert_eq!(
home["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert_eq!(
home["evidence"]["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert!(!payload["evidence"].as_array().expect("evidence").is_empty());
assert!(home["tags"]
.as_array()
.unwrap()
@@ -809,6 +1064,35 @@ mod tests {
.join("index")
.join("search-index.json")
.exists());
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
assert!(evidence_db.exists(), "evidence sqlite should be built");
let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite");
let resource_count: i64 = connection
.query_row("SELECT COUNT(*) FROM evidence_resource", [], |row| {
row.get(0)
})
.expect("resource count");
let block_count: i64 = connection
.query_row("SELECT COUNT(*) FROM evidence_block", [], |row| row.get(0))
.expect("block count");
let fts_count: i64 = connection
.query_row("SELECT COUNT(*) FROM evidence_fts", [], |row| row.get(0))
.expect("fts count");
assert!(resource_count >= 1);
assert!(block_count >= 1);
assert!(fts_count >= 1);
let locator_json: String = connection
.query_row(
"SELECT locator_json FROM evidence_block LIMIT 1",
[],
|row| row.get(0),
)
.expect("locator json");
let locator: Value = serde_json::from_str(&locator_json).expect("locator");
assert_eq!(
locator["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert!(payload["recent"]
.as_array()
.unwrap()
@@ -955,4 +1239,249 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_index_settings_route_keeps_user_settings_and_shared_effective_scope() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-settings-route-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(root.join("docs").join("alice")).expect("alice dir");
fs::create_dir_all(root.join("docs").join("bob")).expect("bob dir");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-settings","ownerId":"alice","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("docs").join("alice").join("keep.md"),
"# Alice\nAliceRouteToken\n",
)
.expect("alice doc");
fs::write(
root.join("docs").join("bob").join("keep.md"),
"# Bob\nBobRouteToken\n",
)
.expect("bob doc");
let root_uri = format!("file://{}", root.display());
let encoded_root = query_escape(&root_uri);
let state = 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: 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,
convex_admin_key: None,
allow_dev_fixtures: true,
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(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("alice".into()),
email: None,
username: "alice".into(),
display_name: "alice".into(),
role: None,
password_hash: None,
})
.expect("upsert alice");
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("bob".into()),
email: None,
username: "bob".into(),
display_name: "bob".into(),
role: None,
password_hash: None,
})
.expect("upsert bob");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "bob".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root.display().to_string(),
permission: "write".into(),
recursive: true,
capabilities: vec!["ai".into()],
source: "test".into(),
created_by: Some("alice".into()),
})
.expect("grant bob local folder access");
let app = build_app(state);
let alice_settings_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/search/local-index/settings")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings",
"rootUri": root_uri,
"includePaths": ["docs/alice"],
"scheduleMode": "manual",
"scheduleTime": "02:00",
"runOnChange": false
})
.to_string(),
))
.expect("alice settings request"),
)
.await
.expect("alice settings response");
assert_eq!(alice_settings_response.status(), StatusCode::OK);
let bob_settings_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/search/local-index/settings")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "bob")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings",
"rootUri": root_uri,
"includePaths": ["docs/bob"],
"scheduleMode": "manual",
"scheduleTime": "02:00",
"runOnChange": true
})
.to_string(),
))
.expect("bob settings request"),
)
.await
.expect("bob settings response");
assert_eq!(bob_settings_response.status(), StatusCode::OK);
let alice_status_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/api/search/local-index/status?workspaceId=local-ws-settings&rootUri={encoded_root}"
))
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("alice status request"),
)
.await
.expect("alice status response");
assert_eq!(alice_status_response.status(), StatusCode::OK);
let alice_status_body = to_bytes(alice_status_response.into_body(), usize::MAX)
.await
.expect("alice status body");
let alice_status_payload: Value =
serde_json::from_slice(&alice_status_body).expect("alice status json");
assert_eq!(
alice_status_payload["result"]["settings"]["includePaths"]
.as_array()
.map(Vec::len),
Some(1)
);
assert_eq!(
alice_status_payload["result"]["effectiveSettings"]["includePaths"]
.as_array()
.map(Vec::len),
Some(2)
);
assert_eq!(
alice_status_payload["result"]["effectiveSettings"]["runOnChange"].as_bool(),
Some(true)
);
let alice_search_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings",
"sourceKind": "local_folder",
"rootUri": root_uri,
"query": "BobRouteToken",
"limit": 10
})
.to_string(),
))
.expect("alice search request"),
)
.await
.expect("alice search response");
assert_eq!(alice_search_response.status(), StatusCode::OK);
let alice_search_body = to_bytes(alice_search_response.into_body(), usize::MAX)
.await
.expect("alice search body");
let alice_search_payload: Value =
serde_json::from_slice(&alice_search_body).expect("alice search json");
assert_eq!(
alice_search_payload["results"].as_array().map(Vec::len),
Some(0)
);
let bob_search_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "bob")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings",
"sourceKind": "local_folder",
"rootUri": root_uri,
"query": "BobRouteToken",
"limit": 10
})
.to_string(),
))
.expect("bob search request"),
)
.await
.expect("bob search response");
assert_eq!(bob_search_response.status(), StatusCode::OK);
let bob_search_body = to_bytes(bob_search_response.into_body(), usize::MAX)
.await
.expect("bob search body");
let bob_search_payload: Value =
serde_json::from_slice(&bob_search_body).expect("bob search json");
assert_eq!(
bob_search_payload["results"].as_array().map(Vec::len),
Some(1)
);
let _ = fs::remove_dir_all(&root);
}
}
+227 -19
View File
@@ -10,7 +10,7 @@ use crate::routes::documents::{
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
};
@@ -875,6 +875,18 @@ fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
Some(resolved)
}
fn resolve_lazy_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path != "tiptap_mindmap_paragraph_runtime.js" {
return None;
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../reference-code/leptos-tiptap/src/js/generated")
.join(asset_path),
)
}
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
if asset_path.ends_with(".wasm") {
"application/wasm"
@@ -952,8 +964,16 @@ pub async fn editor_image_placeholder_asset() -> Response {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPreviewQuery {
#[serde(default, alias = "fileUrl")]
file_url: Option<String>,
#[serde(default, alias = "fileName")]
file_name: Option<String>,
#[serde(default)]
page: Option<u32>,
#[serde(default)]
bbox: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -1385,6 +1405,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("PDF 预览");
let target_page = query.page.unwrap_or_default();
let target_bbox = query.bbox.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -1406,6 +1429,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
.mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
.mnote-pdf-page[data-mnote-evidence-page="true"] {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
.mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
@media (max-width: 640px) {{
.mnote-pdf-viewer {{ padding: 0 6px 18px; }}
@@ -1413,7 +1437,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf">
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-block-id="{target_block_id}">
<main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
<script type="module">
import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
@@ -1422,6 +1446,17 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
const viewer = document.getElementById('mnote-pdf-viewer');
const fileUrl = body.dataset.fileUrl || '';
const pageWidthContentType = 'pdf';
const evidencePage = Number(body.dataset.evidencePage || 0);
const evidenceBBox = parseEvidenceBBox(body.dataset.evidenceBbox || '');
const activeRenderTasks = new Set();
let pdfDocument = null;
let disposed = false;
function parseEvidenceBBox(value) {{
const parts = String(value || '').split(',').map((item) => Number(item.trim()));
if (parts.length < 4 || parts.slice(0, 4).some((item) => !Number.isFinite(item))) return null;
return {{ x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }};
}}
function previewCssMaxWidth(mode) {{
if (mode === 'readable') return '760px';
@@ -1473,29 +1508,78 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
}}
async function renderPage(pdf, pageNumber) {{
if (disposed || !pdf || pdf !== pdfDocument) return;
const page = await pdf.getPage(pageNumber);
if (disposed || pdf !== pdfDocument) return;
const baseViewport = page.getViewport({{ scale: 1 }});
const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
const viewport = page.getViewport({{ scale }});
const outputScale = Math.min(2, window.devicePixelRatio || 1);
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
const canvas = document.createElement('canvas');
canvas.className = 'mnote-pdf-page';
canvas.setAttribute('data-page-number', String(pageNumber));
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
const context = canvas.getContext('2d', {{ alpha: false }});
if (!context) return;
if (viewer) viewer.append(canvas);
await page.render({{
if (disposed || pdf !== pdfDocument) return;
const renderTask = page.render({{
canvasContext: context,
viewport,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
}}).promise;
}});
activeRenderTasks.add(renderTask);
try {{
await renderTask.promise;
}} finally {{
activeRenderTasks.delete(renderTask);
}}
if (disposed || pdf !== pdfDocument) return;
if (viewer) viewer.append(canvas);
if (evidencePage === pageNumber) {{
canvas.setAttribute('data-mnote-evidence-page', 'true');
if (evidenceBBox) {{
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
const x = Math.min(rect[0], rect[2]);
const y = Math.min(rect[1], rect[3]);
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
context.save();
context.scale(outputScale, outputScale);
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
context.lineWidth = 2;
context.fillRect(x, y, width, height);
context.strokeRect(x, y, width, height);
context.restore();
}}
window.setTimeout(() => canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
}}
}}
function disposePreview() {{
disposed = true;
for (const task of Array.from(activeRenderTasks)) {{
try {{ task.cancel(); }} catch (_) {{}}
}}
activeRenderTasks.clear();
const doomedDocument = pdfDocument;
if (doomedDocument && typeof doomedDocument.destroy === 'function') {{
try {{ void doomedDocument.destroy(); }} catch (_) {{}}
}}
pdfDocument = null;
}}
window.__mnotePdfPreviewDispose = disposePreview;
window.addEventListener('pagehide', () => {{
void disposePreview();
}}, {{ once: true }});
async function main() {{
disposed = false;
if (!fileUrl) {{
setStatus('不可用');
showMessage('PDF 链接不可用');
@@ -1504,13 +1588,15 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
try {{
await loadPreviewWidthPreferences();
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin }}).promise;
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }}).promise;
pdfDocument = pdf;
if (viewer) viewer.replaceChildren();
setStatus('0 / ' + pdf.numPages);
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
setStatus(pageNumber + ' / ' + pdf.numPages);
if (disposed || pdf !== pdfDocument) return;
await renderPage(pdf, pageNumber);
setStatus(pageNumber + ' / ' + pdf.numPages);
}}
setStatus(pdf.numPages + ' 页');
}} catch (error) {{
console.warn('[mnote pdf preview] render failed', error);
setStatus('打开失败');
@@ -1525,6 +1611,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
title = escape_html(file_name),
file_url = escape_html(&file_url),
file_name = escape_html(file_name),
target_page = target_page,
target_bbox = escape_html(&target_bbox),
target_block_id = escape_html(&target_block_id),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "pdf-preview");
@@ -2191,7 +2280,8 @@ pub async fn leptos_tiptap_manifest() -> Response {
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
"assetPaths": [
"mnote-leptos-tiptap-spike-island.js",
"mnote-leptos-tiptap-spike-island_bg.wasm"
"mnote-leptos-tiptap-spike-island_bg.wasm",
"tiptap_mindmap_paragraph_runtime.js"
],
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
});
@@ -2211,13 +2301,25 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
"leptos-tiptap runtime asset 路径非法",
));
};
let bytes = std::fs::read(&resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?;
let bytes = match std::fs::read(&resolved) {
Ok(bytes) => bytes,
Err(_) => {
let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
));
};
std::fs::read(&lazy_resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?
}
};
let response = Response::builder()
.status(StatusCode::OK)
.header(
@@ -2712,7 +2814,11 @@ pub(crate) fn render_local_file_tree_html_scoped(
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
load_local_folder_file_tree_children_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
@@ -2755,6 +2861,7 @@ mod tests {
include_str!("../../browser/document-slash-position-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../browser/sidebar-page-settings-runtime.js");
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
include_str!("../../browser/document-tiptap-conversion-runtime.js");
@@ -3249,6 +3356,14 @@ mod tests {
));
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
assert!(resource_runtime.contains("normalizeEvidenceLocatorInput"));
assert!(resource_runtime.contains("applyEvidenceLocatorToEntry"));
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
assert!(sidebar_runtime.contains("data-evidence-locator"));
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
@@ -3313,12 +3428,16 @@ mod tests {
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("openInlinePdfResourceTab"));
assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer"));
assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);"));
assert!(resource_runtime.contains("releaseInlinePdfResource"));
assert!(
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
@@ -3526,6 +3645,27 @@ mod tests {
);
}
#[tokio::test]
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js")
.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 js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(js.contains("simple-mind-map"));
assert!(js.contains("createMindmapParagraphNodeView"));
}
#[tokio::test]
async fn leptos_tiptap_runtime_assets_are_cacheable() {
let manifest_response = app()
@@ -4338,6 +4478,74 @@ mod tests {
);
}
#[tokio::test]
async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-scoped-filetree-reveal-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("design").join("07-ai").join("done"))
.expect("create design done");
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
.expect("create unrelated scope sibling");
std::fs::write(
root.join("design")
.join("07-ai")
.join("done")
.join("Target.md"),
"# Target\n",
)
.expect("write target");
std::fs::write(
root.join("design")
.join("05-editor-mainline")
.join("Other.md"),
"# Other\n",
)
.expect("write unrelated page");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(
html.contains(r#"data-local-relative-path="design/07-ai""#),
"scoped FileTree 应保留 active 文档父级 07-ai"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done""#),
"scoped FileTree 应只 reveal active 文档命中的 done 父链"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#),
"active Markdown 文件应在 scoped FileTree 首屏可见"
);
assert!(
!html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#),
"不相关 sibling 目录不应被 reveal 扫入 scoped FileTree"
);
}
#[test]
fn document_conflict_panel_runtime_contains_dom_helpers() {
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =