Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务
@@ -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": " "
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 271 KiB |
@@ -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 { /* 忽略 */ }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 { /* 忽略 */ }
|
||||
}
|
||||