feat(rag): replace LiteParse flows with LightRAG provider
This commit is contained in:
@@ -1,17 +1,12 @@
|
||||
use core_protocol::{
|
||||
EvidenceBBox, EvidenceRange, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
|
||||
SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
|
||||
PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind, SourceMapPage,
|
||||
SourceMapSection, 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 {
|
||||
@@ -101,14 +96,8 @@ impl ParseProvider for LiteParseProvider {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
let _ = input;
|
||||
ParseCapability::Unsupported
|
||||
}
|
||||
|
||||
fn parse<'a>(
|
||||
@@ -122,12 +111,8 @@ impl ParseProvider for LiteParseProvider {
|
||||
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"
|
||||
"lightrag"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,153 +123,16 @@ pub fn select_parse_provider_id(
|
||||
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"
|
||||
let _ = native_text_confidence;
|
||||
"lightrag"
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
let _ = input;
|
||||
Err(ParseError::new(
|
||||
"liteparse_retired",
|
||||
"LiteParse 已退役;PDF、Office、图片 OCR 与资料索引统一由 LightRAG provider 处理",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
||||
@@ -337,270 +185,6 @@ fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseE
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -686,18 +270,6 @@ fn is_markdown_path(path: &str) -> bool {
|
||||
.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}",
|
||||
@@ -705,13 +277,6 @@ fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -728,12 +293,6 @@ fn stable_segment(value: &str) -> String {
|
||||
#[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()));
|
||||
@@ -781,7 +340,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_provider_routes_text_pdf_to_liteparse_no_ocr() {
|
||||
fn default_provider_routes_pdf_to_lightrag_after_liteparse_retirement() {
|
||||
let input = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
@@ -791,16 +350,13 @@ mod tests {
|
||||
mode: ParseProviderMode::NoOcr,
|
||||
};
|
||||
let liteparse = LiteParseProvider;
|
||||
assert_eq!(default_parse_provider_id(&input), "liteparse");
|
||||
assert_eq!(liteparse.can_parse(&input), ParseCapability::Preferred);
|
||||
assert_eq!(default_parse_provider_id(&input), "lightrag");
|
||||
assert_eq!(liteparse.can_parse(&input), ParseCapability::Unsupported);
|
||||
}
|
||||
|
||||
#[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");
|
||||
async fn liteparse_provider_returns_retired_error() {
|
||||
let root = temp_root("mnote-liteparse-provider");
|
||||
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||||
fs::write(
|
||||
@@ -808,32 +364,6 @@ mod tests {
|
||||
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(),
|
||||
@@ -846,40 +376,15 @@ JSON
|
||||
let output = LiteParseProvider
|
||||
.parse(input)
|
||||
.await
|
||||
.expect("parse liteparse");
|
||||
.expect_err("liteparse retired");
|
||||
|
||||
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()]
|
||||
);
|
||||
assert_eq!(output.code, "liteparse_retired");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_provider_routes_ocr_policy_to_mineru() {
|
||||
fn default_provider_routes_ocr_policy_to_lightrag() {
|
||||
let input = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
@@ -888,11 +393,11 @@ JSON
|
||||
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
||||
mode: ParseProviderMode::Ocr,
|
||||
};
|
||||
assert_eq!(default_parse_provider_id(&input), "mineru");
|
||||
assert_eq!(default_parse_provider_id(&input), "lightrag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_selection_routes_scanned_pdf_or_image_to_mineru() {
|
||||
fn provider_selection_routes_scanned_pdf_or_image_to_lightrag() {
|
||||
let scanned_pdf = ParseInput {
|
||||
root_path: PathBuf::from("/workspace"),
|
||||
root_uri: "file:///workspace".into(),
|
||||
@@ -901,12 +406,15 @@ JSON
|
||||
source_root_relative_path: "Page.assets/scan.pdf".into(),
|
||||
mode: ParseProviderMode::Auto,
|
||||
};
|
||||
assert_eq!(select_parse_provider_id(&scanned_pdf, Some(0.05)), "mineru");
|
||||
assert_eq!(
|
||||
select_parse_provider_id(&scanned_pdf, Some(0.05)),
|
||||
"lightrag"
|
||||
);
|
||||
|
||||
let image = ParseInput {
|
||||
source_root_relative_path: "Page.assets/photo.png".into(),
|
||||
..scanned_pdf
|
||||
};
|
||||
assert_eq!(select_parse_provider_id(&image, None), "mineru");
|
||||
assert_eq!(select_parse_provider_id(&image, None), "lightrag");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::knowledge_rag::{
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn status(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = KnowledgeRagStatusQuery {
|
||||
workspace_id: input.effective_workspace_id().or_else(|| {
|
||||
args.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}),
|
||||
root_uri: input.effective_root_uri().or_else(|| {
|
||||
args.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}),
|
||||
};
|
||||
let Json(payload) = crate::routes::knowledge_rag::status(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Query(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn query(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_query_payload_invalid",
|
||||
format!("资料库问答参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let Json(payload) = crate::routes::knowledge_rag::query_rag(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Json(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(compact_query_result_for_agent(payload))
|
||||
}
|
||||
|
||||
pub async fn open_reference(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_open_reference_payload_invalid",
|
||||
format!("资料库引用打开参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let Json(payload) = crate::routes::knowledge_rag::open_reference(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Json(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
let references = payload
|
||||
.get("references")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(compact_reference_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let citations = references
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.knowledge_rag.agent_query_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"answerGuidance": "Final answers must cite at least one returned citationMarkdown verbatim. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox.",
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
|
||||
"rawStatus": payload.pointer("/raw/status").cloned().unwrap_or(Value::Null),
|
||||
"rawMessage": payload.pointer("/raw/message").cloned().unwrap_or(Value::Null),
|
||||
"rawMetadata": payload.pointer("/raw/metadata").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
let quote = reference
|
||||
.get("quote")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
||||
"quote": quote,
|
||||
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
@@ -12,12 +12,9 @@ pub fn manifest() -> Value {
|
||||
context_resolve_target_tool(),
|
||||
doc_fetch_tool(),
|
||||
doc_find_tool(),
|
||||
evidence_search_tool(),
|
||||
evidence_read_tool(),
|
||||
evidence_open_tool(),
|
||||
index_status_tool(),
|
||||
index_refresh_tool(),
|
||||
index_update_settings_tool(),
|
||||
knowledge_rag_status_tool(),
|
||||
knowledge_rag_query_tool(),
|
||||
knowledge_rag_open_reference_tool(),
|
||||
block_fetch_tool(),
|
||||
doc_plan_update_tool(),
|
||||
block_replace_tool(),
|
||||
@@ -293,6 +290,7 @@ fn doc_find_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_search_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -340,6 +338,7 @@ fn evidence_search_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_read_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -371,6 +370,7 @@ fn evidence_read_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_open_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -391,6 +391,93 @@ fn evidence_open_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_status_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.status",
|
||||
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_query_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("question".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"mode".into(),
|
||||
json!({ "type": "string", "enum": ["local", "global", "hybrid", "naive", "mix", "bypass"], "default": "mix" }),
|
||||
);
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 40 }));
|
||||
map.insert(
|
||||
"chunkTopK".into(),
|
||||
json!({ "type": "integer", "default": 20 }),
|
||||
);
|
||||
map.insert(
|
||||
"includeChunkContent".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"sourcePaths".into(),
|
||||
json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。"
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射后的引用。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_open_reference_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("reference".into(), json!({ "type": "object" }));
|
||||
map.insert("referenceId".into(), json!({ "type": "string" }));
|
||||
map.insert("filePath".into(), json!({ "type": "string" }));
|
||||
map.insert("chunkId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.open_reference",
|
||||
"description": "把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "filePath"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn index_status_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -411,6 +498,7 @@ fn index_status_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn index_refresh_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -431,6 +519,7 @@ fn index_refresh_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn index_update_settings_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -744,7 +833,7 @@ fn page_get_tool() -> Value {
|
||||
fn page_save_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.page.save",
|
||||
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
|
||||
"description": "粗粒度 compat / cloud 兜底:保存当前页面正文;local-first 本地 Markdown 普通编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在用户明确要求整页覆盖/追加且其它工具无法表达时使用",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.write"],
|
||||
"status": "available",
|
||||
@@ -795,7 +884,7 @@ fn available_tool(
|
||||
fn doc_markdown_edit_tool() -> Value {
|
||||
let mut tool = write_tool(
|
||||
"mnote.doc.markdown_edit",
|
||||
"兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。",
|
||||
"compat / remote / cloud fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在远端代理、cloud/compat 或需要 MNote 结构校验时使用。",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod evidence;
|
||||
pub mod index;
|
||||
pub mod knowledge_rag;
|
||||
pub mod manifest;
|
||||
pub mod onlyoffice_live;
|
||||
pub mod page;
|
||||
|
||||
@@ -36,24 +36,22 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
||||
},
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-local-index",
|
||||
title: "本地索引与证据检索",
|
||||
description: "检索本地文档证据,并管理本地索引范围、刷新和删除。",
|
||||
id: "mnote-knowledge-rag",
|
||||
title: "资料库问答",
|
||||
description:
|
||||
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
|
||||
category: "knowledge",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: false,
|
||||
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",
|
||||
"mnote.index.status",
|
||||
"mnote.index.refresh",
|
||||
"mnote.index.update_settings",
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.open_reference",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-local-index/SKILL.md"),
|
||||
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
|
||||
},
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-local-file",
|
||||
@@ -208,7 +206,7 @@ pub fn manifest_capabilities() -> Vec<Value> {
|
||||
|
||||
fn canonical_skill_id(skill_id: &str) -> &str {
|
||||
match skill_id {
|
||||
"mnote-document-evidence" => "mnote-local-index",
|
||||
"mnote-document-evidence" | "mnote-local-index" => "mnote-knowledge-rag",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -364,33 +362,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_local_index_skill_to_agents() {
|
||||
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
|
||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||
assert!(!hermes_skills
|
||||
.iter()
|
||||
.any(|skill| skill["id"] == "mnote-local-index"));
|
||||
let skill = hermes_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-local-index")
|
||||
.expect("hermes should see local index skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
.find(|skill| skill["id"] == "mnote-knowledge-rag")
|
||||
.expect("hermes should see LightRAG skill");
|
||||
assert_eq!(skill["readOnly"], true);
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.evidence.search"));
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.index.update_settings"));
|
||||
.any(|name| name == "mnote.knowledge_rag.query"));
|
||||
assert!(!hermes_skills
|
||||
.iter()
|
||||
.any(|skill| skill["id"] == "mnote-document-evidence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_read_keeps_document_evidence_compat_alias() {
|
||||
fn skill_read_maps_document_evidence_alias_to_lightrag() {
|
||||
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
|
||||
.expect("compat alias should resolve");
|
||||
assert_eq!(skill.id, "mnote-local-index");
|
||||
assert_eq!(skill.id, "mnote-knowledge-rag");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -17,14 +17,23 @@ use serde_json::{json, Value};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub async fn search(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<EvidenceSearchRequest>,
|
||||
Json(_body): Json<EvidenceSearchRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let response = search_payload(&state, &context, body).await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-type", HeaderValue::from_static("application/json"));
|
||||
Ok((StatusCode::OK, headers, Json(response)))
|
||||
Ok((
|
||||
StatusCode::GONE,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": false,
|
||||
"code": "mnote_evidence_search_retired",
|
||||
"message": "旧 LiteParse/evidence 搜索已退役;PDF、Office、图片 OCR 与资料索引统一走 /api/knowledge-rag/query",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn search_payload(
|
||||
@@ -241,7 +250,7 @@ fn suggested_evidence_queries(query: &str) -> Vec<String> {
|
||||
suggestions
|
||||
}
|
||||
|
||||
fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
|
||||
pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
|
||||
let label = citation_label_for_locator(locator);
|
||||
let url = citation_url_for_locator(locator);
|
||||
format!(
|
||||
@@ -280,7 +289,7 @@ fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
|
||||
pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
|
||||
let owner_document_id = locator.owner_document_id.trim();
|
||||
let mut url = if owner_document_id.is_empty() {
|
||||
let existing = locator.open_action.url.trim();
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{
|
||||
artifact, block, context_tools, doc, evidence, index, manifest, onlyoffice_live, page,
|
||||
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
|
||||
resource, skill, ToolCallInput,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
@@ -362,14 +362,27 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
"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.index.status" => index::index_status(&state, &context, &input).await,
|
||||
"mnote.index.refresh" => index::index_refresh(&state, &context, &input).await,
|
||||
"mnote.index.update_settings" => {
|
||||
index::index_update_settings(&state, &context, &input).await
|
||||
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
|
||||
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
|
||||
"mnote.knowledge_rag.open_reference" => {
|
||||
knowledge_rag::open_reference(&state, &context, &input).await
|
||||
}
|
||||
"mnote.index.status" | "mnote.index.refresh" | "mnote.index.update_settings" => Err(
|
||||
WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_index_tools_retired",
|
||||
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
|
||||
)
|
||||
.with_context(&context),
|
||||
),
|
||||
"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,
|
||||
@@ -709,6 +722,9 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.search"
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
| "mnote.index.status"
|
||||
| "mnote.index.refresh"
|
||||
| "mnote.block.fetch"
|
||||
@@ -738,6 +754,9 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.search"
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1604,23 +1623,29 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let manifest = &payload["manifest"];
|
||||
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
|
||||
assert!(capabilities
|
||||
assert!(!capabilities
|
||||
.iter()
|
||||
.any(|capability| capability["id"] == "mnote-local-index"));
|
||||
assert!(capabilities
|
||||
.iter()
|
||||
.any(|capability| capability["id"] == "mnote-knowledge-rag"));
|
||||
assert!(capabilities
|
||||
.iter()
|
||||
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
|
||||
|
||||
let tools = manifest["tools"].as_array().expect("tools");
|
||||
let index_status = tools
|
||||
assert!(!tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.index.status")
|
||||
.expect("index status tool");
|
||||
assert!(index_status["capabilityIds"]
|
||||
.any(|tool| tool["name"] == "mnote.index.status"));
|
||||
let knowledge_rag_query = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.knowledge_rag.query")
|
||||
.expect("knowledge rag query tool");
|
||||
assert!(knowledge_rag_query["capabilityIds"]
|
||||
.as_array()
|
||||
.expect("index capability ids")
|
||||
.expect("knowledge rag capability ids")
|
||||
.iter()
|
||||
.any(|id| id == "mnote-local-index"));
|
||||
.any(|id| id == "mnote-knowledge-rag"));
|
||||
|
||||
let onlyoffice_batch_set = tools
|
||||
.iter()
|
||||
@@ -5573,7 +5598,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_local_index_update_and_status_manage_scope() {
|
||||
async fn hermes_tools_local_index_tools_are_retired() {
|
||||
let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
@@ -5583,11 +5608,7 @@ mod tests {
|
||||
r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("docs").join("indexed.md"),
|
||||
"# Indexed\n\nindex-tool-token\n",
|
||||
)
|
||||
.expect("markdown");
|
||||
fs::write(root.join("docs").join("indexed.md"), "# Indexed\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let app = app();
|
||||
|
||||
@@ -5625,18 +5646,16 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("update response");
|
||||
assert_eq!(update_response.status(), StatusCode::OK);
|
||||
assert_eq!(update_response.status(), StatusCode::GONE);
|
||||
let update_body = to_bytes(update_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("update body");
|
||||
let update_payload: Value = serde_json::from_slice(&update_body).expect("update json");
|
||||
assert_eq!(
|
||||
update_payload["result"]["settings"]["includePaths"][0],
|
||||
"docs"
|
||||
update_payload["code"].as_str(),
|
||||
Some("mnote_index_tools_retired")
|
||||
);
|
||||
assert_eq!(update_payload["result"]["index"]["documentCount"], 1);
|
||||
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||
assert!(!root.join(".mnote/index/search-index.json").exists());
|
||||
|
||||
let status_response = app
|
||||
.oneshot(
|
||||
@@ -5664,16 +5683,15 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("status response");
|
||||
assert_eq!(status_response.status(), StatusCode::OK);
|
||||
assert_eq!(status_response.status(), StatusCode::GONE);
|
||||
let status_body = to_bytes(status_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("status body");
|
||||
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
|
||||
assert_eq!(
|
||||
status_payload["result"]["result"]["settings"]["includePaths"][0],
|
||||
"docs"
|
||||
status_payload["code"].as_str(),
|
||||
Some("mnote_index_tools_retired")
|
||||
);
|
||||
assert_eq!(status_payload["audit"]["effect"], "read");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use crate::routes::{
|
||||
knowledge_rag,
|
||||
local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
},
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, Sse};
|
||||
@@ -230,12 +233,19 @@ async fn build_tree_live_stream(
|
||||
})?;
|
||||
|
||||
let stream = stream::unfold(
|
||||
(Some(initial_payload), subscription, root_uri, workspace_id),
|
||||
|(payload, mut subscription, root_uri, workspace_id)| async move {
|
||||
(
|
||||
Some(initial_payload),
|
||||
subscription,
|
||||
root_uri,
|
||||
workspace_id,
|
||||
state,
|
||||
context,
|
||||
),
|
||||
|(payload, mut subscription, root_uri, workspace_id, state, context)| async move {
|
||||
if let Some(payload) = payload {
|
||||
return Some((
|
||||
Ok(stream_event("snapshot", &payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
(None, subscription, root_uri, workspace_id, state, context),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -253,6 +263,13 @@ async fn build_tree_live_stream(
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = knowledge_rag::sync_registry_for_root(
|
||||
&state,
|
||||
&context,
|
||||
&root_uri,
|
||||
Some(&workspace_id),
|
||||
)
|
||||
.await;
|
||||
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
@@ -260,7 +277,7 @@ async fn build_tree_live_stream(
|
||||
) {
|
||||
return Some((
|
||||
Ok(stream_event("watch_batch", &batch_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
(None, subscription, root_uri, workspace_id, state, context),
|
||||
));
|
||||
}
|
||||
let error_payload = build_tree_live_error_payload(
|
||||
@@ -271,7 +288,7 @@ async fn build_tree_live_stream(
|
||||
);
|
||||
return Some((
|
||||
Ok(stream_event("tree_error", &error_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
(None, subscription, root_uri, workspace_id, state, context),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::routes::local_markdown_parser::{
|
||||
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use crate::routes::{local_ocr, local_search_index};
|
||||
use crate::routes::{knowledge_rag, local_ocr, local_search_index};
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -307,14 +307,16 @@ struct LocalFolderScanResult {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct LocalFileTreeIndexState {
|
||||
indexed_paths: BTreeSet<String>,
|
||||
indexing_paths: BTreeSet<String>,
|
||||
failed_paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl LocalFileTreeIndexState {
|
||||
fn load(root: &Path) -> Self {
|
||||
local_search_index::local_evidence_source_statuses(root)
|
||||
fn load(root: &Path, workspace_id: &str, root_uri: &str) -> Self {
|
||||
knowledge_rag::knowledge_rag_source_statuses(root, workspace_id, root_uri)
|
||||
.map(|statuses| Self {
|
||||
indexed_paths: statuses.indexed_paths,
|
||||
indexing_paths: statuses.indexing_paths,
|
||||
failed_paths: statuses.failed_paths,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -334,6 +336,9 @@ impl LocalFileTreeIndexState {
|
||||
if self.indexed_paths.contains(path) {
|
||||
return Some("indexed".to_string());
|
||||
}
|
||||
if self.indexing_paths.contains(path) {
|
||||
return Some("indexing".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2757,8 @@ fn load_local_folder_file_tree_scope_snapshot(
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
let root_source_uri = file_uri_for_path(&canonical_root);
|
||||
let metadata = load_local_folder_metadata(&canonical_root)?;
|
||||
let index_state = LocalFileTreeIndexState::load(&canonical_root);
|
||||
let index_state =
|
||||
LocalFileTreeIndexState::load(&canonical_root, &workspace_id, &root_source_uri);
|
||||
let parent_relative_path = parent_relative_path
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != ".")
|
||||
@@ -13646,84 +13652,118 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_marks_indexed_and_failed_source_files() {
|
||||
let root = temp_root("mnote-local-filetree-index-status");
|
||||
fn local_file_tree_marks_lightrag_indexed_indexing_and_failed_source_files() {
|
||||
let root = temp_root("mnote-lightrag-filetree-index-status");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("ok.pdf"), b"%PDF-1.4\nok").expect("ok pdf");
|
||||
std::fs::write(root.join("pending.pdf"), b"%PDF-1.4\npending").expect("pending pdf");
|
||||
std::fs::write(root.join("failed.pdf"), b"%PDF-1.4\nfailed").expect("failed pdf");
|
||||
std::fs::write(root.join("deleting.pdf"), b"%PDF-1.4\ndeleting").expect("deleting pdf");
|
||||
std::fs::write(root.join("removed.pdf"), b"%PDF-1.4\nremoved").expect("removed pdf");
|
||||
std::fs::write(root.join("draft.md"), "# Draft\n").expect("draft");
|
||||
|
||||
let index_dir = root.join(".mnote").join("index");
|
||||
std::fs::create_dir_all(&index_dir).expect("index dir");
|
||||
let evidence_path = index_dir.join("evidence.sqlite");
|
||||
let connection = rusqlite::Connection::open(&evidence_path).expect("evidence sqlite");
|
||||
connection
|
||||
.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE evidence_resource(
|
||||
resource_id TEXT PRIMARY KEY,
|
||||
owner_document_id TEXT NOT NULL,
|
||||
owner_document_path TEXT NOT NULL,
|
||||
source_root_relative_path TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
artifact_root_relative_path TEXT NOT NULL,
|
||||
source_map_root_relative_path TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.expect("evidence schema");
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
"local-resource:ok.pdf#parse",
|
||||
"local-resource:ok.pdf",
|
||||
"ok.pdf",
|
||||
"ok.pdf",
|
||||
"liteparse",
|
||||
"hash",
|
||||
"ok.ocr/ok.pdf.parse.md",
|
||||
"ok.ocr/ok.pdf.source-map.json",
|
||||
1_i64,
|
||||
],
|
||||
)
|
||||
.expect("insert indexed evidence");
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
"local-md:draft.md",
|
||||
"local-md:draft.md",
|
||||
"draft.md",
|
||||
"draft.md",
|
||||
"markdown",
|
||||
"hash",
|
||||
"draft.md",
|
||||
"draft.md.source-map.json",
|
||||
1_i64,
|
||||
],
|
||||
)
|
||||
.expect("insert markdown evidence");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let search_index = json!({
|
||||
"version": 1,
|
||||
"builtAt": 1,
|
||||
let workspace_id = local_workspace_id(&root);
|
||||
let registry = json!({
|
||||
"schema": "mnote.knowledge_rag.source_registry.v1",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": "local-filetree-index-status",
|
||||
"indexedPaths": ["."],
|
||||
"documents": [],
|
||||
"resources": [
|
||||
{"resourceId": "local-resource:ok.pdf", "resourceType": "pdf", "title": "ok", "path": "ok.pdf", "updatedAt": 1},
|
||||
{"resourceId": "local-resource:failed.pdf", "resourceType": "pdf", "title": "failed", "path": "failed.pdf", "updatedAt": 1}
|
||||
"updatedAtMs": 1,
|
||||
"entries": [
|
||||
{
|
||||
"sourceId": "lightrag-source-ok",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"sourcePath": root.join("ok.pdf").display().to_string(),
|
||||
"sourceRootRelativePath": "ok.pdf",
|
||||
"sourceHash": "hash-ok",
|
||||
"lightRagDocId": "doc-ok",
|
||||
"lightRagStatus": "processed",
|
||||
"lightRagFilePath": "ok.pdf",
|
||||
"symlinkPath": "/tmp/ok.pdf",
|
||||
"parserHint": null,
|
||||
"indexedAtMs": 2,
|
||||
"deletedAtMs": null,
|
||||
"stale": false,
|
||||
"updatedAtMs": 2
|
||||
},
|
||||
{
|
||||
"sourceId": "lightrag-source-pending",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"sourcePath": root.join("pending.pdf").display().to_string(),
|
||||
"sourceRootRelativePath": "pending.pdf",
|
||||
"sourceHash": "hash-pending",
|
||||
"lightRagDocId": null,
|
||||
"lightRagStatus": "submitted",
|
||||
"lightRagFilePath": "pending.pdf",
|
||||
"symlinkPath": "/tmp/pending.pdf",
|
||||
"parserHint": null,
|
||||
"indexedAtMs": null,
|
||||
"deletedAtMs": null,
|
||||
"stale": false,
|
||||
"updatedAtMs": 2
|
||||
},
|
||||
{
|
||||
"sourceId": "lightrag-source-failed",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"sourcePath": root.join("failed.pdf").display().to_string(),
|
||||
"sourceRootRelativePath": "failed.pdf",
|
||||
"sourceHash": "hash-failed",
|
||||
"lightRagDocId": null,
|
||||
"lightRagStatus": "failed",
|
||||
"lightRagFilePath": "failed.pdf",
|
||||
"symlinkPath": "/tmp/failed.pdf",
|
||||
"parserHint": null,
|
||||
"indexedAtMs": null,
|
||||
"deletedAtMs": 3,
|
||||
"stale": true,
|
||||
"updatedAtMs": 3
|
||||
},
|
||||
{
|
||||
"sourceId": "lightrag-source-deleting",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"sourcePath": root.join("deleting.pdf").display().to_string(),
|
||||
"sourceRootRelativePath": "deleting.pdf",
|
||||
"sourceHash": "hash-deleting",
|
||||
"lightRagDocId": "doc-deleting",
|
||||
"lightRagStatus": "delete_submitted",
|
||||
"lightRagFilePath": "deleting.pdf",
|
||||
"symlinkPath": "/tmp/deleting.pdf",
|
||||
"parserHint": null,
|
||||
"indexedAtMs": 2,
|
||||
"deletedAtMs": 3,
|
||||
"stale": true,
|
||||
"updatedAtMs": 3
|
||||
},
|
||||
{
|
||||
"sourceId": "lightrag-source-removed",
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"sourcePath": root.join("removed.pdf").display().to_string(),
|
||||
"sourceRootRelativePath": "removed.pdf",
|
||||
"sourceHash": "hash-removed",
|
||||
"lightRagDocId": null,
|
||||
"lightRagStatus": "delete_completed",
|
||||
"lightRagFilePath": "removed.pdf",
|
||||
"symlinkPath": "/tmp/removed.pdf",
|
||||
"parserHint": null,
|
||||
"indexedAtMs": null,
|
||||
"deletedAtMs": 3,
|
||||
"stale": true,
|
||||
"updatedAtMs": 3
|
||||
}
|
||||
]
|
||||
});
|
||||
std::fs::write(
|
||||
index_dir.join("search-index.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(&search_index).unwrap()),
|
||||
index_dir.join("lightrag-source-registry.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(®istry).unwrap()),
|
||||
)
|
||||
.expect("search index");
|
||||
.expect("lightrag registry");
|
||||
|
||||
let file_tree = load_local_folder_file_tree_snapshot(&format!("file://{}", root.display()))
|
||||
.expect("file tree");
|
||||
@@ -13737,7 +13777,10 @@ CREATE TABLE evidence_resource(
|
||||
.and_then(|item| item["indexStatus"].as_str())
|
||||
};
|
||||
assert_eq!(status_for("ok.pdf"), Some("indexed"));
|
||||
assert_eq!(status_for("pending.pdf"), Some("indexing"));
|
||||
assert_eq!(status_for("failed.pdf"), Some("failed"));
|
||||
assert_eq!(status_for("deleting.pdf"), Some("indexing"));
|
||||
assert_eq!(status_for("removed.pdf"), None);
|
||||
assert_eq!(status_for("draft.md"), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -2131,6 +2131,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_writes_mock_sidecar_and_reads_status() {
|
||||
let root = temp_root("mnote-local-ocr-route");
|
||||
write_workspace_manifest(&root);
|
||||
@@ -2255,6 +2256,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
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);
|
||||
@@ -2331,6 +2333,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
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);
|
||||
@@ -2405,6 +2408,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
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();
|
||||
@@ -2484,6 +2488,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
|
||||
let root = temp_root("mnote-local-ocr-events");
|
||||
write_workspace_manifest(&root);
|
||||
@@ -2548,6 +2553,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
|
||||
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
|
||||
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
|
||||
@@ -2584,6 +2590,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_runs_mineru_runtime_against_http_mock() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -2832,6 +2839,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_rejects_root_escape_source() {
|
||||
let root = temp_root("mnote-local-ocr-escape");
|
||||
write_workspace_manifest(&root);
|
||||
@@ -2853,6 +2861,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_rejects_non_image_pdf_source() {
|
||||
let root = temp_root("mnote-local-ocr-unsupported");
|
||||
write_workspace_manifest(&root);
|
||||
@@ -2882,6 +2891,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_jobs_route_writes_failed_mock_entry_with_redacted_error() {
|
||||
let root = temp_root("mnote-local-ocr-failed");
|
||||
write_workspace_manifest(&root);
|
||||
@@ -2938,6 +2948,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
|
||||
async fn local_ocr_insert_route_appends_explicit_ocr_link() {
|
||||
let root = temp_root("mnote-local-ocr-insert");
|
||||
write_workspace_manifest(&root);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use crate::error::WebError;
|
||||
use crate::evidence_parse::{
|
||||
ParseInput, ParseProviderMode, liteparse_runtime_available, parse_liteparse_input_blocking,
|
||||
};
|
||||
use crate::routes::local_folder_source::encode_local_id_segment;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
@@ -9,12 +6,12 @@ use crate::routes::local_markdown_parser::{
|
||||
use crate::routes::local_ocr;
|
||||
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult,
|
||||
PARSED_RESOURCE_ARTIFACT_SCHEMA, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
|
||||
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
|
||||
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -183,49 +180,7 @@ pub(crate) fn query_local_search_index_with_settings(
|
||||
}
|
||||
}
|
||||
}
|
||||
if include_ocr && results.len() < limit.max(1) as usize {
|
||||
for entry in local_ocr::ocr_index_entries(root_path)? {
|
||||
if !index_relative_path_is_included(
|
||||
&entry.source_root_relative_path,
|
||||
&result_settings.include_paths,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Some(page_id) = page_id {
|
||||
if let Some(resource_path) = page_resource_path.as_deref() {
|
||||
if entry.source_root_relative_path != resource_path {
|
||||
continue;
|
||||
}
|
||||
} else if entry.owner_document_id != page_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entry.status != "done" {
|
||||
continue;
|
||||
}
|
||||
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
|
||||
let markdown = match fs::read_to_string(&ocr_path) {
|
||||
Ok(markdown) => markdown,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
|
||||
continue;
|
||||
}
|
||||
let body = local_ocr::strip_ocr_frontmatter(&markdown);
|
||||
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
|
||||
continue;
|
||||
}
|
||||
results.push(local_search_ocr_projection(
|
||||
&entry,
|
||||
body,
|
||||
root_uri,
|
||||
&normalized_query,
|
||||
));
|
||||
if results.len() >= limit.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = include_ocr;
|
||||
Ok(json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
@@ -2244,14 +2199,6 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
|
||||
for resource in &index.resources {
|
||||
insert_resource_evidence(&tx, root_path, index, resource)?;
|
||||
}
|
||||
for entry in local_ocr::ocr_index_entries(root_path)?
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
index_relative_path_is_included(&entry.source_root_relative_path, &index.indexed_paths)
|
||||
})
|
||||
{
|
||||
insert_ocr_evidence(&tx, root_path, index, &entry)?;
|
||||
}
|
||||
for document in &index.documents {
|
||||
insert_document_graph_edges(&tx, document)?;
|
||||
}
|
||||
@@ -2373,15 +2320,6 @@ fn refresh_evidence_sqlite_index_for_path(
|
||||
{
|
||||
insert_resource_evidence(&tx, root_path, index, resource)?;
|
||||
}
|
||||
for entry in local_ocr::ocr_index_entries(root_path)?
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.ocr_root_relative_path == relative_path
|
||||
|| entry.source_root_relative_path == relative_path
|
||||
})
|
||||
{
|
||||
insert_ocr_evidence(&tx, root_path, index, &entry)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('schema_version', ?1)",
|
||||
params![EVIDENCE_SQLITE_SCHEMA_VERSION.to_string()],
|
||||
@@ -2664,147 +2602,18 @@ fn insert_resource_evidence(
|
||||
}
|
||||
|
||||
fn parse_resource_evidence_artifact(
|
||||
root_path: &Path,
|
||||
index: &LocalSearchIndex,
|
||||
resource: &LocalSearchResource,
|
||||
_root_path: &Path,
|
||||
_index: &LocalSearchIndex,
|
||||
_resource: &LocalSearchResource,
|
||||
) -> Result<Option<crate::evidence_parse::ParseProviderOutput>, WebError> {
|
||||
if !resource_parse_supported(resource) || !liteparse_runtime_available() {
|
||||
return Ok(None);
|
||||
}
|
||||
let owner = evidence_owner_for_resource(index, resource);
|
||||
let mut output = match parse_liteparse_input_blocking(ParseInput {
|
||||
root_path: root_path.to_path_buf(),
|
||||
root_uri: index.root_uri.clone(),
|
||||
owner_document_id: owner.document_id.clone(),
|
||||
owner_document_path: owner.path.clone(),
|
||||
source_root_relative_path: resource.path.clone(),
|
||||
mode: ParseProviderMode::NoOcr,
|
||||
}) {
|
||||
Ok(output) => output,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let sidecar = resource_parse_sidecar_paths(&owner.path, &resource.path);
|
||||
write_parsed_resource_sidecars(root_path, &sidecar, &output.markdown, &output.source_map)?;
|
||||
output.artifact.artifact_root_relative_path = sidecar.parse_root_relative_path;
|
||||
output.artifact.source_map_root_relative_path = sidecar.source_map_root_relative_path.clone();
|
||||
output.source_map.owner_document_path = owner.path;
|
||||
Ok(Some(output))
|
||||
}
|
||||
|
||||
fn resource_parse_supported(resource: &LocalSearchResource) -> bool {
|
||||
matches!(resource.resource_type.as_str(), "pdf" | "office")
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
|
||||
format!("{}#parse", resource.resource_id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EvidenceResourceOwner {
|
||||
document_id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn evidence_owner_for_resource(
|
||||
index: &LocalSearchIndex,
|
||||
resource: &LocalSearchResource,
|
||||
) -> EvidenceResourceOwner {
|
||||
if let Some(document) = index.documents.iter().find(|document| {
|
||||
document.resource_refs.iter().any(|reference| {
|
||||
normalize_local_reference_path(&document.path, reference) == resource.path
|
||||
})
|
||||
}) {
|
||||
return EvidenceResourceOwner {
|
||||
document_id: document.document_id.clone(),
|
||||
path: document.path.clone(),
|
||||
};
|
||||
}
|
||||
EvidenceResourceOwner {
|
||||
document_id: resource.resource_id.clone(),
|
||||
path: resource.path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResourceParseSidecarPaths {
|
||||
parse_root_relative_path: String,
|
||||
source_map_root_relative_path: String,
|
||||
}
|
||||
|
||||
fn resource_parse_sidecar_paths(
|
||||
owner_document_path: &str,
|
||||
source_root_relative_path: &str,
|
||||
) -> ResourceParseSidecarPaths {
|
||||
let owner_parent = Path::new(owner_document_path)
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let owner_stem = Path::new(owner_document_path)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("Page");
|
||||
let source_leaf = Path::new(source_root_relative_path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("source");
|
||||
let sidecar_dir = owner_parent.join(format!("{owner_stem}.ocr"));
|
||||
let parse_root_relative_path = sidecar_dir
|
||||
.join(format!("{source_leaf}.parse.md"))
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let source_map_root_relative_path = sidecar_dir
|
||||
.join(format!("{source_leaf}.source-map.json"))
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
ResourceParseSidecarPaths {
|
||||
parse_root_relative_path,
|
||||
source_map_root_relative_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_parsed_resource_sidecars(
|
||||
root_path: &Path,
|
||||
sidecar: &ResourceParseSidecarPaths,
|
||||
markdown: &str,
|
||||
source_map: &ResourceSourceMap,
|
||||
) -> Result<(), WebError> {
|
||||
let parse_path = root_path.join(&sidecar.parse_root_relative_path);
|
||||
let source_map_path = root_path.join(&sidecar.source_map_root_relative_path);
|
||||
for path in [&parse_path, &source_map_path] {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"evidence_parse_sidecar_create_failed",
|
||||
format!(
|
||||
"无法创建证据解析 sidecar 目录 {}: {error}",
|
||||
parent.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
fs::write(&parse_path, markdown.trim_end()).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"evidence_parse_sidecar_write_failed",
|
||||
format!(
|
||||
"无法写入证据解析 Markdown {}: {error}",
|
||||
parse_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let source_map_json = serde_json::to_string_pretty(source_map)
|
||||
.map_err(|error| WebError::internal(format!("证据 source-map 序列化失败: {error}")))?;
|
||||
fs::write(&source_map_path, format!("{source_map_json}\n")).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"evidence_parse_sidecar_write_failed",
|
||||
format!(
|
||||
"无法写入证据 source-map {}: {error}",
|
||||
source_map_path.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert_ocr_evidence(
|
||||
connection: &Connection,
|
||||
root_path: &Path,
|
||||
@@ -2863,6 +2672,7 @@ fn insert_ocr_evidence(
|
||||
insert_evidence_block(connection, &resource_id, &resource_id, body, locator)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn ocr_parsed_artifact(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
source_map_root_relative_path: &str,
|
||||
@@ -3461,12 +3271,14 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct LocalEvidenceSourceStatuses {
|
||||
pub(crate) indexed_paths: BTreeSet<String>,
|
||||
pub(crate) failed_paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn local_evidence_source_statuses(
|
||||
root_path: &Path,
|
||||
) -> Result<LocalEvidenceSourceStatuses, WebError> {
|
||||
@@ -3509,21 +3321,6 @@ pub(crate) fn local_evidence_source_statuses(
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry in local_ocr::ocr_index_entries(root_path)? {
|
||||
match entry.status.as_str() {
|
||||
"done" => {
|
||||
statuses
|
||||
.indexed_paths
|
||||
.insert(entry.source_root_relative_path);
|
||||
}
|
||||
"failed" | "interrupted" => {
|
||||
statuses
|
||||
.failed_paths
|
||||
.insert(entry.source_root_relative_path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
@@ -3563,6 +3360,7 @@ fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn path_source_map_path(path: &str) -> Option<String> {
|
||||
if let Some(stripped) = path.strip_suffix(".ocr.md") {
|
||||
return Some(format!("{stripped}.source-map.json"));
|
||||
@@ -3681,6 +3479,7 @@ fn local_search_resource_matches(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn local_search_ocr_matches(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
@@ -3758,6 +3557,7 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn local_search_ocr_projection(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
@@ -4375,55 +4175,41 @@ mod tests {
|
||||
.iter()
|
||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
.expect("home result");
|
||||
assert!(
|
||||
home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
assert!(
|
||||
home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
))
|
||||
);
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||
assert!(home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
)));
|
||||
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
|
||||
let connection = Connection::open(&evidence_db).expect("open evidence sqlite");
|
||||
let markdown_edge_count: i64 = connection
|
||||
@@ -4479,12 +4265,11 @@ mod tests {
|
||||
Some("local-mdid:child-page")
|
||||
);
|
||||
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
let mindmap_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -4497,19 +4282,19 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("mindmap projection");
|
||||
assert!(
|
||||
mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"].as_str().is_some_and(|path| path
|
||||
assert!(mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path
|
||||
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?")))
|
||||
);
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?"))));
|
||||
let office_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -4522,14 +4307,12 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("office projection");
|
||||
assert!(
|
||||
office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
assert!(office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx")));
|
||||
let pdf_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -4542,14 +4325,12 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("pdf projection");
|
||||
assert!(
|
||||
pdf_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("pdf")
|
||||
&& item["path"].as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(pdf_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("pdf")
|
||||
&& item["path"].as_str() == Some("assets/spec.pdf")));
|
||||
let image_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -4562,14 +4343,12 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("image projection");
|
||||
assert!(
|
||||
image_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("image")
|
||||
&& item["path"].as_str() == Some("assets/diagram.png"))
|
||||
);
|
||||
assert!(image_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("image")
|
||||
&& item["path"].as_str() == Some("assets/diagram.png")));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -5194,12 +4973,10 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
let child = index
|
||||
.documents
|
||||
.iter()
|
||||
@@ -5227,18 +5004,14 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(
|
||||
!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md"));
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
let removed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10)
|
||||
.expect("removed evidence")
|
||||
.expect("sqlite exists");
|
||||
@@ -5251,7 +5024,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
|
||||
fn local_search_ocr_sidecar_is_hidden_after_lightrag_retirement() {
|
||||
let root = temp_root("mnote-local-search-ocr");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-ocr";
|
||||
@@ -5344,38 +5117,18 @@ mod tests {
|
||||
true,
|
||||
)
|
||||
.expect("with ocr");
|
||||
let result = with_ocr["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("ocr result");
|
||||
assert_eq!(
|
||||
result["documentId"].as_str(),
|
||||
Some("local-md:docs~2FPage.md")
|
||||
);
|
||||
assert_eq!(result["hasOcr"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/Page.assets/photo.png")
|
||||
);
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
|
||||
Some("docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
assert_eq!(with_ocr["results"].as_array().map(Vec::len), Some(0));
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read search index")
|
||||
.expect("search index");
|
||||
assert!(
|
||||
!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
assert!(
|
||||
!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
|
||||
);
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
|
||||
refresh_local_search_index_for_path(
|
||||
&root,
|
||||
&root_uri,
|
||||
@@ -5386,12 +5139,10 @@ mod tests {
|
||||
let refreshed_index = read_local_search_index(&root)
|
||||
.expect("read refreshed search index")
|
||||
.expect("refreshed search index");
|
||||
assert!(
|
||||
!refreshed_index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
|
||||
);
|
||||
assert!(!refreshed_index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -5492,21 +5243,15 @@ mod tests {
|
||||
.expect("sqlite query")
|
||||
.expect("sqlite exists");
|
||||
assert!(results.len() >= 2);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "README.md")
|
||||
);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|result| result.source.schema == "mnote.evidence_locator.v1")
|
||||
);
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "README.md"));
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "docs/child.md"));
|
||||
assert!(results
|
||||
.iter()
|
||||
.all(|result| result.source.schema == "mnote.evidence_locator.v1"));
|
||||
let home_result = results
|
||||
.iter()
|
||||
.find(|result| result.source.owner_document_path == "README.md")
|
||||
@@ -5664,16 +5409,12 @@ mod tests {
|
||||
.expect("sqlite exists");
|
||||
|
||||
assert_eq!(context.len(), 2);
|
||||
assert!(
|
||||
context
|
||||
.iter()
|
||||
.all(|item| item.source.owner_document_path == "README.md")
|
||||
);
|
||||
assert!(
|
||||
context
|
||||
.iter()
|
||||
.any(|item| item.quote.contains("ReadContextToken"))
|
||||
);
|
||||
assert!(context
|
||||
.iter()
|
||||
.all(|item| item.source.owner_document_path == "README.md"));
|
||||
assert!(context
|
||||
.iter()
|
||||
.any(|item| item.quote.contains("ReadContextToken")));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -5771,18 +5512,16 @@ JSON
|
||||
Some("docs/Page.assets/spec.pdf"),
|
||||
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
|
||||
);
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.parse.md")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.source-map.json")
|
||||
.exists()
|
||||
);
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.parse.md")
|
||||
.exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.source-map.json")
|
||||
.exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ mod hermes;
|
||||
mod hermes_client;
|
||||
mod hermes_tools;
|
||||
mod kernel;
|
||||
pub(crate) mod knowledge_rag;
|
||||
mod local_folder_events;
|
||||
mod local_folder_source;
|
||||
mod local_markdown_parser;
|
||||
#[allow(dead_code)]
|
||||
mod local_ocr;
|
||||
mod local_search_index;
|
||||
mod media;
|
||||
@@ -81,6 +83,21 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/evidence/search", post(evidence::search))
|
||||
.route("/api/evidence/read", post(evidence::read))
|
||||
.route("/api/evidence/open", post(evidence::open))
|
||||
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
|
||||
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
|
||||
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
|
||||
.route(
|
||||
"/api/knowledge-rag/open-reference",
|
||||
post(knowledge_rag::open_reference),
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/delete-source",
|
||||
post(knowledge_rag::delete_source),
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/prune-registry",
|
||||
post(knowledge_rag::prune_registry),
|
||||
)
|
||||
.route(
|
||||
"/mindmap/{doc_id}/{mindmap_id}",
|
||||
get(mindmap_shell::mindmap_object_shell),
|
||||
@@ -558,14 +575,24 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/jobs",
|
||||
get(local_ocr::list_jobs)
|
||||
.post(local_ocr::create_job)
|
||||
.delete(local_ocr::delete_job),
|
||||
any(knowledge_rag::retired_local_ocr_endpoint),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/status",
|
||||
any(knowledge_rag::retired_local_ocr_endpoint),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/read",
|
||||
any(knowledge_rag::retired_local_ocr_endpoint),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/insert",
|
||||
any(knowledge_rag::retired_local_ocr_endpoint),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/delete",
|
||||
any(knowledge_rag::retired_local_ocr_endpoint),
|
||||
)
|
||||
.route("/api/local-folder/ocr/status", get(local_ocr::status))
|
||||
.route("/api/local-folder/ocr/read", get(local_ocr::read))
|
||||
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
|
||||
.route("/api/local-folder/ocr/delete", post(local_ocr::delete_job))
|
||||
.route(
|
||||
"/api/local-folder/workspaces/default",
|
||||
post(local_folder_source::create_default_local_workspace),
|
||||
|
||||
@@ -669,7 +669,9 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| *value == "indexed" || *value == "failed")
|
||||
.filter(|value| {
|
||||
*value == "indexed" || *value == "indexing" || *value == "failed"
|
||||
})
|
||||
.map(ToOwned::to_owned),
|
||||
selected,
|
||||
})
|
||||
|
||||
@@ -188,8 +188,7 @@ 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="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="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" 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>
|
||||
@@ -201,7 +200,6 @@ pub fn PageLayout(
|
||||
{children()}
|
||||
</article>
|
||||
<div class="wolai-floating-actions" aria-label="浮动操作">
|
||||
<button type="button" data-testid="mnote-floating-task-toggle" class="wolai-floating-button wolai-floating-button--tasks mnote-local-ocr-task-toggle" title="后台任务" aria-label="后台任务" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="pending_actions" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,61 +487,55 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_exposes_standalone_index_and_ocr_settings() {
|
||||
fn page_layout_exposes_lightrag_knowledge_rag_settings_only() {
|
||||
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""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-floating-task-toggle""#));
|
||||
assert!(html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
|
||||
assert!(html.contains(r#"data-icon="pending_actions""#));
|
||||
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-testid="mnote-knowledge-rag-settings-toggle""#));
|
||||
assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
|
||||
assert!(html.contains(r#"data-icon="travel_explore""#));
|
||||
assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
|
||||
assert!(!html.contains(r#"data-mnote-action="open-ocr-settings""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-floating-task-toggle""#));
|
||||
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
|
||||
}
|
||||
|
||||
#[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')"));
|
||||
fn sidebar_settings_runtime_routes_index_and_ocr_to_lightrag_settings() {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/status?"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/ingest"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/prune-registry"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("use-filetree-selection"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-action=\"prune\""));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-source-filters"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("filter-sources"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("LightRAG 未映射"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_completed"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("publicKnowledgeRagDashboardUrl"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("label.hidden = status === 'idle'"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
|
||||
"renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat([''])"
|
||||
),
|
||||
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-persisted=\"true\""),
|
||||
"已保存索引范围必须锁定为不可直接修改"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||
.contains("return currentLocalIndexRangeValues(popover).map"),
|
||||
"删除最后一个索引范围后保存应提交空数组,不能强制回填 ."
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||
.contains("var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];"),
|
||||
"索引面板默认不应把空配置回填成工作区根目录"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
|
||||
"var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;"
|
||||
),
|
||||
"无用户配置但已有索引缓存时仍应展示旧索引范围,便于删除"
|
||||
);
|
||||
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_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-ocr-tasks\"]"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("detail: { action: 'tasks' }"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("deleteKnowledgeRagSource"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pruneKnowledgeRagRegistry"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("setKnowledgeRagSourceFilter"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:knowledge-rag-source-updated"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openPageIndexSettingsPopover();"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOcrSettingsPopover();"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
|
||||
assert!(
|
||||
|
||||
@@ -184,6 +184,7 @@ a:hover {
|
||||
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="travel_explore"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='11' cy='11' r='7' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M4 11h14M11 4a10 10 0 0 1 0 14M11 4a10 10 0 0 0 0 14M16.5 16.5 21 21' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M7.5 8.5h2.5l1 1.5 2.5-.5 1 2-2 1 .5 2.5h-3l-1.5-2-2 .5' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="article"] { --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 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
@@ -1747,6 +1748,7 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after,
|
||||
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after,
|
||||
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
@@ -1767,6 +1769,11 @@ body {
|
||||
background: #38B86E;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after {
|
||||
content: "…";
|
||||
background: #D99A2B;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
|
||||
content: "x";
|
||||
background: #D94841;
|
||||
@@ -3867,6 +3874,167 @@ body {
|
||||
width: min(386px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-settings-panel {
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 72px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-meta div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
color: #8B8782;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-meta code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #1B1C1C;
|
||||
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-list,
|
||||
.mnote-knowledge-rag-source-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-control-group .wolai-page-settings-index-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-filters button {
|
||||
border: 1px solid #E3E1DE;
|
||||
border-radius: 6px;
|
||||
background: #FFFFFF;
|
||||
color: #5F5A54;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-filters button[data-active="true"] {
|
||||
border-color: #B8DDBB;
|
||||
background: #EAF7EA;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-filters button span {
|
||||
color: #8B8782;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-row,
|
||||
.mnote-knowledge-rag-source-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-input-row {
|
||||
grid-template-columns: minmax(0, 1fr) 56px 28px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-input-status {
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-input-status[data-status="done"] {
|
||||
color: #15803D;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-input-status[data-status="running"],
|
||||
.mnote-knowledge-rag-input-status[data-status="retry"] {
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-input-status[data-status="failed"] {
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-main strong {
|
||||
overflow: hidden;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-main span {
|
||||
overflow: hidden;
|
||||
color: #8B8782;
|
||||
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-actions button {
|
||||
min-width: 0;
|
||||
border: 1px solid #E3E1DE;
|
||||
border-radius: 6px;
|
||||
background: #FFFFFF;
|
||||
color: #5F5A54;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-actions button:hover:not(:disabled) {
|
||||
background: #F4F3F3;
|
||||
color: #1B1C1C;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.wolai-page-settings-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
Reference in New Issue
Block a user