feat(rag): replace LiteParse flows with LightRAG provider

This commit is contained in:
lix-2026
2026-06-07 01:10:31 +08:00
parent f46c2fb5d0
commit 1f25364374
40 changed files with 7232 additions and 2350 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ node scripts/task490-runtime-surfaces-smoke.js
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
- local-folder OCR`task526-local-folder-ocr-api-smoke.js` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、sidecar 落盘、jobs/status/read、全局 OCR 任务栏 / 任务抽屉、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;`TASK520_OCR_CONTEXT=1 node scripts/task520-page-ai-raw-resource-target-smoke.js` 覆盖 Page AI active image/PDF resource target 读取 OCR sidecar context
- LightRAG 资料库:`task534-knowledge-rag-source-management-scope-smoke.js` 覆盖本地文件夹 source 加入资料库、registry 状态与范围管理;`task529-knowledge-rag-citation-resource-tab-smoke.js``task530-knowledge-rag-page-ai-final-answer-smoke.js` 覆盖资料库引用回到资源页与 Page AI 最终回答。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档
- local Markdown conflict regression`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
- tree realtime / live cache`task446-tree-rename-dual-browser-live-smoke.js``task447-tree-move-order-dual-browser-live-smoke.js``task448-tree-resync-recovery-dual-browser-smoke.js``task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
- 资源对象与 mindmap`task455-local-folder-mindmap-clean-smoke.js``task456-resource-object-shell-sync-smoke.js``task166-mindmap-phase6-block-smoke.js``task167-mindmap-kmind-parity-smoke.js``task168-mindmap-put-validator-smoke.js`Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
+57 -112
View File
@@ -44,11 +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.index.status',
'mnote.index.refresh',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
@@ -248,8 +246,11 @@ 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');
}
if (!promptWithEnvelope.includes('Do not use MNote doc/page write tools for ordinary local Markdown edits.')) {
throw new Error('selftest expected prompt to forbid MNote doc/page write tools for ordinary local Markdown edits');
}
const evidencePayload = buildMnoteToolPayload(
'mnote.evidence.search',
'mnote.knowledge_rag.query',
{ query: 'ResourceBodyToken' },
{
workspaceId: 'ws_local',
@@ -266,8 +267,8 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
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');
if (isWriteMnoteTool('mnote.knowledge_rag.query')) {
throw new Error('selftest expected knowledge RAG query to be read-only');
}
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
@@ -502,12 +503,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',
'mnote.index.status',
'mnote.index.refresh',
'mnote.index.update_settings',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
@@ -515,12 +513,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',
mnote_index_status: 'mnote.index.status',
mnote_index_refresh: 'mnote.index.refresh',
mnote_index_update_settings: 'mnote.index.update_settings',
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
};
async function callMnoteTool(toolName, args) {
@@ -602,117 +597,65 @@ tools.register({
});
tools.register({
name: 'mnote_evidence_search',
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
name: 'mnote_knowledge_rag_status',
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
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,提供时优先使用' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_status, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 answer、provider 原始结果和经 MNote registry 映射后的 references。回答必须引用返回来源。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '资料库问题' },
question: { type: 'string', description: '资料库问题,等价于 query' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
topK: { type: 'integer', description: 'LightRAG top_k' },
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
sourcePaths: {
type: 'array',
items: { type: 'string' },
description: '可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。',
},
},
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,
});
tools.register({
name: 'mnote_index_status',
description: '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。',
parameters: {
type: 'object',
properties: {
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_status, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_index_refresh',
description: '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。',
parameters: {
type: 'object',
properties: {
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_refresh, args),
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
parallelSafe: false,
});
tools.register({
name: 'mnote_index_update_settings',
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
name: 'mnote_knowledge_rag_open_reference',
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
parameters: {
type: 'object',
properties: {
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
referenceId: { type: 'string' },
filePath: { type: 'string', description: 'LightRAG reference file_path' },
chunkId: { type: 'string' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
includePaths: { type: 'array', items: { type: 'string' }, description: 'root 内相对路径列表,空数组表示删除范围' },
scheduleMode: { type: 'string', enum: ['manual', 'daily', 'weekly', 'monthly'], description: '刷新计划' },
scheduleTime: { type: 'string', description: 'HH:mm' },
scheduleDate: { type: 'string', description: '可选日期' },
runOnChange: { type: 'boolean', description: '文件变化时是否自动刷新' },
dryRun: { type: 'boolean', description: 'true 只返回计划;false 写入设置并刷新索引' },
idempotencyKey: { type: 'string', description: '写入幂等键' },
},
required: ['includePaths', 'dryRun', 'idempotencyKey'],
required: ['filePath'],
},
readOnly: false,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
parallelSafe: false,
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
parallelSafe: true,
});
// ── Session Store ────────────────────────────────────
@@ -751,10 +694,12 @@ onRequest('session/new', async (params) => {
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'Do not use MNote doc/page write tools for ordinary local Markdown edits.',
'Final answers must be direct user-facing answers. Do not narrate tool use, search steps, plans, or internal process; do not say phrases like "let me search", "I found", "I will check", or "让我".',
'<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-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; if locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- 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>',
@@ -120,9 +120,7 @@ async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page.md";
const expectOcrContext = process.env.TASK520_OCR_CONTEXT === "1";
const rawPath = expectOcrContext ? "Page/photo.png" : "Page/notes.txt";
const ocrPath = "Page.ocr/photo.png.ocr.md";
const rawPath = "Page/notes.txt";
const documentId = localMdDocumentId(pagePath);
const captured = [];
let caughtError = null;
@@ -130,41 +128,7 @@ async function main() {
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
if (expectOcrContext) {
fs.writeFileSync(path.join(root, rawPath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
fs.mkdirSync(path.join(root, "Page.ocr"), { recursive: true });
fs.writeFileSync(
path.join(root, ocrPath),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page/photo.png\nsource_root_relative_path: Page/photo.png\nsource_size: 6\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nTask520 OCR sidecar context text\n",
"utf8",
);
fs.writeFileSync(
path.join(root, ".mnote", "ocr-index.json"),
`${JSON.stringify({
version: 1,
entries: {
[rawPath]: {
jobId: "ocr_task520",
ownerDocumentId: documentId,
ownerDocumentPath: pagePath,
sourceRootRelativePath: rawPath,
ocrRootRelativePath: ocrPath,
provider: "mock",
modelVersion: "vlm",
status: "done",
sourceSize: 6,
sourceMtimeMs: 1,
createdAtMs: 1,
updatedAtMs: 1,
plainTextPreview: "Task520 OCR sidecar context text",
},
},
}, null, 2)}\n`,
"utf8",
);
} else {
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
}
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
@@ -323,16 +287,12 @@ async function main() {
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
if (expectOcrContext) {
assert.strictEqual(activeEditorRef.ocrContext?.source, "local_ocr_sidecar", `active_editor 应携带 OCR sidecar context: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.ocrContext?.ocrRootRelativePath, ocrPath, `active_editor OCR path 不正确: ${JSON.stringify(activeEditorRef.ocrContext)}`);
assert(activeEditorRef.ocrContext?.plainTextPreview?.includes("Task520 OCR sidecar context text"), `active_editor OCR preview 缺失: ${JSON.stringify(activeEditorRef.ocrContext)}`);
assert.strictEqual(runBody.targetPackage?.ocrContext?.ocrRootRelativePath, ocrPath, `targetPackage 应携带 OCR sidecar context: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, ocrPath, `currentFile 应携带 OCR sidecar path: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
}
assert.strictEqual(activeEditorRef.ocrContext, undefined, `OCR sidecar context 已退役,active_editor 不应携带 ocrContext: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.ocrContext, undefined, `OCR sidecar context 已退役,targetPackage 不应携带 ocrContext: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, undefined, `OCR sidecar path 已退役,currentFile 不应携带 ocrRootRelativePath: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, ocrPath: expectOcrContext ? ocrPath : "", screenshot, captured };
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
@@ -1,436 +0,0 @@
#!/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 { chromium } = require("playwright");
const { ensureAuthenticated, UI_TIMEOUT_MS } = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const TASK = "task526-local-folder-ocr-api-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${Buffer.from(relativePath, "utf8")
.toString("hex")
.replace(/../g, (hex) => {
const code = Number.parseInt(hex, 16);
const ch = String.fromCharCode(code);
return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`;
})}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
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 });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task526`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "ocr"],
}, null, 2)}\n`,
"utf8",
);
}
async function writeResult(payload) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function ensureDocumentVisible(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
if (new URL(page.url()).pathname === "/auth") {
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
}
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task526-ocr-"));
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task526`;
const relativePath = "docs/Page.md";
const documentId = localMdDocumentId(relativePath);
const sourceRootRelativePath = "docs/Page.assets/photo.png";
const failedSourceRootRelativePath = "docs/Page.assets/photo-failed.png";
const ocrToken = "TASK526_OCR_TOKEN";
writeWorkspaceManifest(root, actorId);
fs.mkdirSync(path.join(root, "docs", "Page.assets"), { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# OCR Page\n\n![photo](<./Page.assets/photo.png>)\n", "utf8");
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8FQOQAAAABJRU5ErkJggg==",
"base64",
);
fs.writeFileSync(path.join(root, sourceRootRelativePath), tinyPng);
fs.writeFileSync(path.join(root, failedSourceRootRelativePath), tinyPng);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const screenshots = {};
try {
await ensureAuthenticated(page, context.request);
await ensureDocumentVisible(page, root, relativePath);
await page.waitForFunction(() => {
const image = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror img');
return image instanceof HTMLImageElement
&& image.complete
&& image.naturalWidth > 0
&& image.src.includes("Page.assets%2Fphoto.png")
&& !image.src.includes("%3C")
&& !image.src.includes("%3E");
}, null, { timeout: UI_TIMEOUT_MS });
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
await page.screenshot({ path: screenshots.page, fullPage: true });
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
window.__MNOTE_LOCAL_OCR_PROVIDER = "mock";
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo.png";
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
fileUrl.searchParams.set("rootUri", rootUri);
fileUrl.searchParams.set("path", sourceRootRelativePath);
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: `local-file:${sourceRootRelativePath}`,
assetId: `local-file:${sourceRootRelativePath}`,
title,
fileName: title,
kind: "image",
rootUri,
path: sourceRootRelativePath,
href: fileUrl.toString(),
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
});
if (!opened) throw new Error("OCR source image resource tab did not open");
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath,
workspaceId,
});
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
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.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)}"]`);
return row && row.getAttribute("data-mnote-local-ocr-task-status") === "done";
},
sourceRootRelativePath,
{ 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);
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("sourceRootRelativePath", sourceRootRelativePath);
const response = await fetch(url.toString(), { cache: "no-store", headers: { accept: "application/json" } });
const payload = await response.json().catch(() => null);
return payload?.job?.ocrRootRelativePath || "";
}, {
rootUri: fileUrl(root),
sourceRootRelativePath,
});
assert(uiOcrPath && uiOcrPath.endsWith(".ocr.md"), `UI OCR path invalid: ${uiOcrPath}`);
await page.waitForFunction(
({ before, ocrPath }) => {
const root = document.documentElement;
const marker = root.getAttribute("data-mnote-local-ocr-filetree-refresh") || "";
const applied = root.getAttribute("data-mnote-local-folder-watch-batch-applied") || "";
return marker === ocrPath && applied && applied !== before;
},
{ before: watchBatchBeforeOcr, ocrPath: uiOcrPath },
{ timeout: UI_TIMEOUT_MS },
);
screenshots.ocrToolbar = path.join(OUTPUT_DIR, "02-ocr-toolbar.png");
await page.screenshot({ path: screenshots.ocrToolbar, fullPage: true });
const failedOcrRoute = async (route) => {
if (route.request().method() !== "POST") return route.fallback();
const body = route.request().postDataJSON();
if (body?.sourceRootRelativePath !== failedSourceRootRelativePath) return route.fallback();
return route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ ok: false, error: { message: "local_ocr_job_failed_401" } }),
});
};
await page.route("**/api/local-folder/ocr/jobs", failedOcrRoute);
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
window.__MNOTE_LOCAL_OCR_PROVIDER = "mineru";
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo-failed.png";
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
fileUrl.searchParams.set("rootUri", rootUri);
fileUrl.searchParams.set("path", sourceRootRelativePath);
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: `local-file:${sourceRootRelativePath}`,
assetId: `local-file:${sourceRootRelativePath}`,
title,
fileName: title,
kind: "image",
rootUri,
path: sourceRootRelativePath,
href: fileUrl.toString(),
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
});
if (!opened) throw new Error("OCR failed source image resource tab did not open");
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath: failedSourceRootRelativePath,
workspaceId,
});
const failedImageTab = page.locator('.mnote-main-tab[data-mnote-tab-kind="image"]', { hasText: "photo-failed.png" }).first();
await failedImageTab.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await failedImageTab.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const active = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="image"] .mnote-main-tab-title');
return active && (active.textContent || "").includes("photo-failed.png");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
state: "detached",
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)}"]`);
return row
&& row.getAttribute("data-mnote-local-ocr-task-status") === "failed"
&& (row.textContent || "").includes("local_ocr_job_failed_401");
},
failedSourceRootRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.ocrFailedTask = path.join(OUTPUT_DIR, "04-ocr-failed-task.png");
await page.screenshot({ path: screenshots.ocrFailedTask, fullPage: true });
await page.unroute("**/api/local-folder/ocr/jobs", failedOcrRoute);
const openResourceResult = await page.evaluate(async ({ rootUri, documentId, ocrRootRelativePath, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
throw new Error("缺少 openResourceInActiveTab runtime");
}
const title = ocrRootRelativePath.split("/").filter(Boolean).pop() || "OCR";
return await runtime.openResourceInActiveTab({
kind: "markdown",
title,
path: ocrRootRelativePath,
objectIdentity: `local-ocr:${ocrRootRelativePath}`,
assetId: `local-ocr:${ocrRootRelativePath}`,
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
rootUri,
resourceKind: "markdown",
});
}, {
rootUri: fileUrl(root),
documentId,
ocrRootRelativePath: uiOcrPath,
workspaceId,
});
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
return active && (active.textContent || "").includes("OCR UI smoke text");
},
null,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const debug = await page.evaluate(() => ({
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
kind: panel.getAttribute('data-resource-kind') || '',
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
text: (panel.textContent || '').slice(0, 200),
html: panel.innerHTML.slice(0, 500),
})),
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
}));
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
});
const fileTreeOpenResult = await page.evaluate((ocrRootRelativePath) => {
const parentPath = ocrRootRelativePath.split("/").slice(0, -1).join("/");
const parentRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(parentPath)}"]`);
if (!(parentRow instanceof HTMLElement)) return { ok: false, reason: "ocr_parent_row_missing", parentPath };
if (parentRow.getAttribute("aria-expanded") !== "true") {
const toggle = parentRow.querySelector('[data-rust-action="toggle"]');
if (toggle instanceof HTMLElement) toggle.click();
}
return { ok: true, parentPath };
}, uiOcrPath);
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
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,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => {
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
return active && (active.textContent || "").includes("OCR UI smoke text");
},
null,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const debug = await page.evaluate(() => ({
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
kind: panel.getAttribute('data-resource-kind') || '',
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
text: (panel.textContent || '').slice(0, 200),
html: panel.innerHTML.slice(0, 500),
})),
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
}));
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
});
screenshots.ocrResource = path.join(OUTPUT_DIR, "03-ocr-resource-tab.png");
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
await page.evaluate((sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
const clear = row && row.querySelector("[data-mnote-local-ocr-task-clear]");
if (!(clear instanceof HTMLButtonElement)) throw new Error("missing OCR clear button");
clear.click();
}, failedSourceRootRelativePath);
await page.waitForFunction(
(sourcePath) => !document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`),
failedSourceRootRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
const deleteButtonVisible = await page.evaluate((sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
return Boolean(row && row.querySelector("[data-mnote-local-ocr-task-delete]"));
}, sourceRootRelativePath);
assert.equal(deleteButtonVisible, true, "已完成 OCR 任务应展示删除 OCR 按钮");
await writeResult({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: uiOcrPath,
screenshots,
});
console.log(JSON.stringify({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: uiOcrPath,
screenshots,
}, null, 2));
} catch (error) {
screenshots.failure = path.join(OUTPUT_DIR, "failure.png");
await page.screenshot({ path: screenshots.failure, fullPage: true }).catch(() => undefined);
await writeResult({
ok: false,
task: TASK,
error: error instanceof Error ? error.message : String(error),
root,
documentId,
screenshots,
});
throw error;
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,387 +0,0 @@
#!/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 putJson(pathname, data, headers = {}) {
return (await fetchJson(pathname, {
method: "PUT",
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-local-index": 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",
"mnote.index.status",
"mnote.index.refresh",
"mnote.index.update_settings",
]) {
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
}
const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", {
headers: { cookie },
})).payload;
const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []);
assert(
mnoteCapabilities.some((capability) => capability.id === "mnote-local-index" && capability.enabled !== false),
"Reasonix agent 缺少启用的 mnote-local-index 能力",
);
const settings = await putJson("/api/search/local-index/settings", {
workspaceId,
rootUri,
includePaths: ["."],
scheduleMode: "manual",
runOnChange: false,
}, actorHeaders);
assert.strictEqual(settings.ok, true, "local evidence index settings ok");
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(String(toolHit.citationMarkdown || "").includes("](/documents/"), "agent tool 缺少可点击 citationMarkdown");
assert(String(toolHit.citationUrl || "").includes("resourceTab="), "agent tool citationUrl 缺少资源 tab 定位参数");
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);
});
@@ -0,0 +1,150 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const QUERY = process.env.MNOTE_KNOWLEDGE_RAG_QUERY || "scan image start";
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE || "knowledge-rag-fixtures-7-50/scan-image-start.pdf";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task529-knowledge-rag-citation-resource-tab-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "citation-resource-tab.png");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: CHROMIUM_EXECUTABLE_PATH });
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
try {
const auth = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
});
assert(auth.ok(), `登录失败: ${auth.status()} ${await auth.text()}`);
const queryResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: QUERY,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
},
});
assert(queryResponse.ok(), `knowledge-rag query 失败: ${queryResponse.status()} ${await queryResponse.text()}`);
const queryPayload = await queryResponse.json();
const reference = (queryPayload.references || []).find((item) => item.sourceRootRelativePath === EXPECTED_RESOURCE && item.citationUrl);
assert(reference, `缺少目标引用 ${EXPECTED_RESOURCE}: ${JSON.stringify(queryPayload.references || [], null, 2).slice(0, 3000)}`);
assert.equal(reference.locatorDegraded, false, `目标引用不应降级: ${JSON.stringify(reference, null, 2)}`);
assert(reference.locator?.page, "目标引用缺少 page");
assert(reference.locator?.bbox, "目标引用缺少 bbox");
assert(String(reference.citationUrl || "").includes("resourceTab="), "query citationUrl 缺少 resourceTab");
assert(String(reference.citationMarkdown || "").includes("p."), "query citationMarkdown 缺少页码");
const openResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/open-reference`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
reference: reference.reference,
filePath: reference.filePath,
chunkId: reference.chunkId,
},
});
assert(openResponse.ok(), `knowledge-rag open_reference 失败: ${openResponse.status()} ${await openResponse.text()}`);
const openPayload = await openResponse.json();
assert.equal(openPayload.ok, true, `open_reference ok=false: ${JSON.stringify(openPayload, null, 2)}`);
assert.equal(openPayload.reference?.locatorDegraded, false, `open_reference locator 降级: ${JSON.stringify(openPayload.reference, null, 2)}`);
assert(String(openPayload.reference?.citationUrl || "").includes("resourceTab="), "open_reference citationUrl 缺少 resourceTab");
assert(String(openPayload.reference?.citationMarkdown || "").includes("p."), "open_reference citationMarkdown 缺少页码");
const page = await context.newPage();
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
});
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
EXPECTED_RESOURCE,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(3_000);
const state = await page.evaluate((expectedResource) => {
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
const inlineViewer = document.querySelector("[data-mnote-inline-pdf-viewer]");
const evidencePage = document.querySelector("[data-mnote-evidence-page='true']");
return {
url: location.href,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
inlinePdfVisible: !!inlineViewer,
evidencePageRendered: !!evidencePage,
evidencePageNumber: evidencePage ? evidencePage.getAttribute("data-page-number") : "",
};
}, EXPECTED_RESOURCE);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assert.equal(state.panelVisible, true, `citationUrl 未打开资源标签页: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, EXPECTED_RESOURCE, `资源标签页路径不匹配: ${JSON.stringify(state, null, 2)}`);
assert(String(state.panelLocator || "").includes('"page"'), `资源标签页缺少 evidence locator: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.inlinePdfVisible, true, `资源标签页 PDF 内容未渲染: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.evidencePageRendered, true, `资源标签页未渲染 evidence page 高亮: ${JSON.stringify(state, null, 2)}`);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: QUERY,
expectedResource: EXPECTED_RESOURCE,
citationUrl: reference.citationUrl,
citationMarkdown: reference.citationMarkdown,
page: reference.locator.page,
bbox: reference.locator.bbox,
openReferenceCitationUrl: openPayload.reference.citationUrl,
state,
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().catch(() => undefined);
}
}
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);
});
@@ -0,0 +1,277 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureGrant() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
if (!fs.existsSync(ownerPath)) {
fs.writeFileSync(
ownerPath,
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
"utf8",
);
}
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const content = node?.querySelector(".wolai-page-ai-message-text");
return content ? content.textContent || "" : "";
}, initialCount);
}
async function newestAssistantLinks(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
if (!node) return [];
return Array.from(node.querySelectorAll("a")).map((anchor) => ({
text: anchor.textContent || "",
href: anchor.getAttribute("href") || "",
}));
}, initialCount);
}
function summarizeRun(body) {
return {
workspaceId: body.workspaceId || "",
documentId: body.documentId || "",
sourceKind: body.sourceKind || "",
rootUri: body.rootUri || "",
agentId: body.agentId || "",
profile: body.profile || "",
acpRuntime: body.acpRuntime || "",
contextRefs: Array.isArray(body.contextRefs)
? body.contextRefs.map((item) => (typeof item === "string" ? item : item?.kind || "")).filter(Boolean)
: [],
allowedRootCount: Array.isArray(body.allowedRoots) ? body.allowedRoots.length : 0,
mnoteKnowledgeRagDisabled: body.skillPreferences?.mnote?.["mnote-knowledge-rag"] === false,
message: body.message || "",
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureGrant();
ensureOwnerPage();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleErrors.push({ type: message.type(), text: message.text() });
}
});
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
await signIn(context);
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const assistantCount = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
"问题:scan image start 这份扫描 PDF 里出现了什么关键短语?",
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接,不要描述检索过程,不要输出 raw JSON,不要编造页码。",
].join("\n");
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
await page.waitForFunction(
(countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const text = node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
return text.includes("first image in PDF") && text.includes("scan-image-start.pdf") && text.includes("p.1");
},
assistantCount,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
const assistantText = await newestAssistantText(page, assistantCount);
const assistantLinks = await newestAssistantLinks(page, assistantCount);
assert(assistantText.includes("first image in PDF"), `可见回答缺少关键短语: ${assistantText}`);
assert(assistantText.includes("scan-image-start.pdf"), `可见回答缺少来源文件名: ${assistantText}`);
assert(assistantText.includes("p.1"), `可见回答缺少页码 citation 文本: ${assistantText}`);
assert(
assistantLinks.some((link) => link.text.includes("scan-image-start.pdf") && link.href.includes("resourceTab=")),
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
);
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
assert(!assistantText.includes('"raw"'), `可见回答泄漏 raw JSON: ${assistantText}`);
assert(!assistantText.includes("mnote_knowledge_rag_query"), `可见回答泄漏工具名: ${assistantText}`);
assert(!/让我|我来|我先|查询返回|找到了|检索资料库/.test(assistantText), `可见回答包含检索过程叙述: ${assistantText}`);
assert(
capturedRuns.some((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"),
"未捕获到 Reasonix Page AI run",
);
assert(
capturedRuns.every((body) => body.sourceKind === "local_folder" && body.rootUri === ROOT_URI),
"Page AI run 未保持 local_folder/rootUri 上下文",
);
assert(
capturedRuns.some((body) => body.skillPreferences?.mnote?.["mnote-knowledge-rag"] !== false),
"Page AI run 禁用了 mnote-knowledge-rag",
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
workspaceId: WORKSPACE_ID,
rootUri: ROOT_URI,
documentId: DOCUMENT_ID,
assistantText,
assistantLinks,
capturedRuns: capturedRuns.map(summarizeRun),
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(
path.join(OUTPUT_DIR, "captured-runs-full.json"),
`${JSON.stringify(capturedRuns, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify(
{
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
capturedRuns,
pageErrors,
consoleErrors,
},
null,
2,
)}\n`,
"utf8",
);
throw error;
} finally {
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});
@@ -0,0 +1,234 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const JSZip = require("jszip");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const SOURCE_DOCX = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_SOURCE || path.join(process.cwd(), "tmp", "onlyoffice-direct-bridge.docx");
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task532-knowledge-rag-docx-ingestion-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "docx-resource-tab.png");
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_DOCX_TIMEOUT_MS || 10 * 60_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function xmlEscape(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
async function writeDocxFixture(targetPath, marker) {
assert(fs.existsSync(SOURCE_DOCX), `缺少 DOCX 源文件: ${SOURCE_DOCX}`);
const zip = await JSZip.loadAsync(fs.readFileSync(SOURCE_DOCX));
const documentFile = zip.file("word/document.xml");
assert(documentFile, `DOCX 缺少 word/document.xml: ${SOURCE_DOCX}`);
const originalXml = await documentFile.async("string");
const paragraph = `<w:p><w:r><w:t>${xmlEscape(marker)}</w:t></w:r></w:p>`;
const nextXml = originalXml.includes("</w:body>")
? originalXml.replace("</w:body>", `${paragraph}</w:body>`)
: originalXml + paragraph;
zip.file("word/document.xml", nextXml);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(targetPath, buffer);
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"accept": "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
function findRegistryEntry(payload, sourcePath) {
const entries = payload?.registry?.entries;
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath }],
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function waitForIndexed(context, sourcePath) {
const startedAt = Date.now();
let attempts = 0;
let lastStatus = null;
let lastIngest = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
attempts += 1;
lastStatus = await status(context);
const entry = findRegistryEntry(lastStatus, sourcePath);
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
return { entry, status: lastStatus, attempts, lastIngest };
}
if (!entry || attempts % 6 === 1) {
lastIngest = await ingest(context, sourcePath);
}
await sleep(5_000);
}
throw new Error(`等待 DOCX 入库超时: ${JSON.stringify({ sourcePath, lastIngest, lastStatus }, null, 2).slice(0, 4000)}`);
}
async function queryDocx(context, marker, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: marker,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
const reference = references.find((item) => item.sourceRootRelativePath === sourcePath);
assert(reference, `DOCX 查询缺少目标引用 ${sourcePath}: ${JSON.stringify(references, null, 2).slice(0, 3000)}`);
assert(String(reference.quote || reference.content || result.text).toLowerCase().includes(marker.toLowerCase()), `DOCX 引用缺少 marker: ${JSON.stringify(reference, null, 2).slice(0, 2000)}`);
assert(String(reference.citationUrl || "").includes("resourceTab="), `DOCX 引用缺少 resourceTab citationUrl: ${JSON.stringify(reference, null, 2)}`);
assert(String(reference.citationMarkdown || "").includes(path.basename(sourcePath)), `DOCX 引用缺少 citationMarkdown: ${JSON.stringify(reference, null, 2)}`);
return { payload: result.payload, reference };
}
async function openCitationInBrowser(context, reference, sourcePath) {
const page = await context.newPage();
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
sourcePath,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(2_000);
const state = await page.evaluate((expectedResource) => {
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
return {
url: location.href,
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelKind: panel ? panel.getAttribute("data-resource-kind") : "",
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 160) : "",
bodyText: document.body.innerText.slice(0, 1000),
};
}, sourcePath);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
await page.close().catch(() => undefined);
assert.equal(state.panelVisible, true, `DOCX citationUrl 未打开资源 tab: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, sourcePath, `DOCX resource tab path 不匹配: ${JSON.stringify(state, null, 2)}`);
return state;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const marker = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_MARKER || `DOCX RAG SMOKE ${Date.now()}`;
const fileName = `docx-rag-smoke-${Date.now()}.docx`;
const sourcePath = `${FIXTURE_DIR}/${fileName}`;
const targetPath = path.join(ROOT_PATH, sourcePath);
await writeDocxFixture(targetPath, marker);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
try {
await signIn(context);
const ingestPayload = await ingest(context, sourcePath);
const indexed = await waitForIndexed(context, sourcePath);
const queried = await queryDocx(context, marker, sourcePath);
const browserState = await openCitationInBrowser(context, queried.reference, sourcePath);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
marker,
lightRagDocId: indexed.entry.lightRagDocId,
lightRagFilePath: indexed.entry.lightRagFilePath,
ingestStatus: ingestPayload?.status,
attempts: indexed.attempts,
citationMarkdown: queried.reference.citationMarkdown,
citationUrl: queried.reference.citationUrl,
locatorDegraded: queried.reference.locatorDegraded,
browserState,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().catch(() => undefined);
}
}
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);
});
@@ -0,0 +1,235 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task533-knowledge-rag-source-watcher-sync-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_WATCHER_TIMEOUT_MS || 180_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function registryPath() {
return path.join(ROOT_PATH, ".mnote", "index", "lightrag-source-registry.json");
}
function readRegistry() {
return JSON.parse(fs.readFileSync(registryPath(), "utf8"));
}
function findEntry(sourcePath) {
const registry = readRegistry();
const entries = Array.isArray(registry.entries) ? registry.entries : [];
return entries.find((entry) => entry.sourceRootRelativePath === sourcePath) || null;
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath }],
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function waitForIndexed(context, sourcePath) {
const startedAt = Date.now();
let attempts = 0;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
attempts += 1;
await status(context);
const entry = findEntry(sourcePath);
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
return { entry, attempts };
}
if (!entry || attempts % 6 === 1) {
await ingest(context, sourcePath);
}
await sleep(5_000);
}
throw new Error(`等待 watcher source 入库超时: ${sourcePath}`);
}
async function openTreeLiveWatcher(page) {
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
return page.evaluate(({ rootUri }) => new Promise((resolve, reject) => {
const params = new URLSearchParams({ rootUri, treeLive: "true" });
const source = new EventSource(`/api/local-folder/events?${params.toString()}`);
window.__task533Events = [];
window.__task533Source = source;
const timer = setTimeout(() => reject(new Error("treeLive watcher ready timeout")), 30_000);
source.addEventListener("snapshot", () => {
clearTimeout(timer);
resolve(true);
});
source.onerror = () => {
clearTimeout(timer);
reject(new Error("treeLive watcher error"));
};
}), { rootUri: ROOT_URI });
}
async function waitForWatchBatch(page, relativePath) {
return page.evaluate(({ expectedPath, timeoutMs }) => new Promise((resolve, reject) => {
const source = window.__task533Source;
if (!source) {
reject(new Error("treeLive watcher not opened"));
return;
}
const timer = setTimeout(() => reject(new Error("watch_batch timeout")), timeoutMs);
source.addEventListener("watch_batch", (event) => {
const payload = JSON.parse(event.data || "{}");
window.__task533Events.push(payload);
const changed = Array.isArray(payload.changedPaths) ? payload.changedPaths : [];
if (changed.some((item) => item.relativePath === expectedPath)) {
clearTimeout(timer);
resolve(payload);
}
});
}), { expectedPath: relativePath, timeoutMs: POLL_TIMEOUT_MS });
}
async function waitForRegistryStale(sourcePath) {
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const entry = findEntry(sourcePath);
if (entry && entry.stale === true && entry.deletedAtMs && !entry.lightRagDocId && !entry.indexedAtMs) {
return entry;
}
await sleep(1_000);
}
throw new Error(`watcher 未把 source 标记 stale/deleted: ${JSON.stringify(findEntry(sourcePath), null, 2)}`);
}
async function queryAfterDelete(context, marker, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: marker,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
assert(
!references.some((item) => item.sourceRootRelativePath === sourcePath),
`删除后的 source 不应继续作为有效 reference: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
return result.payload;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
const marker = `WATCHER RAG STALE ${Date.now()}`;
const sourcePath = `${FIXTURE_DIR}/watcher-stale-${Date.now()}.md`;
const absoluteSource = path.join(ROOT_PATH, sourcePath);
fs.writeFileSync(absoluteSource, `# Watcher stale\n${marker}\n`, "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1280, height: 860 } });
const page = await context.newPage();
try {
await signIn(context);
await ingest(context, sourcePath);
const indexed = await waitForIndexed(context, sourcePath);
await openTreeLiveWatcher(page);
fs.unlinkSync(absoluteSource);
const watchBatch = await waitForWatchBatch(page, sourcePath);
const staleEntry = await waitForRegistryStale(sourcePath);
const queryPayload = await queryAfterDelete(context, marker, sourcePath);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
marker,
initialDocId: indexed.entry.lightRagDocId,
indexedAttempts: indexed.attempts,
watchBatch,
staleEntry,
queryReferenceCount: Array.isArray(queryPayload.references) ? queryPayload.references.length : 0,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().catch(() => undefined);
}
}
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);
});
@@ -0,0 +1,217 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task534-knowledge-rag-source-management-scope-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "source-management.png");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_SCOPE_TIMEOUT_MS || 240_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
function entryFor(statusPayload, sourcePath) {
const entries = statusPayload?.registry?.entries;
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
}
async function ingest(context, sourcePaths) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: sourcePaths.map((sourcePath) => ({ sourcePath })),
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function waitForIndexed(context, sourcePaths) {
const startedAt = Date.now();
let lastStatus = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
lastStatus = await status(context);
const allIndexed = sourcePaths.every((sourcePath) => {
const entry = entryFor(lastStatus, sourcePath);
return entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs;
});
if (allIndexed) return lastStatus;
await sleep(5_000);
}
throw new Error(`等待 source scope fixture 入库超时: ${JSON.stringify({ sourcePaths, lastStatus }, null, 2).slice(0, 4000)}`);
}
async function query(context, queryText, sourcePaths) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: queryText,
mode: "mix",
topK: 12,
chunkTopK: 12,
includeChunkContent: true,
sourcePaths,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function openKnowledgePanel(page) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", ROOT_URI);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-popover"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForSourceRow(page, sourcePath) {
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return row;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
const marker = `SOURCE SCOPE RAG ${Date.now()}`;
const sourceA = `${FIXTURE_DIR}/scope-alpha-${Date.now()}.md`;
const sourceB = `${FIXTURE_DIR}/scope-beta-${Date.now()}.md`;
fs.writeFileSync(path.join(ROOT_PATH, sourceA), `# Scope Alpha\n${marker}\nOnly alpha source should remain after scope filter.\n`, "utf8");
fs.writeFileSync(path.join(ROOT_PATH, sourceB), `# Scope Beta\n${marker}\nBeta source must be filtered when sourcePaths targets alpha.\n`, "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
try {
await signIn(context);
await ingest(context, [sourceA, sourceB]);
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
const scoped = await query(context, marker, [sourceA]);
const scopedReferences = Array.isArray(scoped.references) ? scoped.references : [];
assert(scopedReferences.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(
scopedReferences.every((reference) => reference.sourceRootRelativePath === sourceA),
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(scopedReferences, null, 2).slice(0, 3000)}`,
);
await openKnowledgePanel(page);
const row = await waitForSourceRow(page, sourceA);
await row.locator('[data-knowledge-rag-action="reindex-source"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const deleteButton = row.locator('[data-knowledge-rag-action="delete-source"]');
await deleteButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await deleteButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedPath) => {
const row = document.querySelector(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${expectedPath}"]`);
return row && (row.textContent || "").includes("已删除");
},
sourceA,
{ timeout: UI_TIMEOUT_MS },
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "UI 删除索引不应删除原始 source 文件");
const afterDelete = await query(context, marker, [sourceA]);
const afterReferences = Array.isArray(afterDelete.references) ? afterDelete.references : [];
assert.equal(afterReferences.length, 0, `删除索引后 source-scoped query 不应继续返回 alpha: ${JSON.stringify(afterReferences, null, 2)}`);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
marker,
sourceA,
sourceB,
sourceScope: scoped.sourceScope,
scopedReferenceCount: scopedReferences.length,
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
afterDeleteReferenceCount: afterReferences.length,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().catch(() => undefined);
}
}
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);
});