feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut.
This commit is contained in:
@@ -1,399 +1,16 @@
|
||||
#!/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 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 {
|
||||
setupWorkspaceAccess,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
|
||||
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 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 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 WeKnora 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 });
|
||||
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 setupWorkspaceAccess(context.request, BASE_URL, {
|
||||
actorId: ACTOR_ID,
|
||||
email: "mnote.e2e@example.com",
|
||||
username: ACTOR_ID,
|
||||
displayName: ACTOR_ID,
|
||||
role: "user",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceName: "MNote E2E Space",
|
||||
rootPath: ROOT_PATH,
|
||||
rootUri: ROOT_URI,
|
||||
capabilities: ["ai"],
|
||||
timeoutMs: UI_TIMEOUT_MS,
|
||||
});
|
||||
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);
|
||||
});
|
||||
/**
|
||||
* RETIRED (Wave 10 / pre-release legacy purge)
|
||||
* 依赖已删除的 /api/hermes/client/* 或 OpenCode Page AI host。
|
||||
* 产品 Page AI 仅 Pi Lab(/api/page-ai/pi/*);agent tools 为 /api/mnote/tools/*。
|
||||
* 本文件保留作历史对照,直接 exit 0,不再执行浏览器/静态断言。
|
||||
*/
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
|
||||
script: require("node:path").basename(__filename),
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user