Improve local evidence search and AI capabilities

This commit is contained in:
lix-2026
2026-06-05 23:00:53 +08:00
parent a1dcfc9f76
commit 4dbd9a978b
52 changed files with 6415 additions and 527 deletions
@@ -55,6 +55,15 @@ async function saveScreenshot(page, name) {
return target;
}
async function waitForCapturedRunCount(captured, minCount, timeoutMs = UI_TIMEOUT_MS) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (captured.filter((item) => item.kind === "run").length >= minCount) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`captured run 数量不足,期望至少 ${minCount},实际 ${captured.filter((item) => item.kind === "run").length}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
@@ -189,6 +198,44 @@ async function main() {
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/capabilities/toggle", async (route) => {
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
const body = {
ok: true,
runtime: "mnote",
categories: [{
name: "mnote",
title: "MNote AI 能力",
capabilities: [
{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolCount: 1, tools: [{ name: "mnote.context.snapshot", enabled: true }] },
{ id: "mnote-mindmap", name: "mnote-mindmap", title: "思维导图读写", description: "读取、编辑或从 outline 生成思维导图", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolNames: ["mnote.mindmap.fetch", "mnote.mindmap.create_from_outline"], toolCount: 2, tools: [{ name: "mnote.mindmap.fetch", enabled: true }, { name: "mnote.mindmap.create_from_outline", enabled: true }] },
],
}],
archived: [],
};
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
@@ -492,7 +539,38 @@ async function main() {
bridgeSessionId: "mnote-oo-task502-office",
bridgeSessionReady: true,
};
const resources = [mindmapResource, officeResource];
const officePreviewResource = {
objectIdentity: "resource:office-preview:task502",
workspacePath: {
schema: "mnote.workspace_path.v1",
workspaceId,
sourceKind: "local_folder",
rootUri,
relativePath: "office/Task502 Preview.docx",
documentId,
objectIdentity: "resource:office-preview:task502",
assetId: "task502-office-preview",
resourceKind: "attachment",
},
paneRole: "primary",
documentId,
workspaceId,
title: "Task502 Preview DOCX",
kind: "office",
editorKind: "office",
active: false,
dirtyState: "",
preview: true,
pinned: true,
lastActiveAt: Date.now(),
assetId: "task502-office-preview",
path: "office/Task502 Preview.docx",
officeOpenMode: "preview",
onlyofficeSessionId: "",
bridgeSessionId: "",
bridgeSessionReady: false,
};
const resources = [mindmapResource, officeResource, officePreviewResource];
const withoutResource = (items) => (Array.isArray(items) ? items : [])
.filter((item) => !resources.some((resource) => item?.objectIdentity === resource.objectIdentity));
const groups = snapshot.groups || {};
@@ -535,21 +613,24 @@ async function main() {
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillSourceSelect = page.locator('[data-page-ai-panel="skills"] [data-page-ai-skill-source-select]');
await skillSourceSelect.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await skillSourceSelect.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
const skillSourceOptions = await skillSourceSelect.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({ value: node.value, text: node.textContent || "" })),
);
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("mnote")), "能来源应包含 mnote");
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("reasonix")), "能来源应包含 reasonix");
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes_user")), "能来源应包含个人 Hermes profile");
assert(skillSourceOptions.some((item) => item.value === "hermes:shared_lite" && item.text.includes("hermes_lite")), "技能来源应包含 shared lite profile");
await skillSourceSelect.selectOption("mnote", { timeout: UI_TIMEOUT_MS });
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("MNote 公共能力")), "能来源应包含 MNote");
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("Reasonix skill")), "能来源应包含 Reasonix 自带 skill 查看入口");
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes skill")), "能来源应包含个人 Hermes profile skill 查看入口");
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_deepseek_chat"), "Chat-only Hermes profile 不应作为能力来源展示");
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_lite"), "Hermes Lite chat-only profile 不应作为能力来源展示");
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(skillPanelText.includes("mnote"), "能面板应展示 mnote 来源");
assert(!skillPanelText.includes("Reasonix 技能"), "选择 mnote 时不应同时展示 Reasonix 技能");
assert(!skillPanelText.includes("Hermes 技能"), "选择 mnote 时不应同时展示 Hermes 技能");
assert(skillPanelText.includes("MNote 公共能力"), "能面板应展示 MNote 来源");
assert(!skillPanelText.includes("user_sqlite"), "MNote 能力面板不应展示内部 SQLite policy 细节");
assert(!skillPanelText.includes("profile_tool_policy"), "MNote 能力面板不应展示内部 profile policy 细节");
assert(!skillPanelText.includes("reasonix-review"), "选择 MNote 时不应同时展示 Reasonix 能力条目");
assert(!skillPanelText.includes("Hermes writer"), "选择 MNote 时不应同时展示 Hermes 能力条目");
const mnoteSkillsScreenshot = await saveScreenshot(page, "00-mnote-skills-panel");
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
@@ -567,40 +648,20 @@ async function main() {
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').click({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("reasonix", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const reasonixOnlyText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(reasonixOnlyText.includes("reasonix"), "选择 reasonix 时应展示 Reasonix 来源");
assert(!reasonixOnlyText.includes("当前页读取"), "选择 reasonix 时不应残留 MNote 技能");
assert(!reasonixOnlyText.includes("Hermes writer"), "选择 reasonix 时不应残留 Hermes 技能");
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("hermes:shared_lite", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const sharedHermesText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(sharedHermesText.includes("hermes_lite"), "选择 hermes_lite 时应只展示 lite profile 技能");
assert(!sharedHermesText.includes("当前页读取"), "选择 hermes_lite 时不应残留 MNote 技能");
assert(!sharedHermesText.includes("reasonix-review"), "选择 hermes_lite 时不应残留 Reasonix 技能");
assert(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').isDisabled(),
"shared Hermes profile 普通用户应只读",
);
await page.locator('[data-page-ai-hide-hermes-builtin]').check({ timeout: UI_TIMEOUT_MS });
const hiddenBuiltinText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(!hiddenBuiltinText.includes("Hermes builtin"), "隐藏 Hermes 内置后不应显示 Hermes 内置技能");
await page.locator('[data-page-ai-hide-hermes-builtin]').uncheck({ timeout: UI_TIMEOUT_MS });
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] .wolai-page-ai-skill-name', { hasText: "reasonix-review" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').count(),
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').count(),
0,
"Hermes profile 切回 mnoteai 后不应残留 chemist 技能",
"Reasonix 自带 skill 在能力页只读查看,不显示开关",
);
const skillsScreenshot = await saveScreenshot(page, "00-skills-panel");
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-writer",
null,
{ timeout: UI_TIMEOUT_MS },
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] .wolai-page-ai-skill-name', { hasText: "Hermes writer" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').count(),
0,
"Hermes profile skill 在能力页只读查看,不显示开关",
);
const skillsScreenshot = await saveScreenshot(page, "00-hermes-skills-panel");
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
@@ -663,6 +724,39 @@ async function main() {
`默认目标应标记 only_office resourceKind: ${JSON.stringify(ackRunBody.targetPackage)}`,
);
assert.strictEqual(ackRunBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "targetPackage policy 应要求显式目标");
await page.waitForFunction(
() => ["completed", "idle"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: UI_TIMEOUT_MS },
);
await targetButton.click({ timeout: UI_TIMEOUT_MS });
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await targetPopover.locator('[data-page-ai-target-option="resource:office-preview:task502"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Preview DOCX"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const previewRunCountBefore = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill("预览 docx 请正常回复", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await waitForCapturedRunCount(captured, previewRunCountBefore + 1);
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const previewRuns = captured.filter((item) => item.kind === "run");
assert(previewRuns.length >= 2, "未捕获 Office 预览 Page AI run payload");
const previewRunBody = JSON.parse(previewRuns[previewRuns.length - 1].body);
assert.strictEqual(previewRunBody.targetPackage?.primaryTargetId, "resource:office-preview:task502", "Office 预览 targetPackage 应冻结预览资源");
assert.strictEqual(previewRunBody.targetPackage?.resourceKind, "attachment", `Office 预览不应被归一为 only_office: ${JSON.stringify(previewRunBody.targetPackage)}`);
assert.strictEqual(previewRunBody.targetPackage?.onlyofficeSessionId, "", "Office 预览 targetPackage 不应要求 bridge session");
assert(
previewRunBody.targetPackage.targets.some((target) => target.resourceKind === "attachment" && target.relativePath === "office/Task502 Preview.docx" && !target.onlyofficeSessionId),
`Office 预览 target 应作为普通附件上下文发送: ${JSON.stringify(previewRunBody.targetPackage)}`,
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -748,7 +842,6 @@ async function main() {
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-mindmap"], false, "MNote mindmap skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.reasonix?.["reasonix-review"], false, "Reasonix skill 开关应进入 run payload");
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "文档任务 run 应携带目标包");
assert(runBody.targetPackage?.primaryTargetId, "文档任务 targetPackage 应包含 primaryTargetId");
assert(Array.isArray(runBody.targetPackage?.targets), "文档任务 targetPackage 应包含 targets 数组");
@@ -762,12 +855,10 @@ async function main() {
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
.map((item) => JSON.parse(item.body || "{}"));
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "shared_lite"), "Agent 内的 Hermes profile 选择应写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").skillKind === "mnote_builtin" && JSON.parse(item.body || "{}").name === "mnote-current-page"), "MNote skill 开关应调用服务端 per-user SQLite policy");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]?.["reasonix-review"] === false), "Reasonix skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "技能分组折叠状态应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.active_source"] === "hermes:usr_task502_default"), "技能来源选择应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应写入用户 UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profileId === "usr_task502_default"), "Hermes skill 开关应按 profileId 调用");
assert(captured.some((item) => item.kind === "capability-toggle" && JSON.parse(item.body || "{}").id === "mnote-current-page"), "MNote 能力开关应调用服务端 per-user SQLite policy");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "能力分组折叠状态应写入 SQLite UI preference");
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]), "能力页不应再写入 Reasonix 自带 skill 偏好");
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "能力页不应再写入 Hermes 内置 skill 过滤偏好");
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri
@@ -794,6 +885,7 @@ async function main() {
root,
rootUri,
documentId,
mnoteSkillsScreenshot,
screenshot,
skillsScreenshot,
captured,