#!/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 CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.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 EXPECTED_RESOURCE = "新页面233155/image copy 6.png"; 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 assertKnowledgeRagDescriptor(context) { const response = await context.request.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=reasonix`, { headers: { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", accept: "application/json", }, }); assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`); const payload = await response.json(); const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix"); assert(reasonix, "descriptor 缺少 Reasonix"); assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled"); assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag"); const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name)); for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) { assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`); } } async function knowledgeRagStatus(context) { const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`); return await response.json(); } async function ensureKnowledgeRagSourceIndexed(context) { const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/ingest`, { data: { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, sources: [{ sourcePath: EXPECTED_RESOURCE }], }, }); assert(response.ok(), `knowledge-rag ingest 失败: ${response.status()} ${await response.text()}`); const startedAt = Date.now(); let lastEntry = null; while (Date.now() - startedAt < 90_000) { const payload = await knowledgeRagStatus(context); lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null; if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry; await new Promise((resolve) => setTimeout(resolve, 5_000)); } throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`); } async function assertKnowledgeRagQueryReturnsCitationMarkdown(context) { const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, { data: { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, query: "这个图片资料在资料库里是什么内容?", mode: "mix", topK: 8, chunkTopK: 8, includeChunkContent: true, sourcePaths: [EXPECTED_RESOURCE], }, }); assert(response.ok(), `knowledge-rag query 失败: ${response.status()} ${await response.text()}`); const payload = await response.json(); const references = Array.isArray(payload.references) ? payload.references : []; assert( references.some((reference) => String(reference.citationMarkdown || "").trim() && String(reference.citationUrl || "").includes("resourceTab=")), `query 输出缺少 citationMarkdown,Page AI 最终回答 smoke 不能继续: ${JSON.stringify(references, null, 2).slice(0, 3000)}`, ); } 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); } async function clickNewestAssistantCitation(page, initialCount, expectedResource) { const total = await page .locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant') .count(); const latestIndex = Math.max(initialCount, total - 1); const latestMessage = page .locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant') .nth(latestIndex); const citationLink = latestMessage.locator('a[data-page-ai-citation-link="true"]').first(); await citationLink.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const popupPromise = page.waitForEvent("popup", { timeout: 5_000 }).catch(() => null); await citationLink.click({ timeout: UI_TIMEOUT_MS }); const openedPage = (await popupPromise) || page; await openedPage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); await openedPage.waitForFunction( (resourcePath) => { const panel = document.querySelector( `[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`, ); return panel && !panel.hidden; }, expectedResource, { timeout: UI_TIMEOUT_MS }, ); await openedPage.waitForTimeout(1500); const state = await openedPage.evaluate((resourcePath) => { const panel = document.querySelector( `[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`, ); const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active"); const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null; return { url: location.href, openedInPopup: window.opener != null, 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") : "", panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "", imageVisible: !!image, }; }, expectedResource); await openedPage.screenshot({ path: CITATION_OPEN_SCREENSHOT_PATH, fullPage: true }); assert.equal(state.panelVisible, true, `点击 AI citation 后未打开资源 panel: ${JSON.stringify(state, null, 2)}`); assert.equal(state.panelResourcePath, expectedResource, `点击 AI citation 后资源路径不匹配: ${JSON.stringify(state, null, 2)}`); assert( state.url.includes("resourceTab=") || state.url.includes("resourcePath="), `点击 AI citation 后 URL 缺少资源定位参数: ${JSON.stringify(state, null, 2)}`, ); return state; } 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); await assertKnowledgeRagDescriptor(context); await ensureKnowledgeRagSourceIndexed(context); await assertKnowledgeRagQueryReturnsCitationMarkdown(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 检索资料库。", `问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 ["${EXPECTED_RESOURCE}"]。`, "最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。", ].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("来源定位降级") && text.includes("image copy 6.png"); }, assistantCount, { timeout: Math.max(UI_TIMEOUT_MS, 180_000) }, ); const assistantText = await newestAssistantText(page, assistantCount); const assistantLinks = await newestAssistantLinks(page, assistantCount); assert(assistantText.includes("image copy 6.png"), `可见回答缺少当前资料来源文件名: ${assistantText}`); assert(assistantText.includes("来源定位降级"), `可见回答缺少 degraded citation 口径: ${assistantText}`); assert(!/\bp\.\d+\b/i.test(assistantText), `degraded citation 不应编造页码: ${assistantText}`); assert(!/bbox/i.test(assistantText), `degraded citation 不应编造 bbox: ${assistantText}`); assert( assistantLinks.some((link) => link.text.includes("来源定位降级") && 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 citationOpenState = await clickNewestAssistantCitation(page, assistantCount, "新页面233155/image copy 6.png"); 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"), citationOpenState, pageErrors, consoleErrors, screenshots: { answer: SCREENSHOT_PATH, citationOpen: CITATION_OPEN_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); });