feat(rag): align LightRAG native citations and MCP bridge
This commit is contained in:
@@ -402,51 +402,6 @@ struct MindmapOutlineToolPayload {
|
||||
outline: Vec<MindmapOutlineItemPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsSearchDatasetPayload {
|
||||
workspace_id: String,
|
||||
documents: Vec<index_fts::SearchDocumentRecord>,
|
||||
mindmaps: Vec<index_fts::SearchMindmapRecord>,
|
||||
tables: Vec<index_fts::SearchTableRecord>,
|
||||
table_rows: Vec<index_fts::SearchTableRowRecord>,
|
||||
assets: Vec<index_fts::SearchAssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsSearchToolPayload {
|
||||
query: String,
|
||||
workspace_id: Option<String>,
|
||||
limit: Option<u32>,
|
||||
include_deleted: Option<bool>,
|
||||
page_id: Option<String>,
|
||||
title_only: Option<bool>,
|
||||
exact: Option<bool>,
|
||||
include_ocr: Option<bool>,
|
||||
time_range: Option<String>,
|
||||
time_field: Option<String>,
|
||||
custom_range_from: Option<String>,
|
||||
custom_range_to: Option<String>,
|
||||
#[serde(default)]
|
||||
datasets: Vec<DocsSearchDatasetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsReadToolPayload {
|
||||
document_id: String,
|
||||
max_chars: Option<u32>,
|
||||
include_content: Option<bool>,
|
||||
title: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
parent_id: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
raw_text_length: Option<u64>,
|
||||
raw_text: Option<String>,
|
||||
content: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSuccess {
|
||||
@@ -4128,126 +4083,6 @@ fn execute_tool_result(
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
"docs_search" => {
|
||||
let payload: DocsSearchToolPayload = parse_tool_input(&args, &data)?;
|
||||
let normalized_query = payload.query.trim().to_string();
|
||||
if normalized_query.is_empty() {
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"query": normalized_query,
|
||||
"results": [],
|
||||
"enqueueAssetIds": [],
|
||||
}));
|
||||
}
|
||||
let limit = payload.limit.unwrap_or(12).clamp(1, 30) as usize;
|
||||
let requested_workspace = payload
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let mut enqueue_asset_ids = Vec::<String>::new();
|
||||
let mut results = Vec::<Value>::new();
|
||||
for dataset in payload.datasets {
|
||||
if let Some(workspace_id) = requested_workspace.as_ref() {
|
||||
if dataset.workspace_id != *workspace_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let evaluation = evaluate_search_documents(
|
||||
&SearchDocumentsRequest {
|
||||
query: normalized_query.clone(),
|
||||
workspace_id: dataset.workspace_id.clone(),
|
||||
page_id: payload.page_id.clone(),
|
||||
limit,
|
||||
title_only: payload.title_only.unwrap_or(false),
|
||||
exact: payload.exact.unwrap_or(false),
|
||||
include_ocr: payload.include_ocr.unwrap_or(false),
|
||||
time_range: payload.time_range.clone().unwrap_or_else(|| "any".into()),
|
||||
time_field: payload
|
||||
.time_field
|
||||
.clone()
|
||||
.unwrap_or_else(|| "updated".into()),
|
||||
custom_range_from: payload.custom_range_from.clone(),
|
||||
custom_range_to: payload.custom_range_to.clone(),
|
||||
},
|
||||
&SearchDocumentsDataset {
|
||||
documents: dataset.documents,
|
||||
mindmaps: dataset.mindmaps,
|
||||
tables: dataset.tables,
|
||||
table_rows: dataset.table_rows,
|
||||
assets: dataset.assets,
|
||||
},
|
||||
);
|
||||
enqueue_asset_ids.extend(evaluation.enqueue_asset_ids);
|
||||
for item in evaluation.results {
|
||||
results.push(json!({
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"snippet": item.snippet,
|
||||
"updatedAt": item.updated_at,
|
||||
"createdAt": item.created_at,
|
||||
"matchField": item.match_field,
|
||||
"hasOcr": item.has_ocr,
|
||||
"publicPath": item.public_path,
|
||||
"score": item.score,
|
||||
"workspaceId": dataset.workspace_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
results.sort_by(|left, right| {
|
||||
let left_score = left.get("score").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let right_score = right.get("score").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
right_score
|
||||
.partial_cmp(&left_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
let left_updated =
|
||||
left.get("updatedAt").and_then(Value::as_str).unwrap_or("");
|
||||
let right_updated =
|
||||
right.get("updatedAt").and_then(Value::as_str).unwrap_or("");
|
||||
right_updated.cmp(left_updated)
|
||||
})
|
||||
});
|
||||
results.truncate(limit);
|
||||
enqueue_asset_ids.sort();
|
||||
enqueue_asset_ids.dedup();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"query": normalized_query,
|
||||
"results": results,
|
||||
"enqueueAssetIds": enqueue_asset_ids,
|
||||
"includeDeleted": payload.include_deleted.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
"docs_read" => {
|
||||
let payload: DocsReadToolPayload = parse_tool_input(&args, &data)?;
|
||||
let max_chars = payload.max_chars.unwrap_or(2500).clamp(200, 20_000) as usize;
|
||||
let raw_text = payload.raw_text.unwrap_or_default();
|
||||
let raw_text_length = payload
|
||||
.raw_text_length
|
||||
.unwrap_or_else(|| raw_text.chars().count() as u64);
|
||||
let trimmed = trim_text_for_docs_read(&raw_text, max_chars);
|
||||
let mut result = json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"documentId": payload.document_id,
|
||||
"title": payload.title.unwrap_or_else(|| "".into()),
|
||||
"workspaceId": payload.workspace_id.unwrap_or_else(|| "".into()),
|
||||
"parentId": payload.parent_id,
|
||||
"updatedAt": payload.updated_at,
|
||||
"rawTextLength": raw_text_length,
|
||||
"rawText": trimmed,
|
||||
});
|
||||
if payload.include_content.unwrap_or(false) {
|
||||
if let Some(map) = result.as_object_mut() {
|
||||
map.insert("content".into(), payload.content.unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"doc_insert_blocks" => {
|
||||
let blocks = normalize_blocks_from_value(&data);
|
||||
let specs = parse_insert_specs(&args)?;
|
||||
@@ -4604,44 +4439,6 @@ fn build_tool_plan_steps(
|
||||
}]);
|
||||
}
|
||||
|
||||
if invocation.tool == "docs_search" {
|
||||
return Ok(vec![
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transport".into(),
|
||||
name: "docs_search.dataset".into(),
|
||||
function_name: None,
|
||||
description: "通过 transport 拉取文档、导图、表格与 OCR 搜索数据集,再交由 Rust runtime 统一排序。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transform".into(),
|
||||
name: "docs_search".into(),
|
||||
function_name: None,
|
||||
description: "在 Rust runtime 内复用统一搜索评估器,输出跨页文档搜索结果。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if invocation.tool == "docs_read" {
|
||||
return Ok(vec![
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transport".into(),
|
||||
name: "docs_read.document".into(),
|
||||
function_name: None,
|
||||
description: "通过 transport 读取目标文档 meta/content,再交由 Rust runtime 统一裁剪正文与返回结构。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transform".into(),
|
||||
name: "docs_read".into(),
|
||||
function_name: None,
|
||||
description: "在 Rust runtime 内标准化跨页文档读取结果。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if invocation.tool.starts_with("onlyoffice_") {
|
||||
let description = match invocation.tool.as_str() {
|
||||
"onlyoffice_session_resolve" => {
|
||||
@@ -7464,15 +7261,6 @@ fn strip_html_tags(value: &str) -> String {
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn trim_text_for_docs_read(value: &str, max_chars: usize) -> String {
|
||||
let taken = value.chars().take(max_chars).collect::<String>();
|
||||
if value.chars().count() > max_chars {
|
||||
format!("{taken}…")
|
||||
} else {
|
||||
taken
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec<RuntimeMindmapSummary> {
|
||||
let mut list = Vec::new();
|
||||
let mut queue = VecDeque::from([(root, None::<String>, 0usize)]);
|
||||
@@ -14509,130 +14297,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_search_tool_plan_uses_transport_and_transform_steps() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_search".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("plan".into()),
|
||||
args_json: json!({
|
||||
"query": "rust",
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("跨页搜索".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: None,
|
||||
})
|
||||
.expect("docs_search plan should build");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Tool(plan) => {
|
||||
assert_eq!(plan.tool_name, "docs_search");
|
||||
assert_eq!(plan.toolset_id, "toolset.docs_read");
|
||||
assert_eq!(plan.steps.len(), 2);
|
||||
assert_eq!(plan.steps[0].name, "docs_search.dataset");
|
||||
assert_eq!(plan.steps[1].name, "docs_search");
|
||||
}
|
||||
_ => panic!("expected tool plan"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_search_tool_result_uses_rust_search_evaluation() {
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_search".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"query": "rust",
|
||||
"limit": 5,
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("跨页搜索".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: Some(json!({
|
||||
"source": "convex",
|
||||
"datasets": [
|
||||
{
|
||||
"workspaceId": "ws_1",
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"workspaceId": "ws_1",
|
||||
"title": "Rust 文档",
|
||||
"rawText": "这里记录 rust runtime 收口",
|
||||
"createdAt": "2026-04-15T00:00:00Z",
|
||||
"updatedAt": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
}
|
||||
]
|
||||
})),
|
||||
})
|
||||
.expect("docs_search result should build");
|
||||
|
||||
assert_eq!(result.get("query").and_then(Value::as_str), Some("rust"));
|
||||
assert_eq!(
|
||||
result
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(result["results"][0]["id"], json!("page_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_read_tool_result_trims_text_and_keeps_content_optional() {
|
||||
let long_text = "a".repeat(260);
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_read".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"documentId": "page_1",
|
||||
"maxChars": 200,
|
||||
"includeContent": true,
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("读取文档".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: Some(json!({
|
||||
"source": "convex",
|
||||
"title": "示例页面",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": "parent_1",
|
||||
"updatedAt": "2026-04-16T00:00:00Z",
|
||||
"rawText": long_text,
|
||||
"rawTextLength": 260,
|
||||
"content": {"blocks": [{"id": "b1"}]}
|
||||
})),
|
||||
})
|
||||
.expect("docs_read result should build");
|
||||
|
||||
assert_eq!(result["documentId"], json!("page_1"));
|
||||
let trimmed = result["rawText"]
|
||||
.as_str()
|
||||
.expect("rawText should be string");
|
||||
assert_eq!(trimmed.chars().count(), 201);
|
||||
assert!(trimmed.ends_with('…'));
|
||||
assert_eq!(result["rawTextLength"], json!(260));
|
||||
assert_eq!(result["content"]["blocks"][0]["id"], json!("b1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_get_tool_plan_uses_documents_content_query() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
|
||||
@@ -86,13 +86,11 @@ pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||
EVIDENCE_TOOLSET_READ, EVIDENCE_TOOL_OPEN, EVIDENCE_TOOL_READ, EVIDENCE_TOOL_SEARCH,
|
||||
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||
ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS,
|
||||
DOC_TOOL_REPLACE_RANGE, INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE,
|
||||
MINDMAP_TOOL_APPLY_OPS, MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE, MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT,
|
||||
OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
|
||||
REPLAY_TOOL_EVENTS,
|
||||
@@ -140,11 +138,6 @@ mod tests {
|
||||
"search_web",
|
||||
"doc_get",
|
||||
"doc_find",
|
||||
"docs_search",
|
||||
"docs_read",
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
"image_read",
|
||||
"doc_insert_blocks",
|
||||
"doc_replace_range",
|
||||
@@ -204,23 +197,11 @@ mod tests {
|
||||
.expect("slash toolset should exist");
|
||||
assert!(slash.write_toolset);
|
||||
assert_eq!(slash.tool_names, &["slash_run"]);
|
||||
let docs_read = registry
|
||||
.toolset("toolset.docs_read")
|
||||
.expect("docs_read toolset should exist");
|
||||
assert!(!docs_read.write_toolset);
|
||||
assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]);
|
||||
let evidence_read = registry
|
||||
.toolset("toolset.evidence_read")
|
||||
.expect("evidence toolset should exist");
|
||||
assert!(!evidence_read.write_toolset);
|
||||
assert_eq!(
|
||||
evidence_read.tool_names,
|
||||
&[
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
]
|
||||
);
|
||||
assert!(registry.toolset("toolset.docs_read").is_none());
|
||||
assert!(registry.toolset("toolset.evidence_read").is_none());
|
||||
assert!(registry.tool("docs_search").is_none());
|
||||
assert!(registry.tool("docs_read").is_none());
|
||||
assert!(registry.tool("mnote.evidence.search").is_none());
|
||||
let doc_write = registry
|
||||
.toolset("toolset.doc_write")
|
||||
.expect("doc_write toolset should exist");
|
||||
|
||||
@@ -118,63 +118,6 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "docs_search",
|
||||
display_name: "跨页文档搜索",
|
||||
description: "在工作区内搜索文档标题、正文、导图、表格与 OCR 结果。",
|
||||
toolset_id: "toolset.docs_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
name: "docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
description: "读取指定文档的标题、正文摘要与可选 content 快照。",
|
||||
toolset_id: "toolset.docs_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.search",
|
||||
display_name: "证据搜索",
|
||||
description:
|
||||
"在工作区内搜索可回跳原文的证据块,返回 quote、locator、openAction 与 citationMarkdown。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["query","scope"],"properties":{"query":{"type":"string"},"scope":{"type":"object","required":["workspaceId","rootUri"],"properties":{"workspaceId":{"type":"string"},"rootUri":{"type":"string"},"targetDocumentId":{"type":"string"},"includeResources":{"type":"boolean"},"includeOcr":{"type":"boolean"}}},"mode":{"enum":["keyword","tree","hybrid","graph"]},"topK":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.read",
|
||||
display_name: "证据读回",
|
||||
description:
|
||||
"按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用,并返回可点击引用链接。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"},"context":{"type":"object","properties":{"beforeBlocks":{"type":"integer","minimum":0,"maximum":20},"afterBlocks":{"type":"integer","minimum":0,"maximum":20},"includeSectionSummary":{"type":"boolean"}}}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.open",
|
||||
display_name: "证据打开",
|
||||
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"}}}"#,
|
||||
};
|
||||
|
||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
name: "image_read",
|
||||
display_name: "读取图片",
|
||||
@@ -414,26 +357,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
tool_names: &["doc_get", "doc_find"],
|
||||
};
|
||||
|
||||
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
description: "跨页面搜索与读取文档的工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["docs_search", "docs_read"],
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.evidence_read",
|
||||
display_name: "证据读取",
|
||||
description: "搜索、读回和打开可定位原文证据的只读工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &[
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
],
|
||||
};
|
||||
|
||||
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.readonly",
|
||||
display_name: "只读工具",
|
||||
@@ -527,8 +450,6 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||
READONLY_TOOLSET,
|
||||
MEDIA_TOOLSET,
|
||||
SLASH_TOOLSET_WRITE,
|
||||
DOCS_TOOLSET_READ,
|
||||
EVIDENCE_TOOLSET_READ,
|
||||
DOC_TOOLSET_READ,
|
||||
DOC_TOOLSET_WRITE,
|
||||
MINDMAP_TOOLSET_READ,
|
||||
@@ -541,11 +462,6 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||
SEARCH_WEB_TOOL,
|
||||
DOC_TOOL_GET,
|
||||
DOC_TOOL_FIND,
|
||||
DOCS_TOOL_SEARCH,
|
||||
DOCS_TOOL_READ,
|
||||
EVIDENCE_TOOL_SEARCH,
|
||||
EVIDENCE_TOOL_READ,
|
||||
EVIDENCE_TOOL_OPEN,
|
||||
IMAGE_READ_TOOL,
|
||||
DOC_TOOL_INSERT_BLOCKS,
|
||||
DOC_TOOL_REPLACE_RANGE,
|
||||
|
||||
@@ -1737,22 +1737,6 @@ fn load_tool_data(
|
||||
let _ = read_doc_target(args, target)?;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_read" => {
|
||||
let document_id = args
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?;
|
||||
let _ = document_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_search" => {
|
||||
let workspace_id = args
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?;
|
||||
let _ = workspace_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
||||
let document_id = read_doc_target(args, target)?;
|
||||
let mindmap_id = args
|
||||
|
||||
@@ -352,6 +352,10 @@ import {
|
||||
bbox: url.searchParams.get('bbox') || undefined,
|
||||
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||
paragraphOrdinal: String(url.searchParams.get('paragraphOrdinal') || '').trim(),
|
||||
paraIdStart: String(url.searchParams.get('paraIdStart') || '').trim(),
|
||||
paraIdEnd: String(url.searchParams.get('paraIdEnd') || '').trim(),
|
||||
textFingerprint: String(url.searchParams.get('textFingerprint') || '').trim(),
|
||||
evidenceText: String(url.searchParams.get('evidenceText') || '').trim(),
|
||||
lineRange: url.searchParams.get('lineRange') || null,
|
||||
charRange: url.searchParams.get('charRange') || null,
|
||||
|
||||
@@ -988,7 +988,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const searchQuery = String(input.searchQuery || locator?.searchQuery || params.searchQuery || '').trim();
|
||||
const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null;
|
||||
const charRange = input.charRange || locator?.charRange || params.charRange || null;
|
||||
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !evidenceText && !lineRange && !charRange) return null;
|
||||
const paragraphOrdinal = String(input.paragraphOrdinal ?? locator?.paragraphOrdinal ?? params.paragraphOrdinal ?? '').trim();
|
||||
const paraIdStart = String(input.paraIdStart || locator?.paraIdStart || params.paraIdStart || '').trim();
|
||||
const paraIdEnd = String(input.paraIdEnd || locator?.paraIdEnd || params.paraIdEnd || '').trim();
|
||||
const textFingerprint = String(input.textFingerprint || locator?.textFingerprint || params.textFingerprint || '').trim();
|
||||
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !evidenceText && !lineRange && !charRange && !paragraphOrdinal && !paraIdStart && !textFingerprint) return null;
|
||||
return {
|
||||
schema: 'mnote.evidence_locator.v1',
|
||||
...(locator || {}),
|
||||
@@ -1000,6 +1004,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
searchQuery,
|
||||
lineRange,
|
||||
charRange,
|
||||
paragraphOrdinal,
|
||||
paraIdStart,
|
||||
paraIdEnd,
|
||||
textFingerprint,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1017,6 +1025,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
if (bbox) url.searchParams.set('bbox', bbox);
|
||||
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
|
||||
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
|
||||
if (locator.paragraphOrdinal) url.searchParams.set('paragraphOrdinal', String(locator.paragraphOrdinal));
|
||||
if (locator.paraIdStart) url.searchParams.set('paraIdStart', String(locator.paraIdStart));
|
||||
if (locator.paraIdEnd) url.searchParams.set('paraIdEnd', String(locator.paraIdEnd));
|
||||
if (locator.textFingerprint) url.searchParams.set('textFingerprint', String(locator.textFingerprint));
|
||||
if (locator.evidenceText) url.searchParams.set('evidenceText', String(locator.evidenceText));
|
||||
if (locator.searchQuery) url.searchParams.set('searchQuery', String(locator.searchQuery));
|
||||
if (url.pathname === '/office-preview' && frame.contentWindow) {
|
||||
@@ -1038,6 +1050,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
bbox,
|
||||
sourceMapPath: locator.sourceMapPath || '',
|
||||
blockId: locator.blockId || '',
|
||||
paragraphOrdinal: locator.paragraphOrdinal || '',
|
||||
paraIdStart: locator.paraIdStart || '',
|
||||
paraIdEnd: locator.paraIdEnd || '',
|
||||
textFingerprint: locator.textFingerprint || '',
|
||||
evidenceText: locator.evidenceText || '',
|
||||
searchQuery: locator.searchQuery || '',
|
||||
}, window.location.origin);
|
||||
|
||||
@@ -2038,6 +2038,22 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiKnowledgeRagFallbackQuery(prompt) {
|
||||
var text = searchText(prompt);
|
||||
if (!text) return '';
|
||||
var afterColon = text.split(/[::]/).slice(1).join(':').trim();
|
||||
var candidate = afterColon || text;
|
||||
candidate = candidate
|
||||
.replace(/^(请|请用|帮我|帮忙|用)?(资料库|知识库|lightrag|rag)?(搜索|检索|查找|查询|回答|说明|解释|总结)?/i, '')
|
||||
.replace(/(请)?(给出|给我|附上|提供)?(链接|引用|来源|出处|证据|定位).*$/i, '')
|
||||
.trim();
|
||||
var stopIndex = candidate.search(/(在|的|是|有哪些|有什么|如何|怎么|用于|用途|作用|资料|文献|论文)/);
|
||||
if (stopIndex > 0) candidate = candidate.slice(0, stopIndex).trim();
|
||||
var cjkMatch = candidate.match(/[A-Za-z0-9\u4e00-\u9fff·α-ωΑ-Ω\-]{2,24}/);
|
||||
if (cjkMatch && cjkMatch[0]) return cjkMatch[0];
|
||||
return text;
|
||||
}
|
||||
|
||||
function pageAiCleanDegradedCitationNotes(content) {
|
||||
return String(content || '')
|
||||
.split('\n')
|
||||
@@ -2050,38 +2066,92 @@ export function createSidebarPageAiRuntime(context) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pageAiCollectKnowledgeRagApiCitations(payload) {
|
||||
var references = pageAiNormalizeArray(payload && payload.references);
|
||||
var citations = references.map(function(reference) {
|
||||
return String(reference && reference.citationMarkdown || '').trim();
|
||||
}).filter(Boolean);
|
||||
var hasPreciseCitation = references.some(function(reference) {
|
||||
return String(reference && reference.citationMarkdown || '').trim() && reference && reference.locatorDegraded !== true;
|
||||
function pageAiCitationPrecisionLabel(value) {
|
||||
var precision = searchText(value || '').toLowerCase();
|
||||
if (precision === 'bbox') return '精确定位';
|
||||
if (precision === 'paragraph') return '段落级';
|
||||
if (precision === 'file') return '文件级';
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiCitationSourceTitle(citation) {
|
||||
if (!citation || typeof citation !== 'object') return '';
|
||||
var path = searchText(citation.sourceRootRelativePath || citation.sourcePath || citation.filePath || citation.lightRagFilePath || '');
|
||||
if (path) {
|
||||
var parts = path.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
var markdown = searchText(citation.citationMarkdown || '');
|
||||
var labelMatch = markdown.match(/^\[([^\]]+)\]/);
|
||||
return labelMatch ? labelMatch[1] : '';
|
||||
}
|
||||
|
||||
function pageAiCitationDisplayMarkdown(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
var markdown = searchText(value.citationMarkdown || '');
|
||||
var citationId = searchText(value.citationLabel || (value.citationId ? '[' + value.citationId + ']' : ''));
|
||||
var title = pageAiCitationSourceTitle(value);
|
||||
var headingPath = pageAiNormalizeArray(value.headingPath).map(function(item) {
|
||||
return searchText(item);
|
||||
}).filter(Boolean).join(' > ');
|
||||
var precisionLabel = pageAiCitationPrecisionLabel(value.locatorPrecision);
|
||||
var quote = searchText(value.displayQuote || value.quote || '');
|
||||
if (quote.length > 120) quote = quote.slice(0, 117) + '...';
|
||||
var head = markdown || title;
|
||||
if (citationId && head.indexOf(citationId) < 0) head = citationId + ' ' + head;
|
||||
var details = [];
|
||||
if (precisionLabel) details.push(precisionLabel);
|
||||
if (headingPath) details.push(headingPath);
|
||||
if (quote) details.push(quote);
|
||||
return [head].concat(details).filter(Boolean).join(' · ').trim();
|
||||
}
|
||||
|
||||
function pageAiFilterCitationCards(values) {
|
||||
var cards = pageAiNormalizeArray(values);
|
||||
var hasPreciseCitation = cards.some(function(value) {
|
||||
if (typeof value === 'string') return value.indexOf('来源定位降级') < 0;
|
||||
return value && typeof value === 'object' && searchText(value.citationMarkdown || '') && value.locatorDegraded !== true;
|
||||
});
|
||||
var seen = {};
|
||||
return citations.filter(function(citation) {
|
||||
if (seen[citation]) return false;
|
||||
if (hasPreciseCitation && citation.indexOf('来源定位降级') >= 0) return false;
|
||||
seen[citation] = true;
|
||||
return cards.map(pageAiCitationDisplayMarkdown).filter(function(card, index) {
|
||||
var source = cards[index];
|
||||
if (!card || seen[card]) return false;
|
||||
if (hasPreciseCitation) {
|
||||
if (typeof source === 'string' && source.indexOf('来源定位降级') >= 0) return false;
|
||||
if (source && typeof source === 'object' && source.locatorDegraded === true) return false;
|
||||
}
|
||||
seen[card] = true;
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
|
||||
function pageAiCollectKnowledgeRagApiCitations(payload) {
|
||||
var structuredCitations = pageAiNormalizeArray(payload && payload.citations);
|
||||
if (structuredCitations.length) {
|
||||
return pageAiFilterCitationCards(structuredCitations);
|
||||
}
|
||||
var references = pageAiNormalizeArray(payload && payload.references);
|
||||
return pageAiFilterCitationCards(references);
|
||||
}
|
||||
|
||||
async function pageAiAppendKnowledgeRagFallbackCitations(runId, promptText) {
|
||||
if (!pageAiPromptNeedsKnowledgeRagCitations(promptText)) return;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return;
|
||||
var fallbackQuery = pageAiKnowledgeRagFallbackQuery(promptText);
|
||||
if (!fallbackQuery) return;
|
||||
try {
|
||||
var response = await fetch('/api/knowledge-rag/query', {
|
||||
var response = await fetch('/api/knowledge-rag/search', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body) || undefined,
|
||||
rootUri: rootUri,
|
||||
query: String(promptText || '').trim(),
|
||||
query: fallbackQuery,
|
||||
mode: 'mix',
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true
|
||||
})
|
||||
});
|
||||
@@ -2193,10 +2263,11 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object') return;
|
||||
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
|
||||
if (typeof node.citationMarkdown === 'string') add(pageAiCitationDisplayMarkdown(node));
|
||||
if (Array.isArray(node.citationMarkdowns)) {
|
||||
node.citationMarkdowns.forEach(function(value) {
|
||||
if (typeof value === 'string') add(value);
|
||||
else if (value && typeof value === 'object') add(pageAiCitationDisplayMarkdown(value));
|
||||
else visit(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1342,9 +1342,21 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var config = healthBody.configuration && typeof healthBody.configuration === 'object' ? healthBody.configuration : {};
|
||||
var llmModel = String(config.llm_model || config.llmModel || '');
|
||||
var vlmModel = String(config.vlm_model || config.vlmModel || '');
|
||||
var rerank = status.rerank && typeof status.rerank === 'object' ? status.rerank : {};
|
||||
var rerankStatus = String(rerank.status || 'unknown');
|
||||
var rerankBinding = searchText(rerank.binding || '');
|
||||
var rerankModel = searchText(rerank.model || '');
|
||||
var rerankLabel = rerankStatus === 'disabled'
|
||||
? 'disabled'
|
||||
: rerankStatus === 'available'
|
||||
? 'available'
|
||||
: rerankStatus === 'unavailable'
|
||||
? 'unavailable'
|
||||
: 'unknown';
|
||||
metaNode.innerHTML = '' +
|
||||
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
|
||||
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
|
||||
'<div><span>Rerank</span><code>' + escapeHtml(rerankLabel + (rerankBinding ? ' · ' + rerankBinding : '') + (rerankModel ? ' · ' + rerankModel : '')) + '</code></div>' +
|
||||
'<div><span>Input</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
|
||||
'<div><span>Storage</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
|
||||
}
|
||||
|
||||
@@ -2135,6 +2135,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
'<div class="wolai-search-options-left">' +
|
||||
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch" data-search-switch="title" role="switch" aria-checked="false" aria-label="仅匹配标题"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>精确匹配</span><button type="button" class="wolai-search-switch" data-search-switch="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>资料库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="资料库检索模式"><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option><option value="exact">关键词</option></select></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
|
||||
'</div>' +
|
||||
@@ -2170,6 +2171,13 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
});
|
||||
var knowledgeModeSelect = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
if (knowledgeModeSelect) {
|
||||
knowledgeModeSelect.addEventListener('change', function() {
|
||||
searchUiState.hasRendered = false;
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
}
|
||||
if (closeButton) closeButton.addEventListener('click', closeSearchModal);
|
||||
overlay.addEventListener('click', function(event) {
|
||||
if (event.target === overlay) closeSearchModal();
|
||||
@@ -2186,6 +2194,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
metaHtml: '',
|
||||
resultsHtml: '',
|
||||
items: [],
|
||||
knowledgeMode: 'mix',
|
||||
sourceCollapsed: {},
|
||||
scrollTop: 0,
|
||||
signature: '',
|
||||
@@ -2236,6 +2245,27 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true';
|
||||
}
|
||||
|
||||
function searchKnowledgeModeValue(overlay) {
|
||||
var select = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
var value = select && 'value' in select ? searchText(select.value).toLowerCase() : '';
|
||||
if (['mix', 'hybrid', 'naive', 'local', 'global', 'exact'].indexOf(value) >= 0) return value;
|
||||
return 'mix';
|
||||
}
|
||||
|
||||
function knowledgeSearchRequestMode(overlay) {
|
||||
if (searchSwitchValue(overlay, 'exact')) return 'exact';
|
||||
return searchKnowledgeModeValue(overlay);
|
||||
}
|
||||
|
||||
function knowledgeSearchModeLabel(mode) {
|
||||
if (mode === 'exact') return '关键词';
|
||||
if (mode === 'naive') return '向量';
|
||||
if (mode === 'local') return '实体';
|
||||
if (mode === 'global') return '关系';
|
||||
if (mode === 'hybrid') return '图谱混合';
|
||||
return '综合';
|
||||
}
|
||||
|
||||
function readSearchCollapseSourcesDefault() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(SEARCH_COLLAPSE_SOURCES_KEY);
|
||||
@@ -2284,6 +2314,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
title: Boolean(switches.title),
|
||||
exact: Boolean(switches.exact),
|
||||
collapseSource: Boolean(switches.collapseSource),
|
||||
knowledgeMode: searchKnowledgeModeValue(overlay),
|
||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||
rootUri: currentRootUri() || ''
|
||||
});
|
||||
@@ -2297,6 +2328,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var query = input && 'value' in input ? searchText(input.value) : searchUiState.query;
|
||||
searchUiState.query = query;
|
||||
searchUiState.switches = collectSearchSwitchState(overlay);
|
||||
searchUiState.knowledgeMode = searchKnowledgeModeValue(overlay);
|
||||
searchUiState.metaHtml = meta ? meta.innerHTML : searchUiState.metaHtml;
|
||||
searchUiState.resultsHtml = results ? results.innerHTML : searchUiState.resultsHtml;
|
||||
searchUiState.items = Array.isArray(window.__mnoteSearchResults) ? window.__mnoteSearchResults.slice() : searchUiState.items;
|
||||
@@ -2312,6 +2344,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
|
||||
if (input && 'value' in input) input.value = searchUiState.query || '';
|
||||
applySearchSwitchState(overlay, searchUiState.switches);
|
||||
var knowledgeModeSelect = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
if (knowledgeModeSelect && 'value' in knowledgeModeSelect) knowledgeModeSelect.value = searchUiState.knowledgeMode || 'mix';
|
||||
if (options instanceof HTMLElement) options.hidden = !searchText(searchUiState.query);
|
||||
if (meta && searchUiState.metaHtml) meta.innerHTML = searchUiState.metaHtml;
|
||||
if (results && searchUiState.resultsHtml) {
|
||||
@@ -2421,7 +2455,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
|
||||
function renderSearchResultButton(item, index, query, exact, grouped) {
|
||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
||||
var snippet = cleanSearchDisplayText(item.snippet || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||
var snippet = searchText(item.displayQuote || item.snippet || item.evidence && item.evidence.displayQuote || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].displayQuote || item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||
if (!item.displayQuote) snippet = cleanSearchDisplayText(snippet);
|
||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
@@ -2545,10 +2580,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var resourceKind = evidenceLocatorResourceKind(locator, item && item.resourceType);
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
var searchInput = overlay instanceof HTMLElement ? overlay.querySelector('[data-testid="wolai-search-input"]') : null;
|
||||
var searchQueryText = searchText(item && (item.query || item.searchQuery))
|
||||
var searchQueryText = searchText(item && (item.searchQuery || item.query) || locator && locator.openAction && locator.openAction.params && locator.openAction.params.searchQuery)
|
||||
|| searchText(searchInput && 'value' in searchInput ? searchInput.value : '')
|
||||
|| searchText(searchUiState.query);
|
||||
var locatorEvidenceText = searchText(locator.evidenceText || locator.query || locator.openAction && locator.openAction.params && locator.openAction.params.query || '');
|
||||
var locatorEvidenceText = searchText(item && item.locatorEvidenceText || locator.evidenceText || locator.query || locator.openAction && locator.openAction.params && (locator.openAction.params.evidenceText || locator.openAction.params.query) || '');
|
||||
var locatorParams = locator && locator.openAction && locator.openAction.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {};
|
||||
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
||||
if (resourcePath && resourceKind) {
|
||||
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
||||
@@ -2573,6 +2609,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
bbox: locator.bbox,
|
||||
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
||||
blockId: evidenceLocatorBlockId(locator),
|
||||
paragraphOrdinal: searchText(locator.paragraphOrdinal || locatorParams.paragraphOrdinal),
|
||||
paraIdStart: searchText(locator.paraIdStart || locatorParams.paraIdStart),
|
||||
paraIdEnd: searchText(locator.paraIdEnd || locatorParams.paraIdEnd),
|
||||
textFingerprint: searchText(locator.textFingerprint || locatorParams.textFingerprint),
|
||||
evidenceText: locatorEvidenceText,
|
||||
query: searchQueryText,
|
||||
searchQuery: searchQueryText,
|
||||
@@ -2593,6 +2633,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var bbox = evidenceLocatorBbox(locator);
|
||||
if (bbox) target.searchParams.set('bbox', bbox);
|
||||
if (sourceMapPath) target.searchParams.set('sourceMapPath', sourceMapPath);
|
||||
if (locatorParams.paragraphOrdinal != null) target.searchParams.set('paragraphOrdinal', searchText(locatorParams.paragraphOrdinal));
|
||||
if (locatorParams.paraIdStart) target.searchParams.set('paraIdStart', searchText(locatorParams.paraIdStart));
|
||||
if (locatorParams.paraIdEnd) target.searchParams.set('paraIdEnd', searchText(locatorParams.paraIdEnd));
|
||||
if (locatorParams.textFingerprint) target.searchParams.set('textFingerprint', searchText(locatorParams.textFingerprint));
|
||||
window.location.assign(target.pathname + target.search + target.hash);
|
||||
} catch (_) {
|
||||
window.location.assign(url);
|
||||
@@ -2643,6 +2687,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
var requestId = ++activeSearchRequestId;
|
||||
var knowledgeRequestMode = knowledgeMode ? knowledgeSearchRequestMode(overlay) : '';
|
||||
meta.innerHTML = '<span>搜索中…</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-loading="true">搜索中…</div>';
|
||||
try {
|
||||
@@ -2653,7 +2698,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
rootUri: currentRootUri() || null,
|
||||
query: query,
|
||||
mode: 'hybrid',
|
||||
mode: knowledgeRequestMode,
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true
|
||||
@@ -2677,7 +2722,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var payload = await response.json();
|
||||
if (requestId !== activeSearchRequestId) return;
|
||||
var items = Array.isArray(payload.results) ? payload.results : [];
|
||||
meta.innerHTML = '<span>' + (knowledgeMode ? '资料库检索' : '工作区搜索') + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
var ownerLabel = knowledgeMode ? ('资料库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
|
||||
meta.innerHTML = '<span>' + ownerLabel + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
if (!items.length) {
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">没有搜索结果</div>';
|
||||
window.__mnoteSearchResults = [];
|
||||
|
||||
@@ -49,11 +49,29 @@ pub struct AcpRunBridge {
|
||||
}
|
||||
|
||||
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
fn add_citation(text: &str, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
let citation = text.trim();
|
||||
if !citation.is_empty() && seen.insert(citation.to_string()) {
|
||||
out.push(json!({ "citationMarkdown": citation }));
|
||||
fn add_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let citation = citation.trim();
|
||||
if citation.is_empty() || !seen.insert(citation.to_string()) {
|
||||
return;
|
||||
}
|
||||
out.push(json!({
|
||||
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"citationMarkdown": citation,
|
||||
"citationId": value.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null),
|
||||
"filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
||||
"headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
|
||||
fn add_reference_citations(
|
||||
@@ -73,7 +91,7 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
let Some(citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if has_precise
|
||||
@@ -82,7 +100,7 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
continue;
|
||||
}
|
||||
let before = out.len();
|
||||
add_citation(citation, seen, out);
|
||||
add_citation_value(reference, seen, out);
|
||||
added = added || out.len() > before;
|
||||
}
|
||||
added
|
||||
@@ -120,9 +138,9 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
.get("references")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|references| add_reference_citations(references, seen, out));
|
||||
if let Some(citation) = map.get("citationMarkdown").and_then(Value::as_str) {
|
||||
if let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) {
|
||||
if !has_filtered_references {
|
||||
add_citation(citation, seen, out);
|
||||
add_citation_value(value, seen, out);
|
||||
}
|
||||
}
|
||||
for (key, item) in map {
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::routes;
|
||||
use axum::http::StatusCode;
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
||||
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn evidence_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let body = evidence_search_request(input, context)?;
|
||||
routes::evidence::search_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_read_payload_invalid",
|
||||
format!("Evidence read 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::read_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_open(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_open_payload_invalid",
|
||||
format!("Evidence open 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::open_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let payload = evidence_search(state, context, input).await?;
|
||||
Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_search",
|
||||
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"source": "mnote.evidence.search",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if input.arg_value("locator").is_some() {
|
||||
let payload = evidence_read(state, context, input).await?;
|
||||
return Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"result": payload,
|
||||
"source": "mnote.evidence.read",
|
||||
}));
|
||||
}
|
||||
|
||||
let document = doc::doc_fetch(state, context, input).await?;
|
||||
let locator = legacy_document_locator(input);
|
||||
Ok(json!({
|
||||
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"documentId": input.effective_document_id(),
|
||||
"document": document,
|
||||
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
|
||||
"source": {
|
||||
"tool": "mnote.doc.fetch",
|
||||
"locator": locator,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn evidence_search_request(
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
) -> Result<EvidenceSearchRequest, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("scope").is_none() {
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_workspace_required",
|
||||
"Evidence 搜索缺少 workspaceId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let include_resources = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeResources"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let include_ocr = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeOcr"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let target_document_id = input
|
||||
.effective_document_id()
|
||||
.or_else(|| input.arg_string("pageId"))
|
||||
.or_else(|| input.arg_string("targetDocumentId"));
|
||||
args = json!({
|
||||
"query": query,
|
||||
"scope": {
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"targetDocumentId": target_document_id,
|
||||
"includeResources": include_resources,
|
||||
"includeOcr": include_ocr,
|
||||
},
|
||||
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
|
||||
"topK": input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(8),
|
||||
});
|
||||
}
|
||||
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
|
||||
WebError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"mnote_evidence_search_payload_invalid",
|
||||
format!("Evidence search 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
|
||||
let root_uri = local_root_uri_for_evidence(input)?;
|
||||
let document_id = input.effective_document_id()?;
|
||||
let owner_document_path =
|
||||
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
|
||||
Some(EvidenceLocator {
|
||||
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
|
||||
root_uri: root_uri.clone(),
|
||||
owner_document_id: document_id,
|
||||
owner_document_path: owner_document_path.clone(),
|
||||
resource_path: Some(owner_document_path.clone()),
|
||||
resource_kind: EvidenceResourceKind::Markdown,
|
||||
page: None,
|
||||
bbox: None,
|
||||
section_path: Vec::new(),
|
||||
line_range: None,
|
||||
char_range: None,
|
||||
block_id: None,
|
||||
source_map_path: None,
|
||||
open_action: EvidenceOpenAction {
|
||||
action_type: "mnote.open_resource_locator".into(),
|
||||
url: "/".into(),
|
||||
params: json!({
|
||||
"rootUri": root_uri,
|
||||
"ownerDocumentPath": owner_document_path,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
|
||||
input.effective_root_uri().or_else(|| {
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("allowedRoots")
|
||||
.or_else(|| scope.get("allowed_roots"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|allowed_roots| {
|
||||
allowed_roots.as_array().and_then(|roots| {
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
root.get("rootUri")
|
||||
.or_else(|| root.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.find(|root_uri| !root_uri.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn document_path_from_local_id(document_id: &str) -> Option<String> {
|
||||
let encoded = document_id.trim().strip_prefix("local-md:")?;
|
||||
decode_local_id_segment(encoded)
|
||||
}
|
||||
|
||||
fn decode_local_id_segment(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'~' {
|
||||
if index + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hex = &value[index + 1..index + 3];
|
||||
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||
decoded.push(byte);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
@@ -112,21 +112,33 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let citation_references = citation_references_for_ui(&references);
|
||||
let citations = citation_references
|
||||
let payload_citations = payload
|
||||
.get("citations")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let citations = if payload_citations.is_empty() {
|
||||
citation_references_for_ui(&references)
|
||||
.into_iter()
|
||||
.map(compact_citation_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
citation_values_for_ui(&payload_citations)
|
||||
.into_iter()
|
||||
.map(compact_citation_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let citation_markdowns = citations
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
reference
|
||||
.filter_map(|citation| {
|
||||
citation
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations
|
||||
.iter()
|
||||
.map(|citation| json!({ "citationMarkdown": citation }))
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations.clone();
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.knowledge_rag.agent_query_result.v1",
|
||||
@@ -134,6 +146,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
"citationMarkdowns": citation_markdowns,
|
||||
"uiCitations": ui_citations,
|
||||
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
|
||||
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
|
||||
@@ -148,7 +161,13 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
|
||||
fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
let quote = reference
|
||||
.get("quote")
|
||||
.get("displayQuote")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| reference.get("quote").and_then(Value::as_str))
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let locator_evidence_text = reference
|
||||
.get("locatorEvidenceText")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
@@ -159,28 +178,74 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
json!({
|
||||
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
||||
"quote": quote,
|
||||
"displayQuote": reference.get("displayQuote").cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": locator_evidence_text,
|
||||
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
|
||||
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"contentDiagnostics": quote_diagnostics,
|
||||
"citationDiagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
|
||||
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_citation_for_agent(citation: &Value) -> Value {
|
||||
let display_quote = citation
|
||||
.get("displayQuote")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| citation.get("quote").and_then(Value::as_str))
|
||||
.map(|value| value.chars().take(420).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let locator_evidence_text = citation
|
||||
.get("locatorEvidenceText")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(420).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
"sourcePath": citation.get("sourcePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"filePath": citation.get("filePath").or_else(|| citation.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
||||
"chunkId": citation.get("chunkId").or_else(|| citation.get("lightRagChunkId")).cloned().unwrap_or(Value::Null),
|
||||
"blockId": citation.get("blockId").cloned().unwrap_or(Value::Null),
|
||||
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"quote": display_quote,
|
||||
"displayQuote": citation.get("displayQuote").cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": locator_evidence_text,
|
||||
"searchQuery": citation.get("searchQuery").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationMarkdown": citation.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": citation.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
"diagnostics": citation.get("diagnostics").or_else(|| citation.get("citationDiagnostics")).cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
|
||||
let has_precise = references.iter().any(|reference| {
|
||||
citation_values_for_ui(references)
|
||||
}
|
||||
|
||||
fn citation_values_for_ui(values: &[Value]) -> Vec<&Value> {
|
||||
let has_precise = values.iter().any(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
|
||||
});
|
||||
references
|
||||
values
|
||||
.iter()
|
||||
.filter(|reference| {
|
||||
reference
|
||||
@@ -269,6 +334,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_query_result_prefers_structured_citations() {
|
||||
let payload = json!({
|
||||
"references": [{
|
||||
"sourceRootRelativePath": "docs/a.docx",
|
||||
"displayQuote": "raw reference should not be source card",
|
||||
"locatorDegraded": false,
|
||||
"citationMarkdown": "[docs/a.docx](/documents/a)"
|
||||
}],
|
||||
"citations": [{
|
||||
"schema": "mnote.knowledge_rag.citation.v1",
|
||||
"citationId": "c0de",
|
||||
"citationLabel": "[c0de]",
|
||||
"sourceRootRelativePath": "docs/a.docx",
|
||||
"headingPath": ["保护基", "硅基保护"],
|
||||
"displayQuote": "吡咯烷,5 h,90%",
|
||||
"locatorEvidenceText": "如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。",
|
||||
"locatorPrecision": "paragraph",
|
||||
"locatorDegraded": false,
|
||||
"citationMarkdown": "[docs/a.docx](/documents/a)"
|
||||
}]
|
||||
});
|
||||
|
||||
let compact = compact_query_result_for_agent(payload);
|
||||
|
||||
assert_eq!(compact["citations"][0]["citationId"], "c0de");
|
||||
assert_eq!(compact["citations"][0]["headingPath"][0], "保护基");
|
||||
assert_eq!(compact["citations"][0]["displayQuote"], "吡咯烷,5 h,90%");
|
||||
assert!(compact["citations"][0].get("rawQuote").is_none());
|
||||
assert_eq!(
|
||||
compact["uiCitations"][0]["citationMarkdown"].as_str(),
|
||||
Some("[docs/a.docx](/documents/a)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
|
||||
let payload = json!({
|
||||
|
||||
@@ -290,108 +290,6 @@ fn doc_find_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 evidence 工具仅保留为历史对照;当前 manifest() 不注册这些工具,资料库问答走 mnote.knowledge_rag.*。
|
||||
#[allow(dead_code)]
|
||||
fn evidence_search_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"includeResources".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"includeOcr".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"mode".into(),
|
||||
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
|
||||
);
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
|
||||
map.insert(
|
||||
"scope".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"rootUri": { "type": "string" },
|
||||
"targetDocumentId": { "type": "string" },
|
||||
"includeResources": { "type": "boolean" },
|
||||
"includeOcr": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.search",
|
||||
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator、openAction 与可直接放进最终回答的 citationMarkdown 链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_read_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"context".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beforeBlocks": { "type": "integer", "default": 3 },
|
||||
"afterBlocks": { "type": "integer", "default": 3 },
|
||||
"includeSectionSummary": { "type": "boolean", "default": true }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.read",
|
||||
"description": "按 EvidenceLocator 读取原文证据及周边上下文,返回 quote/contextBlocks 与可点击引用链接供回答引用。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_open_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.open",
|
||||
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_status_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod artifact;
|
||||
pub mod block;
|
||||
pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod evidence;
|
||||
pub mod index;
|
||||
pub mod knowledge_rag;
|
||||
pub mod manifest;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -212,7 +212,7 @@ mod tests {
|
||||
"dryRun": false
|
||||
},
|
||||
"tool": {
|
||||
"tool": "docs_search",
|
||||
"tool": "mnote.knowledge_rag.query",
|
||||
"kind": "query",
|
||||
"mode": "plan",
|
||||
"argsJson": {"query": "Rust Web"},
|
||||
|
||||
@@ -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, knowledge_rag, manifest, onlyoffice_live, page,
|
||||
resource, skill, ToolCallInput,
|
||||
artifact, block, context_tools, doc, knowledge_rag, manifest, onlyoffice_live, page, resource,
|
||||
skill, ToolCallInput,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -360,13 +360,15 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
}
|
||||
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
||||
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
||||
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
|
||||
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
|
||||
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
|
||||
"docs_search"
|
||||
| "docs_read"
|
||||
| "mnote.evidence.search"
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open" => {
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
@@ -5466,38 +5468,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"# Evidence Home\n\ncompat-evidence-token 正文\n",
|
||||
)
|
||||
.expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
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");
|
||||
|
||||
async fn hermes_tools_legacy_docs_search_is_retired() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -5511,7 +5482,7 @@ mod tests {
|
||||
"toolName": "docs_search",
|
||||
"workspaceId": "local-ws-docs-search",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"rootUri": "file:///tmp/mnote-retired-docs-search",
|
||||
"sessionId": "sess_docs_search",
|
||||
"runId": "run_docs_search",
|
||||
"toolCallId": "call_docs_search",
|
||||
@@ -5529,72 +5500,15 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["compatTool"], "docs_search");
|
||||
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
|
||||
let result = payload["result"]["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("evidence result");
|
||||
assert_eq!(
|
||||
result["source"]["schema"].as_str(),
|
||||
Some("mnote.evidence_locator.v1")
|
||||
payload["code"].as_str(),
|
||||
Some("mnote_evidence_tools_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
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(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["runReceipt"]["schema"].as_str(),
|
||||
Some("mnote.agent_run_receipt.evidence.v1")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
|
||||
assert_eq!(
|
||||
payload["runReceipt"]["toolCallId"].as_str(),
|
||||
Some("call_docs_search")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["audit"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
let completed_audit =
|
||||
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
|
||||
.into_iter()
|
||||
.find(|event| event["phase"] == "completed")
|
||||
.expect("completed audit");
|
||||
assert_eq!(
|
||||
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert!(payload["result"]["evidence"]
|
||||
.as_array()
|
||||
.expect("evidence")
|
||||
.iter()
|
||||
.any(|item| item["quote"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("compat-evidence-token")));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5697,19 +5611,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
async fn hermes_tools_legacy_docs_read_is_retired() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -5724,7 +5626,7 @@ mod tests {
|
||||
"workspaceId": "local-ws-docs-read",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"rootUri": "file:///tmp/mnote-retired-docs-read",
|
||||
"sessionId": "sess_docs_read",
|
||||
"runId": "run_docs_read",
|
||||
"toolCallId": "call_docs_read",
|
||||
@@ -5741,25 +5643,15 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["compatTool"], "docs_read");
|
||||
assert_eq!(
|
||||
payload["result"]["source"]["locator"]["schema"].as_str(),
|
||||
Some("mnote.evidence_locator.v1")
|
||||
payload["code"].as_str(),
|
||||
Some("mnote_evidence_tools_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
|
||||
Some("README.md")
|
||||
);
|
||||
assert!(payload["result"]["document"]
|
||||
.to_string()
|
||||
.contains("legacy docs read"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@ use crate::routes::local_markdown_parser::{
|
||||
};
|
||||
use crate::routes::local_ocr;
|
||||
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||||
#[cfg(test)]
|
||||
use core_protocol::EvidenceSearchMatchInfo;
|
||||
#[cfg(test)]
|
||||
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
|
||||
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -84,6 +87,7 @@ struct LocalSearchResource {
|
||||
updated_at: u128,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
@@ -636,6 +640,7 @@ pub(crate) fn query_evidence_sqlite_results(
|
||||
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||
root_path: &Path,
|
||||
query: &str,
|
||||
@@ -703,6 +708,7 @@ pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_evidence_sqlite_context(
|
||||
root_path: &Path,
|
||||
locator: &EvidenceLocator,
|
||||
@@ -777,6 +783,7 @@ pub(crate) fn read_evidence_sqlite_context(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_evidence_graph_results(
|
||||
root_path: &Path,
|
||||
query: &str,
|
||||
@@ -839,6 +846,7 @@ pub(crate) fn query_evidence_graph_results(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchResult> {
|
||||
let edge_id: String = row.get(0)?;
|
||||
let edge_type: String = row.get(1)?;
|
||||
@@ -865,6 +873,7 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_locator_matches(
|
||||
block_id: &str,
|
||||
source: &EvidenceLocator,
|
||||
@@ -877,6 +886,7 @@ fn evidence_locator_matches(
|
||||
&& source.source_map_path == locator.source_map_path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_fts(
|
||||
connection: &Connection,
|
||||
fts_query: &str,
|
||||
@@ -929,6 +939,7 @@ fn query_evidence_sqlite_fts(
|
||||
rows.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_like(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
@@ -980,6 +991,7 @@ fn query_evidence_sqlite_like(
|
||||
rows.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_fuzzy(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
@@ -1075,6 +1087,7 @@ fn query_evidence_sqlite_fuzzy(
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_sqlite_row_parts(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<(String, String, EvidenceLocator)> {
|
||||
@@ -1087,6 +1100,7 @@ fn evidence_sqlite_row_parts(
|
||||
Ok((block_id, text, source))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_result_from_sqlite_row(
|
||||
row: &rusqlite::Row<'_>,
|
||||
query: &str,
|
||||
@@ -1118,6 +1132,7 @@ fn evidence_result_from_sqlite_row(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_fts_phrase(query: &str) -> String {
|
||||
format!("\"{}\"", query.replace('"', "\"\""))
|
||||
}
|
||||
@@ -3631,6 +3646,7 @@ struct EvidenceQueryTerm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
struct EvidenceTextMatch {
|
||||
score: f64,
|
||||
matched_terms: Vec<String>,
|
||||
@@ -3640,6 +3656,7 @@ struct EvidenceTextMatch {
|
||||
}
|
||||
|
||||
impl EvidenceTextMatch {
|
||||
#[cfg(test)]
|
||||
fn into_match_info(self, rank: Option<u32>) -> EvidenceSearchMatchInfo {
|
||||
EvidenceSearchMatchInfo {
|
||||
rank,
|
||||
@@ -3821,6 +3838,7 @@ fn push_unique(values: &mut Vec<String>, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
@@ -3850,6 +3868,7 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
@@ -3873,6 +3892,7 @@ fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> St
|
||||
ocr_search_snippet(body, query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String {
|
||||
let start = body[..byte_index]
|
||||
.char_indices()
|
||||
@@ -3883,6 +3903,7 @@ fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String
|
||||
body[start..].chars().take(len).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -994,6 +994,14 @@ pub struct OfficePreviewQuery {
|
||||
source_map_path: Option<String>,
|
||||
#[serde(default, alias = "blockId")]
|
||||
block_id: Option<String>,
|
||||
#[serde(default, alias = "paragraphOrdinal")]
|
||||
paragraph_ordinal: Option<String>,
|
||||
#[serde(default, alias = "paraIdStart")]
|
||||
para_id_start: Option<String>,
|
||||
#[serde(default, alias = "paraIdEnd")]
|
||||
para_id_end: Option<String>,
|
||||
#[serde(default, alias = "textFingerprint")]
|
||||
text_fingerprint: Option<String>,
|
||||
#[serde(default, alias = "evidenceText")]
|
||||
evidence_text: Option<String>,
|
||||
#[serde(default, alias = "searchQuery")]
|
||||
@@ -1032,6 +1040,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
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 target_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default();
|
||||
let target_para_id_start = query.para_id_start.unwrap_or_default();
|
||||
let target_para_id_end = query.para_id_end.unwrap_or_default();
|
||||
let target_text_fingerprint = query.text_fingerprint.unwrap_or_default();
|
||||
let target_evidence_text = query.evidence_text.unwrap_or_default();
|
||||
let target_search_query = query.search_query.unwrap_or_default();
|
||||
let html = format!(
|
||||
@@ -1080,7 +1092,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}" 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}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
|
||||
<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}" data-evidence-paragraph-ordinal="{target_paragraph_ordinal}" data-evidence-para-id-start="{target_para_id_start}" data-evidence-para-id-end="{target_para_id_end}" data-evidence-text-fingerprint="{target_text_fingerprint}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
|
||||
<main class="mnote-office-preview">
|
||||
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
|
||||
</main>
|
||||
@@ -1099,6 +1111,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
let evidenceBbox = body.dataset.evidenceBbox || '';
|
||||
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
|
||||
let evidenceBlockId = body.dataset.evidenceBlockId || '';
|
||||
let evidenceParagraphOrdinal = body.dataset.evidenceParagraphOrdinal || '';
|
||||
let evidenceParaIdStart = body.dataset.evidenceParaIdStart || '';
|
||||
let evidenceParaIdEnd = body.dataset.evidenceParaIdEnd || '';
|
||||
let evidenceTextFingerprint = body.dataset.evidenceTextFingerprint || '';
|
||||
let evidenceText = body.dataset.evidenceText || '';
|
||||
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
|
||||
let currentPptxBuffer = null;
|
||||
@@ -1484,6 +1500,17 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
|
||||
}}
|
||||
const prefixWindow = cleaned.slice(0, 260);
|
||||
if (queryCompact.length >= 2) {{
|
||||
const prefixCompact = compactEvidenceText(prefixWindow);
|
||||
const compactIndex = prefixCompact.indexOf(queryCompact);
|
||||
if (compactIndex >= 0) {{
|
||||
const queryIndex = prefixWindow.indexOf(evidenceSearchQuery);
|
||||
const start = queryIndex >= 0 ? queryIndex : 0;
|
||||
const queryWindow = prefixWindow.slice(start, start + 96).split(/[。;;]/)[0];
|
||||
push(queryWindow, {{ allowShort: true }});
|
||||
queryWindow.split(/[,,]/).slice(0, 2).forEach(part => push(part, {{ allowShort: true }}));
|
||||
}}
|
||||
}}
|
||||
const catalogMatches = prefixWindow.match(/[^,,。;;#]{{2,56}}[,,]\s*[0-90-9]{{1,5}}/g) || [];
|
||||
catalogMatches.slice(0, 8).forEach(match => {{
|
||||
const value = normalizeEvidenceText(match);
|
||||
@@ -1711,6 +1738,42 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
return markEvidenceTarget(marker);
|
||||
}}
|
||||
|
||||
function evidenceParagraphElements() {{
|
||||
if (!viewer) return [];
|
||||
const paragraphs = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
|
||||
if (paragraphs.length) return paragraphs;
|
||||
return Array.from(viewer.querySelectorAll('div, section.docx, section.mnote-docx'))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
|
||||
}}
|
||||
|
||||
function scrollToEvidenceParagraphOrdinal() {{
|
||||
const ordinal = Number(evidenceParagraphOrdinal);
|
||||
if (!Number.isFinite(ordinal) || ordinal < 0) return false;
|
||||
const elements = evidenceParagraphElements();
|
||||
if (!elements.length) return false;
|
||||
const anchors = evidenceParagraphAnchors(evidenceText || evidenceSearchQuery);
|
||||
const indices = [];
|
||||
for (let offset = 0; offset <= 4; offset += 1) {{
|
||||
if (offset === 0) indices.push(ordinal);
|
||||
else {{
|
||||
indices.push(ordinal - offset);
|
||||
indices.push(ordinal + offset);
|
||||
}}
|
||||
}}
|
||||
let best = null;
|
||||
for (const index of indices) {{
|
||||
if (index < 0 || index >= elements.length) continue;
|
||||
const element = elements[index];
|
||||
const score = anchors.length ? scoreEvidenceParagraphElement(element, anchors) : 0;
|
||||
if (!best || score > best.score) best = {{ element, score }};
|
||||
if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
|
||||
}}
|
||||
return best ? markEvidenceTarget(best.element) : false;
|
||||
}}
|
||||
|
||||
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'));
|
||||
@@ -1719,16 +1782,18 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
|
||||
async function applyEvidenceLocator() {{
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText)) return;
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText && !evidenceParagraphOrdinal && !evidenceTextFingerprint)) return;
|
||||
try {{
|
||||
const sourceMap = await fetchEvidenceSourceMap();
|
||||
const block = findEvidenceBlockInSourceMap(sourceMap);
|
||||
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
|
||||
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
|
||||
if (block && scrollToEvidenceParagraph(block.text)) return;
|
||||
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
|
||||
if (block && scrollToEvidenceTextCandidates(block.text)) return;
|
||||
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
|
||||
}} catch (_) {{}}
|
||||
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
|
||||
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
|
||||
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
|
||||
scrollToEvidencePageFallback();
|
||||
@@ -1740,12 +1805,20 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
evidenceBbox = String(next.bbox || '');
|
||||
evidenceSourceMapPath = String(next.sourceMapPath || '');
|
||||
evidenceBlockId = String(next.blockId || '');
|
||||
evidenceParagraphOrdinal = String(next.paragraphOrdinal || '');
|
||||
evidenceParaIdStart = String(next.paraIdStart || '');
|
||||
evidenceParaIdEnd = String(next.paraIdEnd || '');
|
||||
evidenceTextFingerprint = String(next.textFingerprint || '');
|
||||
evidenceText = String(next.evidenceText || next.query || '');
|
||||
evidenceSearchQuery = String(next.searchQuery || '');
|
||||
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
|
||||
body.dataset.evidenceBbox = evidenceBbox;
|
||||
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
|
||||
body.dataset.evidenceBlockId = evidenceBlockId;
|
||||
body.dataset.evidenceParagraphOrdinal = evidenceParagraphOrdinal;
|
||||
body.dataset.evidenceParaIdStart = evidenceParaIdStart;
|
||||
body.dataset.evidenceParaIdEnd = evidenceParaIdEnd;
|
||||
body.dataset.evidenceTextFingerprint = evidenceTextFingerprint;
|
||||
body.dataset.evidenceText = evidenceText;
|
||||
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
|
||||
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
|
||||
@@ -1969,6 +2042,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
target_bbox = escape_html(&target_bbox),
|
||||
target_source_map_path = escape_html(&target_source_map_path),
|
||||
target_block_id = escape_html(&target_block_id),
|
||||
target_paragraph_ordinal = escape_html(&target_paragraph_ordinal),
|
||||
target_para_id_start = escape_html(&target_para_id_start),
|
||||
target_para_id_end = escape_html(&target_para_id_end),
|
||||
target_text_fingerprint = escape_html(&target_text_fingerprint),
|
||||
target_evidence_text = escape_html(&target_evidence_text),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
|
||||
@@ -548,6 +548,7 @@ mod tests {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-source-filters"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("filter-sources"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("LightRAG 未映射"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("Rerank"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
|
||||
|
||||
Reference in New Issue
Block a user