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:
@@ -158,7 +158,10 @@ impl AppState {
|
||||
let control_plane = Arc::new(open_control_plane_store());
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(
|
||||
buffer_store.clone(),
|
||||
control_plane.clone(),
|
||||
),
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
stream_delta_tx,
|
||||
@@ -184,12 +187,12 @@ impl AppState {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
use core_protocol::{
|
||||
EvidenceBBox, EvidenceRange, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
|
||||
SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
|
||||
PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use std::{env::split_paths, process::Command as StdCommand};
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseCapability {
|
||||
Unsupported,
|
||||
Supported,
|
||||
Preferred,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseProviderMode {
|
||||
Auto,
|
||||
NoOcr,
|
||||
Ocr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseInput {
|
||||
pub root_path: PathBuf,
|
||||
pub root_uri: String,
|
||||
pub owner_document_id: String,
|
||||
pub owner_document_path: String,
|
||||
pub source_root_relative_path: String,
|
||||
pub mode: ParseProviderMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseProviderOutput {
|
||||
pub artifact: ParsedResourceArtifact,
|
||||
pub source_map: ResourceSourceMap,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ParseProvider {
|
||||
fn provider_id(&self) -> &'static str;
|
||||
fn can_parse(&self, input: &ParseInput) -> ParseCapability;
|
||||
fn parse<'a>(
|
||||
&'a self,
|
||||
input: ParseInput,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MarkdownParserProvider;
|
||||
|
||||
impl ParseProvider for MarkdownParserProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
"markdown"
|
||||
}
|
||||
|
||||
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
||||
if is_markdown_path(&input.source_root_relative_path) {
|
||||
ParseCapability::Preferred
|
||||
} else {
|
||||
ParseCapability::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<'a>(
|
||||
&'a self,
|
||||
input: ParseInput,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
||||
Box::pin(async move { parse_markdown_input(input) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LiteParseProvider;
|
||||
|
||||
impl ParseProvider for LiteParseProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
"liteparse"
|
||||
}
|
||||
|
||||
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
||||
if is_liteparse_path(&input.source_root_relative_path) {
|
||||
match input.mode {
|
||||
ParseProviderMode::Ocr => ParseCapability::Unsupported,
|
||||
ParseProviderMode::Auto | ParseProviderMode::NoOcr => ParseCapability::Preferred,
|
||||
}
|
||||
} else {
|
||||
ParseCapability::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<'a>(
|
||||
&'a self,
|
||||
input: ParseInput,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
||||
Box::pin(async move { parse_liteparse_input(input).await })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_parse_provider_id(input: &ParseInput) -> &'static str {
|
||||
if is_markdown_path(&input.source_root_relative_path) {
|
||||
"markdown"
|
||||
} else if is_liteparse_path(&input.source_root_relative_path)
|
||||
&& !matches!(input.mode, ParseProviderMode::Ocr)
|
||||
{
|
||||
"liteparse"
|
||||
} else {
|
||||
"mineru"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_parse_provider_id(
|
||||
input: &ParseInput,
|
||||
native_text_confidence: Option<f64>,
|
||||
) -> &'static str {
|
||||
if is_markdown_path(&input.source_root_relative_path) {
|
||||
return "markdown";
|
||||
}
|
||||
if is_liteparse_path(&input.source_root_relative_path) {
|
||||
if matches!(input.mode, ParseProviderMode::Ocr) {
|
||||
return "mineru";
|
||||
}
|
||||
if native_text_confidence.is_some_and(|confidence| confidence < 0.2) {
|
||||
return "mineru";
|
||||
}
|
||||
return "liteparse";
|
||||
}
|
||||
"mineru"
|
||||
}
|
||||
|
||||
async fn parse_liteparse_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
||||
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||
let metadata = read_liteparse_source_metadata(&source_path)?;
|
||||
let bytes = read_liteparse_source_bytes(&source_path)?;
|
||||
let output = Command::new(liteparse_bin())
|
||||
.arg("parse")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg("--no-ocr")
|
||||
.arg("-q")
|
||||
.arg(&source_path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_command_failed",
|
||||
format!("LiteParse 命令执行失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
parse_liteparse_command_output(input, metadata, bytes, output)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_liteparse_input_blocking(
|
||||
input: ParseInput,
|
||||
) -> Result<ParseProviderOutput, ParseError> {
|
||||
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||
let metadata = read_liteparse_source_metadata(&source_path)?;
|
||||
let bytes = read_liteparse_source_bytes(&source_path)?;
|
||||
let output = StdCommand::new(liteparse_bin())
|
||||
.arg("parse")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg("--no-ocr")
|
||||
.arg("-q")
|
||||
.arg(&source_path)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_command_failed",
|
||||
format!("LiteParse 命令执行失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
parse_liteparse_command_output(input, metadata, bytes, output)
|
||||
}
|
||||
|
||||
pub(crate) fn liteparse_runtime_available() -> bool {
|
||||
if let Ok(value) = env::var("MNOTE_LITEPARSE_BIN") {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let path = Path::new(trimmed);
|
||||
return path.components().count() > 1 && path.exists() || command_exists(trimmed);
|
||||
}
|
||||
command_exists("lit") || command_exists("liteparse")
|
||||
}
|
||||
|
||||
fn read_liteparse_source_metadata(source_path: &Path) -> Result<fs::Metadata, ParseError> {
|
||||
fs::metadata(source_path).map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_parse_stat_failed",
|
||||
format!(
|
||||
"无法读取 LiteParse 证据源状态 {}: {error}",
|
||||
source_path.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_liteparse_source_bytes(source_path: &Path) -> Result<Vec<u8>, ParseError> {
|
||||
fs::read(source_path).map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_parse_read_failed",
|
||||
format!(
|
||||
"无法读取 LiteParse 证据源 {}: {error}",
|
||||
source_path.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_liteparse_command_output(
|
||||
input: ParseInput,
|
||||
metadata: fs::Metadata,
|
||||
bytes: Vec<u8>,
|
||||
output: std::process::Output,
|
||||
) -> Result<ParseProviderOutput, ParseError> {
|
||||
let updated_at_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|value| value.as_millis() as u64)
|
||||
.unwrap_or_default();
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(ParseError::new(
|
||||
"liteparse_command_failed",
|
||||
format!("LiteParse 解析失败: {}", stderr.trim()),
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_output_utf8_invalid",
|
||||
format!("LiteParse 输出不是 UTF-8: {error}"),
|
||||
)
|
||||
})?;
|
||||
let value = serde_json::from_str::<Value>(&stdout).map_err(|error| {
|
||||
ParseError::new(
|
||||
"liteparse_output_json_invalid",
|
||||
format!("LiteParse JSON 输出无效: {error}"),
|
||||
)
|
||||
})?;
|
||||
let source_hash = binary_source_hash(&bytes, metadata.len(), updated_at_ms);
|
||||
let markdown = liteparse_markdown(&value);
|
||||
let source_map = liteparse_source_map(&input, &value, &source_hash);
|
||||
let artifact = ParsedResourceArtifact {
|
||||
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||||
provider: "liteparse".into(),
|
||||
model_version: liteparse_version(&value),
|
||||
owner_document_id: input.owner_document_id,
|
||||
owner_document_path: input.owner_document_path,
|
||||
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||
source_hash,
|
||||
artifact_root_relative_path: format!("{}.parse.md", input.source_root_relative_path),
|
||||
source_map_root_relative_path: format!(
|
||||
"{}.source-map.json",
|
||||
input.source_root_relative_path
|
||||
),
|
||||
updated_at_ms,
|
||||
};
|
||||
Ok(ParseProviderOutput {
|
||||
artifact,
|
||||
source_map,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
||||
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||
let markdown = fs::read_to_string(&source_path).map_err(|error| {
|
||||
ParseError::new(
|
||||
"markdown_parse_read_failed",
|
||||
format!(
|
||||
"无法读取 Markdown 证据源 {}: {error}",
|
||||
source_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let metadata = fs::metadata(&source_path).map_err(|error| {
|
||||
ParseError::new(
|
||||
"markdown_parse_stat_failed",
|
||||
format!(
|
||||
"无法读取 Markdown 证据源状态 {}: {error}",
|
||||
source_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let updated_at_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|value| value.as_millis() as u64)
|
||||
.unwrap_or_default();
|
||||
let source_hash = source_hash(&markdown, metadata.len(), updated_at_ms);
|
||||
let source_map = markdown_source_map(&input, &markdown, &source_hash);
|
||||
let artifact = ParsedResourceArtifact {
|
||||
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||||
provider: "markdown".into(),
|
||||
model_version: None,
|
||||
owner_document_id: input.owner_document_id,
|
||||
owner_document_path: input.owner_document_path,
|
||||
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||
source_hash,
|
||||
artifact_root_relative_path: input.source_root_relative_path.clone(),
|
||||
source_map_root_relative_path: format!(
|
||||
"{}.source-map.json",
|
||||
input.source_root_relative_path
|
||||
),
|
||||
updated_at_ms,
|
||||
};
|
||||
Ok(ParseProviderOutput {
|
||||
artifact,
|
||||
source_map,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn liteparse_bin() -> String {
|
||||
env::var("MNOTE_LITEPARSE_BIN")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if command_exists("lit") {
|
||||
"lit".into()
|
||||
} else {
|
||||
"liteparse".into()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn command_exists(command: &str) -> bool {
|
||||
let path = Path::new(command);
|
||||
if path.components().count() > 1 {
|
||||
return path.exists();
|
||||
}
|
||||
let Some(paths) = env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
split_paths(&paths).any(|base| base.join(command).exists())
|
||||
}
|
||||
|
||||
fn liteparse_version(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("version")
|
||||
.or_else(|| value.get("modelVersion"))
|
||||
.or_else(|| value.get("model_version"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some("liteparse-v2".into()))
|
||||
}
|
||||
|
||||
fn liteparse_markdown(value: &Value) -> String {
|
||||
for key in ["markdown", "text", "content"] {
|
||||
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||
let text = text.trim();
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
let lines = liteparse_pages(value)
|
||||
.into_iter()
|
||||
.flat_map(|(_, page)| liteparse_blocks(&page))
|
||||
.filter_map(|block| liteparse_item_text(&block))
|
||||
.collect::<Vec<_>>();
|
||||
lines.join("\n\n")
|
||||
}
|
||||
|
||||
fn liteparse_source_map(input: &ParseInput, value: &Value, source_hash: &str) -> ResourceSourceMap {
|
||||
let mut pages = Vec::new();
|
||||
let mut sections = Vec::new();
|
||||
let mut section_path = Vec::<String>::new();
|
||||
let mut global_char = 0_u64;
|
||||
for (page_index, page_value) in liteparse_pages(value) {
|
||||
let page_number = liteparse_page_number(&page_value).unwrap_or(page_index);
|
||||
let blocks = liteparse_blocks(&page_value);
|
||||
let mut source_blocks = Vec::new();
|
||||
let mut text_items = Vec::new();
|
||||
for (block_index, block) in blocks.iter().enumerate() {
|
||||
let Some(text) = liteparse_item_text(block) else {
|
||||
continue;
|
||||
};
|
||||
let block_id = liteparse_item_id(block)
|
||||
.unwrap_or_else(|| format!("p{page_number}_b{}", block_index + 1));
|
||||
let text_item_id = format!("p{page_number}_t{}", block_index + 1);
|
||||
let block_type = liteparse_block_kind(block);
|
||||
let char_range = EvidenceRange {
|
||||
start: global_char,
|
||||
end: global_char + text.chars().count() as u64,
|
||||
};
|
||||
global_char = char_range.end + 1;
|
||||
let bbox = liteparse_bbox(block);
|
||||
if matches!(block_type, SourceMapBlockKind::Heading) {
|
||||
let level = liteparse_heading_level(block).unwrap_or(1).max(1);
|
||||
section_path.truncate(level.saturating_sub(1));
|
||||
section_path.push(text.clone());
|
||||
sections.push(SourceMapSection {
|
||||
id: format!(
|
||||
"sec_{}",
|
||||
stable_segment(&format!(
|
||||
"{}:{}:{}",
|
||||
input.source_root_relative_path,
|
||||
page_number,
|
||||
section_path.join("/")
|
||||
))
|
||||
),
|
||||
title: text.clone(),
|
||||
path: section_path.clone(),
|
||||
page_start: Some(page_number),
|
||||
page_end: Some(page_number),
|
||||
block_ids: vec![block_id.clone()],
|
||||
});
|
||||
} else if let Some(section) = sections.last_mut() {
|
||||
section.page_end = Some(page_number);
|
||||
if !section.block_ids.iter().any(|id| id == &block_id) {
|
||||
section.block_ids.push(block_id.clone());
|
||||
}
|
||||
}
|
||||
text_items.push(SourceMapTextItem {
|
||||
id: text_item_id,
|
||||
text: text.clone(),
|
||||
bbox: bbox.clone(),
|
||||
char_range: Some(char_range.clone()),
|
||||
});
|
||||
source_blocks.push(SourceMapBlock {
|
||||
id: block_id,
|
||||
block_type,
|
||||
text,
|
||||
bbox,
|
||||
char_range: Some(char_range),
|
||||
});
|
||||
}
|
||||
pages.push(SourceMapPage {
|
||||
page: page_number,
|
||||
width: liteparse_number(&page_value, &["width", "pageWidth"]),
|
||||
height: liteparse_number(&page_value, &["height", "pageHeight"]),
|
||||
text_items,
|
||||
blocks: source_blocks,
|
||||
});
|
||||
}
|
||||
ResourceSourceMap {
|
||||
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||
provider: "liteparse".into(),
|
||||
model_version: liteparse_version(value),
|
||||
owner_document_path: input.owner_document_path.clone(),
|
||||
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||
source_hash: source_hash.to_string(),
|
||||
page_count: pages.iter().map(|page| page.page).max(),
|
||||
pages,
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
fn liteparse_pages(value: &Value) -> Vec<(u32, Value)> {
|
||||
if let Some(pages) = value.get("pages").and_then(Value::as_array) {
|
||||
return pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, page)| ((index + 1) as u32, page.clone()))
|
||||
.collect();
|
||||
}
|
||||
vec![(1, json!({ "blocks": liteparse_blocks(value) }))]
|
||||
}
|
||||
|
||||
fn liteparse_blocks(page: &Value) -> Vec<Value> {
|
||||
for key in ["blocks", "items", "textItems", "text_items", "elements"] {
|
||||
if let Some(items) = page.get(key).and_then(Value::as_array) {
|
||||
return items.clone();
|
||||
}
|
||||
}
|
||||
if let Some(text) = liteparse_item_text(page) {
|
||||
return vec![json!({ "text": text })];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn liteparse_item_text(value: &Value) -> Option<String> {
|
||||
for key in ["text", "content", "markdown", "value"] {
|
||||
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 liteparse_item_id(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("id")
|
||||
.or_else(|| value.get("blockId"))
|
||||
.or_else(|| value.get("block_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn liteparse_page_number(value: &Value) -> Option<u32> {
|
||||
for key in ["page", "pageNumber", "page_number", "page_no", "pageNo"] {
|
||||
if let Some(page) = value.get(key).and_then(Value::as_u64) {
|
||||
return u32::try_from(page.max(1)).ok();
|
||||
}
|
||||
}
|
||||
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
|
||||
return u32::try_from(page_idx + 1).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn liteparse_block_kind(value: &Value) -> SourceMapBlockKind {
|
||||
match value
|
||||
.get("type")
|
||||
.or_else(|| value.get("blockType"))
|
||||
.or_else(|| value.get("block_type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"title" | "heading" | "header" => SourceMapBlockKind::Heading,
|
||||
"table" => SourceMapBlockKind::Table,
|
||||
"figure" => SourceMapBlockKind::Figure,
|
||||
"image" => SourceMapBlockKind::Image,
|
||||
"list" => SourceMapBlockKind::List,
|
||||
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
|
||||
_ => SourceMapBlockKind::Text,
|
||||
}
|
||||
}
|
||||
|
||||
fn liteparse_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 liteparse_bbox(value: &Value) -> Option<EvidenceBBox> {
|
||||
if let Some(bbox) = value
|
||||
.get("bbox")
|
||||
.or_else(|| value.get("boundingBox"))
|
||||
.or_else(|| value.get("bounding_box"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
if bbox.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
return Some(EvidenceBBox {
|
||||
x0: bbox[0].as_f64()?,
|
||||
y0: bbox[1].as_f64()?,
|
||||
x1: bbox[2].as_f64()?,
|
||||
y1: bbox[3].as_f64()?,
|
||||
});
|
||||
}
|
||||
let x = value.get("x").and_then(Value::as_f64)?;
|
||||
let y = value.get("y").and_then(Value::as_f64)?;
|
||||
let width = value
|
||||
.get("width")
|
||||
.or_else(|| value.get("w"))
|
||||
.and_then(Value::as_f64)?;
|
||||
let height = value
|
||||
.get("height")
|
||||
.or_else(|| value.get("h"))
|
||||
.and_then(Value::as_f64)?;
|
||||
Some(EvidenceBBox {
|
||||
x0: x,
|
||||
y0: y,
|
||||
x1: x + width,
|
||||
y1: y + height,
|
||||
})
|
||||
}
|
||||
|
||||
fn liteparse_number(value: &Value, keys: &[&str]) -> Option<f64> {
|
||||
keys.iter()
|
||||
.find_map(|key| value.get(*key).and_then(Value::as_f64))
|
||||
}
|
||||
|
||||
fn markdown_source_map(input: &ParseInput, markdown: &str, source_hash: &str) -> ResourceSourceMap {
|
||||
let mut section_path: Vec<String> = Vec::new();
|
||||
let mut sections = Vec::new();
|
||||
let mut blocks = Vec::new();
|
||||
let mut char_start = 0_u64;
|
||||
for (index, line) in markdown.lines().enumerate() {
|
||||
let line_number = (index + 1) as u64;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
char_start += line.len() as u64 + 1;
|
||||
continue;
|
||||
}
|
||||
let block_type = if let Some((level, title)) = markdown_heading(trimmed) {
|
||||
section_path.truncate(level.saturating_sub(1));
|
||||
section_path.push(title.clone());
|
||||
let id = format!(
|
||||
"sec_{}",
|
||||
stable_segment(&format!(
|
||||
"{}:{}",
|
||||
input.source_root_relative_path,
|
||||
section_path.join("/")
|
||||
))
|
||||
);
|
||||
sections.push(SourceMapSection {
|
||||
id,
|
||||
title,
|
||||
path: section_path.clone(),
|
||||
page_start: Some(line_number as u32),
|
||||
page_end: Some(line_number as u32),
|
||||
block_ids: vec![format!("line{line_number}")],
|
||||
});
|
||||
SourceMapBlockKind::Heading
|
||||
} else {
|
||||
SourceMapBlockKind::Paragraph
|
||||
};
|
||||
blocks.push(SourceMapBlock {
|
||||
id: format!("line{line_number}"),
|
||||
block_type,
|
||||
text: trimmed.to_string(),
|
||||
bbox: None,
|
||||
char_range: Some(core_protocol::EvidenceRange {
|
||||
start: char_start,
|
||||
end: char_start + line.len() as u64,
|
||||
}),
|
||||
});
|
||||
char_start += line.len() as u64 + 1;
|
||||
}
|
||||
ResourceSourceMap {
|
||||
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||
provider: "markdown".into(),
|
||||
model_version: None,
|
||||
owner_document_path: input.owner_document_path.clone(),
|
||||
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||
source_hash: source_hash.to_string(),
|
||||
page_count: Some(1),
|
||||
pages: vec![SourceMapPage {
|
||||
page: 1,
|
||||
width: None,
|
||||
height: None,
|
||||
text_items: Vec::new(),
|
||||
blocks,
|
||||
}],
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
||||
let marker_count = line.chars().take_while(|value| *value == '#').count();
|
||||
if marker_count == 0 || marker_count > 6 {
|
||||
return None;
|
||||
}
|
||||
let rest = line.get(marker_count..)?.trim();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((marker_count, rest.trim_matches('#').trim().to_string()))
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
|
||||
}
|
||||
|
||||
fn is_liteparse_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"pdf" | "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
|
||||
format!(
|
||||
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
|
||||
fnv1a64(content.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn binary_source_hash(content: &[u8], size: u64, updated_at_ms: u64) -> String {
|
||||
format!(
|
||||
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
|
||||
fnv1a64(content)
|
||||
)
|
||||
}
|
||||
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325_u64;
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn stable_segment(value: &str) -> String {
|
||||
format!("{:016x}", fnv1a64(value.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn temp_root(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("docs")).expect("create root");
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn markdown_parser_provider_returns_artifact_and_source_map() {
|
||||
let root = temp_root("mnote-markdown-parse-provider");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.md"),
|
||||
"# 合同\n正文\n## 解除条件\n提前三十日通知\n",
|
||||
)
|
||||
.expect("write markdown");
|
||||
let input = ParseInput {
|
||||
root_path: root.clone(),
|
||||
root_uri: format!("file://{}", root.display()),
|
||||
owner_document_id: "local-md:docs~2FPage.md".into(),
|
||||
owner_document_path: "docs/Page.md".into(),
|
||||
source_root_relative_path: "docs/Page.md".into(),
|
||||
mode: ParseProviderMode::Auto,
|
||||
};
|
||||
let provider = MarkdownParserProvider;
|
||||
assert_eq!(provider.can_parse(&input), ParseCapability::Preferred);
|
||||
let output = provider.parse(input).await.expect("parse markdown");
|
||||
|
||||
assert_eq!(output.artifact.schema, PARSED_RESOURCE_ARTIFACT_SCHEMA);
|
||||
assert_eq!(output.artifact.provider, "markdown");
|
||||
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
|
||||
assert_eq!(output.source_map.schema, RESOURCE_SOURCE_MAP_SCHEMA);
|
||||
assert_eq!(output.source_map.sections.len(), 2);
|
||||
assert!(output
|
||||
.source_map
|
||||
.sections
|
||||
.iter()
|
||||
.any(|section| section.path == vec!["合同".to_string(), "解除条件".to_string()]));
|
||||
assert!(output.source_map.pages[0]
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|block| block.id == "line4" && block.text == "提前三十日通知"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_provider_routes_text_pdf_to_liteparse_no_ocr() {
|
||||
let input = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
owner_document_id: "local-md:Page.md".into(),
|
||||
owner_document_path: "Page.md".into(),
|
||||
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
||||
mode: ParseProviderMode::NoOcr,
|
||||
};
|
||||
let liteparse = LiteParseProvider;
|
||||
assert_eq!(default_parse_provider_id(&input), "liteparse");
|
||||
assert_eq!(liteparse.can_parse(&input), ParseCapability::Preferred);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn liteparse_provider_maps_json_output_to_artifact_and_source_map() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-liteparse-provider");
|
||||
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("spec.pdf"),
|
||||
b"%PDF-1.4",
|
||||
)
|
||||
.expect("pdf");
|
||||
let lit = root.join("fake-lit");
|
||||
fs::write(
|
||||
&lit,
|
||||
r#"#!/usr/bin/env bash
|
||||
cat <<'JSON'
|
||||
{
|
||||
"version": "liteparse-test",
|
||||
"pages": [{
|
||||
"page": 3,
|
||||
"width": 612,
|
||||
"height": 792,
|
||||
"blocks": [
|
||||
{"id": "b1", "type": "heading", "level": 1, "text": "解除条件", "bbox": [72, 120, 220, 150]},
|
||||
{"id": "b2", "type": "text", "text": "提前三十日通知", "x": 72, "y": 160, "width": 228, "height": 28}
|
||||
]
|
||||
}]
|
||||
}
|
||||
JSON
|
||||
"#,
|
||||
)
|
||||
.expect("fake lit");
|
||||
let mut permissions = fs::metadata(&lit).expect("fake lit metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&lit, permissions).expect("chmod fake lit");
|
||||
let old_bin = env::var("MNOTE_LITEPARSE_BIN").ok();
|
||||
env::set_var("MNOTE_LITEPARSE_BIN", &lit);
|
||||
|
||||
let input = ParseInput {
|
||||
root_path: root.clone(),
|
||||
root_uri: format!("file://{}", root.display()),
|
||||
owner_document_id: "local-md:docs~2FPage.md".into(),
|
||||
owner_document_path: "docs/Page.md".into(),
|
||||
source_root_relative_path: "docs/Page.assets/spec.pdf".into(),
|
||||
mode: ParseProviderMode::NoOcr,
|
||||
};
|
||||
let output = LiteParseProvider
|
||||
.parse(input)
|
||||
.await
|
||||
.expect("parse liteparse");
|
||||
|
||||
if let Some(value) = old_bin {
|
||||
env::set_var("MNOTE_LITEPARSE_BIN", value);
|
||||
} else {
|
||||
env::remove_var("MNOTE_LITEPARSE_BIN");
|
||||
}
|
||||
assert_eq!(output.artifact.provider, "liteparse");
|
||||
assert_eq!(
|
||||
output.artifact.model_version.as_deref(),
|
||||
Some("liteparse-test")
|
||||
);
|
||||
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
|
||||
assert_eq!(output.markdown, "解除条件\n\n提前三十日通知");
|
||||
assert_eq!(output.source_map.provider, "liteparse");
|
||||
assert_eq!(output.source_map.pages[0].page, 3);
|
||||
assert_eq!(
|
||||
output.source_map.pages[0].blocks[1]
|
||||
.bbox
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.x0,
|
||||
72.0
|
||||
);
|
||||
assert_eq!(
|
||||
output.source_map.sections[0].path,
|
||||
vec!["解除条件".to_string()]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_provider_routes_ocr_policy_to_mineru() {
|
||||
let input = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
owner_document_id: "local-md:Page.md".into(),
|
||||
owner_document_path: "Page.md".into(),
|
||||
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
||||
mode: ParseProviderMode::Ocr,
|
||||
};
|
||||
assert_eq!(default_parse_provider_id(&input), "mineru");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_selection_routes_scanned_pdf_or_image_to_mineru() {
|
||||
let scanned_pdf = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
owner_document_id: "local-md:Page.md".into(),
|
||||
owner_document_path: "Page.md".into(),
|
||||
source_root_relative_path: "Page.assets/scan.pdf".into(),
|
||||
mode: ParseProviderMode::Auto,
|
||||
};
|
||||
assert_eq!(select_parse_provider_id(&scanned_pdf, Some(0.05)), "mineru");
|
||||
|
||||
let image = ParseInput {
|
||||
source_root_relative_path: "Page.assets/photo.png".into(),
|
||||
..scanned_pdf
|
||||
};
|
||||
assert_eq!(select_parse_provider_id(&image, None), "mineru");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::routes;
|
||||
use axum::http::StatusCode;
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
||||
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn evidence_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let body = evidence_search_request(input, context)?;
|
||||
routes::evidence::search_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_read_payload_invalid",
|
||||
format!("Evidence read 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::read_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_open(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_open_payload_invalid",
|
||||
format!("Evidence open 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::open_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let payload = evidence_search(state, context, input).await?;
|
||||
Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_search",
|
||||
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"source": "mnote.evidence.search",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if input.arg_value("locator").is_some() {
|
||||
let payload = evidence_read(state, context, input).await?;
|
||||
return Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"result": payload,
|
||||
"source": "mnote.evidence.read",
|
||||
}));
|
||||
}
|
||||
|
||||
let document = doc::doc_fetch(state, context, input).await?;
|
||||
let locator = legacy_document_locator(input);
|
||||
Ok(json!({
|
||||
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"documentId": input.effective_document_id(),
|
||||
"document": document,
|
||||
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
|
||||
"source": {
|
||||
"tool": "mnote.doc.fetch",
|
||||
"locator": locator,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn evidence_search_request(
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
) -> Result<EvidenceSearchRequest, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("scope").is_none() {
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_workspace_required",
|
||||
"Evidence 搜索缺少 workspaceId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let include_resources = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeResources"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let include_ocr = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeOcr"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let target_document_id = input
|
||||
.effective_document_id()
|
||||
.or_else(|| input.arg_string("pageId"))
|
||||
.or_else(|| input.arg_string("targetDocumentId"));
|
||||
args = json!({
|
||||
"query": query,
|
||||
"scope": {
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"targetDocumentId": target_document_id,
|
||||
"includeResources": include_resources,
|
||||
"includeOcr": include_ocr,
|
||||
},
|
||||
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
|
||||
"topK": input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(8),
|
||||
});
|
||||
}
|
||||
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
|
||||
WebError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"mnote_evidence_search_payload_invalid",
|
||||
format!("Evidence search 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
|
||||
let root_uri = local_root_uri_for_evidence(input)?;
|
||||
let document_id = input.effective_document_id()?;
|
||||
let owner_document_path =
|
||||
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
|
||||
Some(EvidenceLocator {
|
||||
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
|
||||
root_uri: root_uri.clone(),
|
||||
owner_document_id: document_id,
|
||||
owner_document_path: owner_document_path.clone(),
|
||||
resource_path: Some(owner_document_path.clone()),
|
||||
resource_kind: EvidenceResourceKind::Markdown,
|
||||
page: None,
|
||||
bbox: None,
|
||||
section_path: Vec::new(),
|
||||
line_range: None,
|
||||
char_range: None,
|
||||
block_id: None,
|
||||
source_map_path: None,
|
||||
open_action: EvidenceOpenAction {
|
||||
action_type: "mnote.open_resource_locator".into(),
|
||||
url: "/".into(),
|
||||
params: json!({
|
||||
"rootUri": root_uri,
|
||||
"ownerDocumentPath": owner_document_path,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
|
||||
input.effective_root_uri().or_else(|| {
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("allowedRoots")
|
||||
.or_else(|| scope.get("allowed_roots"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|allowed_roots| {
|
||||
allowed_roots.as_array().and_then(|roots| {
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
root.get("rootUri")
|
||||
.or_else(|| root.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.find(|root_uri| !root_uri.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn document_path_from_local_id(document_id: &str) -> Option<String> {
|
||||
let encoded = document_id.trim().strip_prefix("local-md:")?;
|
||||
decode_local_id_segment(encoded)
|
||||
}
|
||||
|
||||
fn decode_local_id_segment(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'~' {
|
||||
if index + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hex = &value[index + 1..index + 3];
|
||||
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||
decoded.push(byte);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
@@ -19,6 +19,9 @@ pub fn manifest() -> Value {
|
||||
context_resolve_target_tool(),
|
||||
doc_fetch_tool(),
|
||||
doc_find_tool(),
|
||||
evidence_search_tool(),
|
||||
evidence_read_tool(),
|
||||
evidence_open_tool(),
|
||||
block_fetch_tool(),
|
||||
doc_plan_update_tool(),
|
||||
block_replace_tool(),
|
||||
@@ -249,6 +252,104 @@ fn doc_find_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn evidence_search_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"includeResources".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"includeOcr".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"mode".into(),
|
||||
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
|
||||
);
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
|
||||
map.insert(
|
||||
"scope".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"rootUri": { "type": "string" },
|
||||
"targetDocumentId": { "type": "string" },
|
||||
"includeResources": { "type": "boolean" },
|
||||
"includeOcr": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.search",
|
||||
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator 与 openAction。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn evidence_read_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"context".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beforeBlocks": { "type": "integer", "default": 3 },
|
||||
"afterBlocks": { "type": "integer", "default": 3 },
|
||||
"includeSectionSummary": { "type": "boolean", "default": true }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.read",
|
||||
"description": "按 EvidenceLocator 读取原文证据及周边上下文,供回答引用。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn evidence_open_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.open",
|
||||
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn block_fetch_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod artifact;
|
||||
pub mod block;
|
||||
pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod evidence;
|
||||
pub mod manifest;
|
||||
pub mod onlyoffice_live;
|
||||
pub mod page;
|
||||
|
||||
@@ -31,6 +31,22 @@ const SKILLS: &[MnoteSkill] = &[
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-document-evidence",
|
||||
title: "MNote document evidence",
|
||||
description: "Search local documents and resources with clickable evidence locators.",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
requires_context_refs: &["folder"],
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-document-evidence/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-local-file",
|
||||
title: "MNote local file editing",
|
||||
@@ -279,6 +295,21 @@ mod tests {
|
||||
.any(|name| name == "mnote.mindmap.create_from_outline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_document_evidence_skill_to_agents() {
|
||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||
let skill = hermes_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-document-evidence")
|
||||
.expect("hermes should see document evidence skill");
|
||||
assert_eq!(skill["readOnly"], true);
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.evidence.search"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_read_returns_mindmap_skill_content() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod context;
|
||||
pub mod document_buffer_store;
|
||||
pub mod editor_actor;
|
||||
pub mod error;
|
||||
pub mod evidence_parse;
|
||||
pub mod hermes_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::routes::{
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
refresh_local_search_index_for_path,
|
||||
refresh_local_search_index_for_change_path_with_store,
|
||||
refresh_local_search_index_if_scheduled_due_with_store,
|
||||
};
|
||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
@@ -10,13 +12,15 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio::time::MissedTickBehavior;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalFolderWatcherRegistry {
|
||||
inner: Arc<LocalFolderWatcherRegistryInner>,
|
||||
buffer_store: BufferStore,
|
||||
control_plane: Arc<dyn ControlPlaneStore>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
@@ -28,12 +32,13 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistry {
|
||||
pub fn new(buffer_store: BufferStore) -> Self {
|
||||
pub fn new(buffer_store: BufferStore, control_plane: Arc<SqliteControlPlaneStore>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +48,10 @@ impl LocalFolderWatcherRegistry {
|
||||
) -> Result<LocalFolderWatcherSubscription, String> {
|
||||
let key = canonical_root_uri(canonical_root);
|
||||
let buffer_store = self.buffer_store.clone();
|
||||
let channel = self
|
||||
.inner
|
||||
.get_or_create_channel(&key, canonical_root, buffer_store)?;
|
||||
let control_plane = self.control_plane.clone();
|
||||
let channel =
|
||||
self.inner
|
||||
.get_or_create_channel(&key, canonical_root, buffer_store, control_plane)?;
|
||||
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(LocalFolderWatcherSubscription {
|
||||
receiver: channel.sender.subscribe(),
|
||||
@@ -73,6 +79,7 @@ impl LocalFolderWatcherRegistryInner {
|
||||
key: &str,
|
||||
canonical_root: &Path,
|
||||
buffer_store: BufferStore,
|
||||
control_plane: Arc<dyn ControlPlaneStore>,
|
||||
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
||||
if let Some(existing) = self
|
||||
.entries
|
||||
@@ -86,7 +93,12 @@ impl LocalFolderWatcherRegistryInner {
|
||||
|
||||
let channel = Arc::new(LocalFolderWatchChannel::new(
|
||||
key.to_string(),
|
||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf(), buffer_store)?,
|
||||
spawn_local_folder_watcher(
|
||||
key,
|
||||
canonical_root.to_path_buf(),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
)?,
|
||||
));
|
||||
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
@@ -168,6 +180,7 @@ fn spawn_local_folder_watcher(
|
||||
root_uri: &str,
|
||||
canonical_root: PathBuf,
|
||||
buffer_store: BufferStore,
|
||||
control_plane: Arc<dyn ControlPlaneStore>,
|
||||
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
||||
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
@@ -186,13 +199,23 @@ fn spawn_local_folder_watcher(
|
||||
let sender_for_task = sender.clone();
|
||||
let root_uri_for_task = root_uri.to_string();
|
||||
let buffer_store_for_task = buffer_store.clone();
|
||||
let control_plane_for_task = control_plane.clone();
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
let mut index_schedule_tick = tokio::time::interval(Duration::from_secs(60));
|
||||
index_schedule_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_rx => {
|
||||
return;
|
||||
}
|
||||
_ = index_schedule_tick.tick() => {
|
||||
refresh_local_search_index_for_schedule(
|
||||
control_plane_for_task.as_ref(),
|
||||
&canonical_root,
|
||||
&root_uri_for_task,
|
||||
);
|
||||
}
|
||||
maybe_result = event_receiver.recv() => {
|
||||
let Some(result) = maybe_result else {
|
||||
return;
|
||||
@@ -211,6 +234,7 @@ fn spawn_local_folder_watcher(
|
||||
continue;
|
||||
};
|
||||
refresh_local_search_index_for_event(
|
||||
control_plane_for_task.as_ref(),
|
||||
&canonical_root,
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
@@ -298,11 +322,38 @@ fn spawn_local_folder_watcher(
|
||||
Ok((sender, shutdown_tx))
|
||||
}
|
||||
|
||||
fn refresh_local_search_index_for_event(root: &Path, root_uri: &str, relative_path: &str) {
|
||||
fn refresh_local_search_index_for_event(
|
||||
control_plane: &dyn ControlPlaneStore,
|
||||
root: &Path,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
) {
|
||||
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
||||
return;
|
||||
};
|
||||
let _ = refresh_local_search_index_for_path(root, root_uri, &workspace_id, relative_path);
|
||||
let _ = refresh_local_search_index_for_change_path_with_store(
|
||||
control_plane,
|
||||
root,
|
||||
root_uri,
|
||||
&workspace_id,
|
||||
relative_path,
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_local_search_index_for_schedule(
|
||||
control_plane: &dyn ControlPlaneStore,
|
||||
root: &Path,
|
||||
root_uri: &str,
|
||||
) {
|
||||
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
||||
return;
|
||||
};
|
||||
let _ = refresh_local_search_index_if_scheduled_due_with_store(
|
||||
control_plane,
|
||||
root,
|
||||
root_uri,
|
||||
&workspace_id,
|
||||
);
|
||||
}
|
||||
|
||||
fn canonical_root_uri(root: &Path) -> String {
|
||||
@@ -423,8 +474,11 @@ mod tests {
|
||||
LocalFolderWatcherRegistry,
|
||||
};
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::routes::write_local_index_settings;
|
||||
use control_plane::SqliteControlPlaneStore;
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
use notify::EventKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -441,7 +495,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_root_subscribers_share_single_watcher() {
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
||||
let control_plane =
|
||||
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
|
||||
let root = test_root("shared");
|
||||
|
||||
let first = registry.subscribe(&root).expect("first subscription");
|
||||
@@ -462,7 +518,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_roots_create_independent_watchers() {
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
||||
let control_plane =
|
||||
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
|
||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
|
||||
let first_root = test_root("first");
|
||||
let second_root = test_root("second");
|
||||
|
||||
@@ -532,7 +590,10 @@ mod tests {
|
||||
.expect("write watched");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
refresh_local_search_index_for_event(&root, &root_uri, "docs/watched.md");
|
||||
let control_plane = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||||
write_local_index_settings(&root, &[String::from(".")], None, None, None, Some(true))
|
||||
.expect("enable run-on-change indexing");
|
||||
refresh_local_search_index_for_event(&control_plane, &root, &root_uri, "docs/watched.md");
|
||||
|
||||
let index_path = root.join(".mnote").join("index").join("search-index.json");
|
||||
let index = std::fs::read_to_string(&index_path).expect("index exists");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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\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(""));
|
||||
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\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
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -140,7 +140,7 @@ pub fn PageLayout(
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作" data-testid="wolai-sidebar-quick-actions">
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
|
||||
<button type="button" title="导航页" aria-label="导航页" data-mnote-action="open-navigation-page"><span class="material-symbols-outlined nav-icon" data-icon="home" aria-hidden="true"></span></button>
|
||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
||||
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
||||
@@ -188,7 +188,8 @@ pub fn PageLayout(
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
||||
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 任务" aria-label="OCR 任务" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
||||
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 设置" aria-label="OCR 设置" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="open-ocr-settings"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
||||
<button type="button" class="wolai-icon-button" title="索引设置" aria-label="索引设置" data-testid="mnote-local-index-settings-toggle" data-mnote-action="open-index-settings"><span class="material-symbols-outlined" data-icon="manage_search" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
|
||||
@@ -436,7 +437,12 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openNavigationPageForFolder"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openCurrentNavigationPage"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains("openCurrentNavigationPage(navigationPageTrigger)")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/navigation/recent"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("await renderPageProjection(sidebarProjection)"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("authRedirectUrl"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(authRedirectUrl())"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(url)"));
|
||||
@@ -467,6 +473,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_quick_action_opens_current_navigation_page() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||
<main>"正文"</main>
|
||||
</super::PageLayout>
|
||||
});
|
||||
|
||||
assert!(html.contains(r#"data-mnote-action="open-navigation-page""#));
|
||||
assert!(html.contains(r#"title="导航页""#));
|
||||
assert!(!html.contains(r#"href="/actions""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_exposes_standalone_index_and_ocr_settings() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||
<main>"正文"</main>
|
||||
</super::PageLayout>
|
||||
});
|
||||
|
||||
assert!(html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
|
||||
assert!(html.contains(r#"data-mnote-action="open-index-settings""#));
|
||||
assert!(html.contains(r#"data-icon="manage_search""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
|
||||
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_settings_runtime_keeps_index_and_ocr_out_of_page_settings() {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-index-settings-popover"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-ocr-settings-popover"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-range-input"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-index-status=\"' + kind"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (kind === 'indexed')"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
|
||||
"renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat([''])"
|
||||
),
|
||||
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (!values.length) values = [''];"),
|
||||
"删除最后一个索引范围时 UI 应显示空行,不能强制回填 ."
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||
.contains("data-local-ocr-settings-action=\"run-active\""));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("wolai-page-settings-local-index-backlinks")
|
||||
);
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("wolai-page-settings-local-index-tags"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("backlinksUrl"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("tagsUrl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_folder_click_toggles_instead_of_navigation_page() {
|
||||
let folder_branch = SIDEBAR_TREE_RUNTIME_JS
|
||||
@@ -1258,7 +1322,7 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function markExistingFileTreeChildrenLoaded"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (markExistingFileTreeChildrenLoaded(row, button))"));
|
||||
.contains("if (!stale && markExistingFileTreeChildrenLoaded(row, button, options))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
|
||||
@@ -1288,10 +1352,10 @@ mod tests {
|
||||
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
|
||||
);
|
||||
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("async function loadFileTreeChildren(row, button)")
|
||||
.find("async function loadFileTreeChildren(row, button, options)")
|
||||
.expect("lazy children loader");
|
||||
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
.find("setTreeRowExpanded(row, button, true);")
|
||||
.find("setTreeRowExpanded(row, button, true, options);")
|
||||
.expect("lazy loading should mark the requested folder expanded before fetch")
|
||||
+ load_start;
|
||||
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
@@ -1394,6 +1458,11 @@ mod tests {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function watchBatchNeedsPageTreeRefresh")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-local-folder-watch-sidebar-refresh-skipped"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-local-folder-watch-batch-applied"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
|
||||
@@ -1416,6 +1485,41 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_hydrates_visible_expanded_rows_on_idle() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function scheduleHydrateVisibleExpandedFileTreeRows"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function hydrateVisibleExpandedFileTreeRows"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestIdleCallback"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-filetree-idle-hydrate-applied")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("row.getAttribute('aria-expanded') === 'true'"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("row.getAttribute('data-filetree-children-loaded') !== 'true'"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("loadFileTreeChildren(row, button, { persist: false, idleHydrate: true })"));
|
||||
|
||||
let restore_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("function restorePersistedFileTreeExpansionState")
|
||||
.expect("restorePersistedFileTreeExpansionState");
|
||||
let restore_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[restore_start..]
|
||||
.find("function installTreeLiveApplyEventListeners")
|
||||
.map(|offset| restore_start + offset)
|
||||
.expect("restore function end");
|
||||
let restore_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[restore_start..restore_end];
|
||||
assert!(restore_body.contains("scheduleHydrateVisibleExpandedFileTreeRows('restore')"));
|
||||
assert!(
|
||||
!restore_body.contains("then(function(loaded)"),
|
||||
"恢复 view-state 不能递归拉取历史 expanded path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
|
||||
assert!(
|
||||
@@ -1493,6 +1597,24 @@ mod tests {
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("确认删除选中的 "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_bulk_delete_refreshes_local_folder_after_success() {
|
||||
let bulk_delete_start = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.find("async function deleteSelectedSidebarFileTreeRows")
|
||||
.expect("bulk delete function");
|
||||
let bulk_delete_end = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS[bulk_delete_start..]
|
||||
.find("return {")
|
||||
.map(|offset| bulk_delete_start + offset)
|
||||
.expect("bulk delete function end");
|
||||
let bulk_delete = &SIDEBAR_FILETREE_COMMAND_RUNTIME_JS[bulk_delete_start..bulk_delete_end];
|
||||
assert!(bulk_delete.contains(
|
||||
"if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();"
|
||||
));
|
||||
assert!(!bulk_delete.contains(
|
||||
"if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot();"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_blocks_readonly_paste_and_drop_with_action_status() {
|
||||
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("function blockReadonlyFileTreeAction"));
|
||||
@@ -1587,6 +1709,10 @@ mod tests {
|
||||
fn sidebar_filetree_runtime_does_not_keep_retired_table_engine_branches() {
|
||||
let retired_table_engine = ["lucky", "sheet"].concat();
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_table_engine));
|
||||
let retired_api = ["/api/", "lucky"].concat();
|
||||
let retired_constructor = ["create", "Luck"].concat();
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_constructor));
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_api));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -149,6 +149,7 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="tag"]::before { content: "#"; }
|
||||
.material-symbols-outlined[data-icon="edit"]::before { content: "✎"; }
|
||||
.material-symbols-outlined[data-icon="add"]::before { content: "+"; }
|
||||
.material-symbols-outlined[data-icon="manage_search"]::before { content: "⌕"; }
|
||||
.material-symbols-outlined[data-icon="subdirectory_arrow_right"]::before { content: "↳"; }
|
||||
|
||||
.material-symbols-filled {
|
||||
@@ -158,6 +159,7 @@ a:hover {
|
||||
/* 本地 SVG mask 图标,避免 Google Material Symbols 字体未加载时露出英文图标名。 */
|
||||
.material-symbols-outlined[data-icon]::before { content: ""; }
|
||||
.material-symbols-outlined[data-icon="search"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m16 16 4.2 4.2' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="manage_search"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 17.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 15.5 4 4M8 8.5h5M8 11h5M8 13.5h3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="account_tree"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6h4v4H6zM14 4h4v4h-4zM14 16h4v4h-4z' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M10 8h2a2 2 0 0 0 2-2M10 8h2a2 2 0 0 1 2 2v8' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="bolt"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 2 4.5 13h6L9 22l10.5-13h-6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="help"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M9.8 9a2.4 2.4 0 0 1 4.6 1.1c0 1.7-1.7 2-2.2 3.1' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='17' r='1.1' fill='black'/%3E%3C/svg%3E"); }
|
||||
@@ -2909,6 +2911,29 @@ body {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-image-shell {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 80px);
|
||||
overflow: auto;
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.mnote-resource-tab-bbox-highlight {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(37, 99, 235, 0.9);
|
||||
background: rgba(37, 99, 235, 0.16);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editor-surface .ProseMirror [data-mnote-evidence-text-highlight="true"],
|
||||
.mnote-resource-tab-text-shell [data-mnote-evidence-text-highlight="true"] {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.9);
|
||||
outline-offset: 3px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.mnote-resource-tab-text-shell {
|
||||
padding: 34px 48px;
|
||||
}
|
||||
@@ -3546,6 +3571,40 @@ body {
|
||||
box-shadow: 0 18px 48px rgba(27, 28, 28, 0.12);
|
||||
}
|
||||
|
||||
.mnote-settings-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mnote-settings-panel-head strong {
|
||||
color: #1B1C1C;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-settings-panel-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8B8782;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mnote-settings-panel-close:hover {
|
||||
background: #F4F3F3;
|
||||
color: #1B1C1C;
|
||||
}
|
||||
|
||||
.mnote-local-index-settings-panel {
|
||||
width: min(386px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.wolai-page-settings-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -3609,6 +3668,148 @@ body {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-range-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-range-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-status-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9), 0 0 0 3px rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-status-dot[data-index-status="indexed"] {
|
||||
background: #22C55E;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-status-dot[data-index-status="indexing"] {
|
||||
background: #F59E0B;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-status-dot[data-index-status="fault"] {
|
||||
background: #EF4444;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-path {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
border: 1px solid #E2DFDA;
|
||||
border-radius: 6px;
|
||||
padding: 0 9px;
|
||||
background: #FFFFFF;
|
||||
color: #1B1C1C;
|
||||
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-path:disabled {
|
||||
background: #F7F6F4;
|
||||
color: #A19D97;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-remove,
|
||||
.wolai-page-settings-index-add {
|
||||
border: 1px solid #D8D4CE;
|
||||
border-radius: 6px;
|
||||
background: #FFFFFF;
|
||||
color: #1B1C1C;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-remove {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-add {
|
||||
width: fit-content;
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-remove:hover:not(:disabled),
|
||||
.wolai-page-settings-index-add:hover:not(:disabled) {
|
||||
background: #F4F3F3;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-remove:disabled,
|
||||
.wolai-page-settings-index-add:disabled {
|
||||
cursor: default;
|
||||
color: #A19D97;
|
||||
background: #F7F6F4;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-schedule {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(96px, .7fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-schedule label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: #8B8782;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-schedule select,
|
||||
.wolai-page-settings-index-schedule input[type="time"],
|
||||
.wolai-page-settings-index-schedule input[type="date"] {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
border: 1px solid #E2DFDA;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #FFFFFF;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-inline {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-inline input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-actions button {
|
||||
min-height: 30px;
|
||||
border: 1px solid #D8D4CE;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
background: #FFFFFF;
|
||||
color: #1B1C1C;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-settings-index-row,
|
||||
.wolai-page-settings-index-empty {
|
||||
padding: 8px 10px;
|
||||
|
||||
Reference in New Issue
Block a user