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

This commit is contained in:
WpyQwq
2026-09-19 11:52:42 +08:00
commit 103f0b32f5
28 changed files with 5834 additions and 0 deletions
+812
View File
@@ -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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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);
}
+216
View File
@@ -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);
+252
View File
@@ -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();
+650
View File
@@ -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>