Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务

This commit is contained in:
WpyQwq
2026-09-19 11:52:42 +08:00
commit 103f0b32f5
28 changed files with 5834 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env node
/**
* ui-check.mjs — 群聊台的点击手感验收。
*
* 关键点:用 CDP 派发**真实的 mousePressed / mouseReleased**(不是 element.click())。
* 病根是"按钮在你的 mousedown 和 mouseup 之间被 innerHTML 重建掉,click 事件根本不会触发",
* 只有真实鼠标事件才能复现它。
*
* 量的是"点击 → 肉眼可见变化"的耗时(页面内 rAF 计时,分辨率约 1 帧)。
*
* 用法:node tools/ui-check.mjs [url] [--keep]
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const URL_ = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:3099/';
const KEEP = process.argv.includes('--keep');
const PORT = 9331;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const EDGE = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
if (!EDGE) { console.error('找不到 msedge.exe'); process.exit(2); }
const results = [];
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`);
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push('EXCEPTION: ' + (d.exception?.description || d.text || '').split('\n')[0]);
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 30000);
});
}
async eval(expression, awaitPromise = false) {
const r = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval 抛错');
return r.result?.value;
}
async rect(selector) {
const j = await this.eval(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); if (!e) return null; const r = e.getBoundingClientRect(); return {x: r.left + r.width/2, y: r.top + r.height/2, w: r.width, h: r.height}; })()`);
return j;
}
/** 真实鼠标点击:按下与抬起分开派发,中间留出被"重建"打断的窗口。 */
async clickAt(x, y, { holdMs = 45 } = {}) {
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', clickCount: 0 });
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 });
await sleep(holdMs);
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 });
}
}
/* --------------------------------------------------------------- 启动浏览器 */
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-ui-'));
const edge = spawn(EDGE, [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--disable-extensions', '--hide-scrollbars', '--window-size=1440,1000',
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank',
], { stdio: 'ignore', windowsHide: true });
async function browserWsUrl() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (page) return page.webSocketDebuggerUrl;
} catch { /* 还没起来 */ }
await sleep(250);
}
throw new Error('浏览器调试端口没起来');
}
let cdp;
try {
cdp = await Cdp.connect(await browserWsUrl());
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
// 等页面真正就绪:出现成员卡片
for (let i = 0; i < 60; i++) {
await sleep(250);
const ready = await cdp.eval('document.querySelectorAll(".mcard").length').catch(() => 0);
if (ready > 0) break;
}
await cdp.eval(`window.__awaitChange = function (done, timeoutMs) {
return new Promise(function (resolve) {
var t0 = performance.now();
function check() {
var v = false; try { v = done(); } catch (e) {}
if (v) return resolve(Math.round(performance.now() - t0));
if (performance.now() - t0 > timeoutMs) return resolve(-1);
requestAnimationFrame(check);
}
requestAnimationFrame(check);
});
};`);
const members = await cdp.eval('[...document.querySelectorAll(".mcard")].map(c => c.dataset.member)');
step('页面渲染出成员卡片', Array.isArray(members) && members.length >= 3, `成员:${(members || []).join(', ')}`);
/** 点击某个选择器指向的元素,并量到可见变化为止 */
async function clickAndMeasure(label, selector, condition, { holdMs = 45, timeout = 3000 } = {}) {
const r = await cdp.rect(selector);
if (!r) { step(label, false, '找不到元素 ' + selector); return -1; }
await cdp.eval(`window.__p = window.__awaitChange(function(){ return ${condition}; }, ${timeout})`);
await cdp.clickAt(r.x, r.y, { holdMs });
const ms = await cdp.eval('window.__p', true);
return ms;
}
/* 1. @ 它(纯本地动作,应该感觉不到延迟) */
{
const ms = await clickAndMeasure('点「@ 它」→ 目标切到该成员', '.mcard[data-member="opencode"] .mact button:nth-child(2)',
'document.getElementById("target").value === "opencode"');
step('点「@ 它」立刻切换目标', ms >= 0 && ms < 120, `${ms} ms`);
}
/* 2. 自驱开关 */
let autoBefore = null;
{
autoBefore = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).then((s) => s.auto).catch(() => null);
const before = await cdp.eval('document.getElementById("autoBtn").textContent');
const want = before.includes('开') ? '自驱:关' : '自驱:开';
const ms = await clickAndMeasure('点「自驱」→ 标签翻转', '#autoBtn',
`document.getElementById("autoBtn").textContent.includes(${JSON.stringify(want)})`);
step('点「自驱」立刻翻转', ms >= 0 && ms < 400, `${ms} ms(${before.trim()} → ${want})`);
}
/* 3. 发送:自己的话必须立刻出现在屏幕上(乐观回显) */
const sendText = 'UI 验收 ' + Date.now() + ':这条应当立刻出现';
{
await cdp.eval(`(() => { const t = document.getElementById('text'); t.value = ${JSON.stringify(sendText)}; t.dispatchEvent(new Event('input')); document.getElementById('target').value = '__all__'; return true; })()`);
const ms = await clickAndMeasure('点「只发不唤醒」→ 立刻看到自己说的话', '#sendOnlyBtn',
`[...document.querySelectorAll(".row.pending .text")].some(e => e.textContent.includes(${JSON.stringify(sendText)}))`, { timeout: 2000 });
step('发出的消息立刻回显(乐观渲染)', ms >= 0 && ms < 150, `${ms} ms`);
}
/* 4. 服务端确认后不留重复气泡(文本带时间戳,避免和上一轮历史撞车) */
{
let n = -1;
for (let i = 0; i < 40; i++) {
await sleep(150);
n = await cdp.eval(`[...document.querySelectorAll(".text")].filter(e => e.textContent.includes(${JSON.stringify(sendText)})).length`);
if (n >= 1) break;
}
await sleep(700);
const after = await cdp.eval(`[...document.querySelectorAll(".text")].filter(e => e.textContent.includes(${JSON.stringify(sendText)})).length`);
step('服务端确认后不出现重复气泡', after === 1, `气泡数 = ${after}`);
}
/* 5. 节点稳定性:成员卡片与按钮绝不能被周期重建(这就是"点击丢失"的根因) */
{
const id1 = await cdp.eval(`(() => { const c = document.querySelector('.mcard[data-member="opencode"]'); c.__probe = Math.random().toString(36); const b = c.querySelector('.mact button'); b.__probe = Math.random().toString(36); return [c.__probe, b.__probe]; })()`);
await sleep(6000); // 跨越 5s 的 state 轮询与 250ms 的任务计时器
const id2 = await cdp.eval(`(() => { const c = document.querySelector('.mcard[data-member="opencode"]'); const b = c ? c.querySelector('.mact button') : null; return { card: c && c.__probe, btn: b && b.__probe, connected: Boolean(c && c.isConnected) }; })()`);
const same = id2.card === id1[0] && id2.btn === id1[1] && id2.connected;
step('6 秒内成员卡片与按钮从未被重建', same, same ? '节点身份保持不变' : `卡片 ${id1[0]}→${id2.card},按钮 ${id1[1]}→${id2.btn}`);
}
/* 6. 渲染压力下连续快速点击不丢事件
—— 直接数按钮自己收到的 click 事件(这才是"点击有没有落到元素上"的判据;
上一版测试用 target.value 赋值来看 change 事件是错的:程序化赋值不触发 change) */
{
const sel = '.mcard[data-member="workbuddy"] .mact button:nth-child(2)';
const r = await cdp.rect(sel);
await cdp.eval(`(() => { const b = document.querySelector(${JSON.stringify(sel)}); b.__hits = 0; b.addEventListener('click', () => { b.__hits++; }); return true; })()`);
// 制造真实的渲染压力:点的时候同时往群里灌消息,逼服务端推 SSE → 前端 syncMembers/loadState
const churn = (async () => {
for (let i = 0; i < 4; i++) {
await fetch(URL_.replace(/\/$/, '') + '/api/send', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: '渲染压力 ' + i, to: [] }),
}).catch(() => {});
await sleep(120);
}
})();
for (let i = 0; i < 8; i++) { await cdp.clickAt(r.x, r.y, { holdMs: 20 }); await sleep(55); }
await churn;
const hits = await cdp.eval(`document.querySelector(${JSON.stringify(sel)}).__hits`);
step('渲染压力下连续 8 次快速点击全部命中', hits === 8, `命中 ${hits}/8`);
}
/* 6b. 服务端推送延迟:以 human 身份发一条,量它出现在屏幕上的时间 */
{
const text = '推送延迟测量 ' + Date.now();
await cdp.eval(`window.__p = window.__awaitChange(function(){ return [...document.querySelectorAll('.text')].some(e => e.textContent.includes(${JSON.stringify(text)})); }, 4000)`);
await fetch(URL_.replace(/\/$/, '') + '/api/send', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text, to: [] }),
});
const ms = await cdp.eval('window.__p', true);
step('别人发的消息推到屏幕上', ms >= 0 && ms < 400, `${ms} ms`);
}
/* 7. 唤醒按钮:点下去必须立刻变"唤醒中…",并且能中止 */
{
// 先把验收期间带起来的其它任务清干净,免得任务条重排把鼠标点偏(上一版就栽在这)
const jj0 = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] }));
for (const j of (jj0.jobs || []).filter((x) => x.status === 'running')) {
await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: j.id }) }).catch(() => {});
}
for (let i = 0; i < 20; i++) {
const st = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).catch(() => ({ running: [] }));
if (!(st.running || []).length) break;
await sleep(300);
}
const ms = await clickAndMeasure('点「唤醒它」→ 立刻变唤醒中', '.mcard[data-member="workbuddy"] .mact button:nth-child(1)',
'(() => { const b = document.querySelector(\'.mcard[data-member="workbuddy"] .mact button\'); return Boolean(b && b.disabled && /唤醒中|工作中/.test(b.textContent)); })()', { timeout: 3000 });
step('点「唤醒它」立刻给出反馈', ms >= 0 && ms < 200, `${ms} ms`);
let running = null;
for (let i = 0; i < 30; i++) {
const jobs = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] }));
running = (jobs.jobs || []).find((j) => j.status === 'running' && j.member === 'workbuddy');
if (running) break;
await sleep(300);
}
if (running) {
const sel = `.row.typing[data-job="${running.id}"] .stop`;
await cdp.eval(`(() => { const b = document.querySelector(${JSON.stringify(sel)}); if (b) b.scrollIntoView({block:'center'}); return true; })()`);
await sleep(250);
const stopRect = await cdp.rect(sel); // 点之前重取坐标
const msStop = stopRect ? await (async () => {
await cdp.eval(`window.__p = window.__awaitChange(function(){ const b = document.querySelector(${JSON.stringify(sel)}); return Boolean(b && b.disabled === true); }, 3000)`);
await cdp.clickAt(stopRect.x, stopRect.y);
return cdp.eval('window.__p', true);
})() : -1;
step('点「中止」立刻有反馈', msStop >= 0 && msStop < 200, `${msStop} ms`);
await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: running.id }) });
let cancelled = false;
for (let i = 0; i < 40; i++) {
await sleep(300);
const jj = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] }));
const j = (jj.jobs || []).find((x) => x.id === running.id);
if (j && j.status !== 'running') { cancelled = true; break; }
}
step('中止后任务真的停了', cancelled, running.id);
let freed = false;
for (let i = 0; i < 20; i++) {
const st = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).catch(() => ({ running: [] }));
if (!(st.running || []).includes('workbuddy')) { freed = true; break; }
await sleep(300);
}
step('中止后成员锁被释放', freed, '');
} else {
step('中止按钮可用', false, '没找到正在跑的 workbuddy 任务');
}
}
/* 8. 页面无 JS 报错 */
await sleep(400);
step('页面无未捕获异常', cdp.errors.length === 0, cdp.errors.slice(0, 2).join(' | '));
/* 收尾:把自驱恢复成本次验收开始时的状态(别把用户的开关改掉),并中止验收期间带起的任务 */
if (autoBefore !== null) {
await fetch(URL_.replace(/\/$/, '') + '/api/auto', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ enabled: autoBefore }),
}).catch(() => {});
}
try {
const jj = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json());
for (const j of (jj.jobs || []).filter((x) => x.status === 'running')) {
await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: j.id }) });
}
if ((jj.jobs || []).some((x) => x.status === 'running')) step('收尾中止遗留任务', true, '已请求中止');
} catch { /* 忽略 */ }
} catch (e) {
step('验收脚本执行', false, e.message);
} finally {
try { cdp?.ws.close(); } catch { /* 忽略 */ }
try { edge.kill(); } catch { /* 忽略 */ }
if (!KEEP) { await sleep(300); try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ } }
}
const bad = results.filter((r) => !r.ok);
console.log('\n' + '─'.repeat(56));
console.log(bad.length ? `✘ ${results.length - bad.length}/${results.length} 通过,失败 ${bad.length} 项` : `✔ 全部通过(${results.length}/${results.length})`);
process.exit(bad.length ? 1 : 0);