Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* aig-web.mjs — AI 群聊台:一个本地 Web 控制台,用来给群里发号施令。
|
||||
*
|
||||
* 设计:
|
||||
* · 服务端只做两件事——读总线(直接读 JSONL)+ 调 `aig.mjs` 执行动作(send / ask / wake)。
|
||||
* 所有业务规则(锁、游标、唤醒通道、深度护栏、模型退档)都留在 aig.mjs 一处,控制台不重复实现。
|
||||
* · 前端是内嵌的单页(无构建、无依赖):左边群消息,右边成员栏,底部输入框。
|
||||
* · 「主代理」默认是 dsh:你在输入框里直接说话就是发给它,它有权把活分给 opencode / workbuddy。
|
||||
* · SSE 长连:新消息、任务进度、任务结束都实时推。
|
||||
*
|
||||
* 用法:
|
||||
* node aig-web.mjs [--port 3099] [--host 127.0.0.1] [--room main] [--workspace E:\deepseek]
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AIG = path.join(HERE, 'aig.mjs');
|
||||
const UI_FILE = path.join(HERE, 'web', 'ui.html');
|
||||
const HOME_ROOT = process.env.AIGROUP_HOME || path.join(os.homedir(), '.ai-groups');
|
||||
|
||||
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 PORT = Number(flag('port', process.env.AIG_WEB_PORT || 3099));
|
||||
const HOST = String(flag('host', '127.0.0.1'));
|
||||
const WORKSPACE = path.resolve(String(flag('workspace', process.cwd())));
|
||||
|
||||
function currentRoomFromDisk() {
|
||||
try { return fs.readFileSync(path.join(HOME_ROOT, 'current-room'), 'utf8').trim(); } catch { return ''; }
|
||||
}
|
||||
let ROOM = String(flag('room', '')) || currentRoomFromDisk() || 'main';
|
||||
|
||||
/* ------------------------------------------------------------ 读总线 */
|
||||
|
||||
const roomsRoot = () => path.join(HOME_ROOT, 'rooms');
|
||||
const roomDir = (room) => path.join(roomsRoot(), room);
|
||||
const readJson = (p, d) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } };
|
||||
|
||||
function readMessages(room, since = 0) {
|
||||
let raw = '';
|
||||
try { raw = fs.readFileSync(path.join(roomDir(room), 'messages.jsonl'), 'utf8'); } catch { return []; }
|
||||
const out = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try { const m = JSON.parse(t); if ((m.seq || 0) > since) out.push(m); } catch { /* 坏行跳过 */ }
|
||||
}
|
||||
return out.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||||
}
|
||||
|
||||
function readMembers(room) { return readJson(path.join(roomDir(room), 'members.json'), {}); }
|
||||
function readPresence(room, m) { return readJson(path.join(roomDir(room), 'presence', m + '.json'), null); }
|
||||
function readCursor(room, m) { return readJson(path.join(roomDir(room), 'cursors', m + '.json'), { seq: 0 }); }
|
||||
function listRooms() {
|
||||
try {
|
||||
return fs.readdirSync(roomsRoot(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function primaryOf(room) {
|
||||
const members = readMembers(room);
|
||||
for (const [name, rec] of Object.entries(members)) if (rec.role === 'primary') return name;
|
||||
return 'dsh';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调 aig */
|
||||
|
||||
function runAig(args, { timeoutMs = 30 * 60 * 1000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [AIG, ...args], {
|
||||
cwd: WORKSPACE, windowsHide: true,
|
||||
env: { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' },
|
||||
});
|
||||
let out = '', err = '';
|
||||
const timer = setTimeout(() => { try { child.kill(); } catch {} }, timeoutMs);
|
||||
child.stdout.on('data', (d) => { out += d.toString('utf8'); });
|
||||
child.stderr.on('data', (d) => { err += d.toString('utf8'); });
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: out.trim(), err: err.trim() }); });
|
||||
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e.message) }); });
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 任务 */
|
||||
|
||||
const jobs = new Map();
|
||||
const runningByMember = new Map();
|
||||
let jobSeq = 0;
|
||||
const clients = new Set();
|
||||
|
||||
/* 自驱模式:默认开。用户发的消息 → 全体参与;agent 之间也可以互相接话。
|
||||
没有人工放行,所以必须自带刹车:轮次预算(人一说话就重置)+ 每成员冷却 + 正在跑就跳过。 */
|
||||
const CONFIG_FILE = path.join(HOME_ROOT, 'ui-config.json');
|
||||
let uiConfig = readJson(CONFIG_FILE, {});
|
||||
if (typeof uiConfig.auto !== 'boolean') uiConfig.auto = true; // 默认就让大家处于被唤醒状态
|
||||
if (!Number.isFinite(uiConfig.turnBudget)) uiConfig.turnBudget = 10; // 人一次发言后,自动唤醒的总次数上限
|
||||
const DEFAULT_TURN_BUDGET = uiConfig.turnBudget;
|
||||
let budgetLeft = uiConfig.turnBudget;
|
||||
let budgetAnnounced = false;
|
||||
const autoHandled = new Set();
|
||||
const lastWakeAt = new Map();
|
||||
const COOLDOWN_MS = 2500;
|
||||
function saveUiConfig() {
|
||||
try { fs.mkdirSync(HOME_ROOT, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(uiConfig, null, 2), 'utf8'); } catch {}
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
const payload = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';
|
||||
for (const res of clients) { try { res.write(payload); } catch { /* 断了就算了 */ } }
|
||||
}
|
||||
|
||||
function jobSummary(j) {
|
||||
return {
|
||||
id: j.id, kind: j.kind, member: j.member, note: j.note || null, status: j.status,
|
||||
code: j.code, startedAt: j.startedAt, endedAt: j.endedAt,
|
||||
ms: (j.endedAt || Date.now()) - j.startedAt, tail: j.out.join('').slice(-1200),
|
||||
};
|
||||
}
|
||||
|
||||
/** 真的杀干净:Windows 上 aig 自己会再 spawn worker CLI,那是孙进程,
|
||||
* 只 kill 直接子进程会留下孤儿(群里的 opencode 在 #124/#125 指出过这个洞)。 */
|
||||
function killTree(child) {
|
||||
if (!child || child.killed) return;
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
try { child.kill(); } catch { /* 兜底 */ }
|
||||
}
|
||||
|
||||
function startJob({ kind, member, args, note, deadlineMs }) {
|
||||
const id = 'j' + (++jobSeq);
|
||||
const job = { id, kind, member, args, note, status: 'running', startedAt: Date.now(), endedAt: null, code: null, out: [] };
|
||||
jobs.set(id, job);
|
||||
if (jobs.size > 40) jobs.delete(jobs.keys().next().value);
|
||||
if (member) runningByMember.set(member, id);
|
||||
broadcast('job', jobSummary(job));
|
||||
const child = spawn(process.execPath, [AIG, ...args], {
|
||||
cwd: WORKSPACE, windowsHide: true,
|
||||
env: { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' },
|
||||
});
|
||||
job.child = child;
|
||||
// 看门狗:跑太久就掐掉,免得 runningByMember 一直被占(群里 workbuddy 提的那条)
|
||||
const WATCHDOG_MS = Number(deadlineMs || process.env.AIG_WEB_WATCHDOG_MS || 30 * 60 * 1000);
|
||||
job.watchdog = setTimeout(() => {
|
||||
if (job.status !== 'running') return;
|
||||
job.cancelledBy = 'watchdog';
|
||||
killTree(child);
|
||||
}, WATCHDOG_MS);
|
||||
const push = (buf, stream) => {
|
||||
const text = buf.toString('utf8');
|
||||
job.out.push(text);
|
||||
if (job.out.length > 2000) job.out.shift();
|
||||
broadcast('job-chunk', { id, stream, text });
|
||||
};
|
||||
child.stdout.on('data', (b) => push(b, 'out'));
|
||||
child.stderr.on('data', (b) => push(b, 'err'));
|
||||
const done = (code) => {
|
||||
if (job.status !== 'running') return;
|
||||
if (job.watchdog) clearTimeout(job.watchdog);
|
||||
job.status = job.cancelledBy ? 'cancelled' : (code === 0 ? 'ok' : 'failed');
|
||||
job.code = code;
|
||||
job.endedAt = Date.now();
|
||||
delete job.child;
|
||||
if (job.member && runningByMember.get(job.member) === id) runningByMember.delete(job.member);
|
||||
broadcast('job', jobSummary(job));
|
||||
pushNewMessages(); // 唤醒产生的回复立刻推给前端
|
||||
};
|
||||
child.on('close', done);
|
||||
child.on('error', (e) => { job.out.push(String(e.message)); done(-1); });
|
||||
return job;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 新消息轮询 */
|
||||
|
||||
let lastHead = 0;
|
||||
let roomWatcher = null;
|
||||
let pushTimer = null;
|
||||
|
||||
function headSeq(room) {
|
||||
const msgs = readMessages(room);
|
||||
return msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||||
}
|
||||
|
||||
/** 合并同一批文件事件,避免一次写入触发多次推送。 */
|
||||
function schedulePush() {
|
||||
if (pushTimer) return;
|
||||
pushTimer = setTimeout(() => { pushTimer = null; try { pushNewMessages(); } catch { /* 忽略 */ } }, 25);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 fs.watch 盯住房间目录:写入 messages.jsonl 立刻推给前端。
|
||||
* 之前只靠 900ms 轮询,用户自己发的消息要等最多 0.9 秒才出现——这是"点了没反应"的主要来源之一。
|
||||
*/
|
||||
function watchRoom() {
|
||||
try { roomWatcher?.close(); } catch { /* 忽略 */ }
|
||||
roomWatcher = null;
|
||||
try {
|
||||
roomWatcher = fs.watch(roomDir(ROOM), { persistent: true }, (_ev, file) => {
|
||||
if (!file || String(file).includes('messages')) schedulePush();
|
||||
});
|
||||
} catch { /* 房间目录还不存在就等下一次 */ }
|
||||
lastHead = headSeq(ROOM);
|
||||
}
|
||||
|
||||
function pushNewMessages() {
|
||||
const fresh = readMessages(ROOM, lastHead);
|
||||
if (fresh.length) {
|
||||
lastHead = fresh[fresh.length - 1].seq;
|
||||
broadcast('messages', fresh.map(publicMsg));
|
||||
maybeAutoWake(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息该叫醒谁(新版语义):
|
||||
* · 显式点名了成员 → 只叫被点名的;
|
||||
* · 广播、或只指名 human → **除了发信人以外的所有成员**(这样 AI 之间才接得上话)。
|
||||
*/
|
||||
function targetsFor(room, msg) {
|
||||
const all = Object.keys(readMembers(room)).filter((m) => m !== 'human' && m !== 'system' && m !== msg.from);
|
||||
const named = (msg.to || []).filter((x) => all.includes(x));
|
||||
if (named.length) return named;
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群聊自动接话时的提示词:和"用户点名让主代理干活"是两回事。
|
||||
* 没有人工监管的自动轮次里,必须禁止自行开新任务/改代码/跑长命令——否则几个人会互相
|
||||
* 接出一串工程议程,把时间和额度烧穿(第一次自动对话实测就发生了:dsh 单轮跑了 7 分钟)。
|
||||
*/
|
||||
function chatExtra(room) {
|
||||
return [
|
||||
'这是群聊里的自动接话(不是用户在派活):用一两句话表态或补充即可。',
|
||||
'不要在新任务上自行开工,不要改任何文件、不要跑长命令、不要贴日志;',
|
||||
'如果你认为某件事该做,就在群里提出建议并等 human 表态,然后结束本轮。',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** 自动唤醒的单轮上限:到点就掐,成员锁必须放掉。 */
|
||||
function autoWakeTimeoutSec() { return Number(uiConfig.autoWakeTimeoutSec || 240); }
|
||||
|
||||
function startWakeFor(member, why, { fromHuman = false } = {}) {
|
||||
const timeout = autoWakeTimeoutSec();
|
||||
const args = ['wake', member, '--timeout', String(timeout)];
|
||||
if (fromHuman && member === primaryOf(ROOM)) args.push('--prompt', primaryExtra(ROOM));
|
||||
else if (!fromHuman) args.push('--prompt', chatExtra(ROOM));
|
||||
return startJob({ kind: 'wake', member, args, note: why, deadlineMs: (timeout + 30) * 1000 });
|
||||
}
|
||||
|
||||
/** 预算用尽时只播报一次,别刷屏。 */
|
||||
function announceBudget() {
|
||||
if (budgetAnnounced) return;
|
||||
budgetAnnounced = true;
|
||||
broadcast('budget', { left: 0, exhausted: true });
|
||||
}
|
||||
|
||||
function maybeAutoWake(fresh) {
|
||||
if (!uiConfig.auto) return;
|
||||
for (const m of fresh) {
|
||||
if (m.from === 'system' || m.kind === 'dispatch-error') continue;
|
||||
if ((m.tags || []).includes('silent')) continue; // 静默消息不触发自驱
|
||||
if (m.from === 'human') { // 用户一说话:预算重置,全群重新被唤醒
|
||||
budgetLeft = uiConfig.turnBudget;
|
||||
budgetAnnounced = false;
|
||||
broadcast('budget', { left: budgetLeft, reset: true });
|
||||
}
|
||||
for (const t of targetsFor(ROOM, m)) {
|
||||
const key = t + ':' + m.seq;
|
||||
if (autoHandled.has(key)) continue;
|
||||
autoHandled.add(key);
|
||||
if (runningByMember.has(t)) continue; // 正在跑:唤醒本来就按游标取未读,跑完会带上新的
|
||||
if (Date.now() - (lastWakeAt.get(t) || 0) < COOLDOWN_MS) continue; // 冷却,防抖
|
||||
if (budgetLeft <= 0) { announceBudget(); continue; } // 预算闸:没有人工监管时的唯一刹车
|
||||
budgetLeft--;
|
||||
lastWakeAt.set(t, Date.now());
|
||||
broadcast('budget', { left: budgetLeft });
|
||||
broadcast('auto', { member: t, seq: m.seq, at: Date.now() });
|
||||
startWakeFor(t, 'auto:#' + m.seq + ' ' + String(m.text).slice(0, 60), { fromHuman: m.from === 'human' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publicMsg(m) {
|
||||
return {
|
||||
seq: m.seq, ts: m.ts, from: m.from, to: m.to || [], kind: m.kind, via: m.via,
|
||||
text: m.text, reply_to: m.reply_to, meta: m.meta || {}, files: m.files || [],
|
||||
};
|
||||
}
|
||||
|
||||
// 轮询只作兜底(fs.watch 有时会漏事件);真正的即时性靠上面的文件监听。
|
||||
setInterval(() => {
|
||||
try { pushNewMessages(); broadcast('tick', { head: lastHead, jobs: jobs.size }); } catch { /* 忽略 */ }
|
||||
}, 500).unref();
|
||||
|
||||
/* ------------------------------------------------------------ 主代理提示 */
|
||||
|
||||
function primaryExtra(room) {
|
||||
const p = primaryOf(room);
|
||||
return [
|
||||
'用户是在 Web 群聊台里直接对你(主代理)说的,成员名是 human —— 这条是用户本人的真指令。',
|
||||
'请把这件事办掉:能直接给结论就给结论;需要动手就用你手上的工具去做;需要别人参与就用',
|
||||
'`node "' + AIG + '" ask <成员> "具体问题" --as ' + p + ' --room ' + room + '` 真的把他们叫起来,',
|
||||
'最后用一两句话回群说明:你做了什么、结论是什么、有哪些没做完。',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ HTTP */
|
||||
|
||||
function sendJson(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = '';
|
||||
req.on('data', (d) => { raw += d; if (raw.length > 2 * 1024 * 1024) req.destroy(); });
|
||||
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } });
|
||||
});
|
||||
}
|
||||
|
||||
let uiCache = { mtime: 0, html: '' };
|
||||
/** 页面主体在 scripts/web/ui.html(方便单独改、单独做语法检查);读不到才用内嵌的兜底页。 */
|
||||
function pageHtml() {
|
||||
try {
|
||||
const st = fs.statSync(UI_FILE);
|
||||
if (st.mtimeMs !== uiCache.mtime) uiCache = { mtime: st.mtimeMs, html: fs.readFileSync(UI_FILE, 'utf8') };
|
||||
return uiCache.html;
|
||||
} catch {
|
||||
return PAGE;
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const p = url.pathname;
|
||||
|
||||
if (p === '/' || p === '/index.html') {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
return res.end(pageHtml());
|
||||
}
|
||||
|
||||
if (p === '/api/state') {
|
||||
const members = readMembers(ROOM);
|
||||
const primary = primaryOf(ROOM);
|
||||
const now = Date.now();
|
||||
const rows = Object.entries(members).map(([name, rec]) => {
|
||||
const pr = readPresence(ROOM, name);
|
||||
const age = pr?.lastSeen ? Math.round((now - Date.parse(pr.lastSeen)) / 1000) : null;
|
||||
return {
|
||||
member: name, kind: rec.kind, model: rec.model || null, fallback: rec.fallback_model || null,
|
||||
role: rec.role || null, primary: rec.role === 'primary' || name === primary, note: rec.note || null,
|
||||
cursor: readCursor(ROOM, name).seq || 0, lastSeen: pr?.lastSeen || null, ageSec: age,
|
||||
online: age !== null && age < 300,
|
||||
};
|
||||
});
|
||||
const head = headSeq(ROOM);
|
||||
if (head > lastHead && lastHead === 0) lastHead = 0;
|
||||
return sendJson(res, 200, {
|
||||
room: ROOM, rooms: listRooms(), home: HOME_ROOT, roomDir: roomDir(ROOM), workspace: WORKSPACE,
|
||||
primary: primaryOf(ROOM), head, members: rows, auto: uiConfig.auto,
|
||||
budgetLeft, turnBudget: uiConfig.turnBudget,
|
||||
running: [...runningByMember.keys()],
|
||||
jobs: [...jobs.values()].slice(-6).map(jobSummary),
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/messages') {
|
||||
const since = Number(url.searchParams.get('since') || 0);
|
||||
const limit = Number(url.searchParams.get('limit') || 300);
|
||||
const all = readMessages(ROOM, since);
|
||||
return sendJson(res, 200, { messages: all.slice(-limit).map(publicMsg), head: headSeq(ROOM) });
|
||||
}
|
||||
|
||||
if (p === '/api/stream') {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
clients.add(res);
|
||||
res.write('event: hello\ndata: ' + JSON.stringify({ room: ROOM, head: lastHead }) + '\n\n');
|
||||
const ka = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
|
||||
req.on('close', () => { clearInterval(ka); clients.delete(res); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/api/send' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const text = String(body.text || '').trim();
|
||||
if (!text) return sendJson(res, 400, { error: 'empty' });
|
||||
const args = ['send', text, '--as', 'human'];
|
||||
if (Array.isArray(body.to) && body.to.length) args.push('--to', body.to.join(','));
|
||||
if (body.silent) args.push('--tag', 'silent'); // 静默消息不触发自驱
|
||||
const r = await runAig(args, { timeoutMs: 30000 });
|
||||
pushNewMessages();
|
||||
return sendJson(res, r.code === 0 ? 200 : 500, { result: r });
|
||||
}
|
||||
|
||||
if (p === '/api/ask' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const text = String(body.text || '').trim();
|
||||
const target = String(body.target || primaryOf(ROOM));
|
||||
if (!text) return sendJson(res, 400, { error: 'empty' });
|
||||
const isPrimary = target === primaryOf(ROOM);
|
||||
const args = ['ask', target, text, '--as', 'human', '--timeout', String(Number(body.timeout || 1800))];
|
||||
if (isPrimary) args.push('--prompt', primaryExtra(ROOM));
|
||||
const job = startJob({ kind: 'ask', member: target, args, note: text.slice(0, 80) });
|
||||
return sendJson(res, 200, { job: jobSummary(job) });
|
||||
}
|
||||
|
||||
if (p === '/api/wake' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const target = String(body.target || '');
|
||||
if (!target) return sendJson(res, 400, { error: 'no-target' });
|
||||
const timeout = Number(body.timeout || autoWakeTimeoutSec());
|
||||
const args = ['wake', target, '--timeout', String(timeout)];
|
||||
if (target === primaryOf(ROOM)) args.push('--prompt', primaryExtra(ROOM));
|
||||
const job = startJob({ kind: 'wake', member: target, args, deadlineMs: (timeout + 30) * 1000 });
|
||||
return sendJson(res, 200, { job: jobSummary(job) });
|
||||
}
|
||||
|
||||
if (p === '/api/primary' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const target = String(body.member || '');
|
||||
const members = readMembers(ROOM);
|
||||
if (!members[target]) return sendJson(res, 400, { error: 'unknown-member' });
|
||||
for (const [name, rec] of Object.entries(members)) {
|
||||
if (name === target) rec.role = 'primary';
|
||||
else if (rec.role === 'primary') delete rec.role;
|
||||
}
|
||||
fs.writeFileSync(path.join(roomDir(ROOM), 'members.json'), JSON.stringify(members, null, 2), 'utf8');
|
||||
broadcast('state', { primary: target });
|
||||
return sendJson(res, 200, { primary: target });
|
||||
}
|
||||
|
||||
if (p === '/api/room' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const target = String(body.room || '');
|
||||
if (!listRooms().includes(target)) return sendJson(res, 400, { error: 'unknown-room' });
|
||||
ROOM = target;
|
||||
// 切房间后从"当前队尾"开始跟踪,否则整段历史会被当成新消息、把所有人重新唤醒一遍
|
||||
autoHandled.clear();
|
||||
watchRoom();
|
||||
return sendJson(res, 200, { room: ROOM });
|
||||
}
|
||||
|
||||
if (p === '/api/jobs') {
|
||||
return sendJson(res, 200, { jobs: [...jobs.values()].map(jobSummary) });
|
||||
}
|
||||
|
||||
if (p === '/api/cancel' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const id = String(body.id || '');
|
||||
const target = String(body.member || '');
|
||||
const list = [...jobs.values()].filter((j) => j.status === 'running' && ((id && j.id === id) || (target && j.member === target)));
|
||||
for (const j of list) {
|
||||
j.cancelledBy = 'user';
|
||||
killTree(j.child);
|
||||
// 兜底:万一子进程没立刻退,先把状态放掉,别让它一直占着成员
|
||||
setTimeout(() => { if (j.status === 'running') { j.status = 'cancelled'; j.endedAt = Date.now(); if (j.member) runningByMember.delete(j.member); broadcast('job', jobSummary(j)); } }, 3000);
|
||||
}
|
||||
broadcast('state', { running: [...runningByMember.keys()] });
|
||||
return sendJson(res, 200, { cancelled: list.map((j) => j.id) });
|
||||
}
|
||||
|
||||
if (p === '/api/auto' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
uiConfig.auto = body.enabled === undefined ? !uiConfig.auto : Boolean(body.enabled);
|
||||
if (Number.isFinite(Number(body.turnBudget)) && Number(body.turnBudget) > 0) {
|
||||
uiConfig.turnBudget = Number(body.turnBudget);
|
||||
budgetLeft = uiConfig.turnBudget;
|
||||
budgetAnnounced = false;
|
||||
}
|
||||
if (uiConfig.auto) { budgetLeft = uiConfig.turnBudget; budgetAnnounced = false; }
|
||||
saveUiConfig();
|
||||
broadcast('state', { auto: uiConfig.auto, budgetLeft, turnBudget: uiConfig.turnBudget });
|
||||
return sendJson(res, 200, { auto: uiConfig.auto, budgetLeft, turnBudget: uiConfig.turnBudget });
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('404');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 前端页面(兜底) */
|
||||
|
||||
/* 正常走 scripts/web/ui.html;这份内嵌页只在那个文件被删/读不到时兜底,
|
||||
是早期版本,点击手感已被前端重构取代,不要用它来改 UI。 */
|
||||
const PAGE = String.raw`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AI 群聊台</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0c0e; --panel:#14161a; --panel2:#1b1e24; --line:#262a31;
|
||||
--fg:#e8e6df; --dim:#9a9a92; --dim2:#6b6f76;
|
||||
--me:#e8e6df; --agent:#7fb2ff; --wb:#ffb347; --oc:#7ee0a0; --err:#ff6b6b;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Cascadia Mono",monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0}
|
||||
body{background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;display:flex;flex-direction:column}
|
||||
header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
|
||||
.spacer{flex:1}
|
||||
.chip{border:1px solid var(--line);background:var(--panel2);border-radius:999px;padding:3px 10px;font-size:12px;color:var(--dim)}
|
||||
select,input,textarea,button{font:inherit;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 8px}
|
||||
button{cursor:pointer}
|
||||
button:hover{border-color:#3a4049}
|
||||
button.primary{background:#233047;border-color:#31435f}
|
||||
button.primary:hover{background:#2a3a55}
|
||||
main{flex:1;display:flex;min-height:0}
|
||||
#feed{flex:1;overflow:auto;padding:16px 18px;scroll-behavior:smooth}
|
||||
#rail{width:290px;border-left:1px solid var(--line);background:var(--panel);overflow:auto;padding:12px}
|
||||
.msg{border-left:2px solid var(--line);padding:6px 0 10px 12px;margin-bottom:8px}
|
||||
.msg.me{border-color:#5b6b8a}
|
||||
.msg.dsh{border-color:var(--agent)}
|
||||
.msg.opencode{border-color:var(--oc)}
|
||||
.msg.workbuddy{border-color:var(--wb)}
|
||||
.msg.system{border-color:var(--err)}
|
||||
.meta{font:11px/1.4 var(--mono);color:var(--dim2);margin-bottom:3px}
|
||||
.who{color:var(--fg);font-weight:600}
|
||||
.badge{display:inline-block;font:10px/1.6 var(--mono);border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-left:6px;color:var(--dim)}
|
||||
.badge.dispatch{border-color:#3a4a3a;color:#9fd6a8}
|
||||
.badge.err{border-color:#5a3131;color:var(--err)}
|
||||
.text{white-space:pre-wrap;word-break:break-word}
|
||||
.msg.err .text{color:#ffc9c9}
|
||||
.mcard{border:1px solid var(--line);border-radius:10px;padding:10px;margin-bottom:10px;background:var(--panel2)}
|
||||
.mcard .row{display:flex;align-items:center;gap:8px}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:#4a4f57;display:inline-block}
|
||||
.dot.on{background:#5fd08a}
|
||||
.mname{font-weight:600}
|
||||
.msub{font:11px/1.5 var(--mono);color:var(--dim2);margin-top:4px;word-break:break-all}
|
||||
.mact{display:flex;gap:6px;margin-top:8px}
|
||||
.mact button{font-size:12px;padding:4px 8px;flex:1}
|
||||
#composer{border-top:1px solid var(--line);background:var(--panel);padding:10px 14px 12px}
|
||||
#composerRow{display:flex;gap:10px;align-items:flex-end}
|
||||
#text{flex:1;min-height:46px;max-height:220px;resize:vertical;font:14px/1.5 inherit}
|
||||
#jobs{font:11px/1.5 var(--mono);color:var(--dim);margin-bottom:8px;max-height:120px;overflow:auto}
|
||||
#jobs .j{display:flex;gap:8px;align-items:baseline}
|
||||
#jobs .j.failed{color:var(--err)}
|
||||
#jobs .j.cancelled{color:var(--dim2)}
|
||||
#jobs .j .t{color:var(--dim2)}
|
||||
.hint{color:var(--dim2);font-size:11px;margin-top:6px}
|
||||
.empty{color:var(--dim2);text-align:center;margin-top:40px}
|
||||
.spin{display:inline-block;width:8px;height:8px;border:1.5px solid var(--dim);border-top-color:transparent;border-radius:50%;animation:sp .8s linear infinite;vertical-align:middle}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AI 群聊台</h1>
|
||||
<span class="chip" id="roomChip">房间 —</span>
|
||||
<span class="chip">主代理 <b id="primaryName">—</b></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="chip" for="roomSel" style="padding-right:4px">房间</label>
|
||||
<select id="roomSel" title="切换房间"></select>
|
||||
<button class="chip" id="autoBtn" title="开启后:你在下面发的消息会自动唤醒被点名的成员">自驱:关</button>
|
||||
<span class="chip" id="live"><span class="dot" id="liveDot"></span> <span id="liveTxt">连接中</span></span>
|
||||
</header>
|
||||
<main>
|
||||
<div id="feed"><div class="empty">还没有消息。在下面直接说话,默认发给主代理。</div></div>
|
||||
<aside id="rail"><div class="msub">读取成员…</div></aside>
|
||||
</main>
|
||||
<div id="composer">
|
||||
<div id="jobs"></div>
|
||||
<div id="composerRow">
|
||||
<textarea id="text" placeholder="直接说要求(默认发给主代理,它会决定自己做还是叫 opencode / workbuddy)。Enter 发送,Shift+Enter 换行。"></textarea>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;min-width:190px">
|
||||
<select id="target">
|
||||
<option value="__primary__">发给主代理</option>
|
||||
<option value="opencode">@ opencode</option>
|
||||
<option value="workbuddy">@ workbuddy</option>
|
||||
<option value="__all__">全体(只发不唤醒)</option>
|
||||
</select>
|
||||
<button class="primary" id="sendBtn">发送并等回复</button>
|
||||
<button id="sendOnlyBtn">只发消息</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint" id="footHint"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var state = { room:null, primary:'dsh', head:0, members:[], jobs:{} };
|
||||
var feed = document.getElementById('feed');
|
||||
var rail = document.getElementById('rail');
|
||||
var jobsBox = document.getElementById('jobs');
|
||||
var textEl = document.getElementById('text');
|
||||
var targetEl = document.getElementById('target');
|
||||
var roomSel = document.getElementById('roomSel');
|
||||
var liveDot = document.getElementById('liveDot');
|
||||
var liveTxt = document.getElementById('liveTxt');
|
||||
var autoBtn = document.getElementById('autoBtn');
|
||||
|
||||
function setAutoLabel(on){
|
||||
autoBtn.textContent = '自驱:' + (on ? '开' : '关');
|
||||
autoBtn.style.background = on ? '#233047' : '';
|
||||
autoBtn.style.borderColor = on ? '#31435f' : '';
|
||||
}
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
|
||||
function hhmmss(ts){ var d=new Date(ts); var p=function(n){return (n<10?'0':'')+n;}; return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
|
||||
function atBottom(){ return feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; }
|
||||
|
||||
function msgHtml(m){
|
||||
var cls = 'msg';
|
||||
if (m.from === 'human') cls += ' me';
|
||||
else if (m.from === 'dsh' || m.from === 'opencode' || m.from === 'workbuddy') cls += ' ' + m.from;
|
||||
if (m.kind === 'dispatch-error') cls += ' err system';
|
||||
var to = (m.to && m.to.length) ? ('→ ' + m.to.join(',')) : '→ 全体';
|
||||
var badges = '';
|
||||
if (m.kind === 'ask') badges += '<span class="badge">提问</span>';
|
||||
if (m.kind === 'reply') badges += '<span class="badge">回答</span>';
|
||||
if (m.kind === 'dispatch-error') badges += '<span class="badge err">唤醒失败</span>';
|
||||
if (m.via === 'dispatch') badges += '<span class="badge dispatch">被叫醒</span>';
|
||||
var model = (m.meta && m.meta.model) ? ('<span class="badge">' + esc(m.meta.model) + '</span>') : '';
|
||||
return '<div class="' + cls + '">' +
|
||||
'<div class="meta">#' + m.seq + ' · ' + hhmmss(m.ts) + ' · <span class="who">' + esc(m.from) + '</span> ' + esc(to) + badges + model + '</div>' +
|
||||
'<div class="text">' + esc(m.text) + '</div></div>';
|
||||
}
|
||||
|
||||
function append(msgs, replaceAll){
|
||||
if (replaceAll) feed.innerHTML = '';
|
||||
if (!msgs.length && replaceAll) { feed.innerHTML = '<div class="empty">还没有消息。在下面直接说话,默认发给主代理。</div>'; return; }
|
||||
var stick = atBottom() || replaceAll;
|
||||
var html = msgs.map(msgHtml).join('');
|
||||
if (replaceAll) feed.innerHTML = html; else feed.insertAdjacentHTML('beforeend', html);
|
||||
if (stick) feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function renderRail(){
|
||||
var primary = state.primary;
|
||||
var cards = state.members.filter(function(m){ return m.member !== 'human'; }).map(function(m){
|
||||
var on = m.online ? ' on' : '';
|
||||
var role = m.primary ? '<span class="badge">主代理</span>' : '';
|
||||
var btns = '<div class="mact">' +
|
||||
'<button data-wake="' + esc(m.member) + '">唤醒它</button>' +
|
||||
'<button data-to="' + esc(m.member) + '">@ 它</button>' +
|
||||
(m.primary ? '' : '<button data-primary="' + esc(m.member) + '">设为主代理</button>') +
|
||||
'</div>';
|
||||
return '<div class="mcard"><div class="row"><span class="dot' + on + '"></span>' +
|
||||
'<span class="mname">' + esc(m.member) + '</span>' + role +
|
||||
'<span class="spacer" style="flex:1"></span><span class="msub">游标 #' + m.cursor + '</span></div>' +
|
||||
'<div class="msub">模型 ' + esc(m.model || '默认') + (m.fallback ? '(备用 ' + esc(m.fallback) + ')' : '') + '</div>' +
|
||||
'<div class="msub">' + (m.ageSec === null ? '还没活动过' : (m.ageSec + 's 前活动')) + '</div>' +
|
||||
btns + '</div>';
|
||||
}).join('');
|
||||
rail.innerHTML = cards || '<div class="msub">没有成员</div>';
|
||||
document.getElementById('primaryName').textContent = primary;
|
||||
document.getElementById('roomChip').textContent = '房间 ' + state.room;
|
||||
document.getElementById('footHint').textContent = state.roomDir + ' · 工作目录 ' + state.workspace + ' · 只绑定本机';
|
||||
}
|
||||
|
||||
function renderJobs(){
|
||||
var list = Object.keys(state.jobs).map(function(k){ return state.jobs[k]; })
|
||||
.sort(function(a,b){ return b.startedAt - a.startedAt; }).slice(0,4);
|
||||
jobsBox.innerHTML = list.map(function(j){
|
||||
var secs = Math.round(((j.endedAt || Date.now()) - j.startedAt)/1000);
|
||||
var icon = j.status === 'running' ? '<span class="spin"></span>' : (j.status === 'ok' ? '✔' : (j.status === 'cancelled' ? '⊘' : '✘'));
|
||||
var head = j.kind === 'ask' ? '提问 ' : '唤醒 ';
|
||||
var stop = j.status === 'running' ? '<button data-cancel="' + esc(j.id) + '" style="font-size:11px;padding:1px 6px">中止</button>' : '';
|
||||
return '<div class="j ' + esc(j.status) + '">' + icon + ' <span>' + head + esc(j.member) + '</span>' +
|
||||
'<span class="t">' + secs + 's</span>' + stop +
|
||||
'<span class="t">' + esc((j.note || j.tail || '').replace(/\s+/g,' ').slice(-140)) + '</span></div>';
|
||||
}).join('');
|
||||
setTimeout(renderJobs, 1000);
|
||||
}
|
||||
|
||||
function loadState(){
|
||||
fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){
|
||||
var roomChanged = state.room !== s.room;
|
||||
state = s;
|
||||
state.jobs = {};
|
||||
(s.jobs || []).forEach(function(j){ state.jobs[j.id] = j; });
|
||||
roomSel.innerHTML = s.rooms.map(function(r){ return '<option value="' + esc(r) + '"' + (r===s.room?' selected':'') + '>' + esc(r) + '</option>'; }).join('');
|
||||
setAutoLabel(Boolean(s.auto));
|
||||
renderRail();
|
||||
if (roomChanged || !feed.dataset.loaded){
|
||||
fetch('/api/messages?since=0').then(function(r){ return r.json(); }).then(function(d){
|
||||
state.head = d.head; feed.dataset.loaded = '1'; append(d.messages, true);
|
||||
});
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
function stream(){
|
||||
var es = new EventSource('/api/stream');
|
||||
es.addEventListener('hello', function(e){
|
||||
liveDot.className = 'dot on'; liveTxt.textContent = '已连接';
|
||||
var h = JSON.parse(e.data).head || 0;
|
||||
if (h > (state.head || 0)) {
|
||||
fetch('/api/messages?since=' + state.head).then(function(r){ return r.json(); }).then(function(d){
|
||||
state.head = d.head; append(d.messages, false);
|
||||
});
|
||||
}
|
||||
});
|
||||
es.addEventListener('messages', function(e){
|
||||
var rows = JSON.parse(e.data);
|
||||
if (!rows.length) return;
|
||||
state.head = rows[rows.length-1].seq;
|
||||
append(rows, false);
|
||||
fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){ state.members = s.members; state.primary = s.primary; renderRail(); });
|
||||
});
|
||||
es.addEventListener('job', function(e){
|
||||
var j = JSON.parse(e.data); state.jobs[j.id] = j;
|
||||
});
|
||||
es.addEventListener('error', function(){ liveDot.className = 'dot'; liveTxt.textContent = '重连中'; });
|
||||
es.onerror = function(){ liveDot.className = 'dot'; liveTxt.textContent = '重连中'; };
|
||||
}
|
||||
|
||||
function resolveTarget(){
|
||||
var v = targetEl.value;
|
||||
if (v === '__primary__') return state.primary || 'dsh';
|
||||
if (v === '__all__') return '';
|
||||
return v;
|
||||
}
|
||||
|
||||
function send(andWait){
|
||||
var text = textEl.value.trim();
|
||||
if (!text) return;
|
||||
var target = resolveTarget();
|
||||
textEl.value = '';
|
||||
if (!target || !andWait){
|
||||
fetch('/api/send', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ text: text, to: target ? [target] : [] }) });
|
||||
return;
|
||||
}
|
||||
fetch('/api/ask', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ text: text, target: target }) })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if (d.job) state.jobs[d.job.id] = d.job; })
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
document.getElementById('sendBtn').onclick = function(){ send(true); };
|
||||
document.getElementById('sendOnlyBtn').onclick = function(){ send(false); };
|
||||
autoBtn.onclick = function(){
|
||||
fetch('/api/auto', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({}) })
|
||||
.then(function(r){ return r.json(); }).then(function(d){ setAutoLabel(Boolean(d.auto)); });
|
||||
};
|
||||
jobsBox.addEventListener('click', function(e){
|
||||
var b = e.target.closest('button[data-cancel]');
|
||||
if (!b) return;
|
||||
fetch('/api/cancel', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ id: b.dataset.cancel }) })
|
||||
.then(function(){ return fetch('/api/state'); }).then(function(r){ return r.json(); })
|
||||
.then(function(s){ (s.jobs||[]).forEach(function(j){ state.jobs[j.id]=j; }); });
|
||||
});
|
||||
textEl.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(true); }
|
||||
});
|
||||
roomSel.addEventListener('change', function(){
|
||||
fetch('/api/room', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ room: roomSel.value }) })
|
||||
.then(function(){ feed.dataset.loaded=''; loadState(); });
|
||||
});
|
||||
rail.addEventListener('click', function(e){
|
||||
var b = e.target.closest('button'); if (!b) return;
|
||||
if (b.dataset.wake) fetch('/api/wake', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ target: b.dataset.wake }) })
|
||||
.then(function(r){ return r.json(); }).then(function(d){ if (d.job) state.jobs[d.job.id] = d.job; });
|
||||
if (b.dataset.to){ targetEl.value = b.dataset.to; textEl.focus(); }
|
||||
if (b.dataset.primary) fetch('/api/primary', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ member: b.dataset.primary }) })
|
||||
.then(function(){ loadState(); });
|
||||
});
|
||||
|
||||
loadState(); stream(); renderJobs();
|
||||
setInterval(function(){ fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){
|
||||
state.members = s.members; state.primary = s.primary; state.room = s.room; renderRail();
|
||||
(s.jobs || []).forEach(function(j){ state.jobs[j.id] = j; });
|
||||
}); }, 3000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/* ------------------------------------------------------------ 启动 */
|
||||
|
||||
function start() {
|
||||
if (!fs.existsSync(AIG)) {
|
||||
process.stderr.write('aig-web: 找不到同目录的 aig.mjs——请把两个脚本放在一起。\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!listRooms().length) {
|
||||
process.stderr.write('aig-web: 还没有任何房间,先跑一次:node "' + AIG + '" init\n');
|
||||
process.exit(1);
|
||||
}
|
||||
// 从队尾开始跟踪:只在"启动之后到达的新消息"上触发自驱,
|
||||
// 否则重启一次就会把整段历史重放、把所有成员重新唤醒一遍。
|
||||
watchRoom();
|
||||
const shown = HOST === '0.0.0.0' ? '(已绑定所有网卡,能被局域网访问,注意风险)' : '';
|
||||
server.listen(PORT, HOST, () => {
|
||||
process.stdout.write('AI 群聊台已启动\n');
|
||||
process.stdout.write(' 地址 http://' + HOST + ':' + PORT + '/ ' + shown + '\n');
|
||||
process.stdout.write(' 房间 ' + ROOM + '(' + roomDir(ROOM) + ')\n');
|
||||
process.stdout.write(' 主代理 ' + primaryOf(ROOM) + '\n');
|
||||
process.stdout.write(' 工作目录 ' + WORKSPACE + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
+1118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* api-call.mjs — 把任意 OpenAI 兼容的 chat/completions 端点,包装成一个"无头 CLI"。
|
||||
*
|
||||
* 为什么要有它:群聊里每个成员都是"一个能被 spawn 的命令"。有的成员没有 CLI(例如直接用
|
||||
* 智谱 BigModel API 的 GLM-4-Flash),那就给它一个只做一件事的小包装:发一次请求、把回答打到 stdout。
|
||||
*
|
||||
* 用法(提示词必须是**最后一个**位置参数,aig 会把提示词追加在最后):
|
||||
* node api-call.mjs --base https://open.bigmodel.cn/api/paas/v4 --model GLM-4-Flash-250414 \
|
||||
* --key-file ~/.zcode/v2/provider_config.json --key-path config.a.b.0.key "你的提示词"
|
||||
*
|
||||
* 密钥三种来源(按优先级):--key-env <VAR> / --key-file + --key-path / --key <明文>
|
||||
* 输出:一行 JSON {type:"result", result:"…", model, usage},方便 aig 结构化取答复。
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
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 BASE = String(flag('base', '')).replace(/\/+$/, '');
|
||||
const MODEL = String(flag('model', ''));
|
||||
const TIMEOUT = Number(flag('timeout', 180)) * 1000;
|
||||
const SYSTEM = flag('system', '');
|
||||
const prompt = argv[argv.length - 1] && !argv[argv.length - 1].startsWith('--') ? argv[argv.length - 1] : '';
|
||||
const posArgs = argv.filter((a) => !a.startsWith('--'));
|
||||
|
||||
if (!BASE || !MODEL || !prompt) {
|
||||
process.stderr.write('api-call: 需要 --base、--model 和非空提示词\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function resolveKey() {
|
||||
const envName = flag('key-env', '');
|
||||
if (envName) return process.env[envName] || '';
|
||||
const file = flag('key-file', '');
|
||||
if (file) {
|
||||
const p = file.replace(/^~(?=\/|\\)/, os.homedir());
|
||||
let obj;
|
||||
try { obj = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { throw new Error(`读不到密钥文件 ${p}:${e.message}`); }
|
||||
const dotted = String(flag('key-path', ''));
|
||||
if (dotted) {
|
||||
let cur = obj;
|
||||
for (const seg of dotted.split('.')) {
|
||||
if (cur == null) break;
|
||||
cur = Array.isArray(cur) ? cur[Number(seg)] : cur[seg];
|
||||
}
|
||||
if (typeof cur === 'string' && cur) return cur;
|
||||
throw new Error(`key-path "${dotted}" 在 ${p} 里没找到字符串`);
|
||||
}
|
||||
// 没给路径就宽容地找第一个像 key 的字段
|
||||
const found = (function walk(o, depth) {
|
||||
if (depth > 6 || o == null) return null;
|
||||
if (typeof o === 'string') return null;
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (/^(api[_-]?key|apikey|token)$/i.test(k) && typeof v === 'string' && v.length > 8) return v;
|
||||
const deep = walk(v, depth + 1);
|
||||
if (deep) return deep;
|
||||
}
|
||||
return null;
|
||||
})(obj, 0);
|
||||
if (found) return found;
|
||||
throw new Error(`没在 ${p} 里找到 api key`);
|
||||
}
|
||||
return String(flag('key', ''));
|
||||
}
|
||||
|
||||
let key;
|
||||
try { key = resolveKey(); } catch (e) { process.stderr.write(`api-call: ${e.message}\n`); process.exit(3); }
|
||||
if (!key) { process.stderr.write('api-call: 没拿到密钥(--key-env / --key-file / --key 都没给有效的)\n'); process.exit(3); }
|
||||
|
||||
const messages = [];
|
||||
if (SYSTEM) messages.push({ role: 'system', content: SYSTEM });
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), TIMEOUT);
|
||||
try {
|
||||
const res = await fetch(`${BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({ model: MODEL, messages, stream: false }),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`api-call: HTTP ${res.status} ${text.slice(0, 400)}\n`);
|
||||
process.exit(4);
|
||||
}
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { process.stderr.write(`api-call: 返回不是 JSON:${text.slice(0, 300)}\n`); process.exit(5); }
|
||||
const reply = json.choices?.[0]?.message?.content;
|
||||
if (typeof reply !== 'string') {
|
||||
process.stderr.write(`api-call: 拿不到 choices[0].message.content:${text.slice(0, 300)}\n`);
|
||||
process.exit(6);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'result', subtype: 'success', model: json.model || MODEL,
|
||||
result: reply.trim(), usage: json.usage || null,
|
||||
finish_reason: json.choices?.[0]?.finish_reason || null,
|
||||
})}\n`);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
process.stderr.write(`api-call: ${e.name === 'AbortError' ? '请求超时' : e.message}\n`);
|
||||
process.exit(7);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* install.mjs — 把 ai-group-chat 技能装到本机各个 agent 的技能目录。
|
||||
*
|
||||
* 目标目录(本机实测):
|
||||
* agents ~/.agents/skills/ai-group-chat ← DSH 与 opencode 都会自动加载(一份覆盖两家)
|
||||
* workbuddy ~/.workbuddy/skills/ai-group-chat ← WorkBuddy 桌面端
|
||||
* codebuddy ~/.codebuddy/skills/ai-group-chat ← WorkBuddy 随附的 CodeBuddy CLI
|
||||
* opencode ~/.config/opencode/skill/ai-group-chat ← 仅在关闭了 external skills 时才需要
|
||||
*
|
||||
* 用法:
|
||||
* node install.mjs # 装到 agents + workbuddy + codebuddy
|
||||
* node install.mjs --host all # 同上,另外也装 opencode 专用目录
|
||||
* node install.mjs --host workbuddy # 只装一家
|
||||
* node install.mjs --check # 只体检,不写
|
||||
* node install.mjs --link # 用目录联接(junction)替代复制,全部宿主共用一份源码
|
||||
* node install.mjs --uninstall # 卸载
|
||||
*/
|
||||
|
||||
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 SKILL_ROOT = path.resolve(HERE, '..');
|
||||
const SKILL_NAME = 'ai-group-chat';
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const has = (f) => argv.includes(`--${f}`);
|
||||
const val = (f, d) => { const i = argv.indexOf(`--${f}`); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
|
||||
|
||||
const HOME = os.homedir();
|
||||
const TARGETS = {
|
||||
agents: { dir: path.join(HOME, '.agents', 'skills', SKILL_NAME), hosts: ['dsh', 'opencode'], why: 'DSH 与 opencode 的自动加载目录' },
|
||||
workbuddy: { dir: path.join(HOME, '.workbuddy', 'skills', SKILL_NAME), hosts: ['workbuddy'], why: 'WorkBuddy 桌面端用户技能目录' },
|
||||
codebuddy: { dir: path.join(HOME, '.codebuddy', 'skills', SKILL_NAME), hosts: ['workbuddy'], why: 'CodeBuddy CLI(WorkBuddy 的终端形态)' },
|
||||
opencode: { dir: path.join(HOME, '.config', 'opencode', 'skill', SKILL_NAME), hosts: ['opencode'], why: 'opencode 专属技能目录(一般不需要,.agents 已覆盖)' },
|
||||
};
|
||||
|
||||
const REQUESTED = String(val('host', has('all') ? 'all' : 'default')).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const chosen = REQUESTED.includes('all') ? Object.keys(TARGETS) : REQUESTED.includes('default') ? ['agents', 'workbuddy', 'codebuddy'] : REQUESTED;
|
||||
const unknown = chosen.filter((c) => !TARGETS[c]);
|
||||
if (unknown.length) {
|
||||
process.stderr.write(`install: 未知目标 ${unknown.join(', ')}(可选:${Object.keys(TARGETS).join(' / ')} / all)\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const DRY = has('dry-run');
|
||||
const LINK = has('link');
|
||||
const CHECK = has('check');
|
||||
const UNINSTALL = has('uninstall');
|
||||
|
||||
/* --------------------------------------------------------------- 工具 */
|
||||
|
||||
const COPY_ITEMS = ['SKILL.md', 'scripts', 'references', 'README.md', 'VERIFY.md'];
|
||||
|
||||
function rmrf(p) { fs.rmSync(p, { recursive: true, force: true }); }
|
||||
|
||||
function copyInto(dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const item of COPY_ITEMS) {
|
||||
const src = path.join(SKILL_ROOT, item);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
const dst = path.join(dest, item);
|
||||
rmrf(dst);
|
||||
fs.cpSync(src, dst, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function junction(linkPath, targetPath) {
|
||||
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
||||
const r = spawnSync('cmd.exe', ['/d', '/s', '/c', 'mklink', '/J', linkPath, targetPath], { encoding: 'utf8', windowsHide: true });
|
||||
if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'mklink 失败').trim());
|
||||
}
|
||||
|
||||
function frontmatterName(file) {
|
||||
try {
|
||||
const txt = fs.readFileSync(file, 'utf8');
|
||||
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) return null;
|
||||
const n = m[1].match(/^name:\s*(.+)$/m);
|
||||
return n ? n[1].trim() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------- WorkBuddy 沙箱白名单 */
|
||||
|
||||
function patchWorkbuddySettings() {
|
||||
const file = path.join(HOME, '.workbuddy', 'settings.json');
|
||||
const wanted = '~/.ai-groups/';
|
||||
if (!fs.existsSync(file)) return { ok: false, detail: `没找到 ${file}(WorkBuddy 可能还没启动过)` };
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(raw); } catch (e) { return { ok: false, detail: `settings.json 不是合法 JSON:${e.message}` }; }
|
||||
cfg.sandbox = cfg.sandbox || {};
|
||||
cfg.sandbox.extraAllowWrite = cfg.sandbox.extraAllowWrite || [];
|
||||
if (cfg.sandbox.extraAllowWrite.includes(wanted)) return { ok: true, detail: '已在白名单里,未改动' };
|
||||
if (DRY) return { ok: true, detail: `[dry-run] 会把 ${wanted} 加进 sandbox.extraAllowWrite` };
|
||||
const backup = `${file}.bak-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`;
|
||||
fs.copyFileSync(file, backup);
|
||||
cfg.sandbox.extraAllowWrite.push(wanted);
|
||||
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
||||
return { ok: true, detail: `已加入 ${wanted}(备份 ${path.basename(backup)})` };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- 检查 */
|
||||
|
||||
function inspect(name) {
|
||||
const t = TARGETS[name];
|
||||
const skillMd = path.join(t.dir, 'SKILL.md');
|
||||
const cli = path.join(t.dir, 'scripts', 'aig.mjs');
|
||||
const marker = path.join(t.dir, '.aig-host.json');
|
||||
const exists = fs.existsSync(skillMd);
|
||||
const nameInFm = exists ? frontmatterName(skillMd) : null;
|
||||
let link = null;
|
||||
try { if (fs.lstatSync(t.dir).isSymbolicLink()) link = fs.readlinkSync(t.dir); } catch {}
|
||||
return {
|
||||
name, dir: t.dir, why: t.why, hosts: t.hosts,
|
||||
installed: exists, cli: fs.existsSync(cli), name_ok: nameInFm === SKILL_NAME,
|
||||
frontmatter: nameInFm, marker: fs.existsSync(marker) ? JSON.parse(fs.readFileSync(marker, 'utf8')) : null, junction: link,
|
||||
};
|
||||
}
|
||||
|
||||
function printCheck(rows) {
|
||||
const head = ['目标', '状态', 'SKILL.md name', 'CLI', '位置'];
|
||||
const body = rows.map((r) => [
|
||||
r.name,
|
||||
r.installed ? (r.junction ? '已装(链接)' : '已装') : '未装',
|
||||
r.installed ? (r.name_ok ? '✔' : `✘ ${r.frontmatter || '读取失败'}`) : '-',
|
||||
r.installed ? (r.cli ? '✔' : '✘') : '-',
|
||||
r.dir,
|
||||
]);
|
||||
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => String(b[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 b of body) process.stdout.write(`${line(b)}\n`);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- main */
|
||||
|
||||
process.stdout.write(`ai-group-chat 安装器 v${VERSION}\n源目录:${SKILL_ROOT}\n${LINK ? '模式:目录联接(所有宿主共用这一份源码)\n' : '模式:复制\n'}`);
|
||||
|
||||
if (CHECK) {
|
||||
const rows = Object.keys(TARGETS).map(inspect);
|
||||
printCheck(rows);
|
||||
const missing = rows.filter((r) => chosen.includes(r.name) && !r.installed);
|
||||
process.stdout.write(missing.length ? `\n→ ${missing.map((m) => m.name).join(', ')} 还没装:node install.mjs --host ${missing.map((m) => m.name).join(',')}\n`
|
||||
: '\n→ 选中的目标都已安装\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (UNINSTALL) {
|
||||
for (const name of chosen) {
|
||||
const t = TARGETS[name];
|
||||
if (!fs.existsSync(t.dir)) { process.stdout.write(`· ${name}:本来就没有\n`); continue; }
|
||||
if (DRY) { process.stdout.write(`· ${name}:[dry-run] 会删除 ${t.dir}\n`); continue; }
|
||||
rmrf(t.dir);
|
||||
process.stdout.write(`· ${name}:已删除 ${t.dir}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
for (const name of chosen) {
|
||||
const t = TARGETS[name];
|
||||
try {
|
||||
if (DRY) { process.stdout.write(`· ${name}:[dry-run] 会写到 ${t.dir}\n`); continue; }
|
||||
if (fs.existsSync(t.dir)) {
|
||||
const keep = path.join(t.dir, '.aig-host.json');
|
||||
const prev = fs.existsSync(keep) ? fs.readFileSync(keep, 'utf8') : null;
|
||||
rmrf(t.dir);
|
||||
fs.mkdirSync(t.dir, { recursive: true });
|
||||
if (prev) fs.writeFileSync(keep, prev);
|
||||
}
|
||||
if (LINK) junction(t.dir, SKILL_ROOT);
|
||||
else copyInto(t.dir);
|
||||
const marker = {
|
||||
skill: SKILL_NAME, version: VERSION, host: t.hosts[0], hosts: t.hosts,
|
||||
installedAt: new Date().toISOString(), source: SKILL_ROOT, mode: LINK ? 'junction' : 'copy',
|
||||
note: t.why,
|
||||
};
|
||||
fs.writeFileSync(path.join(t.dir, '.aig-host.json'), `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
||||
process.stdout.write(`· ${name}:已安装 → ${t.dir}${LINK ? '(链接)' : ''}\n`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
process.stdout.write(`· ${name}:失败 — ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (chosen.includes('workbuddy') && !DRY && !has('no-wb-settings')) {
|
||||
const r = patchWorkbuddySettings();
|
||||
process.stdout.write(`· WorkBuddy 沙箱白名单:${r.ok ? '✔' : '✘'} ${r.detail}\n`);
|
||||
}
|
||||
|
||||
const rows = Object.keys(TARGETS).map(inspect);
|
||||
printCheck(rows);
|
||||
|
||||
process.stdout.write(`
|
||||
接下来怎么用:
|
||||
1) 自检(不花钱、不调模型):
|
||||
node "${path.join(SKILL_ROOT, 'scripts', 'selfcheck.mjs')}"
|
||||
2) 体检(房间 + 三个成员的唤醒通道 + 安装位置):
|
||||
node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" doctor
|
||||
3) 开群并试一句(会真的调用一次免费模型):
|
||||
node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" init
|
||||
node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" ask opencode "报个到" --as dsh
|
||||
4) 各宿主里怎么触发这个技能:
|
||||
DSH —— 直接说「拉个群,让 opencode 看看这个改动」
|
||||
opencode —— 同一个说法即可(它从 ~/.agents/skills 自动加载)
|
||||
WorkBuddy—— 同一个说法即可(技能来自 ~/.workbuddy/skills)
|
||||
`);
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* selfcheck.mjs — ai-group-chat 的离线端到端自检。
|
||||
*
|
||||
* 全程使用临时 AIGROUP_HOME,**不触碰真实房间、不调用任何模型**(唤醒只走 --dry-run
|
||||
* 和故意失败的分支)。任何一条不过就以非零码退出。
|
||||
*
|
||||
* 用法:node selfcheck.mjs [--keep]
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AIG = path.join(HERE, 'aig.mjs');
|
||||
const ROOM = 'selfcheck';
|
||||
const HOME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-selfcheck-'));
|
||||
const KEEP = process.argv.includes('--keep');
|
||||
|
||||
const ENV = { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' };
|
||||
delete ENV.AIGROUP_DEPTH;
|
||||
delete ENV.AIGROUP_NO_WAKE;
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
|
||||
function record(ok, name, detail = '') {
|
||||
if (ok) { pass++; process.stdout.write(` ✔ ${name}${detail ? ` ${detail}` : ''}\n`); }
|
||||
else { failures.push({ name, detail }); process.stdout.write(` ✘ ${name}${detail ? ` ${detail}` : ''}\n`); }
|
||||
}
|
||||
|
||||
function aig(args, env = {}) {
|
||||
const r = spawnSync(process.execPath, [AIG, ...args], { encoding: 'utf8', windowsHide: true, env: { ...ENV, ...env }, timeout: 60000 });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch {}
|
||||
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), json };
|
||||
}
|
||||
|
||||
function aigAsync(args, env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [AIG, ...args], { windowsHide: true, env: { ...ENV, ...env } });
|
||||
let out = '', err = '';
|
||||
child.stdout.on('data', (d) => { out += d; });
|
||||
child.stderr.on('data', (d) => { err += d; });
|
||||
child.on('close', (code) => resolve({ code, out: out.trim(), err: err.trim() }));
|
||||
});
|
||||
}
|
||||
|
||||
// 直接读日志文件,绕开 CLI —— 否则 `read --all --as dsh` 会顺手推进 dsh 的游标,
|
||||
// 把后面「唤醒 dsh 应该有未读」的用例打空。
|
||||
const msgs = () => {
|
||||
const file = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl');
|
||||
let raw = '';
|
||||
try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
||||
return raw.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
||||
};
|
||||
const unreadOf = (m) => aig(['read', '--json', '--as', m]);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/* ------------------------------------------------------------------ 各阶段 */
|
||||
|
||||
function phaseRooms() {
|
||||
process.stdout.write('【房间与成员】\n');
|
||||
const r = aig(['init']);
|
||||
record(r.code === 0, 'init 建房间', r.out.split('\n')[0]);
|
||||
const members = JSON.parse(fs.readFileSync(path.join(HOME_ROOT, 'rooms', ROOM, 'members.json'), 'utf8'));
|
||||
record(['dsh', 'opencode', 'workbuddy'].every((m) => members[m]), '预置三个成员', Object.keys(members).join(','));
|
||||
record(fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl')), '消息日志已创建');
|
||||
const m = aig(['members', '--json']);
|
||||
record(m.code === 0 && Array.isArray(m.json) && m.json.length === 3, 'members --json 可用', `${m.json?.length} 个成员`);
|
||||
const wakable = (m.json || []).filter((x) => x.wakable).map((x) => x.member);
|
||||
record(wakable.length >= 2, '至少两个成员的唤醒通道能解析', `可唤醒:${wakable.join(',') || '无'}`);
|
||||
}
|
||||
|
||||
function phaseMessages() {
|
||||
process.stdout.write('\n【发消息 / 读消息 / 游标】\n');
|
||||
const s = aig(['send', '大家好,我是 DSH @opencode 先看下这个', '--as', 'dsh']);
|
||||
record(s.code === 0, 'send 成功');
|
||||
const all = msgs();
|
||||
record(all.length === 1 && all[0].from === 'dsh', '消息落库', `#${all[0]?.seq} ${all[0]?.from}`);
|
||||
record(['id', 'seq', 'ts', 'from', 'to', 'text'].every((k) => all[0][k] !== undefined), '消息 schema 齐全');
|
||||
record(all[0].to.includes('opencode'), '正文里的 @opencode 自动定向', JSON.stringify(all[0].to));
|
||||
|
||||
const u1 = unreadOf('opencode');
|
||||
record(u1.json?.length === 1, 'opencode 有 1 条未读', String(u1.json?.length));
|
||||
const u2 = unreadOf('opencode');
|
||||
record(u2.json?.length === 0, '读完后游标推进(第二次读为空)', String(u2.json?.length));
|
||||
aig(['send', '给 workbuddy 的一条,用来验 --peek', '--to', 'workbuddy', '--as', 'dsh']);
|
||||
const peek = aig(['read', '--peek', '--json', '--as', 'workbuddy']);
|
||||
record(peek.json?.length === 1, '--peek 能看到未读', `peek=${peek.json?.length}`);
|
||||
const peek2 = aig(['read', '--peek', '--json', '--as', 'workbuddy']);
|
||||
record(peek2.json?.length === 1, '--peek 不推进游标(连读两次都有)', `第二次 peek=${peek2.json?.length}`);
|
||||
const consumed = aig(['read', '--json', '--as', 'workbuddy']);
|
||||
const after = aig(['read', '--json', '--as', 'workbuddy']);
|
||||
record(consumed.json?.length === 1 && after.json?.length === 0, '真读一次后游标才推进', `${consumed.json?.length} → ${after.json?.length}`);
|
||||
aig(['read', '--as', 'dsh']);
|
||||
}
|
||||
|
||||
function phaseTargeting() {
|
||||
process.stdout.write('\n【定向与广播隔离】\n');
|
||||
aig(['send', '这条只给 opencode', '--to', 'opencode', '--as', 'dsh']);
|
||||
const dub = unreadOf('workbuddy');
|
||||
record(dub.json?.length === 0, '定向消息不会漏给第三方', `workbuddy 未读 ${dub.json?.length}`);
|
||||
const dob = unreadOf('opencode');
|
||||
record(dob.json?.length === 1 && dob.json[0].text.includes('只给 opencode'), '定向消息送达本人');
|
||||
aig(['send', '这条是广播', '--as', 'dsh']);
|
||||
const b1 = unreadOf('opencode');
|
||||
const b2 = unreadOf('workbuddy');
|
||||
record(b1.json?.length === 1 && b2.json?.length === 1, '广播双方都收到', `opencode=${b1.json?.length} workbuddy=${b2.json?.length}`);
|
||||
}
|
||||
|
||||
async function phaseConcurrency() {
|
||||
process.stdout.write('\n【并发写入】\n');
|
||||
const before = msgs().length;
|
||||
const jobs = [];
|
||||
for (let i = 0; i < 8; i++) jobs.push(aigAsync(['send', `并发消息 ${i}`, '--as', i % 2 ? 'opencode' : 'workbuddy']));
|
||||
const results = await Promise.all(jobs);
|
||||
const okCount = results.filter((r) => r.code === 0).length;
|
||||
const all = msgs();
|
||||
const seqs = all.map((m) => m.seq);
|
||||
record(okCount === 8, '8 个并发 send 全部成功', `成功 ${okCount}/8`);
|
||||
record(all.length === before + 8, '并发后消息数正确', `${before} → ${all.length}`);
|
||||
record(new Set(seqs).size === seqs.length, '序号无重复(锁有效)', `唯一 ${new Set(seqs).size}/${seqs.length}`);
|
||||
record(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), '序号严格递增');
|
||||
}
|
||||
|
||||
async function phaseWait() {
|
||||
process.stdout.write('\n【wait 阻塞等待】\n');
|
||||
aig(['read', '--as', 'workbuddy']);
|
||||
const waiting = aigAsync(['wait', '--timeout', '20', '--interval', '0.3', '--as', 'workbuddy']);
|
||||
await sleep(900);
|
||||
aig(['send', '给等待中的 workbuddy 的一条', '--as', 'dsh']);
|
||||
const w = await waiting;
|
||||
record(w.code === 0, 'wait 被新消息唤醒', `exit=${w.code}`);
|
||||
record(w.out.includes('给等待中的 workbuddy'), 'wait 打印了消息内容');
|
||||
const t = aig(['wait', '--timeout', '1', '--interval', '0.3', '--as', 'workbuddy']);
|
||||
record(t.code === 3, '无消息时 wait 超时返回 3', `exit=${t.code}`);
|
||||
}
|
||||
|
||||
function phaseWakeGuards() {
|
||||
process.stdout.write('\n【唤醒:dry-run 与护栏】\n');
|
||||
// 由 human 发广播:这样 dsh 自己也有未读(自己发的消息对自己是不可见的)
|
||||
aig(['send', '广播给所有人的一条,用于唤醒测试', '--as', 'human']);
|
||||
for (const [member, needle] of [['dsh', '--profile'], ['opencode', 'run'], ['workbuddy', '-p']]) {
|
||||
const r = aig(['wake', member, '--dry-run']);
|
||||
record(r.code === 0 && r.out.includes(needle), `wake ${member} --dry-run 组装命令`, r.code === 0 ? '' : r.err);
|
||||
}
|
||||
const selfWake = aig(['wake', 'human', '--dry-run']);
|
||||
record(selfWake.code === 4 || selfWake.code === 0, 'human 没有唤醒通道时不崩', `exit=${selfWake.code}`);
|
||||
const deep = aig(['wake', 'opencode'], { AIGROUP_DEPTH: '3', AIGROUP_MAX_DEPTH: '3' });
|
||||
record(deep.code === 5, '超过最大深度时拒绝唤醒(防死循环)', `exit=${deep.code}`);
|
||||
const nowake = aig(['wake', 'opencode'], { AIGROUP_NO_WAKE: '1' });
|
||||
record(nowake.code === 5, 'NO_WAKE 链条里拒绝再唤醒', `exit=${nowake.code}`);
|
||||
const model = aig(['wake', 'opencode', '--dry-run', '--model', 'opencode/muse-spark-1.3-contributor-free']);
|
||||
record(model.out.includes('muse-spark-1.3-contributor-free'), '--model 覆盖生效');
|
||||
}
|
||||
|
||||
function phaseFailurePath() {
|
||||
process.stdout.write('\n【失败必须进群 / 备用模型】\n');
|
||||
// 用「一定能失败」的通道:把一个必定非零退出的 JS 文件当成 dsh 的入口
|
||||
const broken = path.join(HOME_ROOT, 'broken-transport.js');
|
||||
fs.writeFileSync(broken, 'process.stderr.write("boom: 通道故意失败\\n"); process.exit(3);\n', 'utf8');
|
||||
aig(['send', '这条会触发一次注定失败的唤醒', '--as', 'human']);
|
||||
const before = msgs().length;
|
||||
const bad = aig(['wake', 'dsh', '--timeout', '30'], { AIGROUP_DSH_BIN: broken });
|
||||
record(bad.code === 4, '唤起失败返回 4', `exit=${bad.code}`);
|
||||
const after = msgs();
|
||||
const lastMsg = after[after.length - 1];
|
||||
record(after.length === before + 1 && lastMsg.kind === 'dispatch-error', '失败被写成 dispatch-error 消息进群', String(lastMsg?.kind));
|
||||
record(String(lastMsg?.text || '').includes('boom'), '失败原因带上真实 stderr', String(lastMsg?.text || '').slice(0, 60).replace(/\n/g, ' '));
|
||||
|
||||
// 备用模型:第一次调用失败、第二次成功 —— 模拟「隐身模型下线后自动退档」
|
||||
const flaky = path.join(HOME_ROOT, 'flaky-transport.js');
|
||||
const flagFile = path.join(HOME_ROOT, 'flaky.flag');
|
||||
fs.writeFileSync(flaky, [
|
||||
"import fs from 'node:fs';",
|
||||
`const flag = ${JSON.stringify(flagFile)};`,
|
||||
"if (!fs.existsSync(flag)) { fs.writeFileSync(flag, '1'); process.stderr.write('model unavailable\\n'); process.exit(3); }",
|
||||
"process.stdout.write('来自备用模型的回答');",
|
||||
].join('\n'), 'utf8');
|
||||
aig(['join', 'dsh', '--model', 'opencode/primary-x', '--fallback-model', 'opencode/backup-y']);
|
||||
aig(['send', '这条模拟主模型下线', '--as', 'human']);
|
||||
const fb = aig(['wake', 'dsh', '--timeout', '30', '--json'], { AIGROUP_DSH_BIN: flaky });
|
||||
const fbReply = fb.json?.reply;
|
||||
record(fb.code === 0 && String(fbReply?.text || '').includes('备用模型'), '主模型失败后自动退到备用模型', `exit=${fb.code}`);
|
||||
record(fbReply?.meta?.model === 'opencode/backup-y' && fbReply?.meta?.fallback_note, '消息里记录了换档事实', String(fbReply?.meta?.fallback_note || '').slice(0, 50));
|
||||
}
|
||||
|
||||
function phaseDiagnostics() {
|
||||
process.stdout.write('\n【诊断命令】\n');
|
||||
for (const [cmd, args] of [['status', ['--json']], ['doctor', ['--json']], ['detect', ['--json']], ['members', ['--json']], ['tail', ['--json', '--limit', '3']], ['rooms', ['--json']]]) {
|
||||
const r = aig([cmd, ...args]);
|
||||
record(r.code === 0 && r.json !== null, `${cmd} --json 可取`, `exit=${r.code}`);
|
||||
}
|
||||
const d = aig(['detect', '--json']);
|
||||
record(typeof d.json?.resolved === 'string' && d.json.resolved.length > 0, 'detect 给出身份判断', String(d.json?.resolved));
|
||||
}
|
||||
|
||||
function phaseBadLines() {
|
||||
process.stdout.write('\n【坏行不静默(workbuddy 在群里的建议)】\n');
|
||||
const log = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl');
|
||||
const marker = '{"seq": 这个不是合法 JSON';
|
||||
fs.appendFileSync(log, `${marker}\n`, 'utf8');
|
||||
const r = aig(['read', '--all', '--as', 'dsh']);
|
||||
record(r.code === 0 && r.out.includes('已发送') === false && r.out.length > 0, '有坏行时仍然读得出好消息', `exit=${r.code}`);
|
||||
record(r.err.includes('跳过') && r.err.includes('1 行'), 'stderr 明确警告跳过了坏行', r.err.split('\n').filter((l) => l.includes('跳过'))[0] || '(无警告)');
|
||||
const badLog = path.join(HOME_ROOT, 'rooms', ROOM, 'bad-lines.log');
|
||||
const dumped = fs.existsSync(badLog) ? fs.readFileSync(badLog, 'utf8') : '';
|
||||
record(dumped.includes('这个不是合法 JSON'), '坏行原文落盘到 bad-lines.log', `${dumped.length}B`);
|
||||
// 清掉坏行,免得影响后面的用例
|
||||
fs.writeFileSync(log, fs.readFileSync(log, 'utf8').split('\n').filter((l) => !l.includes('这个不是合法 JSON')).join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
function phaseReset() {
|
||||
process.stdout.write('\n【默认房间与清理】\n');
|
||||
aig(['use', ROOM]);
|
||||
const s = aig(['send', '不指定 --room 时进默认房间', '--as', 'dsh']);
|
||||
record(s.code === 0 && s.out.includes('已发送'), 'use 设的默认房间生效');
|
||||
const resetNo = aig(['reset']);
|
||||
record(resetNo.code === 2, 'reset 缺 --yes 时拒绝', `exit=${resetNo.code}`);
|
||||
const resetYes = aig(['reset', '--yes']);
|
||||
record(resetYes.code === 0 && !fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM)), 'reset --yes 清空房间');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ main */
|
||||
|
||||
async function main() {
|
||||
process.stdout.write(`\nai-group-chat 自检 · 临时总线 ${HOME_ROOT}\n\n`);
|
||||
phaseRooms();
|
||||
phaseMessages();
|
||||
phaseTargeting();
|
||||
await phaseConcurrency();
|
||||
await phaseWait();
|
||||
phaseWakeGuards();
|
||||
phaseFailurePath();
|
||||
phaseDiagnostics();
|
||||
phaseBadLines();
|
||||
phaseReset();
|
||||
|
||||
if (!KEEP) { try { fs.rmSync(HOME_ROOT, { recursive: true, force: true }); } catch {} }
|
||||
const total = pass + failures.length;
|
||||
process.stdout.write(`\n${'─'.repeat(52)}\n`);
|
||||
process.stdout.write(failures.length ? `✘ ${pass}/${total} 通过,失败 ${failures.length} 项:\n` : `✔ 全部通过(${pass}/${total})\n`);
|
||||
for (const f of failures) process.stdout.write(` · ${f.name} ${f.detail}\n`);
|
||||
if (KEEP) process.stdout.write(`临时总线保留在 ${HOME_ROOT}\n`);
|
||||
process.exitCode = failures.length ? 1 : 0;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,650 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AI 群聊</title>
|
||||
<style>
|
||||
/* ============ Design tokens(linear 配方 → IM 语义) ============ */
|
||||
:root{
|
||||
/* 暗色但可读:所有正文/元信息都按 WCAG AA(4.5:1) 反推,不是凭感觉挑的 */
|
||||
--ground:#0B0D10; --s1:#15181D; --s2:#1C2027; --s3:#252932;
|
||||
--bubble:#242832; --bubble-line:rgba(255,255,255,.12);
|
||||
--hair:rgba(255,255,255,.10); --hair2:rgba(255,255,255,.17);
|
||||
--fg:#F5F7FA; /* ~13:1 */
|
||||
--fg2:#C3C9D4; /* ~11:1 */
|
||||
--fg3:#9AA3B0; /* ~7.6:1 —— 元信息用它,不用更暗的灰 */
|
||||
--accent:#5E6AD2; /* 只做填充/色条 */
|
||||
--accent-text:#AEB7FF; /* 需要当文字用时用它(~9:1) */
|
||||
--accent-soft:color-mix(in oklab, var(--accent) 22%, transparent);
|
||||
--accent-line:color-mix(in oklab, var(--accent) 52%, transparent);
|
||||
--ok:#6BD39E; --err:#FF8B80; --ok-text:#7ADFA9;
|
||||
--r1:6px; --r2:12px; --r3:16px;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.35);
|
||||
--mono:"Cascadia Mono",Consolas,"SFMono-Regular",ui-monospace,monospace;
|
||||
--ease:cubic-bezier(.22,1,.36,1);
|
||||
--t-fast:150ms; --t-med:350ms;
|
||||
}
|
||||
html[data-theme="light"]{
|
||||
--ground:#F4F5F7; --s1:#FFFFFF; --s2:#FFFFFF; --s3:#EEF0F3;
|
||||
--bubble:#FFFFFF; --bubble-line:rgba(0,0,0,.12);
|
||||
--hair:rgba(0,0,0,.12); --hair2:rgba(0,0,0,.2);
|
||||
--fg:#14171C; --fg2:#454C57; --fg3:#5F6772;
|
||||
--accent:#4F5AC2; --accent-text:#3B45A8;
|
||||
--accent-soft:color-mix(in oklab, var(--accent) 12%, transparent);
|
||||
--accent-line:color-mix(in oklab, var(--accent) 38%, transparent);
|
||||
--ok:#1F8A5B; --err:#C0392B; --ok-text:#146B44;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0}
|
||||
body{
|
||||
background:var(--ground); color:var(--fg); overflow:hidden;
|
||||
font:14.5px/1.65 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
|
||||
text-wrap:pretty;
|
||||
}
|
||||
button,select,textarea,input{
|
||||
font:inherit; color:var(--fg); background:var(--s3); border:1px solid var(--hair);
|
||||
border-radius:var(--r1); padding:6px 10px;
|
||||
transition:background-color var(--t-fast) ease-out,border-color var(--t-fast) ease-out,transform 80ms ease-out;
|
||||
}
|
||||
button{cursor:pointer}
|
||||
button:hover{background:color-mix(in oklab, var(--s3) 70%, var(--fg) 8%); border-color:var(--hair2)}
|
||||
button:active{transform:translateY(1px)}
|
||||
button:disabled{opacity:.45;cursor:default;transform:none}
|
||||
button:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--accent-line);outline-offset:1px}
|
||||
.btn-accent{background:color-mix(in oklab, var(--accent) 26%, var(--s3));border-color:var(--accent-line);color:var(--fg)}
|
||||
.btn-accent:hover{background:color-mix(in oklab, var(--accent) 38%, var(--s3));border-color:var(--accent-line)}
|
||||
.mono{font-family:var(--mono)}
|
||||
.chip{
|
||||
font:12px/1.7 var(--mono); color:var(--fg3); border:1px solid var(--hair);
|
||||
border-radius:var(--r1); padding:0 6px; white-space:nowrap;
|
||||
}
|
||||
.label{font:12px/1.6 var(--mono); color:var(--fg3); letter-spacing:.06em}
|
||||
|
||||
.app{display:flex;height:100%}
|
||||
|
||||
/* ============================== 左栏 ============================== */
|
||||
.side{width:276px;flex:0 0 276px;background:var(--s1);border-right:1px solid var(--hair);display:flex;flex-direction:column}
|
||||
.side-head{padding:14px 14px 12px;border-bottom:1px solid var(--hair)}
|
||||
.side-title{display:flex;align-items:center;gap:12px}
|
||||
.side-title h1{margin:0;font-size:15px;font-weight:600;letter-spacing:-.01em}
|
||||
.side-sub{margin-top:3px;font:12px/1.6 var(--mono);color:var(--fg3)}
|
||||
.side-tools{display:flex;gap:8px;margin-top:14px}
|
||||
.side-tools button,.side-tools select{flex:1;font-size:12px;padding:5px 8px}
|
||||
.side-list{flex:1;overflow:auto;padding:10px 8px}
|
||||
.sect{font:12px/1.6 var(--mono);color:var(--fg3);letter-spacing:.06em;padding:6px 8px}
|
||||
.mcard{display:flex;gap:11px;padding:10px 8px 10px 12px;border-radius:var(--r2);position:relative;
|
||||
transition:background-color var(--t-fast) ease-out}
|
||||
.mcard:hover{background:var(--s2)}
|
||||
.mcard[data-busy="1"]{background:color-mix(in oklab, var(--accent) 15%, var(--s2));box-shadow:inset 0 0 0 1px var(--accent-line)}
|
||||
.mcard[data-target="1"]{background:var(--s2)}
|
||||
.mcard[data-busy="1"]::before,.mcard[data-target="1"]::before{
|
||||
content:"";position:absolute;left:3px;top:9px;bottom:9px;width:3px;border-radius:3px;background:var(--accent)}
|
||||
.mcard[data-busy="1"] .mname{color:#fff}
|
||||
html[data-theme="light"] .mcard[data-busy="1"] .mname{color:var(--fg)}
|
||||
.mcard .body{flex:1;min-width:0}
|
||||
.line1{display:flex;align-items:center;gap:7px}
|
||||
.mname{font-weight:600;font-size:13.5px}
|
||||
.role{font:12px/1.7 var(--mono);color:var(--accent-text);border:1px solid var(--accent-line);border-radius:var(--r1);padding:0 5px}
|
||||
.mstat{font-size:12px;color:var(--fg3);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mstat.work{color:var(--fg);}
|
||||
.mstat .t{font-family:var(--mono);color:var(--accent-text)}
|
||||
.mmeta{font:12px/1.6 var(--mono);color:var(--fg3);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mact{display:flex;gap:6px;margin-top:8px;opacity:0;transition:opacity var(--t-fast) ease-out}
|
||||
.mcard:hover .mact,.mcard[data-busy="1"] .mact,.mcard[data-target="1"] .mact{opacity:1}
|
||||
.mact button{font-size:12px;padding:3px 8px;flex:1}
|
||||
.side-foot{padding:12px 16px;border-top:1px solid var(--hair);font:12px/1.7 var(--mono);color:var(--fg3);word-break:break-all}
|
||||
|
||||
/* ============================== 右栏 ============================== */
|
||||
.chat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
|
||||
/* 极克制的顶部环境光:给暗色界面一个焦点,不是渐变海报 */
|
||||
.chat::before{content:"";position:absolute;top:0;left:0;right:0;height:120px;z-index:0;pointer-events:none;
|
||||
background:radial-gradient(120% 130% at 18% 0%, color-mix(in oklab, var(--accent) 13%, transparent), transparent 68%)}
|
||||
.chat-head{position:relative;z-index:1;display:flex;align-items:center;gap:12px;padding:13px 24px;background:var(--s1);border-bottom:1px solid var(--hair)}
|
||||
.chat-head h2{margin:0;font-size:14.5px;font-weight:600;letter-spacing:-.01em;white-space:nowrap}
|
||||
.faces{display:flex;align-items:center;margin-left:2px}
|
||||
.faces .av{margin-left:-7px;box-shadow:0 0 0 2px var(--s1)}
|
||||
.faces .av:first-child{margin-left:0}
|
||||
.head-stat{font:12px/1.6 var(--mono);color:var(--fg3);white-space:nowrap}
|
||||
.grow{flex:1}
|
||||
.live{display:flex;align-items:center;gap:6px}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--fg3);flex:0 0 7px}
|
||||
.dot.on{background:var(--ok)}
|
||||
|
||||
/* 工作状态条:只留"谁 · 在做什么 · 多久",长文本交给气泡,别堆在这儿 */
|
||||
.strip{position:relative;z-index:1;display:flex;gap:18px;flex-wrap:wrap;align-items:center;padding:10px 24px;
|
||||
border-bottom:1px solid var(--hair);background:var(--s1)}
|
||||
.strip:empty{display:none}
|
||||
.strip .who{display:flex;align-items:center;gap:8px;font:12px/1.6 var(--mono);color:var(--fg2)}
|
||||
.strip .who b{color:var(--fg);font-weight:600}
|
||||
.strip .who .t{color:var(--accent-text)}
|
||||
.strip .who .st{color:var(--fg3)}
|
||||
.bar{width:54px;height:2px;border-radius:2px;background:var(--s3);overflow:hidden;position:relative}
|
||||
.bar i{position:absolute;inset:0;width:40%;background:var(--accent);border-radius:2px;animation:slide 1.4s var(--ease) infinite}
|
||||
@keyframes slide{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}
|
||||
|
||||
.feed{flex:1;overflow:auto;padding:24px 24px 6px;scroll-behavior:auto;position:relative;z-index:1;background:var(--ground)}
|
||||
.day{text-align:center;margin:14px 0 10px}
|
||||
.day span{font:12px/1.6 var(--mono);color:var(--fg3);background:var(--s3);border:1px solid var(--hair);border-radius:var(--r1);padding:1px 8px}
|
||||
.sys{text-align:center;margin:10px 0}
|
||||
.sys span{display:inline-block;font:12px/1.7 var(--mono);color:var(--fg3);border:1px solid var(--hair);border-radius:var(--r1);padding:2px 10px;background:var(--s1)}
|
||||
.sys.err span{color:var(--err);border-color:color-mix(in oklab,var(--err) 30%,transparent)}
|
||||
|
||||
.row{display:flex;gap:12px;margin:19px 0;align-items:flex-start;animation:in 240ms var(--ease) both}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
.row.self{flex-direction:row-reverse}
|
||||
.bwrap{max-width:min(68%,720px);min-width:0;display:flex;flex-direction:column}
|
||||
.row.self .bwrap{align-items:flex-end}
|
||||
.bhead{display:flex;align-items:baseline;gap:8px;margin-bottom:5px;font:12px/1.6 var(--mono);color:var(--fg3)}
|
||||
.bhead .nm{color:var(--fg);font-weight:600;font-family:inherit;font-size:12.5px}
|
||||
.bubble{background:var(--bubble);border:1px solid var(--bubble-line);border-radius:var(--r2);padding:12px 15px;
|
||||
box-shadow:0 1px 2px rgba(0,0,0,.35);white-space:pre-wrap;word-break:break-word;line-height:1.72}
|
||||
.row.self .bubble{background:var(--accent-soft);border-color:var(--accent-line)}
|
||||
.row.err .bubble{border-color:color-mix(in oklab,var(--err) 32%,transparent);color:#FCA5A5}
|
||||
html[data-theme="light"] .row.err .bubble{color:#B42318}
|
||||
.quote{border-left:2px solid var(--hair2);padding:1px 0 1px 9px;margin-bottom:7px;font-size:12px;color:var(--fg3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.bmeta{display:flex;gap:6px;flex-wrap:wrap;margin-top:5px}
|
||||
.row.self .bmeta{justify-content:flex-end}
|
||||
.pill{font:12px/1.7 var(--mono);color:var(--fg3);border:1px solid var(--hair);border-radius:var(--r1);padding:0 5px}
|
||||
.pill.awake{color:var(--accent-text);border-color:var(--accent-line)}
|
||||
.pill.free{color:var(--ok-text);border-color:color-mix(in oklab,var(--ok) 34%,transparent)}
|
||||
.row.pending .bubble{border-style:dashed;opacity:.7}
|
||||
|
||||
/* 正在输入 */
|
||||
.row.typing .bubble{display:flex;align-items:center;gap:11px;background:var(--s3);min-width:240px}
|
||||
.dots{display:inline-flex;gap:4px}
|
||||
.dots i{width:5px;height:5px;border-radius:50%;background:var(--fg2);animation:bl 1.15s infinite}
|
||||
.dots i:nth-child(2){animation-delay:.14s}
|
||||
.dots i:nth-child(3){animation-delay:.28s}
|
||||
@keyframes bl{0%,60%,100%{opacity:.22;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
|
||||
.tstat{font:12px/1.6 var(--mono);color:var(--fg2)}
|
||||
.row.typing .stop{font-size:12px;padding:1px 7px;margin-left:auto;font-family:var(--mono)}
|
||||
|
||||
/* 头像 */
|
||||
.av{width:36px;height:36px;flex:0 0 36px;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||||
font-weight:600;font-size:13px;border:1px solid var(--hair2);user-select:none;position:relative}
|
||||
.av.sm{width:27px;height:27px;flex:0 0 27px;font-size:11.5px}
|
||||
.av .ring{position:absolute;inset:-3px;border-radius:50%;border:1.5px solid var(--accent);animation:ring 1.5s var(--ease) infinite}
|
||||
@keyframes ring{0%{transform:scale(.9);opacity:.85}80%{transform:scale(1.16);opacity:0}100%{opacity:0}}
|
||||
|
||||
/* 输入区 */
|
||||
.composer{background:var(--s1);border-top:1px solid var(--hair);padding:13px 20px 15px}
|
||||
.crow{display:flex;gap:10px;align-items:flex-end}
|
||||
.crow select{flex:0 0 152px;font-size:13px}
|
||||
#text{flex:1;min-height:84px;max-height:220px;resize:none;background:var(--s2);line-height:1.7}
|
||||
.cbtns{display:flex;flex-direction:column;gap:10px;flex:0 0 128px}
|
||||
.cbtns button{padding:10px}
|
||||
.cbtns button.quiet{background:transparent;border-color:transparent;color:var(--fg3);padding:4px}
|
||||
.cbtns button.quiet:hover{background:var(--s3);border-color:var(--hair);color:var(--fg2)}
|
||||
.chint{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:9px}
|
||||
.empty{color:var(--fg3);text-align:center;margin-top:44px}
|
||||
.jump{position:absolute;right:28px;bottom:132px;display:none;font:12px/1.7 var(--mono);border-radius:var(--r1);
|
||||
background:var(--s3);border:1px solid var(--hair2);padding:4px 11px;box-shadow:var(--shadow);color:var(--fg2)}
|
||||
@media (prefers-reduced-motion:reduce){*{animation-duration:.001ms !important;transition-duration:.001ms !important}}
|
||||
@media (max-width:900px){
|
||||
.app{flex-direction:column-reverse}
|
||||
.side{width:100%;flex:0 0 46%;border-right:0;border-top:1px solid var(--hair)}
|
||||
.bwrap{max-width:88%}
|
||||
.crow select{flex:0 0 116px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="side-head">
|
||||
<div class="side-title">
|
||||
<div class="av" id="groupAv">群</div>
|
||||
<div>
|
||||
<h1 id="groupName">AI 群聊</h1>
|
||||
<div class="side-sub" id="groupSub">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-tools">
|
||||
<button id="autoBtn" title="开启后你发的消息会自动唤醒被点名的成员">自驱:关</button>
|
||||
<select id="roomSel" title="切换房间"></select>
|
||||
<button id="themeBtn" title="明暗切换">亮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-list" id="rail"></div>
|
||||
<div class="side-foot" id="footHint"></div>
|
||||
</aside>
|
||||
|
||||
<section class="chat">
|
||||
<header class="chat-head">
|
||||
<h2 id="chatName">AI 群聊</h2>
|
||||
<div class="faces" id="faces"></div>
|
||||
<span class="head-stat" id="headStat"></span>
|
||||
<span class="grow"></span>
|
||||
<span class="live"><span class="dot" id="liveDot"></span><span class="head-stat" id="liveTxt">连接中</span></span>
|
||||
</header>
|
||||
<div class="strip" id="strip"></div>
|
||||
<div class="feed" id="feed"><div class="empty" id="emptyHint">还没有人说话。在下面直接说,默认发给主代理。</div></div>
|
||||
<button class="jump" id="jump">有新消息 ↓</button>
|
||||
<div class="composer">
|
||||
<div class="crow">
|
||||
<select id="target">
|
||||
<option value="__all__">发给全体(默认)</option>
|
||||
<option value="__primary__">只发给主代理</option>
|
||||
</select>
|
||||
<textarea id="text" placeholder="说点什么…(默认全体;大家会自动接话)"></textarea>
|
||||
<div class="cbtns">
|
||||
<button class="btn-accent" id="sendBtn">发送</button>
|
||||
<button class="quiet" id="sendOnlyBtn">静默发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chint">
|
||||
<span class="chip">Enter 发送</span>
|
||||
<span class="chip">Shift+Enter 换行</span>
|
||||
<span class="chip">Esc 清空</span>
|
||||
<span class="grow"></span>
|
||||
<span class="label" id="hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const feed = $('feed'), rail = $('rail'), strip = $('strip'), textEl = $('text'), targetEl = $('target');
|
||||
const sendBtn = $('sendBtn'), sendOnlyBtn = $('sendOnlyBtn'), autoBtn = $('autoBtn'), roomSel = $('roomSel');
|
||||
const liveTxt = $('liveTxt'), liveDot = $('liveDot'), jump = $('jump'), faces = $('faces'), themeBtn = $('themeBtn');
|
||||
|
||||
// 成员身份色:低彩度、按名字散列,避免彩虹
|
||||
const HUE = { dsh: 265, opencode: 158, workbuddy: 32, qoder: 208, human: 265 };
|
||||
const hueOf = (n) => (HUE[n] != null ? HUE[n] : (() => { let h = 0; for (const c of String(n)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; })());
|
||||
const S = { room: '', primary: 'dsh', head: 0, auto: true, budgetLeft: null, turnBudget: null, members: [], jobs: new Map(), seen: new Set(), pending: new Map(), quote: {}, lastTs: 0 };
|
||||
|
||||
const initialOf = (n) => (n === 'human' ? '我' : String(n).slice(0, 1).toUpperCase());
|
||||
const hhmm = (ts) => { const d = new Date(ts); const p = (n) => (n < 10 ? '0' : '') + n; return p(d.getHours()) + ':' + p(d.getMinutes()); };
|
||||
const setText = (n, t) => { if (n && n.textContent !== t) n.textContent = t; };
|
||||
const mk = (tag, cls, txt) => { const n = document.createElement(tag); if (cls) n.className = cls; if (txt != null) n.textContent = txt; return n; };
|
||||
const atBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 90;
|
||||
|
||||
function avatar(name, { small = false, busy = false } = {}) {
|
||||
const h = hueOf(name);
|
||||
const a = mk('div', 'av' + (small ? ' sm' : ''), initialOf(name));
|
||||
a.style.background = `oklch(0.32 0.035 ${h})`;
|
||||
a.style.color = `oklch(0.93 0.02 ${h})`;
|
||||
a.dataset.av = name;
|
||||
if (busy) a.appendChild(mk('span', 'ring'));
|
||||
return a;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 消息气泡 */
|
||||
function bubble(m) {
|
||||
const self = m.from === 'human';
|
||||
const row = mk('div', 'row' + (self ? ' self' : '') + (m._pending ? ' pending' : '') + (m.kind === 'dispatch-error' ? ' err' : ''));
|
||||
row.appendChild(avatar(m.from));
|
||||
const wrap = mk('div', 'bwrap');
|
||||
const head = mk('div', 'bhead');
|
||||
head.appendChild(mk('span', 'nm', self ? '我' : m.from));
|
||||
head.appendChild(mk('span', null, hhmm(m.ts)));
|
||||
wrap.appendChild(head);
|
||||
const b = mk('div', 'bubble');
|
||||
const q = m.reply_to && S.quote[m.reply_to];
|
||||
if (q) b.appendChild(mk('div', 'quote', '回复 ' + q.from + ':' + String(q.text).replace(/\s+/g, ' ').slice(0, 56)));
|
||||
b.appendChild(mk('div', 'text', m.text));
|
||||
wrap.appendChild(b);
|
||||
const meta = mk('div', 'bmeta');
|
||||
if (m.via === 'dispatch') meta.appendChild(mk('span', 'pill awake', '被叫醒'));
|
||||
if (m.meta && m.meta.model) meta.appendChild(mk('span', 'pill', m.meta.model));
|
||||
if (m.meta && typeof m.meta.cost === 'number') meta.appendChild(mk('span', 'pill free', '¥' + m.meta.cost));
|
||||
meta.appendChild(mk('span', 'pill', (m.to || []).length ? '→ ' + m.to.join(',') : '→ 全体'));
|
||||
if (m._pending) meta.appendChild(mk('span', 'pill', '发送中'));
|
||||
wrap.appendChild(meta);
|
||||
row.appendChild(wrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function pushMessage(m) {
|
||||
if (m.seq != null && S.seen.has(m.seq)) return;
|
||||
if (m.seq != null) { S.seen.add(m.seq); S.quote[m.seq] = m; }
|
||||
if (m.from === 'human' && S.pending.has(m.text)) {
|
||||
const node = S.pending.get(m.text); S.pending.delete(m.text);
|
||||
const stick = atBottom(); node.replaceWith(bubble(m));
|
||||
if (stick) feed.scrollTop = feed.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const stick = atBottom();
|
||||
const hint = $('emptyHint'); if (hint) hint.remove();
|
||||
if (m.kind === 'dispatch-error') {
|
||||
const d = mk('div', 'sys err');
|
||||
d.appendChild(mk('span', null, m.from + ' 唤醒失败 · ' + String(m.text).replace(/\s+/g, ' ').slice(0, 90)));
|
||||
feed.appendChild(d);
|
||||
} else {
|
||||
if (m.ts - S.lastTs > 6 * 60 * 1000) { const d = mk('div', 'day'); d.appendChild(mk('span', null, hhmm(m.ts))); feed.appendChild(d); }
|
||||
feed.appendChild(bubble(m));
|
||||
}
|
||||
S.lastTs = m.ts;
|
||||
if (stick) feed.scrollTop = feed.scrollHeight; else jump.style.display = 'block';
|
||||
}
|
||||
|
||||
function optimistic(text, to) {
|
||||
const hint = $('emptyHint'); if (hint) hint.remove();
|
||||
const node = bubble({ from: 'human', to: to || [], text, ts: Date.now(), kind: 'chat', _pending: true });
|
||||
feed.appendChild(node);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
S.pending.set(text, node);
|
||||
setTimeout(() => { if (S.pending.get(text) === node) { S.pending.delete(text); node.classList.remove('pending'); } }, 15000);
|
||||
}
|
||||
|
||||
jump.addEventListener('click', () => { feed.scrollTop = feed.scrollHeight; jump.style.display = 'none'; });
|
||||
feed.addEventListener('scroll', () => { if (atBottom()) jump.style.display = 'none'; });
|
||||
|
||||
/* ------------------------------------------------ 正在干什么(进行中) */
|
||||
function actText(job, secs) {
|
||||
const n = String(job.note || '').replace(/\s+/g, ' ');
|
||||
if (job.kind === 'ask') return { verb: '正在回答', what: n ? ':' + n.slice(0, 46) : '', secs };
|
||||
if (n.startsWith('auto:')) return { verb: '正在处理', what: ' ' + n.replace(/^auto:/, '').slice(0, 46), secs };
|
||||
return { verb: '正在处理未读', what: '', secs };
|
||||
}
|
||||
|
||||
function typingNode(job) {
|
||||
const row = mk('div', 'row typing'); row.dataset.job = job.id;
|
||||
row.appendChild(avatar(job.member, { busy: true }));
|
||||
const wrap = mk('div', 'bwrap');
|
||||
const head = mk('div', 'bhead');
|
||||
head.appendChild(mk('span', 'nm', job.member));
|
||||
wrap.appendChild(head);
|
||||
const b = mk('div', 'bubble');
|
||||
const dots = mk('span', 'dots'); dots.append(mk('i'), mk('i'), mk('i'));
|
||||
b.appendChild(dots);
|
||||
const a = actText(job, 0);
|
||||
const st = mk('span', 'tstat');
|
||||
const verb = mk('span', null, a.verb + a.what);
|
||||
const time = mk('span', 'mono', ' ' + a.secs + 's');
|
||||
st.append(verb, time);
|
||||
b.appendChild(st);
|
||||
const stop = mk('button', 'stop', '中止');
|
||||
const jid = job.id;
|
||||
stop.addEventListener('click', () => { stop.disabled = true; stop.textContent = '中止中'; post('/api/cancel', { id: jid }).catch(() => {}); });
|
||||
b.appendChild(stop);
|
||||
wrap.appendChild(b);
|
||||
row.appendChild(wrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function liveJobs() {
|
||||
const out = [];
|
||||
for (const [, j] of S.jobs) if (j.status === 'running' && j.member && j.member !== 'human') out.push(j);
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncLive() {
|
||||
const live = liveJobs();
|
||||
for (const node of [...feed.querySelectorAll('.row.typing')]) {
|
||||
const j = S.jobs.get(node.dataset.job);
|
||||
if (!j || j.status !== 'running') node.remove();
|
||||
}
|
||||
for (const j of live) {
|
||||
let node = feed.querySelector('.row.typing[data-job="' + j.id + '"]');
|
||||
if (!node) { node = typingNode(j); feed.appendChild(node); } else feed.appendChild(node);
|
||||
const a = actText(j, Math.round((Date.now() - j.startedAt) / 1000));
|
||||
const st = node.querySelector('.tstat');
|
||||
if (st) {
|
||||
setText(st.firstChild, a.verb + a.what);
|
||||
setText(st.lastChild, ' ' + a.secs + 's');
|
||||
}
|
||||
const av = node.querySelector('.av');
|
||||
if (av && !av.querySelector('.ring')) av.appendChild(mk('span', 'ring'));
|
||||
}
|
||||
// 顶部状态条:谁 · 在做什么 · 多久(长文本留在气泡里,别堆这儿)
|
||||
strip.textContent = '';
|
||||
for (const j of live) {
|
||||
const secs = Math.round((Date.now() - j.startedAt) / 1000);
|
||||
const w = mk('span', 'who');
|
||||
w.appendChild(mk('b', null, j.member));
|
||||
w.appendChild(mk('span', 'st', j.kind === 'ask' ? '回答中' : '处理中'));
|
||||
w.appendChild(mk('span', 't', secs + 's'));
|
||||
const bar = mk('span', 'bar'); bar.appendChild(mk('i'));
|
||||
w.appendChild(bar);
|
||||
strip.appendChild(w);
|
||||
}
|
||||
// 头像光环 + 侧栏状态
|
||||
for (const f of faces.querySelectorAll('.av')) {
|
||||
const busy = live.some((j) => j.member === f.dataset.av);
|
||||
const has = f.querySelector('.ring');
|
||||
if (busy && !has) f.appendChild(mk('span', 'ring'));
|
||||
if (!busy && has) has.remove();
|
||||
}
|
||||
for (const card of rail.querySelectorAll('.mcard')) {
|
||||
const j = live.find((x) => x.member === card.dataset.member);
|
||||
card.dataset.busy = j ? '1' : '0';
|
||||
const st = card.querySelector('.mstat');
|
||||
if (!j) continue;
|
||||
const a = actText(j, Math.round((Date.now() - j.startedAt) / 1000));
|
||||
st.textContent = '';
|
||||
st.appendChild(mk('span', null, a.verb + a.what));
|
||||
st.appendChild(mk('span', 't', ' ' + a.secs + 's'));
|
||||
st.classList.add('work');
|
||||
}
|
||||
}
|
||||
setInterval(syncLive, 300);
|
||||
|
||||
/* -------------------------------------------------------- 成员侧栏 */
|
||||
const mNodes = new Map();
|
||||
function memberCard(m) {
|
||||
const card = mk('div', 'mcard'); card.dataset.member = m.member;
|
||||
card.appendChild(avatar(m.member));
|
||||
const body = mk('div', 'body');
|
||||
const l1 = mk('div', 'line1');
|
||||
const name = mk('span', 'mname', m.member);
|
||||
const role = mk('span', 'role', '主代理'); role.style.display = 'none';
|
||||
l1.append(name, role);
|
||||
const stat = mk('div', 'mstat', '空闲');
|
||||
const meta = mk('div', 'mmeta', '');
|
||||
const act = mk('div', 'mact');
|
||||
const wakeBtn = mk('button', null, '唤醒');
|
||||
const atBtn = mk('button', null, '@');
|
||||
act.append(wakeBtn, atBtn);
|
||||
body.append(l1, stat, meta, act);
|
||||
card.appendChild(body);
|
||||
|
||||
atBtn.addEventListener('click', () => { targetEl.value = m.member; textEl.focus(); });
|
||||
wakeBtn.addEventListener('click', () => {
|
||||
wakeBtn.disabled = true; wakeBtn.textContent = '工作中';
|
||||
const localId = 'local-' + m.member + '-' + Date.now();
|
||||
upsertJob({ id: localId, member: m.member, kind: 'wake', status: 'running', startedAt: Date.now(), note: '手动唤醒', local: true });
|
||||
post('/api/wake', { target: m.member }).catch(() => {}).finally(() => { wakeBtn.disabled = false; wakeBtn.textContent = '唤醒'; });
|
||||
});
|
||||
return { card, name, role, stat, meta, wakeBtn, atBtn };
|
||||
}
|
||||
|
||||
/** 目标下拉按成员自动生成:加人不用改前端 */
|
||||
function syncTargets(list) {
|
||||
const key = list.map((m) => m.member).join(',');
|
||||
if (targetEl.dataset.members === key) return;
|
||||
targetEl.dataset.members = key;
|
||||
const keep = targetEl.value;
|
||||
targetEl.textContent = '';
|
||||
const all = mk('option', null, '发给全体(默认)'); all.value = '__all__';
|
||||
const prim = mk('option', null, '只发给主代理'); prim.value = '__primary__';
|
||||
targetEl.append(all, prim);
|
||||
for (const m of list) { const o = mk('option', null, '只 @ ' + m.member); o.value = m.member; targetEl.appendChild(o); }
|
||||
if ([...targetEl.options].some((o) => o.value === keep)) targetEl.value = keep;
|
||||
}
|
||||
|
||||
function syncMembers(list) {
|
||||
const names = new Set(list.map((m) => m.member));
|
||||
for (const [n, node] of mNodes) if (!names.has(n)) { node.card.remove(); mNodes.delete(n); }
|
||||
for (const m of list) {
|
||||
let n = mNodes.get(m.member);
|
||||
if (!n) { n = memberCard(m); mNodes.set(m.member, n); rail.appendChild(n.card); }
|
||||
n.card.style.order = m.primary ? -1 : 0;
|
||||
n.card.dataset.target = (targetEl.value === m.member) ? '1' : '0';
|
||||
setText(n.name, m.member);
|
||||
n.role.style.display = m.primary ? '' : 'none';
|
||||
n.card.dataset.busy = n.card.dataset.busy === '1' ? '1' : '0';
|
||||
if (n.card.dataset.busy !== '1') {
|
||||
setText(n.stat, m.ageSec == null ? '还没活动过' : (m.ageSec < 120 ? '空闲 · ' + m.ageSec + 's 前活动' : '空闲 · 很久没动作'));
|
||||
n.stat.classList.remove('work');
|
||||
}
|
||||
setText(n.meta, '游标 #' + m.cursor + ' · ' + (m.model || '默认模型') + (m.fallback ? ' / 备用 ' + m.fallback : ''));
|
||||
}
|
||||
faces.textContent = '';
|
||||
for (const m of list) {
|
||||
const a = avatar(m.member, { small: true });
|
||||
a.title = m.member + ' · ' + (m.model || '默认模型');
|
||||
faces.appendChild(a);
|
||||
}
|
||||
syncTargets(list);
|
||||
setText($('headStat'), list.length + ' 个成员 · 主代理 ' + S.primary);
|
||||
setText($('groupSub'), list.length + ' 个成员 · 主代理 ' + S.primary);
|
||||
setText($('chatName'), S.room);
|
||||
setText($('groupName'), S.room);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 任务 */
|
||||
function upsertJob(j) {
|
||||
const prev = S.jobs.get(j.id) || {};
|
||||
const cur = Object.assign({}, prev, j);
|
||||
S.jobs.set(j.id, cur);
|
||||
const mn = mNodes.get(cur.member);
|
||||
if (cur.status === 'running') {
|
||||
if (mn) { mn.wakeBtn.disabled = true; mn.wakeBtn.textContent = '工作中'; }
|
||||
} else {
|
||||
if (mn) {
|
||||
mn.wakeBtn.disabled = false; mn.wakeBtn.textContent = '唤醒';
|
||||
mn.card.dataset.busy = '0';
|
||||
const secs = Math.round(((cur.endedAt || Date.now()) - cur.startedAt) / 1000);
|
||||
setText(mn.stat, (cur.status === 'ok' ? '刚完成 · ' : cur.status === 'cancelled' ? '已中止 · ' : '刚失败 · ') + secs + 's');
|
||||
mn.stat.classList.remove('work');
|
||||
}
|
||||
for (const [id, other] of [...S.jobs]) if (other.local && other.member === cur.member) S.jobs.delete(id);
|
||||
}
|
||||
syncLive();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 网络 */
|
||||
function post(url, body) {
|
||||
return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json().catch(() => ({})));
|
||||
}
|
||||
function applyState(s) {
|
||||
S.room = s.room; S.primary = s.primary; S.head = s.head;
|
||||
if (typeof s.budgetLeft === 'number') S.budgetLeft = s.budgetLeft;
|
||||
if (typeof s.turnBudget === 'number') S.turnBudget = s.turnBudget;
|
||||
paintAuto(Boolean(s.auto));
|
||||
if (roomSel.dataset.rooms !== (s.rooms || []).join(',')) {
|
||||
roomSel.dataset.rooms = (s.rooms || []).join(',');
|
||||
roomSel.textContent = '';
|
||||
for (const r of s.rooms) { const o = mk('option', null, r); o.value = r; roomSel.appendChild(o); }
|
||||
roomSel.value = s.room;
|
||||
}
|
||||
(s.jobs || []).forEach(upsertJob);
|
||||
syncMembers((s.members || []).filter((m) => m.member !== 'human'));
|
||||
$('footHint').textContent = s.roomDir + '\n' + s.workspace + '\n只绑定本机';
|
||||
}
|
||||
const loadState = () => fetch('/api/state').then((r) => r.json()).then(applyState).catch(() => {});
|
||||
function paintAuto(on) {
|
||||
S.auto = Boolean(on);
|
||||
const left = S.budgetLeft;
|
||||
autoBtn.textContent = '自驱:' + (on ? '开' : '关') + (on && left != null ? (left > 0 ? ' · 剩 ' + left + ' 轮' : ' · 预算尽') : '');
|
||||
autoBtn.classList.toggle('btn-accent', Boolean(on) && !(left === 0));
|
||||
autoBtn.title = on
|
||||
? '新消息会自动唤醒相关成员(AI 之间也会互相接话)。你没有人工审批这一环,所以这里有轮次预算:你一发言就重置为 ' + (S.turnBudget || '—') + ' 轮。'
|
||||
: '已关闭自驱:消息只会落进群里,不会自动叫醒任何人';
|
||||
}
|
||||
function paintBudget(d) {
|
||||
if (typeof d.budgetLeft === 'number') S.budgetLeft = d.budgetLeft;
|
||||
if (typeof d.turnBudget === 'number') S.turnBudget = d.turnBudget;
|
||||
paintAuto(S.auto);
|
||||
if (d.exhausted) setText($('hint'), '本轮自动对话已到预算上限(等人说话自动重置)');
|
||||
}
|
||||
function loadMessages() {
|
||||
return fetch('/api/messages?since=0').then((r) => r.json()).then((d) => {
|
||||
feed.textContent = '';
|
||||
const day = mk('div', 'day'); day.appendChild(mk('span', null, hhmm(Date.now()))); feed.appendChild(day);
|
||||
S.seen.clear(); S.pending.clear(); S.quote = {}; S.lastTs = 0;
|
||||
if (!d.messages.length) { const e = mk('div', 'empty', '还没有人说话。在下面直接说,默认发给主代理。'); e.id = 'emptyHint'; feed.appendChild(e); }
|
||||
for (const m of d.messages) pushMessage(m);
|
||||
S.head = d.head;
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}).catch(() => {});
|
||||
}
|
||||
function stream() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('hello', (e) => {
|
||||
liveTxt.textContent = '已连接'; liveDot.className = 'dot on';
|
||||
const h = JSON.parse(e.data).head || 0;
|
||||
if (h > S.head) fetch('/api/messages?since=' + S.head).then((r) => r.json()).then((d) => { d.messages.forEach(pushMessage); S.head = d.head; }).catch(() => {});
|
||||
});
|
||||
es.addEventListener('messages', (e) => {
|
||||
const rows = JSON.parse(e.data);
|
||||
if (!rows.length) return;
|
||||
rows.forEach(pushMessage);
|
||||
S.head = rows[rows.length - 1].seq;
|
||||
loadState();
|
||||
});
|
||||
es.addEventListener('job', (e) => upsertJob(JSON.parse(e.data)));
|
||||
es.addEventListener('state', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof d.auto === 'boolean') { S.auto = d.auto; }
|
||||
if (typeof d.budgetLeft === 'number') S.budgetLeft = d.budgetLeft;
|
||||
paintAuto(S.auto);
|
||||
} catch { /* 忽略 */ }
|
||||
});
|
||||
es.addEventListener('budget', (e) => { try { paintBudget(JSON.parse(e.data)); } catch { /* 忽略 */ } });
|
||||
const down = () => { liveTxt.textContent = '重连中'; liveDot.className = 'dot'; };
|
||||
es.addEventListener('error', down); es.onerror = down;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 发送 */
|
||||
const target = () => { const v = targetEl.value; return v === '__primary__' ? (S.primary || 'dsh') : v === '__all__' ? '' : v; };
|
||||
function send(silent) {
|
||||
const text = textEl.value.trim();
|
||||
if (!text) { textEl.focus(); return; }
|
||||
const to = target();
|
||||
textEl.value = '';
|
||||
optimistic(text, to ? [to] : []);
|
||||
// 默认(全体)走普通发送,由自驱把大家都叫起来;点名某个成员则直接叫醒它
|
||||
if (!to || silent) {
|
||||
const btn = silent ? sendOnlyBtn : sendBtn;
|
||||
const label = silent ? '静默发送' : '发送';
|
||||
btn.disabled = true; btn.textContent = '发送中';
|
||||
post('/api/send', { text, to: to ? [to] : [], silent: Boolean(silent) }).catch(() => {})
|
||||
.finally(() => { btn.disabled = false; btn.textContent = label; });
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = true; sendBtn.textContent = '已叫醒';
|
||||
post('/api/ask', { text, target: to }).then((d) => { if (d.job) upsertJob(Object.assign({ local: true }, d.job)); })
|
||||
.catch(() => {}).finally(() => setTimeout(() => { sendBtn.disabled = false; sendBtn.textContent = '发送'; }, 700));
|
||||
}
|
||||
sendBtn.addEventListener('click', () => send(false));
|
||||
sendOnlyBtn.addEventListener('click', () => send(true));
|
||||
textEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(true); }
|
||||
if (e.key === 'Escape') textEl.value = '';
|
||||
});
|
||||
autoBtn.addEventListener('click', () => {
|
||||
// 先本地立刻翻转,别让用户等 5 秒轮询(上一版就是栽在这)
|
||||
const next = !autoBtn.classList.contains('btn-accent');
|
||||
paintAuto(next);
|
||||
autoBtn.disabled = true;
|
||||
post('/api/auto', {}).then((d) => { if (typeof d.auto === 'boolean') paintAuto(d.auto); })
|
||||
.catch(() => paintAuto(!next)).finally(() => { autoBtn.disabled = false; });
|
||||
});
|
||||
// 侧栏标出当前 @ 目标,一眼知道这条会发给谁
|
||||
targetEl.addEventListener('change', () => {
|
||||
for (const [name, n] of mNodes) n.card.dataset.target = (targetEl.value === name) ? '1' : '0';
|
||||
});
|
||||
roomSel.addEventListener('change', () => post('/api/room', { room: roomSel.value }).then(() => loadMessages().then(loadState)));
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadState(); });
|
||||
|
||||
const applyTheme = (t) => {
|
||||
document.documentElement.dataset.theme = t;
|
||||
themeBtn.textContent = t === 'dark' ? '亮' : '暗';
|
||||
try { localStorage.setItem('aig.theme', t); } catch { /* 忽略 */ }
|
||||
};
|
||||
applyTheme((() => { try { return localStorage.getItem('aig.theme') || 'dark'; } catch { return 'dark'; } })());
|
||||
themeBtn.addEventListener('click', () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
|
||||
|
||||
loadState().then(loadMessages).then(stream);
|
||||
setInterval(loadState, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user