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
+289
View File
@@ -0,0 +1,289 @@
{
"at": "2026-09-19T03:14:00.341Z",
"concurrency": 6,
"totalMs": 197633,
"results": [
{
"channel": "bigmodel",
"model": "GLM-4V-Flash",
"cmd": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"E:\\deepseek\\ai-group-chat\\scripts\\api-call.mjs",
"--base",
"https://open.bigmodel.cn/api/paas/v4",
"--model",
"GLM-4V-Flash",
"--key-file",
"C:\\Users\\Administrator\\.zcode\\v2\\provider_config.json",
"--key-path",
"config.providerConfigRules.providerRules.0.config.access.apiKey"
],
"ok": true,
"code": 0,
"ms": 540,
"cost": null,
"sample": "{\"type\":\"result\",\"subtype\":\"success\",\"model\":\"GLM-4V-Flash\",\"result\":\"GLM-4V-Flash 就绪\",\"usage\":{\"completion_to"
},
{
"channel": "bigmodel",
"model": "GLM-4-Flash-250414",
"cmd": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"E:\\deepseek\\ai-group-chat\\scripts\\api-call.mjs",
"--base",
"https://open.bigmodel.cn/api/paas/v4",
"--model",
"GLM-4-Flash-250414",
"--key-file",
"C:\\Users\\Administrator\\.zcode\\v2\\provider_config.json",
"--key-path",
"config.providerConfigRules.providerRules.0.config.access.apiKey"
],
"ok": true,
"code": 0,
"ms": 1012,
"cost": null,
"sample": "{\"type\":\"result\",\"subtype\":\"success\",\"model\":\"GLM-4-Flash-250414\",\"result\":\"GLM-4-Flash-250414 就绪\",\"usage\":{\"c"
},
{
"channel": "qoder",
"model": "Qwen3.8-Flash",
"cmd": "C:\\Users\\Administrator\\.qoderwake-cn\\bin\\qodercli\\qodercli-cn-wake.exe",
"args": [
"--config-dir",
"C:\\Users\\Administrator\\.qoderwake-cn",
"-p",
"-o",
"json",
"-m",
"Qwen3.8-Flash"
],
"ok": true,
"code": 0,
"ms": 4625,
"cost": 0,
"sample": "{\"type\":\"result\",\"subtype\":\"success\",\"duration_ms\":2016,\"duration_api_ms\":1889,\"is_error\":false,\"num_turns\":1,"
},
{
"channel": "codebuddy",
"model": "hy3",
"cmd": "C:\\Users\\Administrator\\.workbuddy\\binaries\\node\\versions\\22.22.2-3\\node.exe",
"args": [
"H:/workbuddy/resources/app.asar.unpacked/cli/bin/codebuddy",
"-p",
"--output-format",
"json",
"--model",
"hy3"
],
"ok": true,
"code": 0,
"ms": 9065,
"cost": 0,
"sample": "[ { \"type\": \"message\", \"role\": \"user\", \"content\": [ { \"type\": \"input_text\", \"text\": \"<system-reminder data-rol"
},
{
"channel": "bigmodel",
"model": "GLM-4.1V-Thinking-Flash",
"cmd": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"E:\\deepseek\\ai-group-chat\\scripts\\api-call.mjs",
"--base",
"https://open.bigmodel.cn/api/paas/v4",
"--model",
"GLM-4.1V-Thinking-Flash",
"--key-file",
"C:\\Users\\Administrator\\.zcode\\v2\\provider_config.json",
"--key-path",
"config.providerConfigRules.providerRules.0.config.access.apiKey"
],
"ok": true,
"code": 0,
"ms": 10623,
"cost": null,
"sample": "{\"type\":\"result\",\"subtype\":\"success\",\"model\":\"GLM-4.1V-Thinking-Flash\",\"result\":\"就绪\",\"usage\":{\"completion_toke"
},
{
"channel": "codebuddy",
"model": "hy4-preview",
"cmd": "C:\\Users\\Administrator\\.workbuddy\\binaries\\node\\versions\\22.22.2-3\\node.exe",
"args": [
"H:/workbuddy/resources/app.asar.unpacked/cli/bin/codebuddy",
"-p",
"--output-format",
"json",
"--model",
"hy4-preview"
],
"ok": true,
"code": 0,
"ms": 10855,
"cost": 0,
"sample": "[ { \"type\": \"message\", \"role\": \"user\", \"content\": [ { \"type\": \"input_text\", \"text\": \"<system-reminder data-rol"
},
{
"channel": "codebuddy",
"model": "hy4-preview-f",
"cmd": "C:\\Users\\Administrator\\.workbuddy\\binaries\\node\\versions\\22.22.2-3\\node.exe",
"args": [
"H:/workbuddy/resources/app.asar.unpacked/cli/bin/codebuddy",
"-p",
"--output-format",
"json",
"--model",
"hy4-preview-f"
],
"ok": true,
"code": 0,
"ms": 17548,
"cost": 0,
"sample": "[ { \"type\": \"message\", \"role\": \"user\", \"content\": [ { \"type\": \"input_text\", \"text\": \"<system-reminder data-rol"
},
{
"channel": "bigmodel",
"model": "codegeex-4",
"cmd": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"E:\\deepseek\\ai-group-chat\\scripts\\api-call.mjs",
"--base",
"https://open.bigmodel.cn/api/paas/v4",
"--model",
"codegeex-4",
"--key-file",
"C:\\Users\\Administrator\\.zcode\\v2\\provider_config.json",
"--key-path",
"config.providerConfigRules.providerRules.0.config.access.apiKey"
],
"ok": false,
"code": 3221226505,
"ms": 414,
"cost": null,
"sample": " api-call: HTTP 429 {\"error\":{\"code\":\"1113\",\"message\":\"余额不足或无可用资源包,请充值。\"}} Assertion failed: !(handle->flags &"
},
{
"channel": "bigmodel",
"model": "GLM-4-FlashX-250414",
"cmd": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"E:\\deepseek\\ai-group-chat\\scripts\\api-call.mjs",
"--base",
"https://open.bigmodel.cn/api/paas/v4",
"--model",
"GLM-4-FlashX-250414",
"--key-file",
"C:\\Users\\Administrator\\.zcode\\v2\\provider_config.json",
"--key-path",
"config.providerConfigRules.providerRules.0.config.access.apiKey"
],
"ok": false,
"code": 3221226505,
"ms": 488,
"cost": null,
"sample": " api-call: HTTP 429 {\"error\":{\"code\":\"1113\",\"message\":\"余额不足或无可用资源包,请充值。\"}} Assertion failed: !(handle->flags &"
},
{
"channel": "opencode",
"model": "nemotron-3-ultra-free",
"cmd": "C:\\Users\\Administrator\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe",
"args": [
"run",
"--format",
"json",
"-m",
"opencode/nemotron-3-ultra-free"
],
"ok": false,
"code": null,
"ms": 180055,
"cost": null,
"sample": " "
},
{
"channel": "opencode",
"model": "big-pickle",
"cmd": "C:\\Users\\Administrator\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe",
"args": [
"run",
"--format",
"json",
"-m",
"opencode/big-pickle"
],
"ok": false,
"code": null,
"ms": 180085,
"cost": null,
"sample": " "
},
{
"channel": "opencode",
"model": "mimo-v2.5-free",
"cmd": "C:\\Users\\Administrator\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe",
"args": [
"run",
"--format",
"json",
"-m",
"opencode/mimo-v2.5-free"
],
"ok": false,
"code": null,
"ms": 180085,
"cost": null,
"sample": " "
},
{
"channel": "qoder",
"model": "Qwen3.8-Max",
"cmd": "C:\\Users\\Administrator\\.qoderwake-cn\\bin\\qodercli\\qodercli-cn-wake.exe",
"args": [
"--config-dir",
"C:\\Users\\Administrator\\.qoderwake-cn",
"-p",
"-o",
"json",
"-m",
"Qwen3.8-Max"
],
"ok": false,
"code": null,
"ms": 180089,
"cost": null,
"sample": " "
},
{
"channel": "opencode",
"model": "nemotron-3.5-lightning-free",
"cmd": "C:\\Users\\Administrator\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe",
"args": [
"run",
"--format",
"json",
"-m",
"opencode/nemotron-3.5-lightning-free"
],
"ok": false,
"code": null,
"ms": 180138,
"cost": null,
"sample": " "
},
{
"channel": "opencode",
"model": "ling-3.0-flash-fin-free",
"cmd": "C:\\Users\\Administrator\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe",
"args": [
"run",
"--format",
"json",
"-m",
"opencode/ling-3.0-flash-fin-free"
],
"ok": false,
"code": null,
"ms": 180141,
"cost": null,
"sample": " "
}
]
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* model-probe.mjs — 把各渠道的候选免费模型全量探一遍,并按并发压测。
*
* 用法:
* node tools/model-probe.mjs # 全量探测(并发 4)
* node tools/model-probe.mjs --concurrency 6 # 改并发
* node tools/model-probe.mjs --only opencode # 只探某个渠道
*
* 输出:表格 + tools/model-probe-result.json(含每个模型的 ok / 耗时 / 错误)
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SCRIPTS = path.resolve(HERE, '..', 'scripts');
const argv = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
const CONC = Number(flag('concurrency', 4));
const ONLY = flag('only', '');
const HOME = os.homedir();
const QODER = path.join(HOME, '.qoderwake-cn', 'bin', 'qodercli', 'qodercli-cn-wake.exe');
const QODER_CFG = path.join(HOME, '.qoderwake-cn');
const CBC_BIN = 'H:/workbuddy/resources/app.asar.unpacked/cli/bin/codebuddy';
const CBC_NODE = (() => {
const base = path.join(HOME, '.workbuddy', 'binaries', 'node', 'versions');
try {
const v = fs.readdirSync(base).filter((x) => /^\d/.test(x)).sort().reverse()[0];
return path.join(base, v, 'node.exe');
} catch { return process.execPath; }
})();
const OC = path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
/** 候选清单:渠道 / 模型 id / 怎么调 */
const CANDIDATES = [
// --- opencode Zen 的免费档 ---
...['big-pickle', 'nemotron-3.5-lightning-free', 'nemotron-3-ultra-free', 'ling-3.0-flash-fin-free', 'mimo-v2.5-free']
.map((m) => ({ channel: 'opencode', model: m, cmd: OC, args: ['run', '--format', 'json', '-m', `opencode/${m}`] })),
// --- Qoder CLI ---
...['Qwen3.8-Flash', 'Qwen3.8-Max']
.map((m) => ({ channel: 'qoder', model: m, cmd: QODER, args: ['--config-dir', QODER_CFG, '-p', '-o', 'json', '-m', m] })),
// --- WorkBuddy 随附的 CodeBuddy CLI ---
...['hy4-preview-f', 'hy3', 'hy4-preview']
.map((m) => ({ channel: 'codebuddy', model: m, cmd: CBC_NODE, args: [CBC_BIN, '-p', '--output-format', 'json', '--model', m] })),
// --- ZCode 的 BigModel API(enabled: true 的那些) ---
...['GLM-4-Flash-250414', 'GLM-4-FlashX-250414', 'GLM-4.1V-Thinking-Flash', 'GLM-4V-Flash', 'codegeex-4']
.map((m) => ({
channel: 'bigmodel', model: m, cmd: process.execPath,
args: [path.join(SCRIPTS, 'api-call.mjs'), '--base', 'https://open.bigmodel.cn/api/paas/v4', '--model', m,
'--key-file', path.join(HOME, '.zcode', 'v2', 'provider_config.json'),
'--key-path', 'config.providerConfigRules.providerRules.0.config.access.apiKey'],
})),
];
const list = ONLY ? CANDIDATES.filter((c) => c.channel === ONLY) : CANDIDATES;
/** 从各家输出里把"这次花了多少钱"抠出来——判"完全免费"只认模型自己报的数,不认印象。 */
function extractCost(channel, text) {
const t = text.replace(/\u001b\[[0-9;]*m/g, '');
if (channel === 'opencode') {
let sum = 0, seen = false;
for (const line of t.split('\n')) {
const s = line.trim();
if (!s.startsWith('{')) continue;
try {
const ev = JSON.parse(s);
if (ev?.type === 'step_finish' && typeof ev.part?.cost === 'number') { sum += ev.part.cost; seen = true; }
} catch { /* 跳过 */ }
}
return seen ? sum : null;
}
// qoder / codebuddy:Claude Code 风格的 result 对象(或数组)
for (const chunk of [t, ...t.split('\n')]) {
const s = chunk.trim();
if (!s.startsWith('{') && !s.startsWith('[')) continue;
try {
const j = JSON.parse(s);
const objs = Array.isArray(j) ? j.reverse() : [j];
for (const o of objs) if (o && typeof o.total_cost_usd === 'number') return o.total_cost_usd;
} catch { /* 继续 */ }
}
return null; // bigmodel 这类只回 usage,没有价钱字段
}
function runOne(c) {
const prompt = `只回复四个字:${c.model} 就绪`;
const t0 = Date.now();
return new Promise((resolve) => {
const child = spawn(c.cmd, [...c.args, prompt], { windowsHide: true, cwd: os.tmpdir(), env: { ...process.env, NO_COLOR: '1' } });
let out = '', err = '';
const timer = setTimeout(() => { try { child.kill(); } catch { /* 忽略 */ } }, 180000);
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('close', (code) => {
clearTimeout(timer);
const ms = Date.now() - t0;
const text = (out + '\n' + err).replace(/\u001b\[[0-9;]*m/g, '');
const ok = code === 0 && !/not available in your country|No auth|not logged in|HTTP 4\d\d|Error:/i.test(text);
const cost = ok ? extractCost(c.channel, out + '\n' + err) : null;
resolve({ ...c, ok, code, ms, cost, sample: text.replace(/\s+/g, ' ').slice(0, 110) });
});
child.on('error', (e) => { clearTimeout(timer); resolve({ ...c, ok: false, code: -1, ms: Date.now() - t0, cost: null, sample: String(e.message) }); });
});
}
async function pool(items, size, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); }
}));
return out;
}
const t0 = Date.now();
const results = await pool(list, CONC, runOne);
const totalMs = Date.now() - t0;
results.sort((a, b) => Number(b.ok) - Number(a.ok) || a.ms - b.ms);
const fmtCost = (c) => (c === null ? '未报' : c === 0 ? '0 ✔' : String(c));
const head = ['渠道', '模型', '结果', '耗时', '这次花费'];
const rows = results.map((r) => [r.channel, r.model, r.ok ? 'OK' : `FAIL(${r.code})`, `${(r.ms / 1000).toFixed(1)}s`, r.ok ? fmtCost(r.cost) : '-']);
const w = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
const line = (c) => c.map((x, i) => String(x).padEnd(w[i])).join(' ');
console.log(`\n并发 ${CONC} · ${list.length} 个候选 · 总耗时 ${(totalMs / 1000).toFixed(1)}s\n`);
console.log(line(head)); console.log(line(w.map((n) => '─'.repeat(n))));
for (const r of rows) console.log(line(r));
const bad = results.filter((r) => !r.ok);
if (bad.length) { console.log('\n失败详情:'); for (const b of bad) console.log(` ${b.channel}/${b.model}:${b.sample}`); }
const okCount = results.filter((r) => r.ok).length;
console.log(`\n可用 ${okCount}/${results.length}`);
fs.writeFileSync(path.join(HERE, 'model-probe-result.json'), JSON.stringify({ at: new Date().toISOString(), concurrency: CONC, totalMs, results }, null, 2), 'utf8');
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* model-shootout.mjs — 给 opencode 的免费模型跑同一套可判分题,挑出免费档里最强的那个。
*
* 用法:
* node model-shootout.mjs # 跑默认题组 × 全部免费模型
* node model-shootout.mjs --models a,b # 只跑指定模型
* node model-shootout.mjs --timeout 240 # 每个模型超时(秒)
*
* 判分是客观字符串/数值匹配,不打印象分。结果同时打印成表并写入 shootout-result.json。
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_MODELS = [
'opencode/big-pickle',
'opencode/muse-spark-1.3-contributor-free',
'opencode/muse-spark-1.2-contributor-free',
'opencode/nemotron-3-ultra-free',
'opencode/nemotron-3.5-lightning-free',
'opencode/ling-3.0-flash-fin-free',
'opencode/mimo-v2.5-free',
];
const argv = process.argv.slice(2);
const getFlag = (name, def) => {
const i = argv.indexOf(`--${name}`);
if (i === -1) return def;
const v = argv[i + 1];
return v === undefined || v.startsWith('--') ? true : v;
};
const models = String(getFlag('models', '')).trim()
? String(getFlag('models')).split(',').map((s) => s.trim()).filter(Boolean)
: DEFAULT_MODELS;
const timeoutSec = Number(getFlag('timeout', 300));
/* --------------------------------------------------------------- 题组 */
// 长文本里埋一个只出现一次的事实,考长上下文里的定位能力。
const HAYSTACK = (() => {
const facts = [
'仓库代号是 KESTREL-7。',
'构建编号是 4412。',
'负责人是林工。',
'发布窗口在周四凌晨。',
'数据库用的是 SQLite。',
];
const filler = [
'这条记录只用于填充上下文,不包含任何需要记忆的信息。',
'例行巡检没有发现异常,指标都在阈值内。',
'文档结构保持不变,仅调整了少量措辞。',
'监控面板显示过去一小时流量平稳。',
'值班同学已交接,交接内容无特殊事项。',
];
const lines = [];
for (let i = 0; i < 60; i++) {
lines.push(filler[i % filler.length]);
if (i === 37) lines.push(`【关键】本段的唯一标识是 ${facts[1]} 备用代号 ${facts[0]}`);
}
return lines.join('');
})();
const TASKS = [
{
id: 'Q1',
q: '球拍和球一共 1.10 元,球拍比球贵 1.00 元。球多少钱?',
// 接受 0.05 / .05 / 五分 / 5分 —— 中文作答也算对(第一版只认 0.05,把「五分」误判成错)
grade: (t) => (/(^|[^\d.])0?\.05([^\d]|$)/.test(t) || /[五5]\s*分/.test(t) ? 1 : 0),
expect: '0.05(或「五分」/「5分」)',
},
{
id: 'Q2',
q: '小于 1000 的正整数里,有多少个满足「除以 7 余 3」?',
grade: (t) => (/(^|[^\d])143([^\d]|$)/.test(t) ? 1 : 0),
expect: '143',
},
{
id: 'Q3',
q: 'Python 表达式 len(list(range(10))[2:5]) 的值是多少?',
grade: (t) => (/(^|[^\d])3([^\d]|$)/.test(t) ? 1 : 0),
expect: '3',
},
{
id: 'Q4',
q: '已知「所有 A 都是 B」且「有些 B 是 C」。能否必然推出「有些 A 是 C」?只回答 能 或 不能。',
grade: (t) => (/不能/.test(t) && !/能[^不]/.test(t.replace(/不能/g, '')) ? 1 : 0),
expect: '不能',
},
{
id: 'Q5',
q: '下面这段文本里出现过一次「构建编号」,它的数字是多少?\n\n' + HAYSTACK,
grade: (t) => (/(^|[^\d])4412([^\d]|$)/.test(t) ? 1 : 0),
expect: '4412',
},
];
const PROMPT = [
'按顺序回答下面的问题。严格要求输出格式:每题一行,形如 `Q1) 答案`,',
'答案要极短(数字或两个字),不要解释、不要复述题目、不要用 markdown 代码块。',
'',
...TASKS.map((t) => `${t.id}. ${t.q}`),
].join('\n');
/* --------------------------------------------------------------- 运行 */
function opencodeExe() {
const cands = [
path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
path.join(os.homedir(), '.opencode', 'bin', 'opencode'),
];
for (const c of cands) if (c && fs.existsSync(c)) return c;
return 'opencode';
}
function extract(text, id) {
const re = new RegExp(`${id}\\s*[).::]\\s*([^\\n]+)`, 'i');
const m = text.match(re);
return m ? m[1].trim() : text;
}
function runModel(model) {
const started = Date.now();
const r = spawnSync(opencodeExe(), ['run', '-m', model, PROMPT], {
encoding: 'utf8',
windowsHide: true,
timeout: timeoutSec * 1000,
maxBuffer: 16 * 1024 * 1024,
cwd: os.tmpdir(),
env: { ...process.env, NO_COLOR: '1', AIGROUP_NO_WAKE: '1' },
});
const ms = Date.now() - started;
const stdout = (r.stdout || '').replace(/\u001b\[[0-9;]*m/g, '').trim();
const stderr = (r.stderr || '').trim();
if (r.error || r.status !== 0) {
return { model, ok: false, ms, error: r.error?.message || `exit ${r.status}`, stderr: stderr.slice(-400), raw: stdout.slice(0, 400) };
}
const per = {};
let score = 0;
for (const t of TASKS) {
const ans = extract(stdout, t.id);
const g = t.grade(ans) || t.grade(stdout);
per[t.id] = { answer: ans.slice(0, 60), expect: t.expect, pass: Boolean(g) };
if (g) score++;
}
return { model, ok: true, ms, score, total: TASKS.length, per, raw: stdout.slice(0, 1200) };
}
/* --------------------------------------------------------------- main */
const results = [];
for (const model of models) {
process.stderr.write(`… 跑 ${model}\n`);
const res = runModel(model);
results.push(res);
process.stderr.write(` → ${res.ok ? `${res.score}/${res.total} (${(res.ms / 1000).toFixed(1)}s)` : `失败 ${res.error}`}\n`);
}
results.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (a.ms ?? 1e9) - (b.ms ?? 1e9));
const report = {
at: new Date().toISOString(),
tasks: TASKS.map((t) => ({ id: t.id, expect: t.expect, q: t.q.slice(0, 80) })),
results,
};
fs.writeFileSync(path.join(HERE, 'shootout-result.json'), JSON.stringify(report, null, 2), 'utf8');
const head = ['排名', '模型', '得分', '耗时', ...TASKS.map((t) => t.id)];
const rows = results.map((r, i) => [
String(i + 1),
r.model.replace('opencode/', ''),
r.ok ? `${r.score}/${r.total}` : 'ERR',
r.ok ? `${(r.ms / 1000).toFixed(1)}s` : '-',
...TASKS.map((t) => (r.ok ? (r.per[t.id].pass ? '✔' : `✘(${r.per[t.id].answer})`) : '-')),
]);
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length)));
const line = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' ');
process.stdout.write(`\n${line(head)}\n${line(widths.map((w) => '─'.repeat(w)))}\n`);
for (const r of rows) process.stdout.write(`${line(r)}\n`);
const winner = results.find((r) => r.ok);
process.stdout.write(`\n最强免费档(本次同题实测):${winner ? winner.model : '(全部失败)'}\n`);
process.stdout.write(`结果已写入 ${path.join(HERE, 'shootout-result.json')}\n`);
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
/**
* model-shootout2.mjs — 决赛轮:给 opencode 免费档跑「难到能拉开差距」的题。
*
* 与第一轮的区别:
* · 题目更难,且每题都是**客观可判分**(数值 / 枚举 / 可运行的代码)
* · 代码题会把模型输出的函数真的跑一遍断言,不是看它说得像不像
* · 每题独立打分并记录耗时,最后按总分排序、同分比速度
*
* 用法:node model-shootout2.mjs [--models a,b] [--timeout 300]
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
const FINALISTS = [
'opencode/ling-3.0-flash-fin-free',
'opencode/mimo-v2.5-free',
'opencode/nemotron-3-ultra-free',
'opencode/nemotron-3.5-lightning-free',
'opencode/big-pickle',
];
const models = String(flag('models', '')).trim() ? String(flag('models')).split(',').map((s) => s.trim()) : FINALISTS;
const timeoutSec = Number(flag('timeout', 300));
/* ------------------------------------------------- 长文本(多事实检索) */
const HAY = (() => {
const filler = [
'巡检记录:无异常,指标在阈值内。',
'值班交接:无特殊事项。',
'文档结构未变,仅调整措辞。',
'监控显示流量平稳。',
];
const lines = [];
for (let i = 0; i < 80; i++) {
lines.push(filler[i % filler.length]);
if (i === 21) lines.push('【甲】缓存层用的是 Redis 7。');
if (i === 44) lines.push('【乙】灰度比例是 15%。');
if (i === 67) lines.push('【丙】回滚预案的编号是 RB-3390。');
}
return lines.join('');
})();
/* ------------------------------------------------- 可运行代码题的骨架 */
const BUGGY = `function lastN(arr, n) {
const out = [];
for (let i = arr.length; i > arr.length - n; i--) {
out.push(arr[i]);
}
return out;
}`;
const CODE_TEST = `
import assert from 'node:assert';
${'__FN__'}
assert.deepStrictEqual(lastN([1,2,3,4,5], 2), [4,5], 'lastN([1..5],2) 应为 [4,5]');
assert.deepStrictEqual(lastN([1], 3), [1], 'n 超过长度时应返回全部');
assert.deepStrictEqual(lastN([], 2), [], '空数组应为空');
assert.deepStrictEqual(lastN([7,8], 0), [], 'n=0 应为空');
console.log('OK');
`;
/* --------------------------------------------------------------- 题组 */
const TASKS = [
{
id: 'H1',
q: '从 1 到 100 的所有整数(含两端)里,数字字符 "1" 一共出现了多少次?只回答一个整数。',
expect: '21',
grade: (t) => (/(^|[^\d])21([^\d]|$)/.test(t) ? 1 : 0),
},
{
id: 'H2',
q: [
'三个人 A、B、C,每人说一句话:',
'A 说:「B 在说谎。」',
'B 说:「C 在说谎。」',
'C 说:「A 和 B 都在说谎。」',
'问:谁说真话?只回答一个字母。',
].join('\n'),
expect: 'B',
grade: (t) => {
const m = t.match(/(?:^|[^A-Za-z])([ABC])(?:[^A-Za-z]|$)/);
return m && m[1] === 'B' ? 1 : 0;
},
},
{
id: 'H3',
q: [
'在 5×5 棋盘上放 5 个皇后,互不攻击。已知第 1、2、3 行的皇后分别在第 1、3、5 列,',
'求第 4、5 行皇后所在的列。只按 `4=<列> 5=<列>` 输出两个数字。',
].join('\n'),
expect: '4=2 5=4',
grade: (t) => (/4\s*[=::]\s*2/.test(t) && /5\s*[=::]\s*4/.test(t) ? 1 : 0),
},
{
id: 'H4',
q: `下面这段文本里记录了三件事:缓存层用什么、灰度比例多少、回滚预案编号。请全部找出来,按 \`缓存=<x> 灰度=<y> 回滚=<z>\` 输出。\n\n${HAY}`,
expect: '缓存=Redis 7 灰度=15% 回滚=RB-3390',
grade: (t) => (/redis\s*7/i.test(t) ? 1 : 0) + (/15\s*%/.test(t) ? 1 : 0) + (/RB-?3390/i.test(t) ? 1 : 0),
max: 3,
},
{
id: 'H5',
q: [
'下面这个函数有 bug(它要返回数组最后 n 个元素):',
'```js',
BUGGY,
'```',
'只输出**修正后的完整函数**(保留函数名 lastN),不要解释、不要 markdown 代码块外的文字。',
].join('\n'),
expect: '可运行的 lastN(会真的跑断言)',
kind: 'code',
},
];
const PROMPT = [
'按顺序回答下面 5 题。',
'严格输出格式:每题以 `H1)`、`H2)` … 开头占一行,答案尽量短。',
'第 H5 题例外:直接输出修正后的完整 JS 函数本身(不要包在别的文字里)。',
'',
...TASKS.slice(0, 4).map((t) => `${t.id}. ${t.q}`),
'',
`H5. ${TASKS[4].q}`,
].join('\n');
/* --------------------------------------------------------------- 判分 */
function extract(text, id) {
const re = new RegExp(`${id}\\s*[).::]\\s*([\\s\\S]*?)(?=\\n\\s*H[1-5]\\s*[).::]|$)`, 'i');
const m = text.match(re);
return m ? m[1].trim() : '';
}
function gradeCode(fullText) {
const m = fullText.match(/function\s+lastN\s*\([\s\S]*?\n\}/);
if (!m) return { pass: 0, max: 1, note: '没找到函数定义' };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-code-'));
const file = path.join(dir, 't.mjs');
const body = m[0].replace(/^function lastN/, 'function lastN');
fs.writeFileSync(file, CODE_TEST.replace('__FN__', body), 'utf8');
const r = spawnSync(process.execPath, [file], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
fs.rmSync(dir, { recursive: true, force: true });
if (r.status === 0) return { pass: 1, max: 1, note: '断言全过' };
const err = (r.stderr || r.stdout || '').split('\n').find((l) => /AssertionError|Error/.test(l)) || '运行失败';
return { pass: 0, max: 1, note: err.slice(0, 90) };
}
function runModel(model) {
const started = Date.now();
const r = spawnSync(
path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
['run', '-m', model, PROMPT, '--dir', os.tmpdir()],
{ encoding: 'utf8', windowsHide: true, timeout: timeoutSec * 1000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } },
);
const ms = Date.now() - started;
const text = (r.stdout || '').replace(/\u001b\[[0-9;]*m/g, '').trim();
if (r.error || r.status !== 0) return { model, ok: false, ms, error: r.error?.message || `exit ${r.status}`, stderr: (r.stderr || '').slice(-300) };
const per = {};
let score = 0, max = 0;
for (const t of TASKS) {
if (t.kind === 'code') {
const g = gradeCode(text);
per[t.id] = { pass: g.pass, max: g.max, answer: g.note, expect: t.expect };
score += g.pass; max += g.max;
} else {
const ans = extract(text, t.id) || text;
const got = t.grade(ans);
const m2 = t.max || 1;
per[t.id] = { pass: got, max: m2, answer: ans.replace(/\s+/g, ' ').slice(0, 70), expect: t.expect };
score += got; max += m2;
}
}
return { model, ok: true, ms, score, max, per, raw: text.slice(0, 2000) };
}
/* --------------------------------------------------------------- main */
const results = [];
for (const model of models) {
process.stderr.write(`… 决赛轮 ${model}\n`);
const res = runModel(model);
results.push(res);
process.stderr.write(` → ${res.ok ? `${res.score}/${res.max} (${(res.ms / 1000).toFixed(1)}s)` : `失败 ${res.error}`}\n`);
}
results.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (a.ms ?? 1e9) - (b.ms ?? 1e9));
fs.writeFileSync(path.join(HERE, 'shootout2-result.json'), JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
const ids = TASKS.map((t) => t.id);
const head = ['排名', '模型', '总分', '耗时', ...ids];
const rows = results.map((r, i) => [
String(i + 1),
r.model.replace('opencode/', ''),
r.ok ? `${r.score}/${r.max}` : 'ERR',
r.ok ? `${(r.ms / 1000).toFixed(1)}s` : '-',
...ids.map((id) => (r.ok ? (r.per[id].pass === r.per[id].max ? '✔' : `${r.per[id].pass}/${r.per[id].max}`) : '-')),
]);
const w = head.map((h, i) => Math.max(h.length, ...rows.map((r2) => String(r2[i] ?? '').length)));
const line = (c) => c.map((x, i) => String(x ?? '').padEnd(w[i])).join(' ');
process.stdout.write(`\n${line(head)}\n${line(w.map((n) => '─'.repeat(n)))}\n`);
for (const r2 of rows) process.stdout.write(`${line(r2)}\n`);
for (const r2 of results.filter((x) => x.ok)) {
process.stdout.write(`\n${r2.model}\n`);
for (const id of ids) process.stdout.write(` ${id}: ${r2.per[id].pass}/${r2.per[id].max} 「${r2.per[id].answer}」\n`);
}
const winner = results.find((r) => r.ok);
process.stdout.write(`\n决赛轮最强免费档:${winner ? winner.model : '(全部失败)'}\n`);
+207
View File
@@ -0,0 +1,207 @@
{
"at": "2026-09-18T15:28:16.430Z",
"tasks": [
{
"id": "Q1",
"expect": "0.05(或「五分」)",
"q": "球拍和球一共 1.10 元,球拍比球贵 1.00 元。球多少钱?"
},
{
"id": "Q2",
"expect": "143",
"q": "小于 1000 的正整数里,有多少个满足「除以 7 余 3」?"
},
{
"id": "Q3",
"expect": "3",
"q": "Python 表达式 len(list(range(10))[2:5]) 的值是多少?"
},
{
"id": "Q4",
"expect": "不能",
"q": "已知「所有 A 都是 B」且「有些 B 是 C」。能否必然推出「有些 A 是 C」?只回答 能 或 不能。"
},
{
"id": "Q5",
"expect": "4412",
"q": "下面这段文本里出现过一次「构建编号」,它的数字是多少?\n\n这条记录只用于填充上下文,不包含任何需要记忆的信息。例行巡检没有发现异常,指标都在阈值内。文档结构保持"
}
],
"results": [
{
"model": "opencode/big-pickle",
"ok": true,
"ms": 6270,
"score": 5,
"total": 5,
"per": {
"Q1": {
"answer": "0.05",
"expect": "0.05(或「五分」)",
"pass": true
},
"Q2": {
"answer": "143",
"expect": "143",
"pass": true
},
"Q3": {
"answer": "3",
"expect": "3",
"pass": true
},
"Q4": {
"answer": "不能",
"expect": "不能",
"pass": true
},
"Q5": {
"answer": "4412",
"expect": "4412",
"pass": true
}
},
"raw": "Q1) 0.05\nQ2) 143\nQ3) 3\nQ4) 不能\nQ5) 4412"
},
{
"model": "opencode/ling-3.0-flash-fin-free",
"ok": true,
"ms": 6635,
"score": 5,
"total": 5,
"per": {
"Q1": {
"answer": "0.05",
"expect": "0.05(或「五分」)",
"pass": true
},
"Q2": {
"answer": "143",
"expect": "143",
"pass": true
},
"Q3": {
"answer": "3",
"expect": "3",
"pass": true
},
"Q4": {
"answer": "不能",
"expect": "不能",
"pass": true
},
"Q5": {
"answer": "4412",
"expect": "4412",
"pass": true
}
},
"raw": "Q1) 0.05\nQ2) 143\nQ3) 3\nQ4) 不能\nQ5) 4412"
},
{
"model": "opencode/nemotron-3.5-lightning-free",
"ok": true,
"ms": 16481,
"score": 5,
"total": 5,
"per": {
"Q1": {
"answer": "0.05",
"expect": "0.05(或「五分」)",
"pass": true
},
"Q2": {
"answer": "143",
"expect": "143",
"pass": true
},
"Q3": {
"answer": "3",
"expect": "3",
"pass": true
},
"Q4": {
"answer": "不能",
"expect": "不能",
"pass": true
},
"Q5": {
"answer": "4412",
"expect": "4412",
"pass": true
}
},
"raw": "Q1) 0.05\nQ2) 143\nQ3) 3\nQ4) 不能\nQ5) 4412"
},
{
"model": "opencode/nemotron-3-ultra-free",
"ok": true,
"ms": 57671,
"score": 5,
"total": 5,
"per": {
"Q1": {
"answer": "0.05",
"expect": "0.05(或「五分」)",
"pass": true
},
"Q2": {
"answer": "143",
"expect": "143",
"pass": true
},
"Q3": {
"answer": "3",
"expect": "3",
"pass": true
},
"Q4": {
"answer": "不能",
"expect": "不能",
"pass": true
},
"Q5": {
"answer": "4412",
"expect": "4412",
"pass": true
}
},
"raw": "Q1) 0.05\nQ2) 143\nQ3) 3\nQ4) 不能\nQ5) 4412"
},
{
"model": "opencode/mimo-v2.5-free",
"ok": true,
"ms": 19624,
"score": 4,
"total": 5,
"per": {
"Q1": {
"answer": "5分",
"expect": "0.05(或「五分」)",
"pass": false
},
"Q2": {
"answer": "143个",
"expect": "143",
"pass": true
},
"Q3": {
"answer": "3",
"expect": "3",
"pass": true
},
"Q4": {
"answer": "不能",
"expect": "不能",
"pass": true
},
"Q5": {
"answer": "4412",
"expect": "4412",
"pass": true
}
},
"raw": "Q1) 5分\nQ2) 143个\nQ3) 3\nQ4) 不能\nQ5) 4412"
}
]
}
+205
View File
@@ -0,0 +1,205 @@
{
"at": "2026-09-18T15:23:15.243Z",
"results": [
{
"model": "opencode/big-pickle",
"ok": true,
"ms": 16526,
"score": 6,
"max": 7,
"per": {
"H1": {
"pass": 1,
"max": 1,
"answer": "21",
"expect": "21"
},
"H2": {
"pass": 1,
"max": 1,
"answer": "B",
"expect": "B"
},
"H3": {
"pass": 1,
"max": 1,
"answer": "4=2 5=4",
"expect": "4=2 5=4"
},
"H4": {
"pass": 3,
"max": 3,
"answer": "缓存=Redis 7 灰度=15% 回滚=RB-3390",
"expect": "缓存=Redis 7 灰度=15% 回滚=RB-3390"
},
"H5": {
"pass": 0,
"max": 1,
"answer": "AssertionError [ERR_ASSERTION]: n 超过长度时应返回全部\r",
"expect": "可运行的 lastN(会真的跑断言)"
}
},
"raw": "H1) 21\nH2) B\nH3) 4=2 5=4\nH4) 缓存=Redis 7 灰度=15% 回滚=RB-3390\nH5) \n```js\nfunction lastN(arr, n) {\n const out = [];\n for (let i = arr.length - n; i < arr.length; i++) {\n out.push(arr[i]);\n }\n return out;\n}\n```"
},
{
"model": "opencode/nemotron-3-ultra-free",
"ok": true,
"ms": 94384,
"score": 6,
"max": 7,
"per": {
"H1": {
"pass": 1,
"max": 1,
"answer": "21",
"expect": "21"
},
"H2": {
"pass": 1,
"max": 1,
"answer": "B",
"expect": "B"
},
"H3": {
"pass": 1,
"max": 1,
"answer": "4=2 5=4",
"expect": "4=2 5=4"
},
"H4": {
"pass": 3,
"max": 3,
"answer": "缓存=Redis 7 灰度=15% 回滚=RB-3390",
"expect": "缓存=Redis 7 灰度=15% 回滚=RB-3390"
},
"H5": {
"pass": 0,
"max": 1,
"answer": "AssertionError [ERR_ASSERTION]: n 超过长度时应返回全部\r",
"expect": "可运行的 lastN(会真的跑断言)"
}
},
"raw": "H1) 21\nH2) B\nH3) 4=2 5=4\nH4) 缓存=Redis 7 灰度=15% 回滚=RB-3390\nH5) \nfunction lastN(arr, n) {\n const out = [];\n for (let i = arr.length - n; i < arr.length; i++) {\n out.push(arr[i]);\n }\n return out;\n}"
},
{
"model": "opencode/nemotron-3.5-lightning-free",
"ok": true,
"ms": 103197,
"score": 6,
"max": 7,
"per": {
"H1": {
"pass": 1,
"max": 1,
"answer": "21",
"expect": "21"
},
"H2": {
"pass": 1,
"max": 1,
"answer": "B",
"expect": "B"
},
"H3": {
"pass": 1,
"max": 1,
"answer": "4=2 5=4",
"expect": "4=2 5=4"
},
"H4": {
"pass": 3,
"max": 3,
"answer": "缓存=Redis 7 灰度=15% 回滚=RB-3390",
"expect": "缓存=Redis 7 灰度=15% 回滚=RB-3390"
},
"H5": {
"pass": 0,
"max": 1,
"answer": "没找到函数定义",
"expect": "可运行的 lastN(会真的跑断言)"
}
},
"raw": "H1) 21\nH2) B\nH3) 4=2 5=4\nH4) 缓存=Redis 7 灰度=15% 回滚=RB-3390\nH5) function lastN(arr, n) { const out = []; for (let i = arr.length - n; i < arr.length; i++) { out.push(arr[i]); } return out; }"
},
{
"model": "opencode/ling-3.0-flash-fin-free",
"ok": true,
"ms": 19138,
"score": 5,
"max": 7,
"per": {
"H1": {
"pass": 0,
"max": 1,
"answer": "20",
"expect": "21"
},
"H2": {
"pass": 1,
"max": 1,
"answer": "B",
"expect": "B"
},
"H3": {
"pass": 1,
"max": 1,
"answer": "4=2 5=4",
"expect": "4=2 5=4"
},
"H4": {
"pass": 3,
"max": 3,
"answer": "缓存=Redis 7 灰度=15% 回滚=RB-3390",
"expect": "缓存=Redis 7 灰度=15% 回滚=RB-3390"
},
"H5": {
"pass": 0,
"max": 1,
"answer": "AssertionError [ERR_ASSERTION]: lastN([1..5],2) 应为 [4,5]\r",
"expect": "可运行的 lastN(会真的跑断言)"
}
},
"raw": "H1) 20\nH2) B\nH3) 4=2 5=4\nH4) 缓存=Redis 7 灰度=15% 回滚=RB-3390\nH5)\n```js\nfunction lastN(arr, n) {\n const out = [];\n for (let i = arr.length - 1; i >= arr.length - n; i--) {\n out.push(arr[i]);\n }\n return out;\n}\n```"
},
{
"model": "opencode/mimo-v2.5-free",
"ok": true,
"ms": 26756,
"score": 5,
"max": 7,
"per": {
"H1": {
"pass": 0,
"max": 1,
"answer": "20",
"expect": "21"
},
"H2": {
"pass": 1,
"max": 1,
"answer": "B",
"expect": "B"
},
"H3": {
"pass": 1,
"max": 1,
"answer": "4=2 5=4",
"expect": "4=2 5=4"
},
"H4": {
"pass": 3,
"max": 3,
"answer": "缓存=Redis 7 灰度=15% 回滚=RB-3390",
"expect": "缓存=Redis 7 灰度=15% 回滚=RB-3390"
},
"H5": {
"pass": 0,
"max": 1,
"answer": "AssertionError [ERR_ASSERTION]: lastN([1..5],2) 应为 [4,5]\r",
"expect": "可运行的 lastN(会真的跑断言)"
}
},
"raw": "H1) 20\n\nH2) B\n\nH3) 4=2 5=4\n\nH4) 缓存=Redis 7 灰度=15% 回滚=RB-3390\n\nH5) function lastN(arr, n) {\n const out = [];\n for (let i = arr.length - 1; i >= arr.length - n; i--) {\n out.push(arr[i]);\n }\n return out;\n}"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* ui-audit.mjs — 可读性审计:把页面上每段文字的实际对比度算出来(WCAG)。
*
* 为什么要有这个:暗色界面"看不清"是能测量的,不该靠感觉争论。
* 它会把每个可见文本元素的 foreground / 有效背景做真实合成,算出对比度,
* 按最差排序,并指出字号、选择器、文本样本。
*
* 用法:node tools/ui-audit.mjs [url] [--theme dark|light] [--min 4.5] [--json]
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const argv = process.argv.slice(2);
const URL_ = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'http://127.0.0.1:3099/';
const flag = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
const THEME = String(flag('theme', 'dark'));
const MIN = Number(flag('min', 4.5));
const AS_JSON = argv.includes('--json');
const PORT = 9335;
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 AUIDT_JS = String.raw`(() => {
const lum = (rgb) => {
const c = rgb.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
};
const parse = (s) => {
const m = String(s).match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
return { rgb: [p[0], p[1], p[2]], a: p.length > 3 ? p[3] : 1 };
};
const over = (fg, bg) => fg.rgb.map((v, i) => v * fg.a + bg[i] * (1 - fg.a));
const effBg = (el) => {
const chain = [];
let n = el;
while (n && n.nodeType === 1) {
const c = parse(getComputedStyle(n).backgroundColor);
if (c && c.a > 0) chain.push(c);
if (c && c.a >= 0.999) break;
n = n.parentElement;
}
let base = [255, 255, 255];
for (let i = chain.length - 1; i >= 0; i--) base = over(chain[i], base);
return base;
};
const ratio = (a, b) => { const l1 = lum(a), l2 = lum(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); };
const out = [];
for (const el of document.querySelectorAll('body *')) {
// 只看"自己直接带文字"的元素
let text = '';
for (const n of el.childNodes) if (n.nodeType === 3) text += n.textContent;
text = text.trim();
if (!text) continue;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
if (r.width === 0 || r.height === 0 || cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.4) continue;
const fg = parse(cs.color);
if (!fg) continue;
const bg = effBg(el);
const f = over(fg, bg);
const size = parseFloat(cs.fontSize);
const weight = Number(cs.fontWeight) || 400;
const large = size >= 24 || (size >= 18.66 && weight >= 700);
const cr = ratio(f, bg);
out.push({
sel: el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''),
text: text.replace(/\s+/g, ' ').slice(0, 34),
size, weight, large,
cr: Math.round(cr * 100) / 100,
need: large ? 3 : 4.5,
ok: cr >= (large ? 3 : 4.5),
});
}
out.sort((a, b) => a.cr - b.cr);
return JSON.stringify(out);
})()`;
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-audit-'));
const edge = spawn(EDGE, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--disable-extensions', '--hide-scrollbars', '--window-size=1480,940',
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore', windowsHide: true });
async function wsUrl() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const p = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (p) return p.webSocketDebuggerUrl;
} catch { /* 等 */ }
await sleep(250);
}
throw new Error('调试端口没起来');
}
let ws;
try {
ws = new WebSocket(await wsUrl());
await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('CDP 失败')), { once: true }); });
let id = 0; const pend = new Map();
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pend.has(m.id)) { const { resolve, reject } = pend.get(m.id); pend.delete(m.id); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
});
const send = (method, params = {}) => new Promise((resolve, reject) => {
const i = ++id; pend.set(i, { resolve, reject });
ws.send(JSON.stringify({ id: i, method, params }));
setTimeout(() => { if (pend.has(i)) { pend.delete(i); reject(new Error(method + ' 超时')); } }, 30000);
});
const evaluate = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result?.value;
await send('Runtime.enable');
await send('Page.enable');
await send('Page.navigate', { url: URL_ });
for (let i = 0; i < 60; i++) { await sleep(250); if ((await evaluate('document.querySelectorAll(".mcard").length')) > 0) break; }
await evaluate(`try { localStorage.setItem('aig.theme', ${JSON.stringify(THEME)}); } catch(e) {}; document.documentElement.dataset.theme = ${JSON.stringify(THEME)}; true`);
await sleep(1500);
const rows = JSON.parse(await evaluate(AUIDT_JS));
const bad = rows.filter((r) => !r.ok);
const tiny = rows.filter((r) => r.size < 12);
if (AS_JSON) {
console.log(JSON.stringify({ theme: THEME, total: rows.length, failing: bad.length, tiny: tiny.length, worst: rows.slice(0, 25) }, null, 2));
} else {
console.log(`\n主题 ${THEME} · 参与统计的文本元素 ${rows.length} 个 · 不达标(<${MIN}:1 或大字号 <3:1)${bad.length} 个 · 字号 <12px 的 ${tiny.length} 个\n`);
const head = ['对比度', '要求', '字号', '选择器', '文本'];
const body = rows.slice(0, 22).map((r) => [`${r.cr}:1`, `${r.need}:1`, `${r.size}px`, r.sel, r.text]);
const w = head.map((h, i) => Math.max(...[h, ...body.map((b) => b[i])].map((x) => String(x).length)));
const line = (c) => c.map((x, i) => String(x).padEnd(w[i])).join(' ');
console.log(line(head)); console.log(line(w.map((n) => '─'.repeat(n))));
for (const b of body) console.log(line(b));
const passing = rows.filter((r) => r.ok);
console.log(`\n最低对比度 ${rows[0]?.cr}:1(${rows[0]?.sel})· 达标元素最低 ${passing.length ? Math.min(...passing.map((r) => r.cr)) : '—'}:1`);
}
process.exitCode = bad.length ? 1 : 0;
} catch (e) {
console.error('审计失败:' + e.message);
process.exitCode = 2;
} finally {
try { ws?.close(); } catch { /* 忽略 */ }
try { edge.kill(); } catch { /* 忽略 */ }
await sleep(300);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
}
+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);
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* ui-shot.mjs — 给群聊台拍一张截图,用来肉眼审图。
*
* 用法:node tools/ui-shot.mjs [url] [--out 文件.png] [--w 1440] [--h 900] [--wait 2500] [--dark|--light]
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const argv = process.argv.slice(2);
const URL_ = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'http://127.0.0.1:3099/';
const flag = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
const OUT = path.resolve(String(flag('out', path.join(process.cwd(), 'ui-shot.png'))));
const W = Number(flag('w', 1480));
const H = Number(flag('h', 940));
const WAIT = Number(flag('wait', 2600));
const THEME = argv.includes('--light') ? 'light' : 'dark';
const PORT = 9333;
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 profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-shot-'));
const edge = spawn(EDGE, [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions',
'--hide-scrollbars', `--window-size=${W},${H}`, `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank',
], { stdio: 'ignore', windowsHide: true });
async function wsUrl() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const p = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (p) return p.webSocketDebuggerUrl;
} catch { /* 等 */ }
await sleep(250);
}
throw new Error('调试端口没起来');
}
let ws;
try {
ws = new WebSocket(await wsUrl());
await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('CDP 失败')), { once: true }); });
let id = 0; const pend = new Map();
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pend.has(m.id)) { const { resolve, reject } = pend.get(m.id); pend.delete(m.id); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
});
const send = (method, params = {}) => new Promise((resolve, reject) => {
const i = ++id; pend.set(i, { resolve, reject });
ws.send(JSON.stringify({ id: i, method, params }));
setTimeout(() => { if (pend.has(i)) { pend.delete(i); reject(new Error(method + ' 超时')); } }, 30000);
});
const evaluate = (expr) => send('Runtime.evaluate', { expression: expr, returnByValue: true });
await send('Runtime.enable');
await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 2, mobile: false });
await send('Page.navigate', { url: URL_ });
for (let i = 0; i < 60; i++) { await sleep(250); const n = (await evaluate('document.querySelectorAll(".mcard").length')).result?.value || 0; if (n > 0) break; }
await evaluate(`try { localStorage.setItem('aig.theme', ${JSON.stringify(THEME)}); } catch(e) {}; document.documentElement.dataset.theme = ${JSON.stringify(THEME)}; true`);
await sleep(WAIT);
const shot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64'));
const stat = await evaluate('JSON.stringify({members: document.querySelectorAll(".mcard").length, msgs: document.querySelectorAll(".row:not(.typing)").length, typing: document.querySelectorAll(".row.typing").length, strip: document.getElementById("strip").textContent.trim()})');
console.log('已保存 ' + OUT + ' ' + Math.round(fs.statSync(OUT).size / 1024) + 'KB');
console.log('页面状态:' + stat.result.value);
} catch (e) {
console.error('截图失败:' + e.message);
process.exitCode = 1;
} finally {
try { ws?.close(); } catch { /* 忽略 */ }
try { edge.kill(); } catch { /* 忽略 */ }
await sleep(300);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
}