Files
ai-group-chat/scripts/aig-web.mjs
T

813 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();