Improve local evidence search and AI capabilities
This commit is contained in:
@@ -7,10 +7,10 @@ use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use core_protocol::{
|
||||
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
||||
EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse,
|
||||
EvidenceSearchResult, ResourceSourceMap, SourceMapBlock, SourceMapTextItem,
|
||||
EVIDENCE_LOCATOR_SCHEMA,
|
||||
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceRange,
|
||||
EvidenceReadRequest, EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest,
|
||||
EvidenceSearchResponse, EvidenceSearchResult, ResourceSourceMap, SourceMapBlock,
|
||||
SourceMapTextItem, EVIDENCE_LOCATOR_SCHEMA,
|
||||
};
|
||||
use serde_json::Map;
|
||||
use serde_json::{json, Value};
|
||||
@@ -47,18 +47,31 @@ pub(crate) async fn search_payload(
|
||||
)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let page_id = body.scope.target_document_id.as_deref();
|
||||
let evidence_owner_filter = if body.scope.include_resources || body.scope.include_ocr {
|
||||
None
|
||||
} else {
|
||||
page_id
|
||||
};
|
||||
let query = body.query.trim().to_string();
|
||||
if matches!(body.mode, EvidenceSearchMode::Graph) {
|
||||
if let Some(results) = local_search_index::query_evidence_graph_results(
|
||||
&root_path, &query, page_id, body.top_k,
|
||||
if let Some(mut results) = local_search_index::query_evidence_graph_results(
|
||||
&root_path,
|
||||
&query,
|
||||
evidence_owner_filter,
|
||||
body.top_k,
|
||||
)? {
|
||||
enrich_citation_links(&mut results);
|
||||
return Ok(json!(EvidenceSearchResponse { ok: true, results }));
|
||||
}
|
||||
}
|
||||
if let Some(mut results) =
|
||||
local_search_index::query_evidence_sqlite_results(&root_path, &query, page_id, body.top_k)?
|
||||
{
|
||||
if let Some(mut results) = local_search_index::query_evidence_sqlite_results(
|
||||
&root_path,
|
||||
&query,
|
||||
evidence_owner_filter,
|
||||
body.top_k,
|
||||
)? {
|
||||
enrich_sqlite_evidence_results(&mut results, &root_path, &query);
|
||||
enrich_citation_links(&mut results);
|
||||
if !results.is_empty() || !query.is_empty() {
|
||||
let response = EvidenceSearchResponse { ok: true, results };
|
||||
return Ok(json!(response));
|
||||
@@ -77,9 +90,13 @@ pub(crate) async fn search_payload(
|
||||
)?;
|
||||
let response = EvidenceSearchResponse {
|
||||
ok: true,
|
||||
results: evidence_results_from_local_search(
|
||||
&search, &root_path, &root_uri, body.mode, &query,
|
||||
),
|
||||
results: {
|
||||
let mut results = evidence_results_from_local_search(
|
||||
&search, &root_path, &root_uri, body.mode, &query,
|
||||
);
|
||||
enrich_citation_links(&mut results);
|
||||
results
|
||||
},
|
||||
};
|
||||
Ok(json!(response))
|
||||
}
|
||||
@@ -127,6 +144,149 @@ fn enrich_sqlite_evidence_results(
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_citation_links(results: &mut [EvidenceSearchResult]) {
|
||||
for result in results {
|
||||
let citation_url = citation_url_for_locator(&result.source);
|
||||
result.source.open_action.url = citation_url.clone();
|
||||
result.citation_label = Some(citation_label_for_locator(&result.source));
|
||||
result.citation_markdown = Some(citation_markdown_for_locator(&result.source));
|
||||
result.citation_url = Some(citation_url);
|
||||
}
|
||||
}
|
||||
|
||||
fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
|
||||
let label = citation_label_for_locator(locator);
|
||||
let url = citation_url_for_locator(locator);
|
||||
format!(
|
||||
"[{}]({})",
|
||||
markdown_link_label_escape(&label),
|
||||
url.replace(')', "%29")
|
||||
)
|
||||
}
|
||||
|
||||
fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
|
||||
let source_path = locator
|
||||
.resource_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(locator.owner_document_path.as_str());
|
||||
let name = source_path
|
||||
.rsplit('/')
|
||||
.find(|part| !part.trim().is_empty())
|
||||
.unwrap_or(source_path)
|
||||
.trim();
|
||||
let mut parts = vec![if name.is_empty() {
|
||||
"证据".to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
}];
|
||||
if let Some(page) = locator.page {
|
||||
parts.push(format!("p.{page}"));
|
||||
}
|
||||
if let Some(section) = locator
|
||||
.section_path
|
||||
.last()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
parts.push(section.to_string());
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
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();
|
||||
if existing.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
existing.to_string()
|
||||
}
|
||||
} else {
|
||||
format!("/documents/{owner_document_id}")
|
||||
};
|
||||
append_query_param(&mut url, "sourceKind", "local_folder");
|
||||
append_query_param(&mut url, "rootUri", locator.root_uri.trim());
|
||||
if let Some(resource_path) = locator
|
||||
.resource_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
append_query_param(
|
||||
&mut url,
|
||||
"resourceTab",
|
||||
&format!(
|
||||
"resource:file:{}:{}",
|
||||
locator.root_uri.trim(),
|
||||
resource_path
|
||||
),
|
||||
);
|
||||
append_query_param(&mut url, "resourcePath", resource_path);
|
||||
}
|
||||
if let Some(page) = locator.page {
|
||||
append_query_param(&mut url, "page", &page.to_string());
|
||||
}
|
||||
if let Some(bbox) = &locator.bbox {
|
||||
append_query_param(
|
||||
&mut url,
|
||||
"bbox",
|
||||
&format!("{},{},{},{}", bbox.x0, bbox.y0, bbox.x1, bbox.y1),
|
||||
);
|
||||
}
|
||||
if let Some(block_id) = locator.block_id.as_deref() {
|
||||
append_query_param(&mut url, "blockId", block_id);
|
||||
}
|
||||
if let Some(source_map_path) = locator.source_map_path.as_deref() {
|
||||
append_query_param(&mut url, "sourceMapPath", source_map_path);
|
||||
}
|
||||
if let Some(line_range) = &locator.line_range {
|
||||
append_query_param(
|
||||
&mut url,
|
||||
"lineRange",
|
||||
&format!("{}-{}", line_range.start, line_range.end),
|
||||
);
|
||||
}
|
||||
if let Some(char_range) = &locator.char_range {
|
||||
append_query_param(
|
||||
&mut url,
|
||||
"charRange",
|
||||
&format!("{}-{}", char_range.start, char_range.end),
|
||||
);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn append_query_param(url: &mut String, key: &str, value: &str) {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
url.push(separator);
|
||||
url.push_str(&encode_query_component(key));
|
||||
url.push('=');
|
||||
url.push_str(&encode_query_component(value));
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(*byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn markdown_link_label_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -154,12 +314,13 @@ pub(crate) async fn read_payload(
|
||||
state, context, &root_uri,
|
||||
)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
if let Some(results) = read_source_map_context(
|
||||
if let Some(mut results) = read_source_map_context(
|
||||
&root_path,
|
||||
&body.locator,
|
||||
body.context.before_blocks,
|
||||
body.context.after_blocks,
|
||||
) {
|
||||
enrich_citation_links(&mut results);
|
||||
let quote = results
|
||||
.iter()
|
||||
.find(|item| locator_matches(&item.source, &body.locator))
|
||||
@@ -176,17 +337,21 @@ pub(crate) async fn read_payload(
|
||||
"locator": body.locator,
|
||||
"quote": quote,
|
||||
"sectionPath": section_path,
|
||||
"citationUrl": citation_url_for_locator(&body.locator),
|
||||
"citationLabel": citation_label_for_locator(&body.locator),
|
||||
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||
"contextBlocks": results,
|
||||
});
|
||||
return Ok(response);
|
||||
}
|
||||
if let Some(results) = local_search_index::read_evidence_sqlite_context(
|
||||
if let Some(mut results) = local_search_index::read_evidence_sqlite_context(
|
||||
&root_path,
|
||||
&body.locator,
|
||||
body.context.before_blocks,
|
||||
body.context.after_blocks,
|
||||
)? {
|
||||
if !results.is_empty() {
|
||||
enrich_citation_links(&mut results);
|
||||
let quote = results
|
||||
.iter()
|
||||
.find(|item| locator_matches(&item.source, &body.locator))
|
||||
@@ -197,6 +362,9 @@ pub(crate) async fn read_payload(
|
||||
"ok": true,
|
||||
"locator": body.locator,
|
||||
"quote": quote,
|
||||
"citationUrl": citation_url_for_locator(&body.locator),
|
||||
"citationLabel": citation_label_for_locator(&body.locator),
|
||||
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||
"contextBlocks": results,
|
||||
});
|
||||
return Ok(response);
|
||||
@@ -222,13 +390,14 @@ pub(crate) async fn read_payload(
|
||||
false,
|
||||
true,
|
||||
)?;
|
||||
let results = evidence_results_from_local_search(
|
||||
let mut results = evidence_results_from_local_search(
|
||||
&search,
|
||||
&root_path,
|
||||
&root_uri,
|
||||
EvidenceSearchMode::Tree,
|
||||
&query,
|
||||
);
|
||||
enrich_citation_links(&mut results);
|
||||
let quote = results
|
||||
.first()
|
||||
.map(|item| item.quote.clone())
|
||||
@@ -237,6 +406,9 @@ pub(crate) async fn read_payload(
|
||||
"ok": true,
|
||||
"locator": body.locator,
|
||||
"quote": quote,
|
||||
"citationUrl": citation_url_for_locator(&body.locator),
|
||||
"citationLabel": citation_label_for_locator(&body.locator),
|
||||
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||
"contextBlocks": results,
|
||||
});
|
||||
Ok(response)
|
||||
@@ -365,6 +537,9 @@ fn source_map_block_result(
|
||||
quote: block.text.clone(),
|
||||
score,
|
||||
source,
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +569,9 @@ pub(crate) async fn open_payload(
|
||||
context: &RequestContext,
|
||||
body: EvidenceOpenRequest,
|
||||
) -> Result<Value, WebError> {
|
||||
let locator = body.locator;
|
||||
let mut locator = body.locator;
|
||||
let citation_url = citation_url_for_locator(&locator);
|
||||
locator.open_action.url = citation_url.clone();
|
||||
let root_uri = locator.root_uri.trim().to_string();
|
||||
if root_uri.is_empty() {
|
||||
return Err(
|
||||
@@ -410,6 +587,9 @@ pub(crate) async fn open_payload(
|
||||
"ok": true,
|
||||
"locator": locator.clone(),
|
||||
"openAction": locator.open_action,
|
||||
"citationUrl": citation_url,
|
||||
"citationLabel": citation_label_for_locator(&locator),
|
||||
"citationMarkdown": citation_markdown_for_locator(&locator),
|
||||
});
|
||||
Ok(response)
|
||||
}
|
||||
@@ -499,19 +679,29 @@ pub(crate) fn evidence_results_from_local_search(
|
||||
})
|
||||
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.bbox.clone()));
|
||||
let block_id = result
|
||||
.get("ocrEvidence")
|
||||
.and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()))
|
||||
.or_else(|| result.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.get("blockId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
result
|
||||
.get("ocrEvidence")
|
||||
.and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()))
|
||||
})
|
||||
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()));
|
||||
let line_range = result.get("lineRange").and_then(evidence_range_from_value);
|
||||
let char_range = source_map_hit
|
||||
.as_ref()
|
||||
.and_then(|hit| hit.char_range.clone());
|
||||
.and_then(|hit| hit.char_range.clone())
|
||||
.or_else(|| result.get("charRange").and_then(evidence_range_from_value));
|
||||
let open_action_params = evidence_open_action_params(
|
||||
&result,
|
||||
query,
|
||||
&mode,
|
||||
page,
|
||||
bbox.clone(),
|
||||
block_id.clone(),
|
||||
line_range.clone(),
|
||||
char_range.clone(),
|
||||
source_map_path.clone(),
|
||||
);
|
||||
let locator = EvidenceLocator {
|
||||
@@ -534,7 +724,7 @@ pub(crate) fn evidence_results_from_local_search(
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
line_range: None,
|
||||
line_range,
|
||||
char_range,
|
||||
block_id,
|
||||
source_map_path: source_map_path.clone(),
|
||||
@@ -557,6 +747,9 @@ pub(crate) fn evidence_results_from_local_search(
|
||||
.to_string(),
|
||||
score: result.get("score").and_then(Value::as_f64).unwrap_or(0.0),
|
||||
source: locator,
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -568,6 +761,9 @@ fn evidence_open_action_params(
|
||||
mode: &EvidenceSearchMode,
|
||||
page: Option<u32>,
|
||||
bbox: Option<EvidenceBBox>,
|
||||
block_id: Option<String>,
|
||||
line_range: Option<EvidenceRange>,
|
||||
char_range: Option<EvidenceRange>,
|
||||
source_map_path: Option<String>,
|
||||
) -> Map<String, Value> {
|
||||
let mut params = Map::new();
|
||||
@@ -586,12 +782,35 @@ fn evidence_open_action_params(
|
||||
if let Some(bbox) = bbox {
|
||||
params.insert("bbox".into(), json!(bbox));
|
||||
}
|
||||
if let Some(block_id) = block_id {
|
||||
params.insert("blockId".into(), json!(block_id));
|
||||
}
|
||||
if let Some(line_range) = line_range {
|
||||
params.insert("lineRange".into(), json!(line_range));
|
||||
}
|
||||
if let Some(char_range) = char_range {
|
||||
params.insert("charRange".into(), json!(char_range));
|
||||
}
|
||||
if let Some(source_map_path) = source_map_path {
|
||||
params.insert("sourceMapPath".into(), json!(source_map_path));
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn evidence_range_from_value(value: &Value) -> Option<EvidenceRange> {
|
||||
if let Some(map) = value.as_object() {
|
||||
let start = map.get("start")?.as_u64()?;
|
||||
let end = map.get("end")?.as_u64()?;
|
||||
return Some(EvidenceRange { start, end });
|
||||
}
|
||||
let text = value.as_str()?.trim();
|
||||
let (start, end) = text.split_once('-')?;
|
||||
Some(EvidenceRange {
|
||||
start: start.trim().parse().ok()?,
|
||||
end: end.trim().parse().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_resource_kind(resource_type: &str, resource_path: Option<&str>) -> EvidenceResourceKind {
|
||||
let extension = resource_path
|
||||
.and_then(|path| Path::new(path).extension())
|
||||
@@ -788,6 +1007,15 @@ mod tests {
|
||||
)
|
||||
.expect("readme");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("settings");
|
||||
local_search_index::refresh_local_search_index(&root, &root_uri, "local-ws-evidence-route")
|
||||
.expect("refresh");
|
||||
fs::write(
|
||||
@@ -838,6 +1066,7 @@ mod tests {
|
||||
"scope": {
|
||||
"workspaceId": "local-ws-evidence-route",
|
||||
"rootUri": root_uri,
|
||||
"targetDocumentId": "local-md:missing.md",
|
||||
"includeResources": true,
|
||||
"includeOcr": true
|
||||
},
|
||||
@@ -925,13 +1154,14 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let results = evidence_results_from_local_search(
|
||||
let mut results = evidence_results_from_local_search(
|
||||
&payload,
|
||||
&root,
|
||||
"file:///workspace",
|
||||
EvidenceSearchMode::Hybrid,
|
||||
"OCR-only-token",
|
||||
);
|
||||
enrich_citation_links(&mut results);
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
|
||||
@@ -954,6 +1184,16 @@ mod tests {
|
||||
locator.open_action.params["sourceMapPath"].as_str(),
|
||||
Some("docs/Page.ocr/photo.png.source-map.json")
|
||||
);
|
||||
let citation_url = results[0].citation_url.as_deref().unwrap_or_default();
|
||||
assert!(citation_url.starts_with("/documents/local-md:docs~2FPage.md?"));
|
||||
assert!(citation_url.contains("resourceTab=resource%3Afile%3Afile%3A%2F%2F%2Fworkspace%3Adocs%2FPage.assets%2Fphoto.png"));
|
||||
assert!(citation_url.contains("page=1"));
|
||||
assert!(citation_url.contains("blockId=p1_b1"));
|
||||
assert!(results[0]
|
||||
.citation_markdown
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("photo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1011,9 +1251,13 @@ mod tests {
|
||||
quote: "NeedleToken 原文定位".into(),
|
||||
score: 1.0,
|
||||
source: locator,
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
}];
|
||||
|
||||
enrich_sqlite_evidence_results(&mut results, &root, "NeedleToken");
|
||||
enrich_citation_links(&mut results);
|
||||
fs::remove_dir_all(&root).ok();
|
||||
|
||||
let locator = &results[0].source;
|
||||
@@ -1029,6 +1273,16 @@ mod tests {
|
||||
locator.open_action.params["blockId"].as_str(),
|
||||
Some("p2_b7")
|
||||
);
|
||||
assert!(results[0]
|
||||
.citation_url
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("sourceMapPath=docs%2FPage.ocr%2Fspec.pdf.source-map.json"));
|
||||
assert!(results[0]
|
||||
.citation_markdown
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("spec.pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1318,6 +1318,123 @@ pub async fn toggle_skill(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_capabilities(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let runtime = query.get("runtime").map(String::as_str).unwrap_or("mnote");
|
||||
if runtime != "mnote" {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"runtime": runtime,
|
||||
"categories": [],
|
||||
"archived": []
|
||||
})),
|
||||
));
|
||||
}
|
||||
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||
let profile = query
|
||||
.get("profile")
|
||||
.map(String::as_str)
|
||||
.unwrap_or(fallback_profile.as_str());
|
||||
let payload = mnote_capabilities_payload(
|
||||
&state,
|
||||
&context,
|
||||
query.get("agentId").map(String::as_str),
|
||||
profile,
|
||||
)?;
|
||||
Ok((StatusCode::OK, stamp_client_headers(), Json(payload)))
|
||||
}
|
||||
|
||||
pub async fn toggle_capability(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let capability_id = payload
|
||||
.get("id")
|
||||
.or_else(|| payload.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 capability id")
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let enabled = payload
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let runtime = payload
|
||||
.get("runtime")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("mnote");
|
||||
if runtime != "mnote" {
|
||||
return Err(WebError::bad_request_code(
|
||||
"hermes_client_capability_runtime_unsupported",
|
||||
"当前只支持 MNote 内置能力开关",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||
let profile = payload
|
||||
.get("profile")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback_profile.as_str());
|
||||
let skill = crate::hermes_tools::skill::find_skill(capability_id, None).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"mnote_capability_not_found",
|
||||
"未知 MNote AI 能力",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let actor_id = page_ai_actor_id(&state, &context)?;
|
||||
ensure_page_ai_actor_user(&state, &actor_id, page_ai_actor_is_admin(&context))?;
|
||||
set_mnote_builtin_capability_enabled(&state, &actor_id, capability_id, enabled, &context)?;
|
||||
for tool_name in skill.tool_names {
|
||||
if mnote_capability_tool_toggleable(tool_name) {
|
||||
set_mnote_tool_enabled(profile, tool_name, enabled).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_client_capability_tool_toggle_failed",
|
||||
format!("更新 MNote 能力工具设置失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"runtime": "mnote",
|
||||
"id": capability_id,
|
||||
"enabled": enabled,
|
||||
"profile": profile,
|
||||
"configScope": "user_sqlite+profile_tool_policy"
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn toggle_tool(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
@@ -4068,6 +4185,217 @@ fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn mnote_capabilities_payload(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
agent_id: Option<&str>,
|
||||
profile: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut skills_payload = mnote_builtin_skills_payload(agent_id);
|
||||
stamp_mnote_builtin_skill_payload_policy(state, context, &mut skills_payload)?;
|
||||
let tools_by_name = mnote_tools_payload(profile)
|
||||
.into_iter()
|
||||
.filter_map(|tool| {
|
||||
let name = tool.get("name").and_then(Value::as_str)?.to_string();
|
||||
Some((name, tool))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut capability_categories: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
for category in skills_payload
|
||||
.get("categories")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let mut capabilities = Vec::new();
|
||||
for skill in category
|
||||
.get("skills")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(id) = skill.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if id == "mnote-chat-only" {
|
||||
continue;
|
||||
}
|
||||
let tool_names = skill
|
||||
.get("toolNames")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tools = tool_names
|
||||
.iter()
|
||||
.filter_map(|name| tools_by_name.get(name).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
let disabled_tool_count = tools
|
||||
.iter()
|
||||
.filter(|tool| tool.get("enabled").and_then(Value::as_bool) == Some(false))
|
||||
.count();
|
||||
let enabled = skill
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let status = if !enabled {
|
||||
"disabled"
|
||||
} else if disabled_tool_count > 0 {
|
||||
"partial"
|
||||
} else {
|
||||
"available"
|
||||
};
|
||||
let capability_category = skill
|
||||
.get("category")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("mnote");
|
||||
capabilities.push(json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"title": skill.get("title").cloned().unwrap_or_else(|| json!(id)),
|
||||
"description": skill.get("description").cloned().unwrap_or(Value::Null),
|
||||
"enabled": enabled,
|
||||
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
|
||||
"builtin": true,
|
||||
"configurable": true,
|
||||
"configScope": "user_sqlite+profile_tool_policy",
|
||||
"skillKind": "mnote_capability",
|
||||
"source": "mnote",
|
||||
"origin": "builtin",
|
||||
"category": capability_category,
|
||||
"categoryTitle": mnote_capability_category_title(capability_category),
|
||||
"capabilityId": id,
|
||||
"capabilityKind": "mnote_builtin",
|
||||
"uiKind": mnote_capability_ui_kind(capability_category),
|
||||
"skillId": id,
|
||||
"readOnly": skill.get("readOnly").cloned().unwrap_or(Value::Bool(false)),
|
||||
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
|
||||
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null),
|
||||
"toolNames": tool_names,
|
||||
"tools": tools,
|
||||
"toolCount": tools.len(),
|
||||
"disabledToolCount": disabled_tool_count,
|
||||
"status": status,
|
||||
}));
|
||||
}
|
||||
for capability in capabilities {
|
||||
let category_name = capability
|
||||
.get("category")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("mnote")
|
||||
.to_string();
|
||||
capability_categories
|
||||
.entry(category_name)
|
||||
.or_default()
|
||||
.push(capability);
|
||||
}
|
||||
}
|
||||
let categories = ordered_mnote_capability_categories(capability_categories)
|
||||
.into_iter()
|
||||
.map(|(name, capabilities)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": mnote_capability_category_title(&name),
|
||||
"description": mnote_capability_category_description(&name),
|
||||
"capabilities": capabilities.clone(),
|
||||
"skills": capabilities
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"runtime": "mnote",
|
||||
"profile": profile,
|
||||
"categories": categories,
|
||||
"archived": []
|
||||
}))
|
||||
}
|
||||
|
||||
fn ordered_mnote_capability_categories(
|
||||
mut categories: BTreeMap<String, Vec<Value>>,
|
||||
) -> Vec<(String, Vec<Value>)> {
|
||||
let mut ordered = Vec::new();
|
||||
for name in ["mnote", "knowledge", "file", "resource", "office", "chat"] {
|
||||
if let Some(capabilities) = categories.remove(name) {
|
||||
ordered.push((name.to_string(), capabilities));
|
||||
}
|
||||
}
|
||||
ordered.extend(categories);
|
||||
ordered
|
||||
}
|
||||
|
||||
fn mnote_capability_category_title(category: &str) -> &'static str {
|
||||
match category {
|
||||
"knowledge" => "知识库与索引",
|
||||
"file" => "本地文件",
|
||||
"resource" => "资源编辑",
|
||||
"office" => "Office / ONLYOFFICE",
|
||||
"chat" => "聊天",
|
||||
_ => "MNote",
|
||||
}
|
||||
}
|
||||
|
||||
fn mnote_capability_category_description(category: &str) -> &'static str {
|
||||
match category {
|
||||
"knowledge" => "本地索引、证据检索和资料范围管理。",
|
||||
"file" => "授权目录内的本地 Markdown 文件读写。",
|
||||
"resource" => "MNote 资源型编辑器能力,例如思维导图。",
|
||||
"office" => "Office 摘要、建议和 ONLYOFFICE 实时编辑桥。",
|
||||
"chat" => "不读取文档上下文的普通对话能力。",
|
||||
_ => "MNote 页面上下文与基础能力。",
|
||||
}
|
||||
}
|
||||
|
||||
fn mnote_capability_ui_kind(category: &str) -> &'static str {
|
||||
if category == "chat" {
|
||||
"chat"
|
||||
} else {
|
||||
"ai_capability"
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mnote_builtin_capability_enabled(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
capability_id: &str,
|
||||
enabled: bool,
|
||||
context: &RequestContext,
|
||||
) -> Result<(), WebError> {
|
||||
let key = format!("ai.agent.mnote_builtin.skill.{capability_id}.enabled");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: actor_id.to_string(),
|
||||
workspace_id: None,
|
||||
source_kind: None,
|
||||
scope_kind: "page_ai_capability".to_string(),
|
||||
scope_id: "mnote_builtin".to_string(),
|
||||
key,
|
||||
value_json: Value::Bool(enabled).to_string(),
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite MNote AI 能力偏好写入失败: {error}"))
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn mnote_capability_tool_toggleable(tool_name: &str) -> bool {
|
||||
!matches!(
|
||||
tool_name,
|
||||
"mnote.skill.read" | "mnote.context.snapshot" | "mnote.context.resolve_target"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_skill_description(markdown: &str) -> String {
|
||||
markdown
|
||||
.lines()
|
||||
@@ -4451,6 +4779,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
|
||||
"skillKind": "mnote_builtin",
|
||||
"source": "mnote",
|
||||
"origin": "builtin",
|
||||
"category": skill.get("category").cloned().unwrap_or_else(|| json!("mnote")),
|
||||
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
|
||||
"toolNames": skill.get("toolNames").cloned().unwrap_or(Value::Null),
|
||||
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null)
|
||||
@@ -10144,6 +10473,223 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
|
||||
let _env_guard = env_lock().lock().expect("env lock");
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-web-ai-capability-policy-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
fs::create_dir_all(&hermes_home).expect("hermes home");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
let app = build_app(test_state());
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("capabilities");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||
let all_capabilities = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
all_capabilities
|
||||
.iter()
|
||||
.all(|capability| capability["id"] != "mnote-chat-only"),
|
||||
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
|
||||
);
|
||||
let local_index = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.find(|capability| capability["id"] == "mnote-local-index")
|
||||
.expect("local index capability");
|
||||
assert_eq!(local_index["enabled"], true);
|
||||
assert_eq!(local_index["uiKind"], "ai_capability");
|
||||
assert!(local_index["tools"]
|
||||
.as_array()
|
||||
.expect("local index tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.index.status"));
|
||||
assert!(local_index["tools"]
|
||||
.as_array()
|
||||
.expect("local index tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.index.update_settings"));
|
||||
|
||||
let toggle_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/hermes/client/capabilities/toggle")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"runtime": "mnote",
|
||||
"profile": "chemist",
|
||||
"id": "mnote-local-index",
|
||||
"enabled": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("toggle capability");
|
||||
assert_eq!(toggle_response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("capabilities after toggle");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||
let local_index = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||
.find(|capability| capability["id"] == "mnote-local-index")
|
||||
.expect("local index capability");
|
||||
assert_eq!(local_index["enabled"], false);
|
||||
assert_eq!(local_index["status"], "disabled");
|
||||
assert!(local_index["tools"]
|
||||
.as_array()
|
||||
.expect("local index tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.index.status" && tool["enabled"] == false));
|
||||
|
||||
let tools_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("tools after toggle");
|
||||
assert_eq!(tools_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(tools_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("tools json");
|
||||
let tools = payload["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
(
|
||||
tool["name"].as_str().unwrap_or_default().to_string(),
|
||||
tool.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
assert_eq!(tools["mnote.index.status"]["enabled"], false);
|
||||
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_ai_capabilities_group_onlyoffice_live_bridge() {
|
||||
let _env_guard = env_lock().lock().expect("env lock");
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-web-ai-onlyoffice-capability-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
fs::create_dir_all(&hermes_home).expect("hermes home");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = build_app(test_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("capabilities");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||
let office_category = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
.iter()
|
||||
.find(|category| category["name"] == "office")
|
||||
.expect("office category");
|
||||
assert_eq!(office_category["title"], "Office / ONLYOFFICE");
|
||||
let onlyoffice = office_category["capabilities"]
|
||||
.as_array()
|
||||
.expect("office capabilities")
|
||||
.iter()
|
||||
.find(|capability| capability["id"] == "mnote-onlyoffice-live")
|
||||
.expect("onlyoffice capability");
|
||||
assert_eq!(onlyoffice["title"], "ONLYOFFICE 实时编辑");
|
||||
assert_eq!(onlyoffice["categoryTitle"], "Office / ONLYOFFICE");
|
||||
assert_eq!(onlyoffice["enabled"], true);
|
||||
assert!(onlyoffice["tools"]
|
||||
.as_array()
|
||||
.expect("onlyoffice tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.onlyoffice.session.current"));
|
||||
assert!(onlyoffice["tools"]
|
||||
.as_array()
|
||||
.expect("onlyoffice tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values"));
|
||||
assert!(onlyoffice["tools"]
|
||||
.as_array()
|
||||
.expect("onlyoffice tools")
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_shape"));
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasonix_memory_policy_defaults_off_and_reads_user_preference() {
|
||||
let state = test_state();
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{
|
||||
artifact, block, context_tools, doc, evidence, manifest, onlyoffice_live, page, resource,
|
||||
skill, ToolCallInput,
|
||||
artifact, block, context_tools, doc, evidence, index, manifest, onlyoffice_live, page,
|
||||
resource, skill, ToolCallInput,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -365,6 +365,11 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
"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.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,
|
||||
@@ -704,6 +709,8 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.search"
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.index.status"
|
||||
| "mnote.index.refresh"
|
||||
| "mnote.block.fetch"
|
||||
| "mnote.mindmap.fetch"
|
||||
| "mnote.office.fetch_summary"
|
||||
@@ -1578,6 +1585,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_exposes_mnote_capability_packs() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/hermes/tools/mnote/manifest")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let manifest = &payload["manifest"];
|
||||
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
|
||||
assert!(capabilities
|
||||
.iter()
|
||||
.any(|capability| capability["id"] == "mnote-local-index"));
|
||||
assert!(capabilities
|
||||
.iter()
|
||||
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
|
||||
|
||||
let tools = manifest["tools"].as_array().expect("tools");
|
||||
let index_status = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.index.status")
|
||||
.expect("index status tool");
|
||||
assert!(index_status["capabilityIds"]
|
||||
.as_array()
|
||||
.expect("index capability ids")
|
||||
.iter()
|
||||
.any(|id| id == "mnote-local-index"));
|
||||
|
||||
let onlyoffice_batch_set = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values")
|
||||
.expect("onlyoffice batch set tool");
|
||||
assert_eq!(
|
||||
onlyoffice_batch_set["capabilityId"],
|
||||
"mnote-onlyoffice-live"
|
||||
);
|
||||
assert!(onlyoffice_batch_set["capabilityIds"]
|
||||
.as_array()
|
||||
.expect("onlyoffice capability ids")
|
||||
.iter()
|
||||
.any(|id| id == "mnote-onlyoffice-live"));
|
||||
|
||||
let context_snapshot = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.context.snapshot")
|
||||
.expect("context snapshot tool");
|
||||
assert!(
|
||||
context_snapshot["capabilityIds"]
|
||||
.as_array()
|
||||
.expect("context capability ids")
|
||||
.len()
|
||||
> 1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_onlyoffice_session_current_reads_registered_bridge_session() {
|
||||
let app = app();
|
||||
@@ -5386,6 +5457,21 @@ mod tests {
|
||||
)
|
||||
.expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("settings");
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
"local-ws-docs-search",
|
||||
)
|
||||
.expect("refresh");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -5437,6 +5523,12 @@ mod tests {
|
||||
result["source"]["ownerDocumentPath"].as_str(),
|
||||
Some("README.md")
|
||||
);
|
||||
assert!(result["citationMarkdown"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("](/documents/")));
|
||||
assert!(result["citationUrl"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("resourceTab=")));
|
||||
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
||||
assert_eq!(
|
||||
payload["result"]["evidenceIds"][0].as_str(),
|
||||
@@ -5480,6 +5572,112 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_local_index_update_and_status_manage_scope() {
|
||||
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");
|
||||
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
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");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let app = app();
|
||||
|
||||
let update_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.index.update_settings",
|
||||
"workspaceId": "local-ws-index-tool",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_index_tool",
|
||||
"runId": "run_index_tool",
|
||||
"toolCallId": "call_index_update",
|
||||
"traceId": "trace_index_tool",
|
||||
"dryRun": false,
|
||||
"idempotencyKey": "idem_index_tool_update",
|
||||
"args": {
|
||||
"includePaths": ["docs"],
|
||||
"scheduleMode": "manual",
|
||||
"scheduleTime": "02:00",
|
||||
"runOnChange": false
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("update request"),
|
||||
)
|
||||
.await
|
||||
.expect("update response");
|
||||
assert_eq!(update_response.status(), StatusCode::OK);
|
||||
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"
|
||||
);
|
||||
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());
|
||||
|
||||
let status_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.index.status",
|
||||
"workspaceId": "local-ws-index-tool",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_index_tool",
|
||||
"runId": "run_index_tool",
|
||||
"toolCallId": "call_index_status",
|
||||
"traceId": "trace_index_tool",
|
||||
"args": {}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("status request"),
|
||||
)
|
||||
.await
|
||||
.expect("status response");
|
||||
assert_eq!(status_response.status(), StatusCode::OK);
|
||||
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"
|
||||
);
|
||||
assert_eq!(status_payload["audit"]["effect"], "read");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
||||
let root =
|
||||
|
||||
@@ -295,6 +295,7 @@ struct LocalFolderRow {
|
||||
capabilities: Vec<String>,
|
||||
workspace_id: String,
|
||||
root_source_uri: String,
|
||||
index_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -303,6 +304,40 @@ struct LocalFolderScanResult {
|
||||
watch_revision: LocalFolderWatchRevision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct LocalFileTreeIndexState {
|
||||
indexed_paths: BTreeSet<String>,
|
||||
failed_paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl LocalFileTreeIndexState {
|
||||
fn load(root: &Path) -> Self {
|
||||
local_search_index::local_evidence_source_statuses(root)
|
||||
.map(|statuses| Self {
|
||||
indexed_paths: statuses.indexed_paths,
|
||||
failed_paths: statuses.failed_paths,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn status_for_entry(&self, entry: &LocalFolderEntry) -> Option<String> {
|
||||
if entry.is_dir || entry.is_symlink {
|
||||
return None;
|
||||
}
|
||||
let path = entry.relative_path.trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.failed_paths.contains(path) {
|
||||
return Some("failed".to_string());
|
||||
}
|
||||
if self.indexed_paths.contains(path) {
|
||||
return Some("indexed".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LocalUploadFile {
|
||||
name: String,
|
||||
@@ -2717,6 +2752,7 @@ 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 parent_relative_path = parent_relative_path
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != ".")
|
||||
@@ -2746,6 +2782,7 @@ fn load_local_folder_file_tree_scope_snapshot(
|
||||
&root_source_uri,
|
||||
&workspace_id,
|
||||
&metadata,
|
||||
&index_state,
|
||||
)?;
|
||||
append_file_tree_reveal_rows(
|
||||
&canonical_root,
|
||||
@@ -2754,6 +2791,7 @@ fn load_local_folder_file_tree_scope_snapshot(
|
||||
&root_source_uri,
|
||||
&workspace_id,
|
||||
&metadata,
|
||||
&index_state,
|
||||
&mut scan_result.rows,
|
||||
)?;
|
||||
|
||||
@@ -6648,6 +6686,7 @@ fn scan_directory(
|
||||
root_source_uri: &str,
|
||||
workspace_id: &str,
|
||||
metadata: &LocalFolderMetadata,
|
||||
index_state: &LocalFileTreeIndexState,
|
||||
rows: &mut Vec<LocalFolderRow>,
|
||||
) -> Result<(), WebError> {
|
||||
let mut entries = read_sorted_entries(directory, root)?;
|
||||
@@ -6698,6 +6737,7 @@ fn scan_directory(
|
||||
capabilities: local_entry_capabilities(&entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: index_state.status_for_entry(&entry),
|
||||
});
|
||||
if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) {
|
||||
scan_directory(
|
||||
@@ -6709,6 +6749,7 @@ fn scan_directory(
|
||||
root_source_uri,
|
||||
workspace_id,
|
||||
metadata,
|
||||
index_state,
|
||||
rows,
|
||||
)?;
|
||||
}
|
||||
@@ -6725,6 +6766,7 @@ fn scan_directory_shallow_with_revision(
|
||||
root_source_uri: &str,
|
||||
workspace_id: &str,
|
||||
metadata: &LocalFolderMetadata,
|
||||
index_state: &LocalFileTreeIndexState,
|
||||
) -> Result<LocalFolderScanResult, WebError> {
|
||||
let mut entries = read_sorted_entries(directory, root)?;
|
||||
let parent_key = file_order_parent_key_for_directory(root, directory)?;
|
||||
@@ -6797,6 +6839,7 @@ fn scan_directory_shallow_with_revision(
|
||||
capabilities: local_entry_capabilities(entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: index_state.status_for_entry(entry),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6867,6 +6910,7 @@ fn append_file_tree_reveal_rows(
|
||||
root_source_uri: &str,
|
||||
workspace_id: &str,
|
||||
metadata: &LocalFolderMetadata,
|
||||
index_state: &LocalFileTreeIndexState,
|
||||
rows: &mut Vec<LocalFolderRow>,
|
||||
) -> Result<(), WebError> {
|
||||
let Some(reveal_relative_path) = reveal_relative_path
|
||||
@@ -6924,6 +6968,7 @@ fn append_file_tree_reveal_rows(
|
||||
root_source_uri,
|
||||
workspace_id,
|
||||
metadata,
|
||||
index_state,
|
||||
&mut scoped_rows,
|
||||
)?;
|
||||
for row in scoped_rows {
|
||||
@@ -7124,9 +7169,25 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
}
|
||||
relative_path == ".mnote/trash"
|
||||
|| relative_path.starts_with(".mnote/trash/")
|
||||
|| is_local_index_artifact_entry(relative_path)
|
||||
|| is_local_ocr_intermediate_entry(relative_path, file_name)
|
||||
}
|
||||
|
||||
fn is_local_index_artifact_entry(relative_path: &str) -> bool {
|
||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let segments = normalized
|
||||
.split('/')
|
||||
.filter(|segment| !segment.trim().is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
segments
|
||||
.windows(2)
|
||||
.any(|window| window[0] == ".mnote" && window[1] == "index")
|
||||
|| segments.iter().any(|segment| segment.ends_with(".ocr"))
|
||||
}
|
||||
|
||||
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
@@ -7435,6 +7496,7 @@ fn scan_markdown_page_tree(
|
||||
capabilities: local_entry_capabilities(&entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: None,
|
||||
});
|
||||
directory_rows.extend(child_rows);
|
||||
contains_markdown = true;
|
||||
@@ -7483,6 +7545,7 @@ fn scan_markdown_page_tree(
|
||||
capabilities: local_entry_capabilities(&entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: None,
|
||||
});
|
||||
}
|
||||
directory_rows.extend(child_rows);
|
||||
@@ -7510,6 +7573,7 @@ fn scan_markdown_page_tree(
|
||||
capabilities: local_entry_capabilities(&entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -7546,6 +7610,7 @@ fn scan_markdown_page_tree(
|
||||
capabilities: local_entry_capabilities(&entry),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
root_source_uri: root_source_uri.to_string(),
|
||||
index_status: None,
|
||||
});
|
||||
contains_markdown = true;
|
||||
}
|
||||
@@ -7691,6 +7756,10 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
item["assetId"] = Value::String(asset_id.clone());
|
||||
item["resourceMeta"]["assetId"] = Value::String(asset_id);
|
||||
}
|
||||
if let Some(index_status) = row.index_status.as_deref() {
|
||||
item["indexStatus"] = Value::String(index_status.to_string());
|
||||
item["resourceMeta"]["indexStatus"] = Value::String(index_status.to_string());
|
||||
}
|
||||
|
||||
// Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath
|
||||
let object_kind = match row.row_kind.as_str() {
|
||||
@@ -11648,6 +11717,15 @@ fn main() {}
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
crate::routes::local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.expect("settings");
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
@@ -12369,6 +12447,15 @@ fn main() {}
|
||||
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
crate::routes::local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.expect("settings");
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
@@ -13474,7 +13561,7 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_ocr_sidecar_is_filetree_resource_not_page_tree_document() {
|
||||
fn local_ocr_sidecar_artifacts_are_hidden_from_filetree_and_page_tree() {
|
||||
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
@@ -13515,15 +13602,22 @@ fn main() {}
|
||||
)
|
||||
.expect("ocr image asset");
|
||||
|
||||
let docs_file_tree =
|
||||
load_local_folder_file_tree_children_snapshot(&root_uri, "docs").expect("file tree");
|
||||
let docs_file_items = docs_file_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("file items");
|
||||
assert!(!docs_file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some("Page.ocr")));
|
||||
|
||||
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
|
||||
.expect("file tree");
|
||||
.expect("direct hidden sidecar file tree");
|
||||
let file_items = file_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("file items");
|
||||
assert!(file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
|
||||
for hidden_title in [
|
||||
"photo.png.ocr.md",
|
||||
"layout.json",
|
||||
"abc_content_list.json",
|
||||
"abc_model.json",
|
||||
@@ -13534,7 +13628,7 @@ fn main() {}
|
||||
!file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some(hidden_title)),
|
||||
"OCR 中间产物不应出现在 FileTree: {hidden_title}"
|
||||
"OCR / 索引产物不应出现在 FileTree: {hidden_title}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13551,6 +13645,104 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_marks_indexed_and_failed_source_files() {
|
||||
let root = temp_root("mnote-local-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("failed.pdf"), b"%PDF-1.4\nfailed").expect("failed 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,
|
||||
"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}
|
||||
]
|
||||
});
|
||||
std::fs::write(
|
||||
index_dir.join("search-index.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(&search_index).unwrap()),
|
||||
)
|
||||
.expect("search index");
|
||||
|
||||
let file_tree = load_local_folder_file_tree_snapshot(&format!("file://{}", root.display()))
|
||||
.expect("file tree");
|
||||
let items = file_tree.projection["items"].as_array().expect("items");
|
||||
let status_for = |path: &str| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["resourceMeta"]["extra"]["source"]["relativePath"].as_str() == Some(path)
|
||||
})
|
||||
.and_then(|item| item["indexStatus"].as_str())
|
||||
};
|
||||
assert_eq!(status_for("ok.pdf"), Some("indexed"));
|
||||
assert_eq!(status_for("failed.pdf"), Some("failed"));
|
||||
assert_eq!(status_for("draft.md"), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_rename_markdown_page_renames_nested_bundle() {
|
||||
let root = temp_root("mnote-local-rename-nested-bundle");
|
||||
|
||||
@@ -15,6 +15,7 @@ use core_protocol::{
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -134,13 +135,19 @@ pub(crate) fn query_local_search_index_with_settings(
|
||||
)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let page_resource_path = page_id.and_then(local_resource_path_from_document_id);
|
||||
let markdown_page_id = if page_resource_path.is_some() {
|
||||
None
|
||||
} else {
|
||||
page_id
|
||||
};
|
||||
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
||||
let mut results = Vec::new();
|
||||
for document in index.documents.iter() {
|
||||
if !index_relative_path_is_included(&document.path, &result_settings.include_paths) {
|
||||
continue;
|
||||
}
|
||||
if let Some(page_id) = page_id {
|
||||
if let Some(page_id) = markdown_page_id {
|
||||
if document.document_id != page_id {
|
||||
continue;
|
||||
}
|
||||
@@ -157,11 +164,16 @@ pub(crate) fn query_local_search_index_with_settings(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if page_id.is_none() && results.len() < limit.max(1) as usize {
|
||||
if markdown_page_id.is_none() && results.len() < limit.max(1) as usize {
|
||||
for resource in index.resources.iter() {
|
||||
if !index_relative_path_is_included(&resource.path, &result_settings.include_paths) {
|
||||
continue;
|
||||
}
|
||||
if let Some(resource_path) = page_resource_path.as_deref() {
|
||||
if resource.path != resource_path {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
|
||||
continue;
|
||||
}
|
||||
@@ -180,7 +192,11 @@ pub(crate) fn query_local_search_index_with_settings(
|
||||
continue;
|
||||
}
|
||||
if let Some(page_id) = page_id {
|
||||
if entry.owner_document_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;
|
||||
}
|
||||
}
|
||||
@@ -364,6 +380,43 @@ pub(crate) fn write_user_local_index_settings(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_local_index_settings(
|
||||
store: &dyn ControlPlaneStore,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
root_path: &Path,
|
||||
include_paths: &[String],
|
||||
schedule_mode: Option<&str>,
|
||||
schedule_time: Option<&str>,
|
||||
schedule_date: Option<&str>,
|
||||
run_on_change: Option<bool>,
|
||||
) -> Result<LocalIndexSettings, WebError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() || user_id == "anonymous" {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_index_settings_auth_required",
|
||||
"本地索引设置需要登录用户",
|
||||
));
|
||||
}
|
||||
let existing = read_user_local_index_settings(store, user_id, workspace_id, root_path)?;
|
||||
let include_paths = normalize_index_include_paths(root_path, include_paths)?;
|
||||
let schedule_mode =
|
||||
normalize_index_schedule_mode(schedule_mode.unwrap_or(&existing.schedule_mode))?;
|
||||
let schedule_time =
|
||||
normalize_index_schedule_time(schedule_time.unwrap_or(&existing.schedule_time))?;
|
||||
let schedule_date =
|
||||
normalize_index_schedule_date(schedule_date.or(existing.schedule_date.as_deref()))?;
|
||||
Ok(LocalIndexSettings {
|
||||
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||||
include_paths,
|
||||
schedule_mode,
|
||||
schedule_time,
|
||||
schedule_date,
|
||||
run_on_change: run_on_change.unwrap_or(existing.run_on_change),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn effective_local_index_settings_for_root(
|
||||
store: &dyn ControlPlaneStore,
|
||||
workspace_id: &str,
|
||||
@@ -452,7 +505,7 @@ fn local_index_status_for_settings(
|
||||
let mut document_count = 0usize;
|
||||
let mut resource_count = 0usize;
|
||||
let mut built_at = Value::Null;
|
||||
let mut cache_matches_settings = false;
|
||||
let cache_matches_settings;
|
||||
let scheduled_due = if let Some(index) = index.as_ref() {
|
||||
document_count = index.documents.len();
|
||||
resource_count = index.resources.len();
|
||||
@@ -463,6 +516,7 @@ fn local_index_status_for_settings(
|
||||
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths;
|
||||
local_index_schedule_is_due(&settings, index.built_at)
|
||||
} else {
|
||||
cache_matches_settings = settings.include_paths.is_empty();
|
||||
local_index_schedule_is_due(&settings, 0)
|
||||
};
|
||||
let evidence_block_count = if evidence_path.exists() {
|
||||
@@ -622,6 +676,16 @@ pub(crate) fn query_evidence_sqlite_results(
|
||||
query: &str,
|
||||
owner_document_id: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||||
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
|
||||
}
|
||||
|
||||
pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||
root_path: &Path,
|
||||
query: &str,
|
||||
owner_document_id: Option<&str>,
|
||||
limit: u32,
|
||||
exact: bool,
|
||||
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||||
let path = evidence_sqlite_path(root_path);
|
||||
if !path.exists() {
|
||||
@@ -631,24 +695,54 @@ pub(crate) fn query_evidence_sqlite_results(
|
||||
if normalized_query.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let owner_document_id = owner_document_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let resource_path = owner_document_id.and_then(local_resource_path_from_document_id);
|
||||
let owner_document_id = if resource_path.is_some() {
|
||||
None
|
||||
} else {
|
||||
owner_document_id
|
||||
};
|
||||
let connection = Connection::open(&path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"evidence_index_open_failed",
|
||||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||||
)
|
||||
})?;
|
||||
if !exact {
|
||||
return Ok(Some(query_evidence_sqlite_fuzzy(
|
||||
&connection,
|
||||
normalized_query,
|
||||
owner_document_id,
|
||||
resource_path.as_deref(),
|
||||
limit,
|
||||
)?));
|
||||
}
|
||||
let fts_query = evidence_fts_phrase(normalized_query);
|
||||
let results = match query_evidence_sqlite_fts(
|
||||
&connection,
|
||||
&fts_query,
|
||||
normalized_query,
|
||||
owner_document_id,
|
||||
resource_path.as_deref(),
|
||||
limit,
|
||||
) {
|
||||
Ok(results) if results.is_empty() => query_evidence_sqlite_like(
|
||||
&connection,
|
||||
normalized_query,
|
||||
owner_document_id,
|
||||
resource_path.as_deref(),
|
||||
limit,
|
||||
)?,
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
query_evidence_sqlite_like(&connection, normalized_query, owner_document_id, limit)?
|
||||
}
|
||||
Err(_) => query_evidence_sqlite_like(
|
||||
&connection,
|
||||
normalized_query,
|
||||
owner_document_id,
|
||||
resource_path.as_deref(),
|
||||
limit,
|
||||
)?,
|
||||
};
|
||||
Ok(Some(results))
|
||||
}
|
||||
@@ -718,6 +812,9 @@ pub(crate) fn read_evidence_sqlite_context(
|
||||
0.8
|
||||
},
|
||||
source: source.clone(),
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Some(results))
|
||||
@@ -804,6 +901,9 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
|
||||
block_id: source.block_id.or(Some(source_block_id)),
|
||||
..source
|
||||
},
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -824,6 +924,7 @@ fn query_evidence_sqlite_fts(
|
||||
fts_query: &str,
|
||||
display_query: &str,
|
||||
owner_document_id: Option<&str>,
|
||||
resource_path: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||
let mut sql = String::from(
|
||||
@@ -835,8 +936,10 @@ fn query_evidence_sqlite_fts(
|
||||
);
|
||||
if owner_document_id.is_some() {
|
||||
sql.push_str(" AND r.owner_document_id = ?2");
|
||||
} else if resource_path.is_some() {
|
||||
sql.push_str(" AND r.source_root_relative_path = ?2");
|
||||
}
|
||||
sql.push_str(if owner_document_id.is_some() {
|
||||
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
|
||||
" ORDER BY rank LIMIT ?3"
|
||||
} else {
|
||||
" ORDER BY rank LIMIT ?2"
|
||||
@@ -850,6 +953,13 @@ fn query_evidence_sqlite_fts(
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
} else if let Some(resource_path) = resource_path {
|
||||
statement
|
||||
.query_map(params![fts_query, resource_path, limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, display_query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
} else {
|
||||
statement
|
||||
.query_map(params![fts_query, limit], |row| {
|
||||
@@ -865,6 +975,7 @@ fn query_evidence_sqlite_like(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
owner_document_id: Option<&str>,
|
||||
resource_path: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||
let mut sql = String::from(
|
||||
@@ -875,8 +986,10 @@ fn query_evidence_sqlite_like(
|
||||
);
|
||||
if owner_document_id.is_some() {
|
||||
sql.push_str(" AND r.owner_document_id = ?2");
|
||||
} else if resource_path.is_some() {
|
||||
sql.push_str(" AND r.source_root_relative_path = ?2");
|
||||
}
|
||||
sql.push_str(if owner_document_id.is_some() {
|
||||
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
|
||||
" LIMIT ?3"
|
||||
} else {
|
||||
" LIMIT ?2"
|
||||
@@ -891,6 +1004,13 @@ fn query_evidence_sqlite_like(
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
} else if let Some(resource_path) = resource_path {
|
||||
statement
|
||||
.query_map(params![like_query, resource_path, limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
} else {
|
||||
statement
|
||||
.query_map(params![like_query, limit], |row| {
|
||||
@@ -902,6 +1022,80 @@ fn query_evidence_sqlite_like(
|
||||
rows.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
fn query_evidence_sqlite_fuzzy(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
owner_document_id: Option<&str>,
|
||||
resource_path: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT b.block_id, b.text, b.locator_json, 0.0 AS rank \
|
||||
FROM evidence_block b \
|
||||
JOIN evidence_resource r ON r.resource_id = b.resource_id",
|
||||
);
|
||||
let first_char_like = query
|
||||
.chars()
|
||||
.find(|ch| !ch.is_whitespace())
|
||||
.map(|ch| format!("%{}%", ch));
|
||||
match (
|
||||
owner_document_id.is_some() || resource_path.is_some(),
|
||||
first_char_like.is_some(),
|
||||
resource_path.is_some(),
|
||||
) {
|
||||
(true, true, true) => {
|
||||
sql.push_str(" WHERE r.source_root_relative_path = ?1 AND b.text LIKE ?2 LIMIT ?3")
|
||||
}
|
||||
(true, true, false) => {
|
||||
sql.push_str(" WHERE r.owner_document_id = ?1 AND b.text LIKE ?2 LIMIT ?3")
|
||||
}
|
||||
(true, false, true) => sql.push_str(" WHERE r.source_root_relative_path = ?1 LIMIT ?2"),
|
||||
(true, false, false) => sql.push_str(" WHERE r.owner_document_id = ?1 LIMIT ?2"),
|
||||
(false, true, _) => sql.push_str(" WHERE b.text LIKE ?1 LIMIT ?2"),
|
||||
(false, false, _) => sql.push_str(" LIMIT ?1"),
|
||||
}
|
||||
let mut statement = connection.prepare(&sql).map_err(sqlite_error)?;
|
||||
let scan_limit = i64::from(limit.max(1)) * 200;
|
||||
let scope_value = owner_document_id.or(resource_path);
|
||||
let rows = match (scope_value, first_char_like.as_deref()) {
|
||||
(Some(scope_value), Some(first_char_like)) => statement
|
||||
.query_map(params![scope_value, first_char_like, scan_limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>(),
|
||||
(Some(scope_value), None) => statement
|
||||
.query_map(params![scope_value, scan_limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>(),
|
||||
(None, Some(first_char_like)) => statement
|
||||
.query_map(params![first_char_like, scan_limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>(),
|
||||
(None, None) => statement
|
||||
.query_map(params![scan_limit], |row| {
|
||||
evidence_result_from_sqlite_row(row, query)
|
||||
})
|
||||
.map_err(sqlite_error)?
|
||||
.collect::<Result<Vec<_>, _>>(),
|
||||
}
|
||||
.map_err(sqlite_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|result| {
|
||||
fuzzy_search_match(
|
||||
&normalize_search_text(&result.quote),
|
||||
&normalize_search_text(query),
|
||||
)
|
||||
})
|
||||
.take(limit.max(1) as usize)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn evidence_result_from_sqlite_row(
|
||||
row: &rusqlite::Row<'_>,
|
||||
query: &str,
|
||||
@@ -922,6 +1116,9 @@ fn evidence_result_from_sqlite_row(
|
||||
1.0 / (1.0 + rank.abs())
|
||||
},
|
||||
source,
|
||||
citation_url: None,
|
||||
citation_label: None,
|
||||
citation_markdown: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -945,6 +1142,18 @@ pub(crate) fn refresh_local_search_index_with_settings(
|
||||
workspace_id: &str,
|
||||
settings: &LocalIndexSettings,
|
||||
) -> Result<Value, WebError> {
|
||||
if settings.include_paths.is_empty() {
|
||||
clear_local_search_index(root_path)?;
|
||||
return Ok(json!({
|
||||
"version": LOCAL_SEARCH_INDEX_VERSION,
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"indexedPaths": [],
|
||||
"builtAt": now_ms(),
|
||||
"documentCount": 0,
|
||||
"resourceCount": 0
|
||||
}));
|
||||
}
|
||||
let index =
|
||||
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, settings)?;
|
||||
Ok(json!({
|
||||
@@ -1427,7 +1636,7 @@ fn parse_local_index_settings_value(
|
||||
fn default_local_index_settings() -> LocalIndexSettings {
|
||||
LocalIndexSettings {
|
||||
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||||
include_paths: default_indexed_paths(),
|
||||
include_paths: Vec::new(),
|
||||
schedule_mode: default_index_schedule_mode(),
|
||||
schedule_time: default_index_schedule_time(),
|
||||
schedule_date: None,
|
||||
@@ -1605,11 +1814,46 @@ fn normalize_index_include_paths(
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let raw_path = PathBuf::from(trimmed);
|
||||
let normalized = if trimmed == "." || trimmed == "/" {
|
||||
".".to_string()
|
||||
} else if raw_path.is_absolute() {
|
||||
let canonical = raw_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_index_scope_not_found",
|
||||
format!("本地索引范围不存在 {}: {error}", raw_path.display()),
|
||||
)
|
||||
})?;
|
||||
if !canonical.starts_with(&root_canonical) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_index_scope_escape",
|
||||
"本地索引目录必须位于当前授权 root 内",
|
||||
));
|
||||
}
|
||||
if !canonical.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_index_scope_not_directory",
|
||||
"本地索引范围必须是目录",
|
||||
));
|
||||
}
|
||||
canonical
|
||||
.strip_prefix(&root_canonical)
|
||||
.map_err(|_| {
|
||||
WebError::bad_request_code(
|
||||
"local_index_scope_escape",
|
||||
"本地索引目录必须位于当前授权 root 内",
|
||||
)
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
} else {
|
||||
normalize_index_relative_path(trimmed.trim_start_matches("./"))?
|
||||
};
|
||||
let normalized = if normalized.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
if normalized.split('/').any(|part| part == ".mnote") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_index_scope_reserved",
|
||||
@@ -1640,9 +1884,6 @@ fn normalize_index_include_paths(
|
||||
}
|
||||
output.push(normalized);
|
||||
}
|
||||
if output.is_empty() {
|
||||
output.push(".".to_string());
|
||||
}
|
||||
Ok(normalized_indexed_paths(&output))
|
||||
}
|
||||
|
||||
@@ -1884,6 +2125,27 @@ fn write_local_search_index_json(
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
|
||||
let index_path = root_path
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json");
|
||||
let evidence_path = evidence_sqlite_path(root_path);
|
||||
for path in [index_path, evidence_path] {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_index_delete_failed",
|
||||
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_evidence_sqlite_index(
|
||||
root_path: &Path,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -3151,6 +3413,72 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct LocalEvidenceSourceStatuses {
|
||||
pub(crate) indexed_paths: BTreeSet<String>,
|
||||
pub(crate) failed_paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn local_evidence_source_statuses(
|
||||
root_path: &Path,
|
||||
) -> Result<LocalEvidenceSourceStatuses, WebError> {
|
||||
let mut statuses = LocalEvidenceSourceStatuses::default();
|
||||
let evidence_path = evidence_sqlite_path(root_path);
|
||||
if evidence_path.exists() {
|
||||
let connection = Connection::open(&evidence_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"evidence_index_open_failed",
|
||||
format!(
|
||||
"无法打开 evidence 索引 {}: {error}",
|
||||
evidence_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT DISTINCT source_root_relative_path \
|
||||
FROM evidence_resource \
|
||||
WHERE provider NOT IN ('resource', 'markdown')",
|
||||
)
|
||||
.map_err(sqlite_error)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(sqlite_error)?;
|
||||
for row in rows {
|
||||
if let Ok(path) = row {
|
||||
if !path.trim().is_empty() {
|
||||
statuses.indexed_paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(index) = read_local_search_index(root_path)? {
|
||||
for resource in index.resources {
|
||||
if matches!(resource.resource_type.as_str(), "pdf" | "office")
|
||||
&& !statuses.indexed_paths.contains(&resource.path)
|
||||
{
|
||||
statuses.failed_paths.insert(resource.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
||||
match resource_type {
|
||||
"mindmap" => "mindmap",
|
||||
@@ -3179,6 +3507,14 @@ fn evidence_resource_kind_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
document_id
|
||||
.trim()
|
||||
.strip_prefix("local-resource:")
|
||||
.map(|value| value.replace("~2F", "/").replace("~2f", "/"))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
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"));
|
||||
@@ -3267,9 +3603,9 @@ fn local_search_document_matches(
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
} else {
|
||||
fuzzy_search_match(&haystack, query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3291,9 +3627,9 @@ fn local_search_resource_matches(
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
} else {
|
||||
fuzzy_search_match(&haystack, query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3319,9 +3655,9 @@ fn local_search_ocr_matches(
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
} else {
|
||||
fuzzy_search_match(&haystack, query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3330,6 +3666,7 @@ fn local_search_document_projection(
|
||||
root_uri: &str,
|
||||
query: &str,
|
||||
) -> Value {
|
||||
let hit = search_document_hit(document, query);
|
||||
json!({
|
||||
"id": document.document_id,
|
||||
"documentId": document.document_id,
|
||||
@@ -3338,7 +3675,12 @@ fn local_search_document_projection(
|
||||
"resourceType": "markdown",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"snippet": search_snippet(document, query),
|
||||
"snippet": hit.snippet,
|
||||
"blockId": hit.block_id,
|
||||
"lineRange": {
|
||||
"start": hit.line_number,
|
||||
"end": hit.line_number,
|
||||
},
|
||||
"tags": document.tags,
|
||||
"backlinks": document.backlinks,
|
||||
"resourceRefs": document.resource_refs,
|
||||
@@ -3548,16 +3890,43 @@ fn is_local_resource_reference(target: &str) -> bool {
|
||||
}
|
||||
|
||||
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
||||
for line in document.raw_text.lines() {
|
||||
search_document_hit(document, query).snippet
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchDocumentHit {
|
||||
snippet: String,
|
||||
line_number: usize,
|
||||
block_id: String,
|
||||
}
|
||||
|
||||
fn search_document_hit(document: &LocalSearchDocument, query: &str) -> SearchDocumentHit {
|
||||
for (line_index, line) in document.raw_text.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if normalize_search_text(trimmed).contains(query) {
|
||||
return trimmed.chars().take(180).collect();
|
||||
let line_number = line_index + 1;
|
||||
return SearchDocumentHit {
|
||||
snippet: trimmed.chars().take(180).collect(),
|
||||
line_number,
|
||||
block_id: format!("{}#line{}", document.document_id, line_number),
|
||||
};
|
||||
}
|
||||
}
|
||||
document.raw_text.chars().take(180).collect()
|
||||
let line_number = document
|
||||
.raw_text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.find(|(_, line)| !line.trim().is_empty())
|
||||
.map(|(line_index, _)| line_index + 1)
|
||||
.unwrap_or(1);
|
||||
SearchDocumentHit {
|
||||
snippet: document.raw_text.chars().take(180).collect(),
|
||||
line_number,
|
||||
block_id: format!("{}#line{}", document.document_id, line_number),
|
||||
}
|
||||
}
|
||||
|
||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
@@ -3576,11 +3945,73 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
normalized_body[start..].chars().take(160).collect()
|
||||
} else if let Some(byte_index) = fuzzy_search_start_byte(&normalized_body, normalized_query) {
|
||||
let start = normalized_body[..byte_index]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(40)
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
normalized_body[start..].chars().take(180).collect()
|
||||
} else {
|
||||
normalized_body.chars().take(160).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_search_match(haystack: &str, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if haystack.contains(query) {
|
||||
return true;
|
||||
}
|
||||
let mut query_chars = query.chars().filter(|ch| !ch.is_whitespace());
|
||||
let Some(mut wanted) = query_chars.next() else {
|
||||
return false;
|
||||
};
|
||||
for ch in haystack.chars().filter(|ch| !ch.is_whitespace()) {
|
||||
if ch == wanted {
|
||||
match query_chars.next() {
|
||||
Some(next) => wanted = next,
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let query_chars = query
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect::<Vec<_>>();
|
||||
if query_chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let haystack_chars = haystack.char_indices().collect::<Vec<_>>();
|
||||
for (start_index, (byte_index, ch)) in haystack_chars.iter().enumerate() {
|
||||
if ch != &query_chars[0] {
|
||||
continue;
|
||||
}
|
||||
let mut query_index = 1usize;
|
||||
for (_, next_ch) in haystack_chars.iter().skip(start_index + 1) {
|
||||
if next_ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if query_index < query_chars.len() && next_ch == &query_chars[query_index] {
|
||||
query_index += 1;
|
||||
if query_index >= query_chars.len() {
|
||||
return Some(*byte_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
@@ -3905,6 +4336,89 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_index_settings_accepts_absolute_path_under_root_as_frozen_scope() {
|
||||
let root = temp_root("mnote-local-index-settings-absolute");
|
||||
fs::create_dir_all(root.join("docs").join("absolute")).expect("create absolute dir");
|
||||
let absolute_scope = root.join("docs").join("absolute");
|
||||
|
||||
let settings = write_local_index_settings(
|
||||
&root,
|
||||
&[absolute_scope.to_string_lossy().to_string()],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("absolute scope under root");
|
||||
|
||||
assert_eq!(settings.include_paths, vec![String::from("docs/absolute")]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_local_index_empty_scope_deletes_index_files() {
|
||||
let root = temp_root("mnote-local-index-empty-delete");
|
||||
fs::write(
|
||||
root.join("docs").join("keep.md"),
|
||||
"# Keep\nDeleteIndexToken\n",
|
||||
)
|
||||
.expect("write doc");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-empty-delete";
|
||||
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||||
|
||||
write_user_local_index_settings(
|
||||
&store,
|
||||
"alice",
|
||||
workspace_id,
|
||||
&root,
|
||||
&[String::from("docs")],
|
||||
Some("manual"),
|
||||
Some("02:00"),
|
||||
None,
|
||||
Some(false),
|
||||
)
|
||||
.expect("write indexed scope");
|
||||
let effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||||
.expect("effective indexed");
|
||||
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &effective)
|
||||
.expect("refresh indexed");
|
||||
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||
|
||||
write_user_local_index_settings(
|
||||
&store,
|
||||
"alice",
|
||||
workspace_id,
|
||||
&root,
|
||||
&[],
|
||||
Some("manual"),
|
||||
Some("02:00"),
|
||||
None,
|
||||
Some(false),
|
||||
)
|
||||
.expect("delete indexed scopes");
|
||||
let empty_effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||||
.expect("effective empty");
|
||||
assert!(empty_effective.include_paths.is_empty());
|
||||
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &empty_effective)
|
||||
.expect("clear index files");
|
||||
|
||||
assert!(!root.join(".mnote/index/search-index.json").exists());
|
||||
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
|
||||
let status = local_index_status_with_settings(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
&empty_effective,
|
||||
&empty_effective,
|
||||
)
|
||||
.expect("status");
|
||||
assert_eq!(status["cacheMatchesSettings"].as_bool(), Some(true));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_index_settings_rejects_escape_and_internal_cache_scope() {
|
||||
let root = temp_root("mnote-local-index-settings-escape");
|
||||
@@ -3949,6 +4463,14 @@ mod tests {
|
||||
initial_status["settings"]["runOnChange"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
initial_status["settings"]["includePaths"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(0),
|
||||
"默认不应索引工作区根目录;用户新增范围后才开始索引"
|
||||
);
|
||||
assert_eq!(initial_status["cacheMatchesSettings"].as_bool(), Some(true));
|
||||
|
||||
let settings = write_local_index_settings(
|
||||
&root,
|
||||
@@ -4356,6 +4878,8 @@ mod tests {
|
||||
)
|
||||
.expect("write child");
|
||||
|
||||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||
.expect("settings");
|
||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
|
||||
fs::write(
|
||||
root.join("docs").join("child.md"),
|
||||
@@ -4643,6 +5167,8 @@ mod tests {
|
||||
)
|
||||
.expect("write child");
|
||||
|
||||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||
.expect("settings");
|
||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||
|
||||
let results = query_evidence_sqlite_results(&root, "EvidenceToken", None, 10)
|
||||
@@ -4750,6 +5276,8 @@ mod tests {
|
||||
)
|
||||
.expect("write home");
|
||||
|
||||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||
.expect("settings");
|
||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||
let results = query_evidence_sqlite_results(&root, "ReadContextToken", None, 10)
|
||||
.expect("sqlite query")
|
||||
@@ -4821,6 +5349,8 @@ JSON
|
||||
let old_bin = std::env::var("MNOTE_LITEPARSE_BIN").ok();
|
||||
std::env::set_var("MNOTE_LITEPARSE_BIN", &lit);
|
||||
|
||||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||
.expect("settings");
|
||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||
|
||||
if let Some(value) = old_bin {
|
||||
@@ -4850,6 +5380,21 @@ JSON
|
||||
hit.source.source_map_path.as_deref(),
|
||||
Some("docs/Page.ocr/spec.pdf.source-map.json")
|
||||
);
|
||||
let resource_scoped = query_evidence_sqlite_results_with_mode(
|
||||
&root,
|
||||
"ResourceBodyToken",
|
||||
Some("local-resource:docs~2FPage.assets~2Fspec.pdf"),
|
||||
10,
|
||||
true,
|
||||
)
|
||||
.expect("resource scoped sqlite query")
|
||||
.expect("sqlite exists");
|
||||
assert_eq!(resource_scoped.len(), 1);
|
||||
assert_eq!(
|
||||
resource_scoped[0].source.resource_path.as_deref(),
|
||||
Some("docs/Page.assets/spec.pdf"),
|
||||
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
|
||||
);
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
|
||||
@@ -37,16 +37,21 @@ pub(crate) mod ui_preferences;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use gateway::current_actor_id;
|
||||
pub(crate) use local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use local_search_index::write_local_index_settings;
|
||||
pub(crate) use local_search_index::{
|
||||
refresh_local_search_index_for_change_path_with_store,
|
||||
effective_local_index_settings_for_root, local_index_status_with_settings,
|
||||
preview_user_local_index_settings, read_local_index_settings_or_default,
|
||||
read_user_local_index_settings, refresh_local_search_index_for_change_path_with_store,
|
||||
refresh_local_search_index_if_scheduled_due_with_store,
|
||||
refresh_local_search_index_with_settings, write_user_local_index_settings, LocalIndexSettings,
|
||||
};
|
||||
|
||||
use crate::app::AppState;
|
||||
@@ -641,6 +646,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/client/skills", get(hermes_client::list_skills))
|
||||
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
||||
.route(
|
||||
"/client/capabilities",
|
||||
get(hermes_client::list_capabilities),
|
||||
)
|
||||
.route(
|
||||
"/client/capabilities/toggle",
|
||||
put(hermes_client::toggle_capability),
|
||||
)
|
||||
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
||||
.route("/client/runs", post(hermes_client::create_run))
|
||||
.route(
|
||||
|
||||
@@ -229,6 +229,25 @@ pub async fn documents(
|
||||
EvidenceSearchMode::Hybrid,
|
||||
&normalized_query,
|
||||
);
|
||||
let limit = body.limit.unwrap_or(30).max(1) as usize;
|
||||
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
|
||||
local_search_index::query_evidence_sqlite_results_with_mode(
|
||||
&root_path,
|
||||
&normalized_query,
|
||||
page_id.as_deref(),
|
||||
body.limit.unwrap_or(30),
|
||||
filters.exact.unwrap_or(false),
|
||||
)?
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (result, evidence_results) = merge_local_search_with_evidence_results(
|
||||
result,
|
||||
evidence_results,
|
||||
direct_evidence_results,
|
||||
limit,
|
||||
);
|
||||
(result, evidence_results)
|
||||
} else {
|
||||
let result = load_search_results_with_filters(
|
||||
@@ -419,6 +438,12 @@ pub async fn update_local_index_settings(
|
||||
&effective_workspace_id,
|
||||
&root_path,
|
||||
)?;
|
||||
let refreshed = local_search_index::refresh_local_search_index_with_settings(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
&effective_settings,
|
||||
)?;
|
||||
let status = local_search_index::local_index_status_with_settings(
|
||||
&root_path,
|
||||
root_uri,
|
||||
@@ -434,6 +459,7 @@ pub async fn update_local_index_settings(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"settings": settings,
|
||||
"index": refreshed,
|
||||
"result": status,
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
@@ -446,6 +472,75 @@ pub async fn update_local_index_settings(
|
||||
))
|
||||
}
|
||||
|
||||
fn merge_local_search_with_evidence_results(
|
||||
mut result: Value,
|
||||
mut evidence_results: Vec<EvidenceSearchResult>,
|
||||
direct_evidence_results: Vec<EvidenceSearchResult>,
|
||||
limit: usize,
|
||||
) -> (Value, Vec<EvidenceSearchResult>) {
|
||||
if direct_evidence_results.is_empty() {
|
||||
return (result, evidence_results);
|
||||
}
|
||||
let mut result_items = result
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut seen_evidence_ids = evidence_results
|
||||
.iter()
|
||||
.map(|item| item.evidence_id.clone())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
for evidence in direct_evidence_results {
|
||||
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
result_items.push(search_result_from_evidence(&evidence));
|
||||
evidence_results.push(evidence);
|
||||
}
|
||||
if let Some(map) = result.as_object_mut() {
|
||||
map.insert("results".into(), Value::Array(result_items));
|
||||
}
|
||||
(result, evidence_results)
|
||||
}
|
||||
|
||||
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
|
||||
let source = &evidence.source;
|
||||
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
||||
let resource_path = source
|
||||
.resource_path
|
||||
.as_deref()
|
||||
.unwrap_or(source.owner_document_path.as_str());
|
||||
let title = std::path::Path::new(resource_path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(resource_path)
|
||||
.to_string();
|
||||
let resource_type = match source.resource_kind {
|
||||
core_protocol::EvidenceResourceKind::Markdown => "markdown",
|
||||
core_protocol::EvidenceResourceKind::Pdf => "pdf",
|
||||
core_protocol::EvidenceResourceKind::Image => "image",
|
||||
core_protocol::EvidenceResourceKind::Office => "office",
|
||||
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
|
||||
core_protocol::EvidenceResourceKind::RawFile => "resource",
|
||||
};
|
||||
json!({
|
||||
"id": format!("evidence:{}", evidence.evidence_id),
|
||||
"documentId": source.owner_document_id,
|
||||
"title": title,
|
||||
"path": source.owner_document_path,
|
||||
"resourceType": resource_type,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": source.root_uri,
|
||||
"snippet": evidence.quote,
|
||||
"score": evidence.score,
|
||||
"publicPath": source.open_action.url,
|
||||
"evidence": evidence_value,
|
||||
"source": {
|
||||
"locator": source
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn attach_evidence_to_search_results(
|
||||
results: Value,
|
||||
evidence_results: &[EvidenceSearchResult],
|
||||
@@ -463,6 +558,9 @@ fn attach_evidence_to_search_results(
|
||||
};
|
||||
let mut item = item;
|
||||
if let Some(map) = item.as_object_mut() {
|
||||
if map.get("evidence").is_some() {
|
||||
return item;
|
||||
}
|
||||
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
||||
map.insert("evidence".into(), evidence_value);
|
||||
let source = map.entry("source").or_insert_with(|| json!({}));
|
||||
@@ -793,6 +891,7 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::routes::local_search_index;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
@@ -998,6 +1097,15 @@ mod tests {
|
||||
.expect("readme");
|
||||
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("settings");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -1102,6 +1210,104 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-search-evidence-route-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-search-evidence","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"# Search Home\nBodyOnlyEvidenceToken only exists inside evidence.sqlite after refresh.\n",
|
||||
)
|
||||
.expect("readme");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("settings");
|
||||
local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
"local-ws-search-evidence",
|
||||
)
|
||||
.expect("refresh");
|
||||
fs::write(
|
||||
root.join(".mnote").join("index").join("search-index.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"version": 1,
|
||||
"builtAt": 1,
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": "local-ws-search-evidence",
|
||||
"documents": [],
|
||||
"resources": []
|
||||
}))
|
||||
.expect("stale search index"),
|
||||
)
|
||||
.expect("overwrite search index");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/documents")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "local-ws-search-evidence",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"query": "BodyOnlyEvidenceToken",
|
||||
"limit": 10,
|
||||
"filters": {
|
||||
"titleOnly": false,
|
||||
"includeOcr": true
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let results = payload["results"].as_array().expect("results");
|
||||
let hit = results
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["snippet"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.contains("BodyOnlyEvidenceToken")
|
||||
})
|
||||
.expect("evidence sqlite body hit should be promoted to search result");
|
||||
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
|
||||
assert_eq!(
|
||||
hit["evidence"]["source"]["schema"].as_str(),
|
||||
Some("mnote.evidence_locator.v1")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_local_index_refresh_rebuilds_authorized_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -1156,6 +1362,102 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_index_settings_empty_scope_deletes_index_files() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-search-settings-delete-route-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-settings-delete","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files","search"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("docs").join("keep.md"),
|
||||
"# Keep\nRouteDeleteToken\n",
|
||||
)
|
||||
.expect("doc");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let app = app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/search/local-index/settings")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "local-ws-settings-delete",
|
||||
"rootUri": root_uri,
|
||||
"includePaths": ["docs"],
|
||||
"scheduleMode": "manual",
|
||||
"scheduleTime": "02:00",
|
||||
"runOnChange": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("create settings request"),
|
||||
)
|
||||
.await
|
||||
.expect("create settings response");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||
|
||||
let delete_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/search/local-index/settings")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "local-ws-settings-delete",
|
||||
"rootUri": root_uri,
|
||||
"includePaths": [],
|
||||
"scheduleMode": "manual",
|
||||
"scheduleTime": "02:00",
|
||||
"runOnChange": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("delete settings request"),
|
||||
)
|
||||
.await
|
||||
.expect("delete settings response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("delete body");
|
||||
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
|
||||
assert_eq!(
|
||||
delete_payload["index"]["indexedPaths"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
delete_payload["result"]["settings"]["includePaths"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
assert!(!root.join(".mnote/index/search-index.json").exists());
|
||||
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -660,6 +660,17 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
object_identity: resource_meta
|
||||
.and_then(|meta| meta.get("objectIdentity"))
|
||||
.and_then(|value| serde_json::to_string(value).ok()),
|
||||
index_status: item
|
||||
.get("indexStatus")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("indexStatus"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| *value == "indexed" || *value == "failed")
|
||||
.map(ToOwned::to_owned),
|
||||
selected,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -986,6 +986,14 @@ pub struct OfficePreviewQuery {
|
||||
source_kind: Option<String>,
|
||||
root_uri: Option<String>,
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
page: Option<u32>,
|
||||
#[serde(default)]
|
||||
bbox: Option<String>,
|
||||
#[serde(default, alias = "sourceMapPath")]
|
||||
source_map_path: Option<String>,
|
||||
#[serde(default, alias = "blockId")]
|
||||
block_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
|
||||
@@ -1013,6 +1021,13 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
let source_kind = query.source_kind.unwrap_or_default();
|
||||
let root_uri = query.root_uri.unwrap_or_default();
|
||||
let document_id = query.document_id.unwrap_or_default();
|
||||
let target_page = query
|
||||
.page
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default();
|
||||
let target_bbox = query.bbox.unwrap_or_default();
|
||||
let target_source_map_path = query.source_map_path.unwrap_or_default();
|
||||
let target_block_id = query.block_id.unwrap_or_default();
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -1047,6 +1062,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
.mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
|
||||
.mnote-office-viewer .docx-wrapper > section.docx,
|
||||
.mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
||||
.mnote-office-viewer {{ position: relative; }}
|
||||
.mnote-office-viewer [data-mnote-office-evidence-target="true"] {{ outline: 0; border-radius: 2px; background: #FFE9E6; color: #D83A32; box-shadow: 0 0 0 1px rgba(216, 58, 50, .22); }}
|
||||
.mnote-office-evidence-marker {{ position: absolute; z-index: 3; left: 24px; max-width: min(720px, calc(100% - 48px)); padding: 6px 10px; border: 1px solid rgba(216, 58, 50, .45); border-radius: 6px; background: rgba(255, 249, 248, .96); color: #D83A32; font-size: 13px; line-height: 1.5; box-shadow: 0 2px 10px rgba(15, 23, 42, .12); }}
|
||||
.mnote-office-evidence-marker[data-mnote-office-evidence-marker-mode="range"] {{ pointer-events: none; background: rgba(255, 233, 230, .72); box-shadow: 0 0 0 1px rgba(216, 58, 50, .28); }}
|
||||
.mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
|
||||
.mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
|
||||
.mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
||||
@@ -1055,7 +1074,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}">
|
||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}">
|
||||
<main class="mnote-office-preview">
|
||||
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
|
||||
</main>
|
||||
@@ -1070,6 +1089,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
const fileName = body.dataset.fileName || '';
|
||||
const fileType = (body.dataset.fileType || '').toLowerCase();
|
||||
const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
|
||||
let evidencePage = Number(body.dataset.evidencePage || 0);
|
||||
let evidenceBbox = body.dataset.evidenceBbox || '';
|
||||
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
|
||||
let evidenceBlockId = body.dataset.evidenceBlockId || '';
|
||||
let currentPptxBuffer = null;
|
||||
let pptxRenderToken = 0;
|
||||
let pptxResizeTimer = 0;
|
||||
@@ -1136,6 +1159,299 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
viewer.append(message);
|
||||
}}
|
||||
|
||||
function normalizeEvidenceText(value) {{
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}}
|
||||
|
||||
function markEvidenceTarget(target) {{
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
|
||||
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-office-evidence-target');
|
||||
}});
|
||||
target.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||
window.setTimeout(() => target.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
|
||||
document.documentElement.setAttribute('data-mnote-office-evidence-applied', 'true');
|
||||
return true;
|
||||
}}
|
||||
|
||||
function normalizedTextWithRawOffsets(value) {{
|
||||
const raw = String(value || '');
|
||||
let text = '';
|
||||
const offsets = [];
|
||||
let previousWhitespace = true;
|
||||
for (let index = 0; index < raw.length; index += 1) {{
|
||||
const ch = raw[index];
|
||||
if (/\s/.test(ch)) {{
|
||||
if (text && !previousWhitespace) {{
|
||||
text += ' ';
|
||||
offsets.push(index);
|
||||
}}
|
||||
previousWhitespace = true;
|
||||
}} else {{
|
||||
text += ch;
|
||||
offsets.push(index);
|
||||
previousWhitespace = false;
|
||||
}}
|
||||
}}
|
||||
if (text.endsWith(' ')) {{
|
||||
text = text.slice(0, -1);
|
||||
offsets.pop();
|
||||
}}
|
||||
return {{ text, offsets }};
|
||||
}}
|
||||
|
||||
function compactTextWithRawOffsets(value) {{
|
||||
const raw = String(value || '');
|
||||
let text = '';
|
||||
const offsets = [];
|
||||
for (let index = 0; index < raw.length; index += 1) {{
|
||||
const ch = raw[index];
|
||||
if (/\s/.test(ch)) continue;
|
||||
text += ch;
|
||||
offsets.push(index);
|
||||
}}
|
||||
return {{ text, offsets }};
|
||||
}}
|
||||
|
||||
function wrapEvidenceTextNode(node, needle) {{
|
||||
if (!(node instanceof Text)) return null;
|
||||
const raw = String(node.textContent || '');
|
||||
let start = raw.indexOf(needle);
|
||||
let end = start >= 0 ? start + needle.length : -1;
|
||||
if (start < 0) {{
|
||||
const compact = compactTextWithRawOffsets(raw);
|
||||
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
|
||||
let normalizedStart = compact.text.indexOf(compactNeedle);
|
||||
let sourceOffsets = compact.offsets;
|
||||
if (normalizedStart < 0) {{
|
||||
const mapped = normalizedTextWithRawOffsets(raw);
|
||||
const mappedNeedle = normalizeEvidenceText(needle);
|
||||
normalizedStart = mapped.text.indexOf(mappedNeedle);
|
||||
sourceOffsets = mapped.offsets;
|
||||
if (normalizedStart < 0) return null;
|
||||
start = sourceOffsets[normalizedStart];
|
||||
end = sourceOffsets[normalizedStart + mappedNeedle.length - 1] + 1;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||
try {{
|
||||
range.surroundContents(span);
|
||||
return span;
|
||||
}} catch (_) {{
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
if (normalizedStart < 0) return null;
|
||||
start = sourceOffsets[normalizedStart];
|
||||
end = sourceOffsets[normalizedStart + compactNeedle.length - 1] + 1;
|
||||
}}
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||
try {{
|
||||
range.surroundContents(span);
|
||||
return span;
|
||||
}} catch (_) {{
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
|
||||
function markEvidenceRangeAcrossTextNodes(needle) {{
|
||||
if (!viewer) return false;
|
||||
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
|
||||
if (!compactNeedle) return false;
|
||||
const refs = [];
|
||||
let compactText = '';
|
||||
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {{
|
||||
const raw = String(node.textContent || '');
|
||||
for (let offset = 0; offset < raw.length; offset += 1) {{
|
||||
const ch = raw[offset];
|
||||
if (/\s/.test(ch)) continue;
|
||||
compactText += ch;
|
||||
refs.push({{ node, offset }});
|
||||
}}
|
||||
node = walker.nextNode();
|
||||
}}
|
||||
const startIndex = compactText.indexOf(compactNeedle);
|
||||
if (startIndex < 0) return false;
|
||||
const endIndex = startIndex + compactNeedle.length - 1;
|
||||
const startRef = refs[startIndex];
|
||||
const endRef = refs[endIndex];
|
||||
if (!startRef || !endRef) return false;
|
||||
const range = document.createRange();
|
||||
range.setStart(startRef.node, startRef.offset);
|
||||
range.setEnd(endRef.node, endRef.offset + 1);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||
if (!(marker instanceof HTMLElement)) {{
|
||||
marker = document.createElement('div');
|
||||
marker.className = 'mnote-office-evidence-marker';
|
||||
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
|
||||
viewer.append(marker);
|
||||
}}
|
||||
const viewerRect = viewer.getBoundingClientRect();
|
||||
marker.textContent = '';
|
||||
marker.setAttribute('data-mnote-office-evidence-target-text', normalizeEvidenceText(needle));
|
||||
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'range');
|
||||
marker.style.left = Math.max(0, Math.round(rect.left - viewerRect.left + viewer.scrollLeft)).toString() + 'px';
|
||||
marker.style.top = Math.max(0, Math.round(rect.top - viewerRect.top + viewer.scrollTop)).toString() + 'px';
|
||||
marker.style.width = Math.max(8, Math.round(rect.width)).toString() + 'px';
|
||||
marker.style.height = Math.max(8, Math.round(rect.height)).toString() + 'px';
|
||||
marker.style.maxWidth = 'none';
|
||||
marker.style.padding = '0';
|
||||
return markEvidenceTarget(marker);
|
||||
}}
|
||||
|
||||
function pageForEvidenceBlock(sourceMap, block) {{
|
||||
if (!sourceMap || typeof sourceMap !== 'object' || !block) return null;
|
||||
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
|
||||
for (const page of pages) {{
|
||||
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
|
||||
if (blocks.includes(block)) return page;
|
||||
}}
|
||||
return null;
|
||||
}}
|
||||
|
||||
function findEvidenceBlockInSourceMap(sourceMap) {{
|
||||
if (!sourceMap || typeof sourceMap !== 'object' || !evidenceBlockId) return null;
|
||||
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
|
||||
for (const page of pages) {{
|
||||
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
|
||||
const block = blocks.find(item => String(item && (item.id || item.blockId || item.block_id) || '') === evidenceBlockId);
|
||||
if (block) return block;
|
||||
}}
|
||||
return null;
|
||||
}}
|
||||
|
||||
async function fetchEvidenceSourceMap() {{
|
||||
if (!evidenceSourceMapPath || !body.dataset.mnoteRootUri) return null;
|
||||
const params = new URLSearchParams();
|
||||
params.set('rootUri', body.dataset.mnoteRootUri);
|
||||
params.set('path', evidenceSourceMapPath);
|
||||
const response = await fetch('/api/local-folder/files/open?' + params.toString(), {{
|
||||
credentials: 'same-origin',
|
||||
headers: {{ accept: 'application/json, text/plain, */*' }}
|
||||
}});
|
||||
if (!response.ok) return null;
|
||||
return response.json().catch(() => null);
|
||||
}}
|
||||
|
||||
function scrollToEvidenceText(text) {{
|
||||
const needle = normalizeEvidenceText(text);
|
||||
if (!needle || !viewer) return false;
|
||||
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
|
||||
if (node instanceof HTMLElement) {{
|
||||
if (node.tagName === 'SPAN' && node.childNodes.length === 1 && node.firstChild instanceof Text) {{
|
||||
node.replaceWith(node.firstChild);
|
||||
}} else {{
|
||||
node.removeAttribute('data-mnote-office-evidence-target');
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {{
|
||||
if (normalizeEvidenceText(node.textContent).includes(needle)) {{
|
||||
const inlineTarget = wrapEvidenceTextNode(node, needle);
|
||||
if (inlineTarget) return markEvidenceTarget(inlineTarget);
|
||||
const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
|
||||
if (target instanceof HTMLElement) {{
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
|
||||
}}
|
||||
return markEvidenceTarget(target);
|
||||
}}
|
||||
node = walker.nextNode();
|
||||
}}
|
||||
if (markEvidenceRangeAcrossTextNodes(needle)) return true;
|
||||
return false;
|
||||
}}
|
||||
|
||||
function scrollToEvidenceCoordinate(sourceMap, block) {{
|
||||
if (!viewer || !sourceMap || !block) return false;
|
||||
const page = pageForEvidenceBlock(sourceMap, block);
|
||||
const bbox = block.bbox && typeof block.bbox === 'object' ? block.bbox : null;
|
||||
const pageNumber = Number(page && page.page || evidencePage || 0);
|
||||
const pageCount = Math.max(1, Number(sourceMap.pageCount || (Array.isArray(sourceMap.pages) ? sourceMap.pages.length : 0)) || 1);
|
||||
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return false;
|
||||
const renderedPages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'))
|
||||
.filter(node => node instanceof HTMLElement);
|
||||
const pageElement = renderedPages[pageNumber - 1];
|
||||
let top = 0;
|
||||
if (pageElement instanceof HTMLElement) {{
|
||||
const pageHeight = Math.max(1, pageElement.scrollHeight || pageElement.getBoundingClientRect().height || 1);
|
||||
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || pageHeight);
|
||||
top = pageElement.offsetTop + (bbox ? (Number(bbox.y0) / sourcePageHeight) * pageHeight : pageHeight / 2);
|
||||
}} else {{
|
||||
const contentHeight = Math.max(1, viewer.scrollHeight || document.documentElement.scrollHeight || 1);
|
||||
const estimatedPageHeight = contentHeight / pageCount;
|
||||
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || estimatedPageHeight);
|
||||
top = estimatedPageHeight * (pageNumber - 1) + (bbox ? (Number(bbox.y0) / sourcePageHeight) * estimatedPageHeight : estimatedPageHeight / 2);
|
||||
}}
|
||||
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||
if (!(marker instanceof HTMLElement)) {{
|
||||
marker = document.createElement('div');
|
||||
marker.className = 'mnote-office-evidence-marker';
|
||||
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
|
||||
viewer.append(marker);
|
||||
}}
|
||||
marker.textContent = normalizeEvidenceText(block.text || evidenceBlockId || '命中位置');
|
||||
marker.removeAttribute('data-mnote-office-evidence-target-text');
|
||||
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'estimated');
|
||||
marker.style.width = '';
|
||||
marker.style.height = '';
|
||||
marker.style.padding = '';
|
||||
marker.style.top = Math.max(0, Math.round(top)).toString() + 'px';
|
||||
return markEvidenceTarget(marker);
|
||||
}}
|
||||
|
||||
function scrollToEvidencePageFallback() {{
|
||||
if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
|
||||
const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
|
||||
const target = pages[Math.max(0, Math.min(pages.length - 1, evidencePage - 1))];
|
||||
return markEvidenceTarget(target);
|
||||
}}
|
||||
|
||||
async function applyEvidenceLocator() {{
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox)) return;
|
||||
try {{
|
||||
const sourceMap = await fetchEvidenceSourceMap();
|
||||
const block = findEvidenceBlockInSourceMap(sourceMap);
|
||||
if (block && scrollToEvidenceText(block.text)) return;
|
||||
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
|
||||
}} catch (_) {{}}
|
||||
scrollToEvidencePageFallback();
|
||||
}}
|
||||
|
||||
function updateEvidenceLocator(locator) {{
|
||||
const next = locator && typeof locator === 'object' ? locator : {{}};
|
||||
evidencePage = Number(next.page || 0);
|
||||
evidenceBbox = String(next.bbox || '');
|
||||
evidenceSourceMapPath = String(next.sourceMapPath || '');
|
||||
evidenceBlockId = String(next.blockId || '');
|
||||
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
|
||||
body.dataset.evidenceBbox = evidenceBbox;
|
||||
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
|
||||
body.dataset.evidenceBlockId = evidenceBlockId;
|
||||
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
|
||||
void applyEvidenceLocator();
|
||||
}}
|
||||
|
||||
window.addEventListener('message', (event) => {{
|
||||
if (event.origin !== window.location.origin) return;
|
||||
const data = event.data && typeof event.data === 'object' ? event.data : {{}};
|
||||
if (data.type === 'mnote:office-evidence-locator') updateEvidenceLocator(data);
|
||||
}});
|
||||
|
||||
async function fetchArrayBuffer() {{
|
||||
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
|
||||
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
|
||||
@@ -1321,6 +1637,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
else if (fileType === 'csv') await renderCsv();
|
||||
else if (fileType === 'pptx') await renderPptx();
|
||||
else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + ',请用 OnlyOffice 打开。');
|
||||
await applyEvidenceLocator();
|
||||
setStatus('完成');
|
||||
}} catch (error) {{
|
||||
console.warn('[mnote office preview] render failed', error);
|
||||
@@ -1342,6 +1659,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
source_kind = escape_html(&source_kind),
|
||||
root_uri = escape_html(&root_uri),
|
||||
document_id = escape_html(&document_id),
|
||||
target_page = escape_html(&target_page),
|
||||
target_bbox = escape_html(&target_bbox),
|
||||
target_source_map_path = escape_html(&target_source_map_path),
|
||||
target_block_id = escape_html(&target_block_id),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "office-preview");
|
||||
@@ -3310,6 +3631,12 @@ mod tests {
|
||||
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
|
||||
assert!(runtime.contains("document-resource-tab-runtime.js"));
|
||||
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
|
||||
assert!(resource_runtime.contains("后台任务"));
|
||||
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
|
||||
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
|
||||
assert!(resource_runtime.contains("role=\"progressbar\""));
|
||||
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
|
||||
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
|
||||
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
|
||||
assert!(runtime.contains("getOpenEditorsSnapshot"));
|
||||
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
|
||||
@@ -3361,6 +3688,8 @@ mod tests {
|
||||
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
|
||||
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
|
||||
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
|
||||
assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')"));
|
||||
assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')"));
|
||||
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
|
||||
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
|
||||
assert!(sidebar_runtime.contains("data-evidence-locator"));
|
||||
|
||||
Reference in New Issue
Block a user