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
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env node
"use strict";
/**
* mnote-web 生产构建与启动入口。
*
* 规则:
* - 只按已提交的 git HEAD 判断 release 是否已存在,未提交工作区改动不参与判定。
* - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。
* - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。
* - 默认启动端口是 3003,避免占用开发端口 3000。
*/
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn, spawnSync, execFileSync } = require("node:child_process");
const ROOT = path.resolve(__dirname, "..");
const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web");
const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web");
const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod");
const RUN_STATE_PATH = path.join(RUN_DIR, "process.json");
const DEFAULT_PORT = 3003;
const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
function runChecked(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: ROOT,
env: process.env,
encoding: "utf8",
stdio: options.stdio || "pipe",
...options,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr ? `\n${result.stderr.trim()}` : "";
throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}${stderr}`);
}
return result.stdout || "";
}
function currentGitHead() {
return runChecked("git", ["rev-parse", "HEAD"]).trim();
}
function parseVersion(version) {
const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) return null;
return match.slice(1).map((part) => Number(part));
}
function compareVersion(a, b) {
const av = parseVersion(a);
const bv = parseVersion(b);
if (!av || !bv) return 0;
for (let i = 0; i < 3; i += 1) {
if (av[i] !== bv[i]) return av[i] - bv[i];
}
return 0;
}
function nextVersionAfter(version) {
if (!version) return "0.0.1";
let [major, minor, patch] = parseVersion(version) || [0, 0, 0];
patch += 1;
if (patch > 9) {
patch = 0;
minor += 1;
}
if (minor > 9) {
minor = 0;
major += 1;
}
return `${major}.${minor}.${patch}`;
}
async function pathExists(filePath) {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
async function listReleases() {
if (!(await pathExists(RELEASE_ROOT))) return [];
const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true });
const releases = [];
for (const entry of entries) {
if (!entry.isDirectory() || !parseVersion(entry.name)) continue;
const releaseDir = path.join(RELEASE_ROOT, entry.name);
const metadataPath = path.join(releaseDir, "metadata.json");
if (!(await pathExists(metadataPath))) {
releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
continue;
}
try {
const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8"));
const binaryPath = path.join(releaseDir, "mnote-web");
const sourceDir = path.join(releaseDir, "source");
const usable = (await pathExists(binaryPath)) && (await pathExists(sourceDir));
releases.push({ version: entry.name, releaseDir, metadata, usable });
} catch {
releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
}
}
releases.sort((a, b) => compareVersion(a.version, b.version));
return releases;
}
async function findReleaseForHead(head) {
const releases = await listReleases();
return releases.find((release) => release.usable && release.metadata?.gitHead === head) || null;
}
async function nextReleaseVersion() {
const releases = await listReleases();
let version = releases.length > 0 ? releases[releases.length - 1].version : null;
let next = nextVersionAfter(version);
while (await pathExists(path.join(RELEASE_ROOT, next))) {
next = nextVersionAfter(next);
}
return next;
}
function pipeGitArchive(head, destination) {
return new Promise((resolve, reject) => {
const git = spawn("git", ["archive", "--format=tar", head], {
cwd: ROOT,
stdio: ["ignore", "pipe", "inherit"],
});
const tar = spawn("tar", ["-x", "-C", destination], {
cwd: ROOT,
stdio: ["pipe", "inherit", "inherit"],
});
git.stdout.pipe(tar.stdin);
let gitCode = null;
let tarCode = null;
const maybeDone = () => {
if (gitCode === null || tarCode === null) return;
if (gitCode !== 0) {
reject(new Error(`git archive failed with code ${gitCode}`));
return;
}
if (tarCode !== 0) {
reject(new Error(`tar extract failed with code ${tarCode}`));
return;
}
resolve();
};
git.on("error", reject);
tar.on("error", reject);
git.on("close", (code) => {
gitCode = code;
maybeDone();
});
tar.on("close", (code) => {
tarCode = code;
maybeDone();
});
});
}
async function buildRelease(head) {
const version = await nextReleaseVersion();
const releaseDir = path.join(RELEASE_ROOT, version);
const sourceDir = path.join(releaseDir, "source");
const binaryPath = path.join(releaseDir, "mnote-web");
const metadataPath = path.join(releaseDir, "metadata.json");
await fsp.mkdir(sourceDir, { recursive: true });
console.log(`[prod] 创建 release ${version}: ${releaseDir}`);
await pipeGitArchive(head, sourceDir);
const targetDir = path.join(BUILD_CACHE_DIR, "target");
await fsp.mkdir(targetDir, { recursive: true });
console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`);
runChecked(
"cargo",
["build", "--manifest-path", "rust/Cargo.toml", "-p", "mnote-web", "--bin", "mnote-web", "--release"],
{
cwd: sourceDir,
env: {
...process.env,
CARGO_TARGET_DIR: targetDir,
},
stdio: "inherit",
},
);
const builtBinary = path.join(targetDir, "release", "mnote-web");
await fsp.copyFile(builtBinary, binaryPath);
await fsp.chmod(binaryPath, 0o755);
const metadata = {
schema: "mnote.prod_release.v1",
version,
gitHead: head,
builtAt: new Date().toISOString(),
binaryPath,
sourceDir,
cargoTargetDir: targetDir,
};
await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
return { version, releaseDir, metadata, usable: true };
}
function readJsonIfExists(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function processCommandLine(pid) {
try {
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
} catch {
return "";
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isPrivateLanAddress(address) {
if (/^10\./.test(address)) return true;
if (/^192\.168\./.test(address)) return true;
const match = address.match(/^172\.(\d+)\./);
if (!match) return false;
const second = Number(match[1]);
return second >= 16 && second <= 31;
}
function lanAddresses() {
const ignoredInterface = /^(docker|br-|veth|virbr|tailscale|zt|lo)/;
const addresses = [];
for (const [name, entries] of Object.entries(os.networkInterfaces())) {
if (ignoredInterface.test(name)) continue;
for (const entry of entries || []) {
if (entry.family !== "IPv4" || entry.internal) continue;
if (!isPrivateLanAddress(entry.address)) continue;
addresses.push(entry.address);
}
}
return [...new Set(addresses)];
}
function defaultPublicBind(port) {
return `${lanAddresses()[0] || "127.0.0.1"}:${port}`;
}
async function stopProcess(pid) {
if (!isProcessAlive(pid)) return;
console.log(`[prod] 停止旧进程 pid=${pid}`);
process.kill(pid, "SIGTERM");
for (let i = 0; i < 40; i += 1) {
if (!isProcessAlive(pid)) return;
await sleep(250);
}
if (isProcessAlive(pid)) {
console.log(`[prod] 旧进程未及时退出,发送 SIGKILL pid=${pid}`);
process.kill(pid, "SIGKILL");
}
}
function isManagedMnoteProcess(pid, expectedBinaryPath = "") {
const commandLine = processCommandLine(pid);
if (!commandLine) return false;
if (expectedBinaryPath && commandLine.includes(expectedBinaryPath)) return true;
return commandLine.includes(RELEASE_ROOT) || /\bmnote-web\b/.test(commandLine);
}
function pidsListeningOnPort(port) {
const pids = new Set();
try {
const out = execFileSync("ss", ["-ltnp", `sport = :${port}`], { encoding: "utf8" });
for (const match of out.matchAll(/pid=(\d+)/g)) {
pids.add(Number(match[1]));
}
} catch {
// ignore
}
if (pids.size > 0) return [...pids];
try {
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" });
for (const line of out.split(/\r?\n/)) {
const pid = Number(line.trim());
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
}
} catch {
// ignore
}
return [...pids];
}
async function stopPreviousProcess(port) {
const state = readJsonIfExists(RUN_STATE_PATH);
if (state?.pid) {
const pid = Number(state.pid);
if (isManagedMnoteProcess(pid, state.binaryPath || "")) {
await stopProcess(pid);
}
}
for (const pid of pidsListeningOnPort(port)) {
if (isManagedMnoteProcess(pid)) {
await stopProcess(pid);
continue;
}
const commandLine = processCommandLine(pid);
throw new Error(`端口 ${port} 已被非 mnote-web 进程占用: pid=${pid} ${commandLine}`);
}
}
function requestLocalAuth(port) {
return new Promise((resolve) => {
const req = http.get(
{
host: "127.0.0.1",
port,
path: "/auth",
timeout: 1000,
},
(res) => {
res.resume();
resolve(res.statusCode && res.statusCode < 500);
},
);
req.on("timeout", () => {
req.destroy();
resolve(false);
});
req.on("error", () => resolve(false));
});
}
async function waitForReady(port, logPath, pid) {
for (let i = 0; i < 60; i += 1) {
if (!isProcessAlive(pid)) break;
if (await requestLocalAuth(port)) return;
await sleep(250);
}
let logTail = "";
try {
const content = await fsp.readFile(logPath, "utf8");
logTail = content.split(/\r?\n/).slice(-40).join(os.EOL);
} catch {
// ignore
}
throw new Error(`mnote-web 启动失败或未就绪,日志:\n${logTail}`);
}
async function startRelease(release) {
const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`MNOTE_PROD_PORT 非法: ${process.env.MNOTE_PROD_PORT}`);
}
await fsp.mkdir(RUN_DIR, { recursive: true });
await stopPreviousProcess(port);
const binaryPath = path.join(release.releaseDir, "mnote-web");
const logPath = path.join(release.releaseDir, "mnote-web.log");
const logFd = fs.openSync(logPath, "a");
const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);
const child = spawn(binaryPath, [], {
cwd: release.metadata?.sourceDir || release.releaseDir,
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
MNOTE_WEB_BIND: bind,
MNOTE_WEB_PUBLIC_BIND: publicBind,
MNOTE_CONTROL_PLANE_DB_PATH:
process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB,
},
});
child.unref();
fs.closeSync(logFd);
const state = {
schema: "mnote.prod_process.v1",
pid: child.pid,
version: release.version,
gitHead: release.metadata?.gitHead,
startedAt: new Date().toISOString(),
bind,
publicBind,
releaseDir: release.releaseDir,
binaryPath,
logPath,
};
await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await waitForReady(port, logPath, child.pid);
console.log(`[prod] 已启动 mnote-web release ${release.version}`);
console.log(`[prod] pid=${child.pid}`);
console.log(`[prod] local URL=http://127.0.0.1:${port}`);
for (const address of lanAddresses()) {
console.log(`[prod] LAN URL=http://${address}:${port}`);
}
console.log(`[prod] log=${logPath}`);
}
async function main() {
const head = currentGitHead();
await fsp.mkdir(RELEASE_ROOT, { recursive: true });
let release = await findReleaseForHead(head);
if (release) {
console.log(`[prod] 当前 HEAD ${head.slice(0, 12)} 已包含在 release ${release.version},跳过 build`);
} else {
release = await buildRelease(head);
}
await startRelease(release);
}
main().catch((error) => {
console.error(`[prod] ${error.message}`);
process.exit(1);
});
+63 -2
View File
@@ -47,6 +47,8 @@ function isWriteMnoteTool(toolName) {
'mnote.evidence.search',
'mnote.evidence.read',
'mnote.evidence.open',
'mnote.index.status',
'mnote.index.refresh',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
@@ -503,6 +505,9 @@ const MNOTE_TOOL_NAMES = [
'mnote.evidence.search',
'mnote.evidence.read',
'mnote.evidence.open',
'mnote.index.status',
'mnote.index.refresh',
'mnote.index.update_settings',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
@@ -513,6 +518,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
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',
};
async function callMnoteTool(toolName, args) {
@@ -595,7 +603,7 @@ tools.register({
tools.register({
name: 'mnote_evidence_search',
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。',
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
parameters: {
type: 'object',
properties: {
@@ -654,6 +662,59 @@ tools.register({
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),
parallelSafe: false,
});
tools.register({
name: 'mnote_index_update_settings',
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
parameters: {
type: 'object',
properties: {
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'],
},
readOnly: false,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
parallelSafe: false,
});
// ── Session Store ────────────────────────────────────
const sessions = new Map();
@@ -693,7 +754,7 @@ onRequest('session/new', async (params) => {
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-document-evidence — Search local documents and resources with clickable evidence locators.',
'- mnote-local-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-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>',
@@ -99,6 +99,13 @@ async function main() {
}),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
skillToggleBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
@@ -50,6 +50,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
@@ -62,6 +62,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
sessionBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
+91 -16
View File
@@ -30,6 +30,7 @@ async function main() {
const title = `TEST-HERMES-AI-smoke-${suffix}`;
const sessionId = `mnote_smoke_${suffix}`;
const runId = `run_smoke_${suffix}`;
let runRequestCount = 0;
let sessionDetailHits = 0;
const captured = [];
const createdIds = [];
@@ -117,6 +118,8 @@ async function main() {
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
runRequestCount += 1;
const currentRunId = `${runId}_${runRequestCount}`;
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
@@ -124,27 +127,47 @@ async function main() {
body: JSON.stringify({
ok: true,
sessionId,
runId,
runId: currentRunId,
events: [],
traceId: "trace_smoke",
}),
});
});
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
await page.route("**/api/hermes/client/events/**", async (route) => {
const currentRunId = route.request().url().split("/").pop() || runId;
captured.push({ kind: "events", method: route.request().method(), body: "" });
const evidenceEvents = Array.from({ length: 24 }, (_, index) => {
const callId = `call_smoke_evidence_${index}`;
return (
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", args: { query: `evidence ${index}` } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", summary: `证据 ${index}`, auditId: `audit_smoke_evidence_${index}` })}\n\n`
);
}).join("");
if (runRequestCount > 1) {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Second " })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: currentRunId, session_id: sessionId, output: "Second response" })}\n\n`,
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` +
evidenceEvents +
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.failed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Smoke " })}\n\n` +
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
`data: ${JSON.stringify({
event: "run.completed",
run_id: runId,
run_id: currentRunId,
session_id: sessionId,
output: "Smoke response",
agentAudit: {
@@ -193,23 +216,75 @@ async function main() {
const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) =>
cards.map((card) => ({
id: card.getAttribute("data-page-ai-tool-call-id"),
group: card.getAttribute("data-page-ai-tool-group"),
status: card.getAttribute("data-page-ai-tool-status"),
text: card.textContent || "",
})),
);
assert.equal(toolCards.length, 3, `应渲染 3 张工具卡:${JSON.stringify(toolCards)}`);
assert.equal(toolCards.length, 1, `批量工具调用应折叠成 1 张工具卡:${JSON.stringify(toolCards)}`);
assert(toolCards[0].text.includes("调用工具") && toolCards[0].text.includes("27 个"), `工具组摘要应显示调用数量:${JSON.stringify(toolCards)}`);
const toolItems = await page.$$eval("[data-page-ai-tool-item]", (items) =>
items.map((item) => ({
id: item.getAttribute("data-page-ai-tool-call-id"),
status: item.getAttribute("data-page-ai-tool-status"),
text: item.textContent || "",
})),
);
assert(toolItems.length >= 27, `工具组展开内容应包含批量工具调用:${JSON.stringify(toolItems)}`);
assert(
toolCards.some((card) => card.status === "completed" && card.text.includes("call_smoke_page_get")),
`缺少 completed 工具${JSON.stringify(toolCards)}`,
toolItems.some((item) => item.status === "completed" && item.text.includes("call_smoke_page_get")),
`缺少 completed 工具${JSON.stringify(toolItems)}`,
);
assert(
toolCards.some((card) => card.status === "failed" && card.text.includes("permission_denied")),
`缺少 failed 工具${JSON.stringify(toolCards)}`,
toolItems.some((item) => item.status === "failed" && item.text.includes("permission_denied")),
`缺少 failed 工具${JSON.stringify(toolItems)}`,
);
assert(
toolCards.some((card) => card.status === "completed" && card.text.includes("agent.changed_files") && card.text.includes("README.md")),
`缺少 changed files 工具${JSON.stringify(toolCards)}`,
toolItems.some((item) => item.status === "completed" && item.text.includes("agent.changed_files") && item.text.includes("README.md")),
`缺少 changed files 工具${JSON.stringify(toolItems)}`,
);
const toolDetailsOpenByDefault = await page.$$eval("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details", (details) => details.map((node) => node.open));
assert.equal(toolDetailsOpenByDefault.length, 1, "工具调用应共用一个 details 折叠容器");
assert(toolDetailsOpenByDefault.every((open) => open === false), "工具调用组默认应折叠");
const orderState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
const toolGroup = node.querySelector("[data-page-ai-tool-card]");
const assistant = Array.from(node.querySelectorAll(".wolai-page-ai-message--assistant")).find((item) =>
(item.textContent || "").includes("Smoke response")
);
return {
toolBeforeAssistant: Boolean(toolGroup && assistant && (toolGroup.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING)),
};
});
assert(orderState.toolBeforeAssistant, "工具调用组应显示在 AI 输出结果之前");
await page.locator("[data-page-ai-tool-card] summary").first().click({ timeout: UI_TIMEOUT_MS });
assert(
await page.locator("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details").first().evaluate((node) => node.open),
"点击工具组 summary 后应展开详情",
);
const scrollStateBeforeFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
node.scrollTop = 0;
return {
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
};
});
assert(scrollStateBeforeFollowup.scrollHeight > scrollStateBeforeFollowup.clientHeight, `批量工具调用应撑出滚动区:${JSON.stringify(scrollStateBeforeFollowup)}`);
await page.locator("[data-page-ai-input]").fill("继续补充一句", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Second response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const scrollStateAfterFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => ({
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
firstToolOpen: Boolean(node.querySelector("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details")?.open),
}));
assert(scrollStateAfterFollowup.scrollTop <= 4, `用户已上滚时新输出不应强制贴底:${JSON.stringify(scrollStateAfterFollowup)}`);
assert(scrollStateAfterFollowup.firstToolOpen, "重渲染后应保留用户展开的工具调用详情");
const sessionRequest = captured.find((entry) => entry.kind === "session");
const runRequest = captured.find((entry) => entry.kind === "run");
@@ -54,7 +54,7 @@ async function main() {
const sidebar = page.getByTestId("wolai-sidebar");
const topbar = page.getByTestId("wolai-topbar");
const floatingAi = page.getByTestId("wolai-floating-ai");
const floatingHelp = page.getByTestId("wolai-floating-help");
const floatingHelp = page.getByTestId("mnote-floating-task-toggle");
const sidebarTreeTabs = page.getByTestId("wolai-sidebar-tree-tabs");
const pageTreePanel = page.locator('[data-mnote-sidebar-tree-panel="page"]').first();
const fileTreePanel = page.locator('[data-mnote-sidebar-tree-panel="filetree"]').first();
@@ -198,6 +198,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
await route.fulfill({
status: 200,
@@ -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,
@@ -144,6 +144,8 @@ async function main() {
},
];
const capturedRuns = [];
const capturedSessionCreates = [];
const splitDeltaDetailText = "历史回答不应按 delta 拆成多个气泡。";
let caughtError = null;
const screenshots = {};
@@ -282,8 +284,62 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions**", async (route) => {
const requestUrl = new URL(route.request().url());
const detailMatch = requestUrl.pathname.match(/\/api\/hermes\/client\/sessions\/([^/]+)(?:\/resume)?$/);
if (route.request().method() === "GET") {
if (detailMatch) {
const sessionId = decodeURIComponent(detailMatch[1]);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessionStorage: "sqlite_control_plane",
sessionId,
session: {
sessionId,
messages: [],
runs: [{
sessionId,
runId: `run_task504_detail_${suffix}`,
title: "Gemini 问答",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
status: "completed",
payload: {
agentId: "chat_only",
profileId: "shared_gemini_chat",
profile: "shared_gemini_chat",
acpRuntime: "hermes",
message: "历史详情测试",
},
createdAt: "2026-05-30T08:00:00Z",
updatedAt: "2026-05-30T08:02:00Z",
persistence: "sqlite_acp_runtime_store",
}],
},
events: Array.from(splitDeltaDetailText).map((delta, index) => ({
eventId: `evt_task504_detail_${index}`,
sessionId,
runId: `run_task504_detail_${suffix}`,
eventType: "message.delta",
payload: { delta },
createdAt: `2026-05-30T08:01:${String(index).padStart(2, "0")}Z`,
persistence: "sqlite_acp_runtime_store",
})),
}),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
@@ -295,6 +351,27 @@ async function main() {
});
return;
}
if (route.request().method() === "POST" && detailMatch && requestUrl.pathname.endsWith("/resume")) {
const sessionId = decodeURIComponent(detailMatch[1]);
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
persistence: "sqlite_acp_runtime_store",
sessionStorage: "sqlite_control_plane",
sessionId,
session: {
sessionId,
messages: [{ role: "assistant", content: splitDeltaDetailText }],
runs: [],
},
events: [],
}),
});
return;
}
capturedSessionCreates.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
@@ -359,6 +436,7 @@ async function main() {
assert(historyText.includes("Hermes / Lite"), "历史会话应显示 Hermes profile");
assert(!historyText.includes("ChatOnly / myHermes"), "ChatOnly 历史不应显示 Hermes profile 标签");
assert(!historyText.includes("FULL_TAIL_SHOULD_NOT_RENDER"), "历史预览不应显示 ChatOnly 完整长消息尾部");
assert.strictEqual(capturedSessionCreates.length, 0, "打开 Page AI 和历史列表不应创建空白后端会话");
const filter = page.locator('[data-page-ai-session-agent-filter]');
await filter.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -454,6 +532,7 @@ async function main() {
rawStrongMarkers: false,
}, `AI 面板应渲染 Markdown,而不是显示原始标记: ${JSON.stringify(markdownState)}`);
assert.strictEqual(capturedRuns.length, 1, "当前页 ChatOnly 请求应通过前置 target 校验并到达 runs API");
assert.strictEqual(capturedSessionCreates.length, 1, "只有真实发送消息时才应创建后端会话");
assert.strictEqual(capturedRuns[0].agentId, "chat_only", "应使用 ChatOnly agent");
assert.strictEqual(capturedRuns[0].profile, "shared_deepseek_chat", "应使用 DeepSeek ChatOnly profile");
assert.strictEqual(
@@ -470,6 +549,24 @@ async function main() {
"取消打开资源后不应发送 active_editor contextRef",
);
screenshots.currentPageTarget = await saveScreenshot(page, "current-page-target");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-session-agent-filter]').selectOption("all", { timeout: UI_TIMEOUT_MS });
await page.locator(`[data-page-ai-session-resume="mnote_task504_gemini_${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').getByText(splitDeltaDetailText).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const restoredAssistantMessages = await page.locator(".wolai-page-ai-message--assistant").evaluateAll((nodes) => (
nodes.map((node) => node.textContent || "").filter((text) => text.includes("历史回答") || text.includes("不应按"))
));
assert.deepStrictEqual(
restoredAssistantMessages,
[`AI${splitDeltaDetailText}`],
"恢复历史详情时 message.delta 必须合并为一条 assistant 消息,不能按字拆气泡",
);
screenshots.historyRestore = await saveScreenshot(page, "history-restore");
} catch (error) {
caughtError = error;
try {
@@ -241,6 +241,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
@@ -241,6 +241,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
@@ -196,6 +196,13 @@ async function main() {
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
@@ -46,6 +46,14 @@ async function postJson(pathname, data, headers = {}) {
})).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);
@@ -170,7 +178,7 @@ async function runReasonixAcpEvidenceCheck(input) {
allowedRoots: [{ rootUri, permission: "write" }],
skillPreferences: {
mnote: {
"mnote-document-evidence": true,
"mnote-local-index": true,
"mnote-chat-only": false,
},
},
@@ -245,18 +253,33 @@ async function main() {
headers: { cookie },
})).payload;
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
for (const name of ["mnote.evidence.search", "mnote.evidence.read", "mnote.evidence.open"]) {
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 skillsPayload = (await fetchJson("/api/hermes/client/skills?runtime=mnote&agentId=reasonix", {
const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", {
headers: { cookie },
})).payload;
const mnoteSkills = (skillsPayload.categories || []).flatMap((category) => category.skills || []);
const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []);
assert(
mnoteSkills.some((skill) => skill.id === "mnote-document-evidence" && skill.enabled !== false),
"Reasonix agent 缺少启用的 mnote-document-evidence skill",
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");
@@ -297,6 +320,8 @@ async function main() {
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");
@@ -0,0 +1,195 @@
#!/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 {
BASE_URL,
UI_TIMEOUT_MS,
} = require("./tree-shell-smoke-helpers");
const TASK = "task529-local-search-result-open-locator-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
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 fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
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();
}
async function saveScreenshot(page, name) {
const target = path.join(OUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function loginTestAccount(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLogin = page.locator('[data-auth-test-login]').first();
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
}
return await page.evaluate(async () => {
const response = await fetch("/api/auth/whoami", { headers: { accept: "application/json" } });
return await response.json();
});
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task529-search-"));
const rootUri = fileUrl(root);
const query = "三甲基硅酯";
const currentPath = "Current.md";
const targetPath = "docs/Silicon.md";
const debug = { root, rootUri, query, baseUrl: BASE_URL };
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
});
const page = await context.newPage();
try {
const viewer = await loginTestAccount(page);
const actorId = viewer.userId || "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task529`;
debug.viewer = viewer;
debug.workspaceId = workspaceId;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId: actorId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "search"],
}, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(path.join(root, currentPath), "# Current\n\n从这个页面打开搜索结果。\n", "utf8");
const filler = Array.from({ length: 42 }, (_, index) => `普通段落 ${index + 1}`).join("\n\n");
fs.writeFileSync(
path.join(root, targetPath),
`# Silicon\n\n${filler}\n\n命中段落:羧酸可以转化成${query},本行用于测试搜索定位。\n\n尾部段落。\n`,
"utf8",
);
await page.goto(documentUrl(root, currentPath), { 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,
});
const refresh = await page.evaluate(async ({ workspaceId, rootUri }) => {
const response = await fetch("/api/search/local-index/settings", {
method: "PUT",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
workspaceId,
rootUri,
includePaths: ["."],
scheduleMode: "manual",
scheduleTime: "02:00",
runOnChange: false,
}),
});
return { status: response.status, payload: await response.json().catch(() => null) };
}, { workspaceId, rootUri });
assert.equal(refresh.status, 200, `刷新本地索引应成功: ${JSON.stringify(refresh)}`);
debug.refresh = refresh.payload;
await page.locator('[data-mnote-action="open-search-modal"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-search-input"]').fill(query, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expected) => {
return Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
.some((row) => (row.textContent || "").includes(expected));
}, query, { timeout: UI_TIMEOUT_MS });
debug.searchScreenshot = await saveScreenshot(page, "search-results");
await page.locator('[data-testid="wolai-search-result-row"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expected) => {
const activeTab = document.querySelector('.mnote-main-tab[aria-selected="true"][data-mnote-tab-kind="markdown"]');
const highlighted = document.querySelector('[data-mnote-resource-tab-panel][data-pane-role="primary"] [data-mnote-evidence-text-highlight="true"]');
const visibleHit = Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel][data-pane-role="primary"]:not([hidden]) .ProseMirror *'))
.find((node) => node instanceof HTMLElement && (node.textContent || "").includes(expected));
return Boolean(activeTab && (
highlighted && (highlighted.textContent || "").includes(expected)
|| visibleHit && visibleHit.getBoundingClientRect().top > 80 && visibleHit.getBoundingClientRect().bottom < window.innerHeight
));
}, query, { timeout: UI_TIMEOUT_MS });
debug.openScreenshot = await saveScreenshot(page, "opened-resource-tab");
const state = await page.evaluate((expectedCurrentPath) => {
const url = new URL(window.location.href);
const activeTab = document.querySelector('.mnote-main-tab[aria-selected="true"][data-mnote-tab-kind="markdown"]');
const highlighted = document.querySelector('[data-mnote-resource-tab-panel][data-pane-role="primary"] [data-mnote-evidence-text-highlight="true"]');
const visibleHit = Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel][data-pane-role="primary"]:not([hidden]) .ProseMirror *'))
.find((node) => node instanceof HTMLElement && (node.textContent || "").includes("三甲基硅酯"));
const target = highlighted instanceof HTMLElement ? highlighted : visibleHit;
const rect = target instanceof HTMLElement ? target.getBoundingClientRect() : null;
return {
pathname: url.pathname,
stayedOnCurrentDocument: url.pathname.includes(encodeURIComponent(`local-md:${expectedCurrentPath}`)),
resourceTab: url.searchParams.get("resourceTab") || "",
activeTabTitle: activeTab ? activeTab.textContent.trim() : "",
highlighted: Boolean(highlighted),
highlightedText: target ? target.textContent.trim() : "",
highlightedRect: rect ? { top: rect.top, bottom: rect.bottom, height: rect.height } : null,
};
}, currentPath);
debug.state = state;
assert.equal(state.stayedOnCurrentDocument, true, `点击搜索结果不应整页跳走: ${JSON.stringify(state)}`);
assert(state.resourceTab.includes(targetPath), `URL 应记录当前资源标签: ${JSON.stringify(state)}`);
assert(state.highlightedText.includes(query), `应高亮正文命中块: ${JSON.stringify(state)}`);
assert(
state.highlightedRect && state.highlightedRect.top > 80 && state.highlightedRect.bottom < 900,
`命中块应滚动到可视区域: ${JSON.stringify(state)}`,
);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(debug, null, 2)}\n`, "utf8");
} catch (error) {
debug.error = error && error.stack || String(error);
try {
debug.failureScreenshot = await saveScreenshot(page, "failure");
} catch (_) {}
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(debug, null, 2)}\n`, "utf8");
throw error;
} finally {
await browser.close().catch(() => undefined);
fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
}
main().catch((error) => {
console.error(error && error.stack || error);
process.exit(1);
});