feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening

- ACP client/session manager: Reasonix desktop live session context
- Hermes tools: knowledge_rag tool manifest and skill updates
- Browser runtime: sidebar page AI permission/profile/render/session/tree modules
- Routes: hermes_client, hermes_tools, knowledge_rag, web_shell
- Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562
- Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
This commit is contained in:
lix-2026
2026-06-13 22:20:01 +08:00
parent 2236a053c0
commit 4a7efd4a30
30 changed files with 5321 additions and 291 deletions
@@ -3,7 +3,8 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
KnowledgeRagStatusQuery,
};
use axum::extract::{Extension, Json, Query, State};
use serde_json::{json, Value};
@@ -100,6 +101,39 @@ pub async fn open_reference(
Ok(payload)
}
pub async fn section_context(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_section_context_payload_invalid",
format!("资料库章节上下文参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::section_context(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(compact_section_context_for_agent(payload))
}
fn compact_query_result_for_agent(payload: Value) -> Value {
let references = payload
.get("references")
@@ -139,16 +173,25 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
})
.collect::<Vec<_>>();
let ui_citations = citations.clone();
let document_structure_index = payload
.get("documentStructureIndex")
.cloned()
.filter(|value| !value.is_null())
.unwrap_or(Value::Null);
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"answerGuidance": "Final answers 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.",
"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. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. 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,
"documentStructureIndex": compact_document_structure_index_for_agent(&document_structure_index),
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
"requestedRetrievalMode": payload.get("requestedRetrievalMode").cloned().unwrap_or(Value::Null),
"effectiveRetrievalMode": payload.get("effectiveRetrievalMode").cloned().or_else(|| payload.get("retrievalMode").cloned()).unwrap_or(Value::Null),
"retrievalModeReason": payload.get("retrievalModeReason").cloned().unwrap_or(Value::Null),
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
@@ -159,6 +202,168 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
})
}
fn compact_document_structure_index_for_agent(value: &Value) -> Value {
if value.is_null() {
return Value::Null;
}
let documents = value
.get("documents")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(4)
.map(|doc| {
let mut sections = doc
.get("sections")
.and_then(Value::as_array)
.map(|sections| {
sections
.iter()
.filter(|section| {
section
.get("queryMatchCount")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0
|| section
.get("matchedReferenceCount")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0
})
.take(12)
.map(compact_structure_section_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if sections.is_empty() {
sections = doc
.get("sections")
.and_then(Value::as_array)
.map(|sections| {
sections
.iter()
.take(12)
.map(compact_structure_section_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
}
json!({
"sourceRootRelativePath": doc.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"sourceId": doc.get("sourceId").cloned().unwrap_or(Value::Null),
"lightRagDocId": doc.get("lightRagDocId").cloned().unwrap_or(Value::Null),
"sectionCount": doc.get("sectionCount").cloned().unwrap_or(Value::Null),
"queryMatchedSections": doc.get("queryMatchedSections").cloned().unwrap_or(Value::Null),
"referenceMatchedSections": doc.get("referenceMatchedSections").cloned().unwrap_or(Value::Null),
"sections": sections,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.document_structure_index.v1")),
"mode": value.get("mode").cloned().unwrap_or(Value::Null),
"referenceCount": value.get("referenceCount").cloned().unwrap_or(Value::Null),
"documents": documents,
})
}
fn compact_structure_section_for_agent(section: &Value) -> Value {
json!({
"sectionId": section.get("sectionId").cloned().unwrap_or(Value::Null),
"title": section.get("title").cloned().unwrap_or(Value::Null),
"level": section.get("level").cloned().unwrap_or(Value::Null),
"headingPath": section.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"startBlockOrdinal": section.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
"endBlockOrdinal": section.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
"startParagraphOrdinal": section.get("startParagraphOrdinal").cloned().unwrap_or(Value::Null),
"endParagraphOrdinal": section.get("endParagraphOrdinal").cloned().unwrap_or(Value::Null),
"blockCount": section.get("blockCount").cloned().unwrap_or(Value::Null),
"queryMatchCount": section.get("queryMatchCount").cloned().unwrap_or(Value::Null),
"matchedReferenceCount": section.get("matchedReferenceCount").cloned().unwrap_or(Value::Null),
"sample": section
.get("sample")
.and_then(Value::as_str)
.map(|value| value.chars().take(220).collect::<String>())
.unwrap_or_default(),
})
}
fn compact_section_context_for_agent(payload: Value) -> Value {
let blocks = payload
.get("blocks")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(40)
.map(|block| {
let text = block
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(900).collect::<String>())
.unwrap_or_default();
json!({
"blockOrdinal": block.get("blockOrdinal").cloned().unwrap_or(Value::Null),
"blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
"paragraphOrdinal": block.get("paragraphOrdinal").cloned().unwrap_or(Value::Null),
"headingPath": block.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"text": text,
"textTruncated": block.get("textTruncated").cloned().unwrap_or(Value::Bool(false)),
"locator": block.get("locator").cloned().unwrap_or(Value::Null),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let text = payload
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(12_000).collect::<String>())
.unwrap_or_default();
let chunks = payload
.get("chunks")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(20)
.map(|chunk| {
let text = chunk
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(2_400).collect::<String>())
.unwrap_or_default();
json!({
"chunkOrdinal": chunk.get("chunkOrdinal").cloned().unwrap_or(Value::Null),
"startBlockOrdinal": chunk.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
"endBlockOrdinal": chunk.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
"blockIds": chunk.get("blockIds").cloned().unwrap_or_else(|| json!([])),
"text": text,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
"section": payload.get("section").cloned().unwrap_or(Value::Null),
"limits": payload.get("limits").cloned().unwrap_or(Value::Null),
"blocks": blocks,
"chunks": chunks,
"text": text,
"usageGuidance": "This is bounded sidecar context for interpreting a documentStructureIndex section. Use it as supporting reading context, but cite final answers with references/citations returned by mnote.knowledge_rag.query when possible.",
})
}
fn compact_reference_for_agent(reference: &Value) -> Value {
let quote = reference
.get("displayQuote")
@@ -14,6 +14,7 @@ pub fn manifest() -> Value {
doc_find_tool(),
knowledge_rag_status_tool(),
knowledge_rag_query_tool(),
knowledge_rag_section_context_tool(),
knowledge_rag_open_reference_tool(),
block_fetch_tool(),
doc_plan_update_tool(),
@@ -328,6 +329,14 @@ fn knowledge_rag_query_tool() -> Value {
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"includeDocumentStructureIndex".into(),
json!({
"type": "boolean",
"default": false,
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
}),
);
map.insert(
"sourcePaths".into(),
json!({
@@ -376,6 +385,51 @@ fn knowledge_rag_open_reference_tool() -> Value {
})
}
fn knowledge_rag_section_context_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("sourcePath".into(), json!({ "type": "string" }));
map.insert("sourceId".into(), json!({ "type": "string" }));
map.insert("lightRagDocId".into(), json!({ "type": "string" }));
map.insert("filePath".into(), json!({ "type": "string" }));
map.insert("sectionId".into(), json!({ "type": "string" }));
map.insert("startBlockOrdinal".into(), json!({ "type": "integer" }));
map.insert("endBlockOrdinal".into(), json!({ "type": "integer" }));
map.insert("startParagraphOrdinal".into(), json!({ "type": "integer" }));
map.insert("endParagraphOrdinal".into(), json!({ "type": "integer" }));
map.insert(
"contextBefore".into(),
json!({ "type": "integer", "default": 1 }),
);
map.insert(
"contextAfter".into(),
json!({ "type": "integer", "default": 1 }),
);
map.insert(
"maxBlocks".into(),
json!({ "type": "integer", "default": 24 }),
);
map.insert(
"maxChars".into(),
json!({ "type": "integer", "default": 12000 }),
);
}
json!({
"name": "mnote.knowledge_rag.section_context",
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": properties
}
})
}
// 旧 local index agent 工具仅保留为历史对照;当前 manifest() 不注册这些工具。
#[allow(dead_code)]
fn index_status_tool() -> Value {
@@ -49,6 +49,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
"mnote.context.resolve_target",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
],
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),