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:
@@ -30,10 +30,38 @@ function cargoWatchCommand(env = process.env) {
|
||||
return `cargo watch ${watchArgs.join(" ")}`;
|
||||
}
|
||||
|
||||
function bindHost(bindAddr) {
|
||||
const value = String(bindAddr || "").trim();
|
||||
if (!value) return "";
|
||||
const ipv6Match = value.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if (ipv6Match) return ipv6Match[1];
|
||||
const lastColon = value.lastIndexOf(":");
|
||||
if (lastColon <= 0) return "";
|
||||
return value.slice(0, lastColon);
|
||||
}
|
||||
|
||||
function bindPort(bindAddr, fallbackPort) {
|
||||
const value = String(bindAddr || "").trim();
|
||||
const match = value.match(/:(\d+)$/);
|
||||
if (!match) return fallbackPort;
|
||||
const port = Number(match[1]);
|
||||
return Number.isFinite(port) && port > 0 ? Math.floor(port) : fallbackPort;
|
||||
}
|
||||
|
||||
function devHotBindAddr(env = process.env) {
|
||||
const frontendPort = bindPort(env.MNOTE_WEB_BIND, Number(env.FRONTEND_PORT || 3000));
|
||||
const host = bindHost(env.MNOTE_WEB_BIND);
|
||||
if (!host || host === "127.0.0.1" || host === "localhost" || host === "::1") {
|
||||
return `0.0.0.0:${frontendPort}`;
|
||||
}
|
||||
return String(env.MNOTE_WEB_BIND).trim();
|
||||
}
|
||||
|
||||
function buildDevHotEnv(baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
||||
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||
};
|
||||
}
|
||||
@@ -70,4 +98,5 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
buildDevHotEnv,
|
||||
cargoWatchCommand,
|
||||
devHotBindAddr,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,5 +15,13 @@ assert.match(env.MNOTE_WEB_CMD, /run -p mnote-web --bin mnote-web/);
|
||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
|
||||
assert.equal(env.FRONTEND_PORT, "3200");
|
||||
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
|
||||
|
||||
const loopbackEnv = buildDevHotEnv({
|
||||
MNOTE_WEB_BIND: "127.0.0.1:3300",
|
||||
MNOTE_WEB_CMD: "custom",
|
||||
});
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300");
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, command: env.MNOTE_WEB_CMD }, null, 2));
|
||||
|
||||
@@ -30,6 +30,14 @@ function documentUrl(root, relativePath) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function navigationUrl(root) {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
@@ -66,13 +74,31 @@ async function browserSearch(page, root, query) {
|
||||
}, { rootUri: fileUrl(root), queryText: query });
|
||||
}
|
||||
|
||||
async function browserRefreshLocalIndex(page, root) {
|
||||
return page.evaluate(async ({ rootUri }) => {
|
||||
const response = await fetch("/api/search/local-index/refresh", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "local-ws:user_real:task452",
|
||||
rootUri,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
status: response.status,
|
||||
payload,
|
||||
};
|
||||
}, { rootUri: fileUrl(root) });
|
||||
}
|
||||
|
||||
async function capturePanelDiagnostics(page) {
|
||||
try {
|
||||
return await page.evaluate(function() {
|
||||
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||
return {
|
||||
backlinksHtml: bl ? bl.innerHTML : '(missing)',
|
||||
tagsHtml: tg ? tg.innerHTML : '(missing)',
|
||||
@@ -131,6 +157,9 @@ async function run() {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const refreshedBeforeSearch = await browserRefreshLocalIndex(page, root);
|
||||
assert.equal(refreshedBeforeSearch.status, 200, `刷新本地索引应成功: ${JSON.stringify(refreshedBeforeSearch)}`);
|
||||
debug.refreshedBeforeSearch = refreshedBeforeSearch.payload;
|
||||
const first = await browserSearch(page, root, token);
|
||||
assert.equal(first.status, 200, `初次搜索应成功: ${JSON.stringify(first)}`);
|
||||
debug.first = first.payload;
|
||||
@@ -140,6 +169,29 @@ async function run() {
|
||||
`新建页面应立即可搜索: ${JSON.stringify(firstResults)}`,
|
||||
);
|
||||
|
||||
await page.goto(navigationUrl(root), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-index-settings-popover"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
debug.navigationIndexSettings = await page.evaluate(function() {
|
||||
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
|
||||
return {
|
||||
path: window.location.pathname,
|
||||
visible: Boolean(popover && !popover.hidden),
|
||||
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
|
||||
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
|
||||
};
|
||||
});
|
||||
assert.equal(debug.navigationIndexSettings.visible, true, `导航页应能打开索引设置: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
assert.equal(debug.navigationIndexSettings.hasPageSettingsIndexTab, false, `索引设置不应留在页面设置页签: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
|
||||
await page.goto(documentUrl(root, firstRelativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
@@ -162,8 +214,7 @@ async function run() {
|
||||
if (msg.type() === 'error') { diagApiResponses._consoleErrors = (diagApiResponses._consoleErrors || []).concat([msg.text()]); }
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-settings-tab="index"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
// 等待本地索引面板渲染;超时时捕获 DOM 诊断再抛
|
||||
try {
|
||||
@@ -195,6 +246,9 @@ async function run() {
|
||||
debug.diagApiResponses = diagApiResponses;
|
||||
|
||||
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
||||
const refreshedAfterRename = await browserRefreshLocalIndex(page, root);
|
||||
assert.equal(refreshedAfterRename.status, 200, `重命名后刷新本地索引应成功: ${JSON.stringify(refreshedAfterRename)}`);
|
||||
debug.refreshedAfterRename = refreshedAfterRename.payload;
|
||||
const second = await browserSearch(page, root, token);
|
||||
assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`);
|
||||
debug.second = second.payload;
|
||||
|
||||
@@ -262,9 +262,9 @@ async function main() {
|
||||
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
|
||||
});
|
||||
assert.equal(
|
||||
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")),
|
||||
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({ "data-object-kind": "table" }, "table.asset", "table")),
|
||||
"table",
|
||||
"legacy luckysheet 行仍应归为 table 资源",
|
||||
"table objectKind/iconKind 行应归为 table 资源",
|
||||
);
|
||||
const dirtyCommandRow = new FakeCommandRow({
|
||||
"data-row-id": "local:markdown:docs/Page.md",
|
||||
|
||||
@@ -32,6 +32,10 @@ function firstTableCellText(doc) {
|
||||
return doc?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.text || "";
|
||||
}
|
||||
|
||||
function taskItemChecked(doc) {
|
||||
return doc?.content?.[0]?.content?.[0]?.attrs?.checked;
|
||||
}
|
||||
|
||||
const {
|
||||
pageBodyTiptapDocumentSource,
|
||||
pageBodyTiptapDocument,
|
||||
@@ -99,6 +103,24 @@ assert.equal(
|
||||
"Provider 类别",
|
||||
);
|
||||
|
||||
const localWithProjectedTodo = {
|
||||
projectionSource: "local_markdown.content",
|
||||
blockDocument: {
|
||||
documentId: "local-md:design~2F07-ai~2Fprocess~2F7-46-document-evidence-retrieval-kernel-v1.md",
|
||||
rootBlockIds: ["todo-1"],
|
||||
blocks: [{
|
||||
blockId: "todo-1",
|
||||
type: "todo",
|
||||
attrs: { checked: true },
|
||||
contentNodes: [{ text: "graph traversal 结果必须带证据引用。", styles: {} }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
taskItemChecked(pageBodyTiptapDocument(localWithProjectedTodo)),
|
||||
true,
|
||||
);
|
||||
|
||||
const localLegacyOnly = {
|
||||
projectionSource: "local_markdown.content",
|
||||
content: legacyContent,
|
||||
|
||||
@@ -148,16 +148,21 @@ async function main() {
|
||||
const watchBatchBeforeOcr = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-local-folder-watch-batch-applied") || "");
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||
const topbarOcrButtonInfo = await page.getByTestId("mnote-local-ocr-task-toggle").evaluate((node) => {
|
||||
const topbar = node.closest(".wolai-topbar-actions");
|
||||
return {
|
||||
inTopbar: Boolean(topbar),
|
||||
action: node.getAttribute("data-mnote-action") || "",
|
||||
label: node.getAttribute("aria-label") || "",
|
||||
text: node.textContent || "",
|
||||
badge: node.querySelector("[data-mnote-local-ocr-task-count]")?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 任务入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 设置入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||
assert.equal(topbarOcrButtonInfo.action, "open-ocr-settings", `OCR 顶栏按钮应打开设置: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||
await page.waitForFunction(
|
||||
(sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
@@ -167,6 +172,8 @@ async function main() {
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="tasks"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const uiOcrPath = await page.evaluate(async ({ rootUri, sourceRootRelativePath }) => {
|
||||
const url = new URL("/api/local-folder/ocr/status", window.location.origin);
|
||||
@@ -247,6 +254,8 @@ async function main() {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
@@ -322,9 +331,28 @@ async function main() {
|
||||
return { ok: true, parentPath };
|
||||
}, uiOcrPath);
|
||||
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
|
||||
const ocrFileTreeRow = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${uiOcrPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`).first();
|
||||
await ocrFileTreeRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await ocrFileTreeRow.click({ timeout: UI_TIMEOUT_MS });
|
||||
const ocrFileTreeOpenResult = await page.waitForFunction(
|
||||
(ocrRootRelativePath) => {
|
||||
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
|
||||
const exact = rows.find((row) => row.getAttribute("data-local-relative-path") === ocrRootRelativePath);
|
||||
const fileName = ocrRootRelativePath.split("/").filter(Boolean).pop() || ocrRootRelativePath;
|
||||
const fallback = rows.find((row) => {
|
||||
const relativePath = row.getAttribute("data-local-relative-path") || "";
|
||||
return relativePath.endsWith(`/${fileName}`) || relativePath === fileName || (row.textContent || "").includes(fileName);
|
||||
});
|
||||
const target = exact || fallback;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
target.click();
|
||||
return {
|
||||
ok: true,
|
||||
exact: Boolean(exact),
|
||||
relativePath: target.getAttribute("data-local-relative-path") || "",
|
||||
};
|
||||
},
|
||||
uiOcrPath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).then((handle) => handle.jsonValue());
|
||||
assert.equal(ocrFileTreeOpenResult.ok, true, `OCR sidecar filetree row should open: ${JSON.stringify(ocrFileTreeOpenResult)}`);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-local-ocr-filetree-open") === "resource-tab",
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task528-document-evidence-liteparse-agent-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const ACTOR_ID = "mnote-e2e";
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
const RUN_REASONIX_ACP = process.env.MNOTE_TASK528_SKIP_REASONIX_ACP !== "1";
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath}`;
|
||||
}
|
||||
|
||||
async function fetchJson(pathname, init = {}) {
|
||||
const response = await fetch(`${BASE_URL}${pathname}`, init);
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
assert(
|
||||
response.ok,
|
||||
`${pathname} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
return { payload, response };
|
||||
}
|
||||
|
||||
async function postJson(pathname, data, headers = {}) {
|
||||
return (await fetchJson(pathname, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(data),
|
||||
})).payload;
|
||||
}
|
||||
|
||||
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${pathname}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `${pathname} 请求失败: ${response.status} ${text.slice(0, 500)}`);
|
||||
return text;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function signInCookie() {
|
||||
const { response } = await fetchJson("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: ACTOR_ID,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const setCookie = response.headers.get("set-cookie") || "";
|
||||
const session = setCookie.match(/mnote_session=[^;]+/u)?.[0];
|
||||
assert(session, `登录响应缺少 mnote_session cookie: ${setCookie}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createAiGrant(rootUri) {
|
||||
const payload = await postJson("/api/admin/access-policy/grants", {
|
||||
userId: ACTOR_ID,
|
||||
rootUri,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["ai"],
|
||||
}, {
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
});
|
||||
assert(payload.grant?.id, "创建 AI 目录授权后缺少 grant id");
|
||||
return payload.grant;
|
||||
}
|
||||
|
||||
function assertEvidenceHit(hit, expected) {
|
||||
assert(hit, `${expected.label} 缺少正文级 PDF evidence 命中`);
|
||||
assert(String(hit.quote || "").includes("Printer test page"), `${expected.label} quote 不包含 PDF 正文 token`);
|
||||
assert.strictEqual(hit.source?.ownerDocumentPath, expected.ownerRel, `${expected.label} ownerDocumentPath`);
|
||||
assert.strictEqual(hit.source?.resourcePath, expected.pdfRel, `${expected.label} resourcePath`);
|
||||
assert.strictEqual(hit.source?.resourceKind, "pdf", `${expected.label} resourceKind`);
|
||||
assert(hit.source?.page, `${expected.label} 缺少 page locator`);
|
||||
assert(hit.source?.bbox, `${expected.label} 缺少 bbox locator`);
|
||||
assert(hit.source?.sourceMapPath, `${expected.label} 缺少 sourceMapPath`);
|
||||
}
|
||||
|
||||
function decodeSseToolPayloads(sse) {
|
||||
return String(sse || "")
|
||||
.split(/\n\n+/u)
|
||||
.map((eventText) => {
|
||||
const eventName = eventText
|
||||
.split(/\n/u)
|
||||
.find((line) => line.startsWith("event:"))
|
||||
?.slice("event:".length)
|
||||
.trim();
|
||||
const dataLines = eventText
|
||||
.split(/\n/u)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart());
|
||||
if (!dataLines.length) return null;
|
||||
try {
|
||||
return { event: eventName || null, payload: JSON.parse(dataLines.join("\n")) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function assertReasonixAcpEvidenceSse(sse) {
|
||||
assert(sse.includes('"tool":"mnote_evidence_search"'), "Reasonix ACP SSE 未出现 mnote_evidence_search 工具调用");
|
||||
assert(sse.includes("event: tool.completed"), "Reasonix ACP evidence 工具未标记为 completed");
|
||||
assert(!sse.includes("event: tool.failed"), "Reasonix ACP evidence 工具被错误标记为 failed");
|
||||
const payloads = decodeSseToolPayloads(sse);
|
||||
const completedTool = payloads.find(({ event, payload }) =>
|
||||
event === "tool.completed" && payload?.status === "completed"
|
||||
);
|
||||
assert(completedTool, "Reasonix ACP SSE 缺少 completed evidence tool payload");
|
||||
const outputText = (completedTool.payload.output || [])
|
||||
.map((item) => item?.content?.text || "")
|
||||
.join("\n");
|
||||
assert(outputText.includes('"quote":"Printer test page"'), "Reasonix ACP 工具结果未返回 PDF 正文 quote");
|
||||
assert(outputText.includes('"page":1'), "Reasonix ACP 工具结果未返回 page locator");
|
||||
assert(outputText.includes("mnote.agent_run_receipt.evidence.v1"), "Reasonix ACP 工具结果缺少 evidence run receipt");
|
||||
}
|
||||
|
||||
async function runReasonixAcpEvidenceCheck(input) {
|
||||
const { workspaceId, rootUri, documentId, actorHeaders } = input;
|
||||
await createAiGrant(rootUri);
|
||||
const sessionId = `task528_reasonix_tools_${Date.now().toString(36)}`;
|
||||
const traceId = `task528-reasonix-tools-${Date.now().toString(36)}`;
|
||||
const run = await postJson("/api/hermes/client/runs", {
|
||||
workspaceId,
|
||||
documentId,
|
||||
sessionId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
agentId: "reasonix",
|
||||
profile: "reasonix",
|
||||
acpRuntime: "reasonix",
|
||||
contextScope: "page",
|
||||
contextRefs: ["current_page", "folder"],
|
||||
allowedRoots: [{ rootUri, permission: "write" }],
|
||||
skillPreferences: {
|
||||
mnote: {
|
||||
"mnote-document-evidence": true,
|
||||
"mnote-chat-only": false,
|
||||
},
|
||||
},
|
||||
message: "请调用 mnote_evidence_search 搜索 Printer test page,然后用一句中文回答页码和 quote。必须使用工具,不能只说正在搜索。",
|
||||
traceId,
|
||||
pageContext: {
|
||||
contextScope: "page",
|
||||
node: { documentId, title: "EvidenceLive" },
|
||||
aiContext: {
|
||||
schema: "mnote.page_ai_context.v1",
|
||||
workspaceId,
|
||||
documentId,
|
||||
scope: "page",
|
||||
selectedText: "",
|
||||
selectedBlockIds: [],
|
||||
contextBlocks: [],
|
||||
pageText: "",
|
||||
pageXml: `<page id=\"${documentId}\"></page>`,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
}, actorHeaders);
|
||||
assert(run.ok === true && run.runId, `Reasonix ACP run 创建失败: ${JSON.stringify(run)}`);
|
||||
|
||||
const sse = await getText(`/api/hermes/client/events/${encodeURIComponent(run.runId)}`, actorHeaders);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), sse, "utf8");
|
||||
assertReasonixAcpEvidenceSse(sse);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-live-run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8");
|
||||
return {
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
eventPath: path.join(OUTPUT_DIR, "reasonix-tools-events.sse"),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-evidence-live-"));
|
||||
const workspaceId = `local-ws:${ACTOR_ID}:task528-evidence`;
|
||||
const rootUri = fileUrl(root);
|
||||
const ownerRel = "EvidenceLive.md";
|
||||
const pdfRel = "assets/default-testpage.pdf";
|
||||
const documentId = localMdDocumentId(ownerRel);
|
||||
const actorHeaders = {
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": workspaceId,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId: ACTOR_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.copyFileSync("/usr/share/cups/data/default-testpage.pdf", path.join(root, pdfRel));
|
||||
fs.writeFileSync(
|
||||
path.join(root, ownerRel),
|
||||
["# Evidence Live", "", "测试正文级 PDF evidence。", "", `[Printer PDF](${pdfRel})`, ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cookie = await signInCookie();
|
||||
const toolsPayload = (await fetchJson("/api/hermes/client/tools?scope=mnote&profile=reasonix", {
|
||||
headers: { cookie },
|
||||
})).payload;
|
||||
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
|
||||
for (const name of ["mnote.evidence.search", "mnote.evidence.read", "mnote.evidence.open"]) {
|
||||
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
|
||||
}
|
||||
const skillsPayload = (await fetchJson("/api/hermes/client/skills?runtime=mnote&agentId=reasonix", {
|
||||
headers: { cookie },
|
||||
})).payload;
|
||||
const mnoteSkills = (skillsPayload.categories || []).flatMap((category) => category.skills || []);
|
||||
assert(
|
||||
mnoteSkills.some((skill) => skill.id === "mnote-document-evidence" && skill.enabled !== false),
|
||||
"Reasonix agent 缺少启用的 mnote-document-evidence skill",
|
||||
);
|
||||
|
||||
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
|
||||
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
|
||||
|
||||
const direct = await postJson("/api/evidence/search", {
|
||||
query: "Printer test page",
|
||||
scope: { workspaceId, rootUri, includeResources: true, includeOcr: true },
|
||||
mode: "hybrid",
|
||||
topK: 5,
|
||||
}, actorHeaders);
|
||||
const directHit = (direct.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||
assertEvidenceHit(directHit, { label: "direct", ownerRel, pdfRel });
|
||||
|
||||
const read = await postJson("/api/evidence/read", {
|
||||
locator: directHit.source,
|
||||
context: { beforeBlocks: 1, afterBlocks: 1, includeSectionSummary: true },
|
||||
}, actorHeaders);
|
||||
assert.strictEqual(read.ok, true, "evidence read ok");
|
||||
assert(String(read.quote || "").includes("Printer test page"), "evidence read 未读回 PDF 正文 quote");
|
||||
|
||||
const open = await postJson("/api/evidence/open", { locator: directHit.source }, actorHeaders);
|
||||
assert.strictEqual(open.ok, true, "evidence open ok");
|
||||
assert(open.openAction?.params?.sourceMapPath, "evidence open 缺少 sourceMapPath params");
|
||||
|
||||
const toolEnvelope = await postJson("/api/hermes/tools/mnote/call", {
|
||||
toolName: "mnote.evidence.search",
|
||||
workspaceId,
|
||||
documentId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
actorId: ACTOR_ID,
|
||||
profile: "reasonix",
|
||||
sessionId: "task528_evidence_session",
|
||||
runId: "task528_evidence_run",
|
||||
toolCallId: "task528_evidence_tool_call",
|
||||
args: { query: "Printer test page", includeResources: true, includeOcr: true, topK: 5 },
|
||||
}, actorHeaders);
|
||||
assert.strictEqual(toolEnvelope.ok, true, "MNote evidence tool envelope ok");
|
||||
const toolResult = toolEnvelope.result || toolEnvelope;
|
||||
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
|
||||
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
|
||||
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
|
||||
|
||||
const reasonixAcp = RUN_REASONIX_ACP
|
||||
? await runReasonixAcpEvidenceCheck({ workspaceId, rootUri, documentId, actorHeaders })
|
||||
: { skipped: true };
|
||||
|
||||
execFileSync(process.execPath, ["scripts/reasonix-acp-wrapper.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, MNOTE_REASONIX_ACP_SELFTEST: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const parseMd = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.parse.md");
|
||||
const sourceMap = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.source-map.json");
|
||||
const sqlitePath = path.join(root, ".mnote", "index", "evidence.sqlite");
|
||||
assert(fs.existsSync(parseMd), `缺少 LiteParse parse sidecar: ${parseMd}`);
|
||||
assert(fs.existsSync(sourceMap), `缺少 source-map sidecar: ${sourceMap}`);
|
||||
assert(fs.existsSync(sqlitePath), `缺少 evidence sqlite: ${sqlitePath}`);
|
||||
const ftsCount = Number(execFileSync(
|
||||
"sqlite3",
|
||||
[sqlitePath, "SELECT count(*) FROM evidence_fts WHERE evidence_fts MATCH 'Printer';"],
|
||||
{ encoding: "utf8" },
|
||||
).trim());
|
||||
assert(ftsCount >= 1, `evidence.sqlite FTS 未命中 PDF 正文: ${ftsCount}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
workspaceId,
|
||||
documentId,
|
||||
directEvidenceId: directHit.evidenceId,
|
||||
toolEvidenceId: toolHit.evidenceId,
|
||||
quote: directHit.quote,
|
||||
page: directHit.source.page,
|
||||
bbox: directHit.source.bbox,
|
||||
ownerDocumentPath: directHit.source.ownerDocumentPath,
|
||||
resourcePath: directHit.source.resourcePath,
|
||||
sourceMapPath: directHit.source.sourceMapPath,
|
||||
parseMd,
|
||||
sourceMap,
|
||||
sqlitePath,
|
||||
ftsCount,
|
||||
reasonixTools: toolNames.filter((name) => name.startsWith("mnote.evidence.")),
|
||||
evidenceSkillEnabled: true,
|
||||
receiptToolName: toolEnvelope.audit.runReceipt.toolName,
|
||||
reasonixAcp,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user