feat: add evidence search and stabilize pdf previews

- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes
- fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug
- keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable

Verification:
- node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
- node scripts/task-dev-hot-plan-test.js
- cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar
- cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot
- cargo build -p mnote-web --manifest-path rust/Cargo.toml
- browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
lix-2026
2026-06-04 18:51:16 +08:00
parent 9f4b5c4d48
commit 627213dc01
51 changed files with 15960 additions and 1844 deletions
+117 -1
View File
@@ -44,6 +44,9 @@ function isWriteMnoteTool(toolName) {
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.evidence.search',
'mnote.evidence.read',
'mnote.evidence.open',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
@@ -243,6 +246,35 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (!promptWithEnvelope.includes('native file tools')) {
throw new Error('selftest expected prompt to instruct native file tools');
}
const evidencePayload = buildMnoteToolPayload(
'mnote.evidence.search',
{ query: 'ResourceBodyToken' },
{
workspaceId: 'ws_local',
mnoteCapabilities: {
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
aiAccessScope: {
permissionLevel: 'read',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'read' }],
},
},
},
);
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected evidence payload to inherit local root context');
}
if (isWriteMnoteTool('mnote.evidence.search')) {
throw new Error('selftest expected evidence search to be read-only');
}
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
throw new Error('selftest expected ok evidence result with error:null to be completed');
}
const failedEvidenceToolResult = JSON.stringify({ ok: false, error: { code: 'failed' } });
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
throw new Error('selftest expected ok:false evidence result to be failed');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -435,6 +467,22 @@ function emitToolResult(sessionId, toolCallId, status, text) {
});
}
function toolResultStatusFromContent(content) {
const text = String(content || '');
if (!text.trim()) return 'completed';
try {
const payload = JSON.parse(text);
if (payload && typeof payload === 'object') {
if (payload.ok === false) return 'failed';
if (payload.error && payload.error !== null) return 'failed';
return 'completed';
}
} catch {
// 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。
}
return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed';
}
function emitUsage(sessionId, used, size) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'usage_update',
@@ -452,6 +500,9 @@ const MNOTE_TOOL_NAMES = [
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.evidence.search',
'mnote.evidence.read',
'mnote.evidence.open',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
@@ -459,6 +510,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
mnote_evidence_search: 'mnote.evidence.search',
mnote_evidence_read: 'mnote.evidence.read',
mnote_evidence_open: 'mnote.evidence.open',
};
async function callMnoteTool(toolName, args) {
@@ -539,6 +593,67 @@ tools.register({
parallelSafe: false,
});
tools.register({
name: 'mnote_evidence_search',
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '要搜索的问题或关键词' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
targetDocumentId: { type: 'string', description: '可选,限制到当前文档' },
includeResources: { type: 'boolean', description: '是否包含附件/资源标题' },
includeOcr: { type: 'boolean', description: '是否包含 OCR/source-map 证据' },
mode: { type: 'string', enum: ['hybrid', 'tree', 'graph'], description: '检索模式' },
topK: { type: 'integer', description: '最多返回结果数' },
scope: { type: 'object', description: '完整 EvidenceSearchScope,提供时优先使用' },
},
required: ['query'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_search, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_evidence_read',
description: '按 EvidenceLocator 读取原文证据及周边上下文。',
parameters: {
type: 'object',
properties: {
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
context: {
type: 'object',
properties: {
beforeBlocks: { type: 'integer' },
afterBlocks: { type: 'integer' },
includeSectionSummary: { type: 'boolean' },
},
},
},
required: ['locator'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_read, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_evidence_open',
description: '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。',
parameters: {
type: 'object',
properties: {
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
},
required: ['locator'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_open, args),
parallelSafe: true,
});
// ── Session Store ────────────────────────────────────
const sessions = new Map();
@@ -578,6 +693,7 @@ onRequest('session/new', async (params) => {
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-document-evidence — Search local documents and resources with clickable evidence locators.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
@@ -739,7 +855,7 @@ onRequest('session/prompt', async (params) => {
emitToolResult(
session.id,
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
resultText.includes('"error"') ? 'failed' : 'completed',
toolResultStatusFromContent(resultText),
resultText,
);
break;