813 lines
38 KiB
JavaScript
813 lines
38 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* aig-web.mjs — AI 群聊台:一个本地 Web 控制台,用来给群里发号施令。
|
||
*
|
||
* 设计:
|
||
* · 服务端只做两件事——读总线(直接读 JSONL)+ 调 `aig.mjs` 执行动作(send / ask / wake)。
|
||
* 所有业务规则(锁、游标、唤醒通道、深度护栏、模型退档)都留在 aig.mjs 一处,控制台不重复实现。
|
||
* · 前端是内嵌的单页(无构建、无依赖):左边群消息,右边成员栏,底部输入框。
|
||
* · 「主代理」默认是 dsh:你在输入框里直接说话就是发给它,它有权把活分给 opencode / workbuddy。
|
||
* · SSE 长连:新消息、任务进度、任务结束都实时推。
|
||
*
|
||
* 用法:
|
||
* node aig-web.mjs [--port 3099] [--host 127.0.0.1] [--room main] [--workspace E:\deepseek]
|
||
*/
|
||
|
||
import http from 'node:http';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import os from 'node:os';
|
||
import { spawn } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const AIG = path.join(HERE, 'aig.mjs');
|
||
const UI_FILE = path.join(HERE, 'web', 'ui.html');
|
||
const HOME_ROOT = process.env.AIGROUP_HOME || path.join(os.homedir(), '.ai-groups');
|
||
|
||
const argv = process.argv.slice(2);
|
||
const flag = (n, d) => {
|
||
const i = argv.indexOf('--' + n);
|
||
return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d;
|
||
};
|
||
const PORT = Number(flag('port', process.env.AIG_WEB_PORT || 3099));
|
||
const HOST = String(flag('host', '127.0.0.1'));
|
||
const WORKSPACE = path.resolve(String(flag('workspace', process.cwd())));
|
||
|
||
function currentRoomFromDisk() {
|
||
try { return fs.readFileSync(path.join(HOME_ROOT, 'current-room'), 'utf8').trim(); } catch { return ''; }
|
||
}
|
||
let ROOM = String(flag('room', '')) || currentRoomFromDisk() || 'main';
|
||
|
||
/* ------------------------------------------------------------ 读总线 */
|
||
|
||
const roomsRoot = () => path.join(HOME_ROOT, 'rooms');
|
||
const roomDir = (room) => path.join(roomsRoot(), room);
|
||
const readJson = (p, d) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } };
|
||
|
||
function readMessages(room, since = 0) {
|
||
let raw = '';
|
||
try { raw = fs.readFileSync(path.join(roomDir(room), 'messages.jsonl'), 'utf8'); } catch { return []; }
|
||
const out = [];
|
||
for (const line of raw.split('\n')) {
|
||
const t = line.trim();
|
||
if (!t) continue;
|
||
try { const m = JSON.parse(t); if ((m.seq || 0) > since) out.push(m); } catch { /* 坏行跳过 */ }
|
||
}
|
||
return out.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||
}
|
||
|
||
function readMembers(room) { return readJson(path.join(roomDir(room), 'members.json'), {}); }
|
||
function readPresence(room, m) { return readJson(path.join(roomDir(room), 'presence', m + '.json'), null); }
|
||
function readCursor(room, m) { return readJson(path.join(roomDir(room), 'cursors', m + '.json'), { seq: 0 }); }
|
||
function listRooms() {
|
||
try {
|
||
return fs.readdirSync(roomsRoot(), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
||
} catch { return []; }
|
||
}
|
||
|
||
function primaryOf(room) {
|
||
const members = readMembers(room);
|
||
for (const [name, rec] of Object.entries(members)) if (rec.role === 'primary') return name;
|
||
return 'dsh';
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 调 aig */
|
||
|
||
function runAig(args, { timeoutMs = 30 * 60 * 1000 } = {}) {
|
||
return new Promise((resolve) => {
|
||
const child = spawn(process.execPath, [AIG, ...args], {
|
||
cwd: WORKSPACE, windowsHide: true,
|
||
env: { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' },
|
||
});
|
||
let out = '', err = '';
|
||
const timer = setTimeout(() => { try { child.kill(); } catch {} }, timeoutMs);
|
||
child.stdout.on('data', (d) => { out += d.toString('utf8'); });
|
||
child.stderr.on('data', (d) => { err += d.toString('utf8'); });
|
||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: out.trim(), err: err.trim() }); });
|
||
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e.message) }); });
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 任务 */
|
||
|
||
const jobs = new Map();
|
||
const runningByMember = new Map();
|
||
let jobSeq = 0;
|
||
const clients = new Set();
|
||
|
||
/* 自驱模式:默认开。用户发的消息 → 全体参与;agent 之间也可以互相接话。
|
||
没有人工放行,所以必须自带刹车:轮次预算(人一说话就重置)+ 每成员冷却 + 正在跑就跳过。 */
|
||
const CONFIG_FILE = path.join(HOME_ROOT, 'ui-config.json');
|
||
let uiConfig = readJson(CONFIG_FILE, {});
|
||
if (typeof uiConfig.auto !== 'boolean') uiConfig.auto = true; // 默认就让大家处于被唤醒状态
|
||
if (!Number.isFinite(uiConfig.turnBudget)) uiConfig.turnBudget = 10; // 人一次发言后,自动唤醒的总次数上限
|
||
const DEFAULT_TURN_BUDGET = uiConfig.turnBudget;
|
||
let budgetLeft = uiConfig.turnBudget;
|
||
let budgetAnnounced = false;
|
||
const autoHandled = new Set();
|
||
const lastWakeAt = new Map();
|
||
const COOLDOWN_MS = 2500;
|
||
function saveUiConfig() {
|
||
try { fs.mkdirSync(HOME_ROOT, { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(uiConfig, null, 2), 'utf8'); } catch {}
|
||
}
|
||
|
||
function broadcast(event, data) {
|
||
const payload = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';
|
||
for (const res of clients) { try { res.write(payload); } catch { /* 断了就算了 */ } }
|
||
}
|
||
|
||
function jobSummary(j) {
|
||
return {
|
||
id: j.id, kind: j.kind, member: j.member, note: j.note || null, status: j.status,
|
||
code: j.code, startedAt: j.startedAt, endedAt: j.endedAt,
|
||
ms: (j.endedAt || Date.now()) - j.startedAt, tail: j.out.join('').slice(-1200),
|
||
};
|
||
}
|
||
|
||
/** 真的杀干净:Windows 上 aig 自己会再 spawn worker CLI,那是孙进程,
|
||
* 只 kill 直接子进程会留下孤儿(群里的 opencode 在 #124/#125 指出过这个洞)。 */
|
||
function killTree(child) {
|
||
if (!child || child.killed) return;
|
||
try {
|
||
if (process.platform === 'win32' && child.pid) {
|
||
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
||
} else {
|
||
child.kill('SIGTERM');
|
||
}
|
||
} catch { /* 忽略 */ }
|
||
try { child.kill(); } catch { /* 兜底 */ }
|
||
}
|
||
|
||
function startJob({ kind, member, args, note, deadlineMs }) {
|
||
const id = 'j' + (++jobSeq);
|
||
const job = { id, kind, member, args, note, status: 'running', startedAt: Date.now(), endedAt: null, code: null, out: [] };
|
||
jobs.set(id, job);
|
||
if (jobs.size > 40) jobs.delete(jobs.keys().next().value);
|
||
if (member) runningByMember.set(member, id);
|
||
broadcast('job', jobSummary(job));
|
||
const child = spawn(process.execPath, [AIG, ...args], {
|
||
cwd: WORKSPACE, windowsHide: true,
|
||
env: { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' },
|
||
});
|
||
job.child = child;
|
||
// 看门狗:跑太久就掐掉,免得 runningByMember 一直被占(群里 workbuddy 提的那条)
|
||
const WATCHDOG_MS = Number(deadlineMs || process.env.AIG_WEB_WATCHDOG_MS || 30 * 60 * 1000);
|
||
job.watchdog = setTimeout(() => {
|
||
if (job.status !== 'running') return;
|
||
job.cancelledBy = 'watchdog';
|
||
killTree(child);
|
||
}, WATCHDOG_MS);
|
||
const push = (buf, stream) => {
|
||
const text = buf.toString('utf8');
|
||
job.out.push(text);
|
||
if (job.out.length > 2000) job.out.shift();
|
||
broadcast('job-chunk', { id, stream, text });
|
||
};
|
||
child.stdout.on('data', (b) => push(b, 'out'));
|
||
child.stderr.on('data', (b) => push(b, 'err'));
|
||
const done = (code) => {
|
||
if (job.status !== 'running') return;
|
||
if (job.watchdog) clearTimeout(job.watchdog);
|
||
job.status = job.cancelledBy ? 'cancelled' : (code === 0 ? 'ok' : 'failed');
|
||
job.code = code;
|
||
job.endedAt = Date.now();
|
||
delete job.child;
|
||
if (job.member && runningByMember.get(job.member) === id) runningByMember.delete(job.member);
|
||
broadcast('job', jobSummary(job));
|
||
pushNewMessages(); // 唤醒产生的回复立刻推给前端
|
||
};
|
||
child.on('close', done);
|
||
child.on('error', (e) => { job.out.push(String(e.message)); done(-1); });
|
||
return job;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 新消息轮询 */
|
||
|
||
let lastHead = 0;
|
||
let roomWatcher = null;
|
||
let pushTimer = null;
|
||
|
||
function headSeq(room) {
|
||
const msgs = readMessages(room);
|
||
return msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||
}
|
||
|
||
/** 合并同一批文件事件,避免一次写入触发多次推送。 */
|
||
function schedulePush() {
|
||
if (pushTimer) return;
|
||
pushTimer = setTimeout(() => { pushTimer = null; try { pushNewMessages(); } catch { /* 忽略 */ } }, 25);
|
||
}
|
||
|
||
/**
|
||
* 用 fs.watch 盯住房间目录:写入 messages.jsonl 立刻推给前端。
|
||
* 之前只靠 900ms 轮询,用户自己发的消息要等最多 0.9 秒才出现——这是"点了没反应"的主要来源之一。
|
||
*/
|
||
function watchRoom() {
|
||
try { roomWatcher?.close(); } catch { /* 忽略 */ }
|
||
roomWatcher = null;
|
||
try {
|
||
roomWatcher = fs.watch(roomDir(ROOM), { persistent: true }, (_ev, file) => {
|
||
if (!file || String(file).includes('messages')) schedulePush();
|
||
});
|
||
} catch { /* 房间目录还不存在就等下一次 */ }
|
||
lastHead = headSeq(ROOM);
|
||
}
|
||
|
||
function pushNewMessages() {
|
||
const fresh = readMessages(ROOM, lastHead);
|
||
if (fresh.length) {
|
||
lastHead = fresh[fresh.length - 1].seq;
|
||
broadcast('messages', fresh.map(publicMsg));
|
||
maybeAutoWake(fresh);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 这条消息该叫醒谁(新版语义):
|
||
* · 显式点名了成员 → 只叫被点名的;
|
||
* · 广播、或只指名 human → **除了发信人以外的所有成员**(这样 AI 之间才接得上话)。
|
||
*/
|
||
function targetsFor(room, msg) {
|
||
const all = Object.keys(readMembers(room)).filter((m) => m !== 'human' && m !== 'system' && m !== msg.from);
|
||
const named = (msg.to || []).filter((x) => all.includes(x));
|
||
if (named.length) return named;
|
||
return all;
|
||
}
|
||
|
||
/**
|
||
* 群聊自动接话时的提示词:和"用户点名让主代理干活"是两回事。
|
||
* 没有人工监管的自动轮次里,必须禁止自行开新任务/改代码/跑长命令——否则几个人会互相
|
||
* 接出一串工程议程,把时间和额度烧穿(第一次自动对话实测就发生了:dsh 单轮跑了 7 分钟)。
|
||
*/
|
||
function chatExtra(room) {
|
||
return [
|
||
'这是群聊里的自动接话(不是用户在派活):用一两句话表态或补充即可。',
|
||
'不要在新任务上自行开工,不要改任何文件、不要跑长命令、不要贴日志;',
|
||
'如果你认为某件事该做,就在群里提出建议并等 human 表态,然后结束本轮。',
|
||
].join(' ');
|
||
}
|
||
|
||
/** 自动唤醒的单轮上限:到点就掐,成员锁必须放掉。 */
|
||
function autoWakeTimeoutSec() { return Number(uiConfig.autoWakeTimeoutSec || 240); }
|
||
|
||
function startWakeFor(member, why, { fromHuman = false } = {}) {
|
||
const timeout = autoWakeTimeoutSec();
|
||
const args = ['wake', member, '--timeout', String(timeout)];
|
||
if (fromHuman && member === primaryOf(ROOM)) args.push('--prompt', primaryExtra(ROOM));
|
||
else if (!fromHuman) args.push('--prompt', chatExtra(ROOM));
|
||
return startJob({ kind: 'wake', member, args, note: why, deadlineMs: (timeout + 30) * 1000 });
|
||
}
|
||
|
||
/** 预算用尽时只播报一次,别刷屏。 */
|
||
function announceBudget() {
|
||
if (budgetAnnounced) return;
|
||
budgetAnnounced = true;
|
||
broadcast('budget', { left: 0, exhausted: true });
|
||
}
|
||
|
||
function maybeAutoWake(fresh) {
|
||
if (!uiConfig.auto) return;
|
||
for (const m of fresh) {
|
||
if (m.from === 'system' || m.kind === 'dispatch-error') continue;
|
||
if ((m.tags || []).includes('silent')) continue; // 静默消息不触发自驱
|
||
if (m.from === 'human') { // 用户一说话:预算重置,全群重新被唤醒
|
||
budgetLeft = uiConfig.turnBudget;
|
||
budgetAnnounced = false;
|
||
broadcast('budget', { left: budgetLeft, reset: true });
|
||
}
|
||
for (const t of targetsFor(ROOM, m)) {
|
||
const key = t + ':' + m.seq;
|
||
if (autoHandled.has(key)) continue;
|
||
autoHandled.add(key);
|
||
if (runningByMember.has(t)) continue; // 正在跑:唤醒本来就按游标取未读,跑完会带上新的
|
||
if (Date.now() - (lastWakeAt.get(t) || 0) < COOLDOWN_MS) continue; // 冷却,防抖
|
||
if (budgetLeft <= 0) { announceBudget(); continue; } // 预算闸:没有人工监管时的唯一刹车
|
||
budgetLeft--;
|
||
lastWakeAt.set(t, Date.now());
|
||
broadcast('budget', { left: budgetLeft });
|
||
broadcast('auto', { member: t, seq: m.seq, at: Date.now() });
|
||
startWakeFor(t, 'auto:#' + m.seq + ' ' + String(m.text).slice(0, 60), { fromHuman: m.from === 'human' });
|
||
}
|
||
}
|
||
}
|
||
|
||
function publicMsg(m) {
|
||
return {
|
||
seq: m.seq, ts: m.ts, from: m.from, to: m.to || [], kind: m.kind, via: m.via,
|
||
text: m.text, reply_to: m.reply_to, meta: m.meta || {}, files: m.files || [],
|
||
};
|
||
}
|
||
|
||
// 轮询只作兜底(fs.watch 有时会漏事件);真正的即时性靠上面的文件监听。
|
||
setInterval(() => {
|
||
try { pushNewMessages(); broadcast('tick', { head: lastHead, jobs: jobs.size }); } catch { /* 忽略 */ }
|
||
}, 500).unref();
|
||
|
||
/* ------------------------------------------------------------ 主代理提示 */
|
||
|
||
function primaryExtra(room) {
|
||
const p = primaryOf(room);
|
||
return [
|
||
'用户是在 Web 群聊台里直接对你(主代理)说的,成员名是 human —— 这条是用户本人的真指令。',
|
||
'请把这件事办掉:能直接给结论就给结论;需要动手就用你手上的工具去做;需要别人参与就用',
|
||
'`node "' + AIG + '" ask <成员> "具体问题" --as ' + p + ' --room ' + room + '` 真的把他们叫起来,',
|
||
'最后用一两句话回群说明:你做了什么、结论是什么、有哪些没做完。',
|
||
].join(' ');
|
||
}
|
||
|
||
/* ------------------------------------------------------------ HTTP */
|
||
|
||
function sendJson(res, code, obj) {
|
||
const body = JSON.stringify(obj);
|
||
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||
res.end(body);
|
||
}
|
||
|
||
function readBody(req) {
|
||
return new Promise((resolve) => {
|
||
let raw = '';
|
||
req.on('data', (d) => { raw += d; if (raw.length > 2 * 1024 * 1024) req.destroy(); });
|
||
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } });
|
||
});
|
||
}
|
||
|
||
let uiCache = { mtime: 0, html: '' };
|
||
/** 页面主体在 scripts/web/ui.html(方便单独改、单独做语法检查);读不到才用内嵌的兜底页。 */
|
||
function pageHtml() {
|
||
try {
|
||
const st = fs.statSync(UI_FILE);
|
||
if (st.mtimeMs !== uiCache.mtime) uiCache = { mtime: st.mtimeMs, html: fs.readFileSync(UI_FILE, 'utf8') };
|
||
return uiCache.html;
|
||
} catch {
|
||
return PAGE;
|
||
}
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
const url = new URL(req.url, 'http://localhost');
|
||
const p = url.pathname;
|
||
|
||
if (p === '/' || p === '/index.html') {
|
||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||
return res.end(pageHtml());
|
||
}
|
||
|
||
if (p === '/api/state') {
|
||
const members = readMembers(ROOM);
|
||
const primary = primaryOf(ROOM);
|
||
const now = Date.now();
|
||
const rows = Object.entries(members).map(([name, rec]) => {
|
||
const pr = readPresence(ROOM, name);
|
||
const age = pr?.lastSeen ? Math.round((now - Date.parse(pr.lastSeen)) / 1000) : null;
|
||
return {
|
||
member: name, kind: rec.kind, model: rec.model || null, fallback: rec.fallback_model || null,
|
||
role: rec.role || null, primary: rec.role === 'primary' || name === primary, note: rec.note || null,
|
||
cursor: readCursor(ROOM, name).seq || 0, lastSeen: pr?.lastSeen || null, ageSec: age,
|
||
online: age !== null && age < 300,
|
||
};
|
||
});
|
||
const head = headSeq(ROOM);
|
||
if (head > lastHead && lastHead === 0) lastHead = 0;
|
||
return sendJson(res, 200, {
|
||
room: ROOM, rooms: listRooms(), home: HOME_ROOT, roomDir: roomDir(ROOM), workspace: WORKSPACE,
|
||
primary: primaryOf(ROOM), head, members: rows, auto: uiConfig.auto,
|
||
budgetLeft, turnBudget: uiConfig.turnBudget,
|
||
running: [...runningByMember.keys()],
|
||
jobs: [...jobs.values()].slice(-6).map(jobSummary),
|
||
});
|
||
}
|
||
|
||
if (p === '/api/messages') {
|
||
const since = Number(url.searchParams.get('since') || 0);
|
||
const limit = Number(url.searchParams.get('limit') || 300);
|
||
const all = readMessages(ROOM, since);
|
||
return sendJson(res, 200, { messages: all.slice(-limit).map(publicMsg), head: headSeq(ROOM) });
|
||
}
|
||
|
||
if (p === '/api/stream') {
|
||
res.writeHead(200, {
|
||
'content-type': 'text/event-stream; charset=utf-8',
|
||
'cache-control': 'no-cache, no-transform',
|
||
connection: 'keep-alive',
|
||
});
|
||
res.write('retry: 2000\n\n');
|
||
clients.add(res);
|
||
res.write('event: hello\ndata: ' + JSON.stringify({ room: ROOM, head: lastHead }) + '\n\n');
|
||
const ka = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
|
||
req.on('close', () => { clearInterval(ka); clients.delete(res); });
|
||
return;
|
||
}
|
||
|
||
if (p === '/api/send' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const text = String(body.text || '').trim();
|
||
if (!text) return sendJson(res, 400, { error: 'empty' });
|
||
const args = ['send', text, '--as', 'human'];
|
||
if (Array.isArray(body.to) && body.to.length) args.push('--to', body.to.join(','));
|
||
if (body.silent) args.push('--tag', 'silent'); // 静默消息不触发自驱
|
||
const r = await runAig(args, { timeoutMs: 30000 });
|
||
pushNewMessages();
|
||
return sendJson(res, r.code === 0 ? 200 : 500, { result: r });
|
||
}
|
||
|
||
if (p === '/api/ask' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const text = String(body.text || '').trim();
|
||
const target = String(body.target || primaryOf(ROOM));
|
||
if (!text) return sendJson(res, 400, { error: 'empty' });
|
||
const isPrimary = target === primaryOf(ROOM);
|
||
const args = ['ask', target, text, '--as', 'human', '--timeout', String(Number(body.timeout || 1800))];
|
||
if (isPrimary) args.push('--prompt', primaryExtra(ROOM));
|
||
const job = startJob({ kind: 'ask', member: target, args, note: text.slice(0, 80) });
|
||
return sendJson(res, 200, { job: jobSummary(job) });
|
||
}
|
||
|
||
if (p === '/api/wake' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const target = String(body.target || '');
|
||
if (!target) return sendJson(res, 400, { error: 'no-target' });
|
||
const timeout = Number(body.timeout || autoWakeTimeoutSec());
|
||
const args = ['wake', target, '--timeout', String(timeout)];
|
||
if (target === primaryOf(ROOM)) args.push('--prompt', primaryExtra(ROOM));
|
||
const job = startJob({ kind: 'wake', member: target, args, deadlineMs: (timeout + 30) * 1000 });
|
||
return sendJson(res, 200, { job: jobSummary(job) });
|
||
}
|
||
|
||
if (p === '/api/primary' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const target = String(body.member || '');
|
||
const members = readMembers(ROOM);
|
||
if (!members[target]) return sendJson(res, 400, { error: 'unknown-member' });
|
||
for (const [name, rec] of Object.entries(members)) {
|
||
if (name === target) rec.role = 'primary';
|
||
else if (rec.role === 'primary') delete rec.role;
|
||
}
|
||
fs.writeFileSync(path.join(roomDir(ROOM), 'members.json'), JSON.stringify(members, null, 2), 'utf8');
|
||
broadcast('state', { primary: target });
|
||
return sendJson(res, 200, { primary: target });
|
||
}
|
||
|
||
if (p === '/api/room' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const target = String(body.room || '');
|
||
if (!listRooms().includes(target)) return sendJson(res, 400, { error: 'unknown-room' });
|
||
ROOM = target;
|
||
// 切房间后从"当前队尾"开始跟踪,否则整段历史会被当成新消息、把所有人重新唤醒一遍
|
||
autoHandled.clear();
|
||
watchRoom();
|
||
return sendJson(res, 200, { room: ROOM });
|
||
}
|
||
|
||
if (p === '/api/jobs') {
|
||
return sendJson(res, 200, { jobs: [...jobs.values()].map(jobSummary) });
|
||
}
|
||
|
||
if (p === '/api/cancel' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
const id = String(body.id || '');
|
||
const target = String(body.member || '');
|
||
const list = [...jobs.values()].filter((j) => j.status === 'running' && ((id && j.id === id) || (target && j.member === target)));
|
||
for (const j of list) {
|
||
j.cancelledBy = 'user';
|
||
killTree(j.child);
|
||
// 兜底:万一子进程没立刻退,先把状态放掉,别让它一直占着成员
|
||
setTimeout(() => { if (j.status === 'running') { j.status = 'cancelled'; j.endedAt = Date.now(); if (j.member) runningByMember.delete(j.member); broadcast('job', jobSummary(j)); } }, 3000);
|
||
}
|
||
broadcast('state', { running: [...runningByMember.keys()] });
|
||
return sendJson(res, 200, { cancelled: list.map((j) => j.id) });
|
||
}
|
||
|
||
if (p === '/api/auto' && req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
uiConfig.auto = body.enabled === undefined ? !uiConfig.auto : Boolean(body.enabled);
|
||
if (Number.isFinite(Number(body.turnBudget)) && Number(body.turnBudget) > 0) {
|
||
uiConfig.turnBudget = Number(body.turnBudget);
|
||
budgetLeft = uiConfig.turnBudget;
|
||
budgetAnnounced = false;
|
||
}
|
||
if (uiConfig.auto) { budgetLeft = uiConfig.turnBudget; budgetAnnounced = false; }
|
||
saveUiConfig();
|
||
broadcast('state', { auto: uiConfig.auto, budgetLeft, turnBudget: uiConfig.turnBudget });
|
||
return sendJson(res, 200, { auto: uiConfig.auto, budgetLeft, turnBudget: uiConfig.turnBudget });
|
||
}
|
||
|
||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||
res.end('404');
|
||
});
|
||
|
||
/* ------------------------------------------------------------ 前端页面(兜底) */
|
||
|
||
/* 正常走 scripts/web/ui.html;这份内嵌页只在那个文件被删/读不到时兜底,
|
||
是早期版本,点击手感已被前端重构取代,不要用它来改 UI。 */
|
||
const PAGE = String.raw`<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AI 群聊台</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0b0c0e; --panel:#14161a; --panel2:#1b1e24; --line:#262a31;
|
||
--fg:#e8e6df; --dim:#9a9a92; --dim2:#6b6f76;
|
||
--me:#e8e6df; --agent:#7fb2ff; --wb:#ffb347; --oc:#7ee0a0; --err:#ff6b6b;
|
||
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Cascadia Mono",monospace;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{height:100%;margin:0}
|
||
body{background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;display:flex;flex-direction:column}
|
||
header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||
h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
|
||
.spacer{flex:1}
|
||
.chip{border:1px solid var(--line);background:var(--panel2);border-radius:999px;padding:3px 10px;font-size:12px;color:var(--dim)}
|
||
select,input,textarea,button{font:inherit;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 8px}
|
||
button{cursor:pointer}
|
||
button:hover{border-color:#3a4049}
|
||
button.primary{background:#233047;border-color:#31435f}
|
||
button.primary:hover{background:#2a3a55}
|
||
main{flex:1;display:flex;min-height:0}
|
||
#feed{flex:1;overflow:auto;padding:16px 18px;scroll-behavior:smooth}
|
||
#rail{width:290px;border-left:1px solid var(--line);background:var(--panel);overflow:auto;padding:12px}
|
||
.msg{border-left:2px solid var(--line);padding:6px 0 10px 12px;margin-bottom:8px}
|
||
.msg.me{border-color:#5b6b8a}
|
||
.msg.dsh{border-color:var(--agent)}
|
||
.msg.opencode{border-color:var(--oc)}
|
||
.msg.workbuddy{border-color:var(--wb)}
|
||
.msg.system{border-color:var(--err)}
|
||
.meta{font:11px/1.4 var(--mono);color:var(--dim2);margin-bottom:3px}
|
||
.who{color:var(--fg);font-weight:600}
|
||
.badge{display:inline-block;font:10px/1.6 var(--mono);border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-left:6px;color:var(--dim)}
|
||
.badge.dispatch{border-color:#3a4a3a;color:#9fd6a8}
|
||
.badge.err{border-color:#5a3131;color:var(--err)}
|
||
.text{white-space:pre-wrap;word-break:break-word}
|
||
.msg.err .text{color:#ffc9c9}
|
||
.mcard{border:1px solid var(--line);border-radius:10px;padding:10px;margin-bottom:10px;background:var(--panel2)}
|
||
.mcard .row{display:flex;align-items:center;gap:8px}
|
||
.dot{width:7px;height:7px;border-radius:50%;background:#4a4f57;display:inline-block}
|
||
.dot.on{background:#5fd08a}
|
||
.mname{font-weight:600}
|
||
.msub{font:11px/1.5 var(--mono);color:var(--dim2);margin-top:4px;word-break:break-all}
|
||
.mact{display:flex;gap:6px;margin-top:8px}
|
||
.mact button{font-size:12px;padding:4px 8px;flex:1}
|
||
#composer{border-top:1px solid var(--line);background:var(--panel);padding:10px 14px 12px}
|
||
#composerRow{display:flex;gap:10px;align-items:flex-end}
|
||
#text{flex:1;min-height:46px;max-height:220px;resize:vertical;font:14px/1.5 inherit}
|
||
#jobs{font:11px/1.5 var(--mono);color:var(--dim);margin-bottom:8px;max-height:120px;overflow:auto}
|
||
#jobs .j{display:flex;gap:8px;align-items:baseline}
|
||
#jobs .j.failed{color:var(--err)}
|
||
#jobs .j.cancelled{color:var(--dim2)}
|
||
#jobs .j .t{color:var(--dim2)}
|
||
.hint{color:var(--dim2);font-size:11px;margin-top:6px}
|
||
.empty{color:var(--dim2);text-align:center;margin-top:40px}
|
||
.spin{display:inline-block;width:8px;height:8px;border:1.5px solid var(--dim);border-top-color:transparent;border-radius:50%;animation:sp .8s linear infinite;vertical-align:middle}
|
||
@keyframes sp{to{transform:rotate(360deg)}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>AI 群聊台</h1>
|
||
<span class="chip" id="roomChip">房间 —</span>
|
||
<span class="chip">主代理 <b id="primaryName">—</b></span>
|
||
<span class="spacer"></span>
|
||
<label class="chip" for="roomSel" style="padding-right:4px">房间</label>
|
||
<select id="roomSel" title="切换房间"></select>
|
||
<button class="chip" id="autoBtn" title="开启后:你在下面发的消息会自动唤醒被点名的成员">自驱:关</button>
|
||
<span class="chip" id="live"><span class="dot" id="liveDot"></span> <span id="liveTxt">连接中</span></span>
|
||
</header>
|
||
<main>
|
||
<div id="feed"><div class="empty">还没有消息。在下面直接说话,默认发给主代理。</div></div>
|
||
<aside id="rail"><div class="msub">读取成员…</div></aside>
|
||
</main>
|
||
<div id="composer">
|
||
<div id="jobs"></div>
|
||
<div id="composerRow">
|
||
<textarea id="text" placeholder="直接说要求(默认发给主代理,它会决定自己做还是叫 opencode / workbuddy)。Enter 发送,Shift+Enter 换行。"></textarea>
|
||
<div style="display:flex;flex-direction:column;gap:8px;min-width:190px">
|
||
<select id="target">
|
||
<option value="__primary__">发给主代理</option>
|
||
<option value="opencode">@ opencode</option>
|
||
<option value="workbuddy">@ workbuddy</option>
|
||
<option value="__all__">全体(只发不唤醒)</option>
|
||
</select>
|
||
<button class="primary" id="sendBtn">发送并等回复</button>
|
||
<button id="sendOnlyBtn">只发消息</button>
|
||
</div>
|
||
</div>
|
||
<div class="hint" id="footHint"></div>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
var state = { room:null, primary:'dsh', head:0, members:[], jobs:{} };
|
||
var feed = document.getElementById('feed');
|
||
var rail = document.getElementById('rail');
|
||
var jobsBox = document.getElementById('jobs');
|
||
var textEl = document.getElementById('text');
|
||
var targetEl = document.getElementById('target');
|
||
var roomSel = document.getElementById('roomSel');
|
||
var liveDot = document.getElementById('liveDot');
|
||
var liveTxt = document.getElementById('liveTxt');
|
||
var autoBtn = document.getElementById('autoBtn');
|
||
|
||
function setAutoLabel(on){
|
||
autoBtn.textContent = '自驱:' + (on ? '开' : '关');
|
||
autoBtn.style.background = on ? '#233047' : '';
|
||
autoBtn.style.borderColor = on ? '#31435f' : '';
|
||
}
|
||
|
||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
|
||
function hhmmss(ts){ var d=new Date(ts); var p=function(n){return (n<10?'0':'')+n;}; return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
|
||
function atBottom(){ return feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; }
|
||
|
||
function msgHtml(m){
|
||
var cls = 'msg';
|
||
if (m.from === 'human') cls += ' me';
|
||
else if (m.from === 'dsh' || m.from === 'opencode' || m.from === 'workbuddy') cls += ' ' + m.from;
|
||
if (m.kind === 'dispatch-error') cls += ' err system';
|
||
var to = (m.to && m.to.length) ? ('→ ' + m.to.join(',')) : '→ 全体';
|
||
var badges = '';
|
||
if (m.kind === 'ask') badges += '<span class="badge">提问</span>';
|
||
if (m.kind === 'reply') badges += '<span class="badge">回答</span>';
|
||
if (m.kind === 'dispatch-error') badges += '<span class="badge err">唤醒失败</span>';
|
||
if (m.via === 'dispatch') badges += '<span class="badge dispatch">被叫醒</span>';
|
||
var model = (m.meta && m.meta.model) ? ('<span class="badge">' + esc(m.meta.model) + '</span>') : '';
|
||
return '<div class="' + cls + '">' +
|
||
'<div class="meta">#' + m.seq + ' · ' + hhmmss(m.ts) + ' · <span class="who">' + esc(m.from) + '</span> ' + esc(to) + badges + model + '</div>' +
|
||
'<div class="text">' + esc(m.text) + '</div></div>';
|
||
}
|
||
|
||
function append(msgs, replaceAll){
|
||
if (replaceAll) feed.innerHTML = '';
|
||
if (!msgs.length && replaceAll) { feed.innerHTML = '<div class="empty">还没有消息。在下面直接说话,默认发给主代理。</div>'; return; }
|
||
var stick = atBottom() || replaceAll;
|
||
var html = msgs.map(msgHtml).join('');
|
||
if (replaceAll) feed.innerHTML = html; else feed.insertAdjacentHTML('beforeend', html);
|
||
if (stick) feed.scrollTop = feed.scrollHeight;
|
||
}
|
||
|
||
function renderRail(){
|
||
var primary = state.primary;
|
||
var cards = state.members.filter(function(m){ return m.member !== 'human'; }).map(function(m){
|
||
var on = m.online ? ' on' : '';
|
||
var role = m.primary ? '<span class="badge">主代理</span>' : '';
|
||
var btns = '<div class="mact">' +
|
||
'<button data-wake="' + esc(m.member) + '">唤醒它</button>' +
|
||
'<button data-to="' + esc(m.member) + '">@ 它</button>' +
|
||
(m.primary ? '' : '<button data-primary="' + esc(m.member) + '">设为主代理</button>') +
|
||
'</div>';
|
||
return '<div class="mcard"><div class="row"><span class="dot' + on + '"></span>' +
|
||
'<span class="mname">' + esc(m.member) + '</span>' + role +
|
||
'<span class="spacer" style="flex:1"></span><span class="msub">游标 #' + m.cursor + '</span></div>' +
|
||
'<div class="msub">模型 ' + esc(m.model || '默认') + (m.fallback ? '(备用 ' + esc(m.fallback) + ')' : '') + '</div>' +
|
||
'<div class="msub">' + (m.ageSec === null ? '还没活动过' : (m.ageSec + 's 前活动')) + '</div>' +
|
||
btns + '</div>';
|
||
}).join('');
|
||
rail.innerHTML = cards || '<div class="msub">没有成员</div>';
|
||
document.getElementById('primaryName').textContent = primary;
|
||
document.getElementById('roomChip').textContent = '房间 ' + state.room;
|
||
document.getElementById('footHint').textContent = state.roomDir + ' · 工作目录 ' + state.workspace + ' · 只绑定本机';
|
||
}
|
||
|
||
function renderJobs(){
|
||
var list = Object.keys(state.jobs).map(function(k){ return state.jobs[k]; })
|
||
.sort(function(a,b){ return b.startedAt - a.startedAt; }).slice(0,4);
|
||
jobsBox.innerHTML = list.map(function(j){
|
||
var secs = Math.round(((j.endedAt || Date.now()) - j.startedAt)/1000);
|
||
var icon = j.status === 'running' ? '<span class="spin"></span>' : (j.status === 'ok' ? '✔' : (j.status === 'cancelled' ? '⊘' : '✘'));
|
||
var head = j.kind === 'ask' ? '提问 ' : '唤醒 ';
|
||
var stop = j.status === 'running' ? '<button data-cancel="' + esc(j.id) + '" style="font-size:11px;padding:1px 6px">中止</button>' : '';
|
||
return '<div class="j ' + esc(j.status) + '">' + icon + ' <span>' + head + esc(j.member) + '</span>' +
|
||
'<span class="t">' + secs + 's</span>' + stop +
|
||
'<span class="t">' + esc((j.note || j.tail || '').replace(/\s+/g,' ').slice(-140)) + '</span></div>';
|
||
}).join('');
|
||
setTimeout(renderJobs, 1000);
|
||
}
|
||
|
||
function loadState(){
|
||
fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){
|
||
var roomChanged = state.room !== s.room;
|
||
state = s;
|
||
state.jobs = {};
|
||
(s.jobs || []).forEach(function(j){ state.jobs[j.id] = j; });
|
||
roomSel.innerHTML = s.rooms.map(function(r){ return '<option value="' + esc(r) + '"' + (r===s.room?' selected':'') + '>' + esc(r) + '</option>'; }).join('');
|
||
setAutoLabel(Boolean(s.auto));
|
||
renderRail();
|
||
if (roomChanged || !feed.dataset.loaded){
|
||
fetch('/api/messages?since=0').then(function(r){ return r.json(); }).then(function(d){
|
||
state.head = d.head; feed.dataset.loaded = '1'; append(d.messages, true);
|
||
});
|
||
}
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function stream(){
|
||
var es = new EventSource('/api/stream');
|
||
es.addEventListener('hello', function(e){
|
||
liveDot.className = 'dot on'; liveTxt.textContent = '已连接';
|
||
var h = JSON.parse(e.data).head || 0;
|
||
if (h > (state.head || 0)) {
|
||
fetch('/api/messages?since=' + state.head).then(function(r){ return r.json(); }).then(function(d){
|
||
state.head = d.head; append(d.messages, false);
|
||
});
|
||
}
|
||
});
|
||
es.addEventListener('messages', function(e){
|
||
var rows = JSON.parse(e.data);
|
||
if (!rows.length) return;
|
||
state.head = rows[rows.length-1].seq;
|
||
append(rows, false);
|
||
fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){ state.members = s.members; state.primary = s.primary; renderRail(); });
|
||
});
|
||
es.addEventListener('job', function(e){
|
||
var j = JSON.parse(e.data); state.jobs[j.id] = j;
|
||
});
|
||
es.addEventListener('error', function(){ liveDot.className = 'dot'; liveTxt.textContent = '重连中'; });
|
||
es.onerror = function(){ liveDot.className = 'dot'; liveTxt.textContent = '重连中'; };
|
||
}
|
||
|
||
function resolveTarget(){
|
||
var v = targetEl.value;
|
||
if (v === '__primary__') return state.primary || 'dsh';
|
||
if (v === '__all__') return '';
|
||
return v;
|
||
}
|
||
|
||
function send(andWait){
|
||
var text = textEl.value.trim();
|
||
if (!text) return;
|
||
var target = resolveTarget();
|
||
textEl.value = '';
|
||
if (!target || !andWait){
|
||
fetch('/api/send', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ text: text, to: target ? [target] : [] }) });
|
||
return;
|
||
}
|
||
fetch('/api/ask', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ text: text, target: target }) })
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d.job) state.jobs[d.job.id] = d.job; })
|
||
.catch(function(){});
|
||
}
|
||
|
||
document.getElementById('sendBtn').onclick = function(){ send(true); };
|
||
document.getElementById('sendOnlyBtn').onclick = function(){ send(false); };
|
||
autoBtn.onclick = function(){
|
||
fetch('/api/auto', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({}) })
|
||
.then(function(r){ return r.json(); }).then(function(d){ setAutoLabel(Boolean(d.auto)); });
|
||
};
|
||
jobsBox.addEventListener('click', function(e){
|
||
var b = e.target.closest('button[data-cancel]');
|
||
if (!b) return;
|
||
fetch('/api/cancel', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ id: b.dataset.cancel }) })
|
||
.then(function(){ return fetch('/api/state'); }).then(function(r){ return r.json(); })
|
||
.then(function(s){ (s.jobs||[]).forEach(function(j){ state.jobs[j.id]=j; }); });
|
||
});
|
||
textEl.addEventListener('keydown', function(e){
|
||
if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(true); }
|
||
});
|
||
roomSel.addEventListener('change', function(){
|
||
fetch('/api/room', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ room: roomSel.value }) })
|
||
.then(function(){ feed.dataset.loaded=''; loadState(); });
|
||
});
|
||
rail.addEventListener('click', function(e){
|
||
var b = e.target.closest('button'); if (!b) return;
|
||
if (b.dataset.wake) fetch('/api/wake', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ target: b.dataset.wake }) })
|
||
.then(function(r){ return r.json(); }).then(function(d){ if (d.job) state.jobs[d.job.id] = d.job; });
|
||
if (b.dataset.to){ targetEl.value = b.dataset.to; textEl.focus(); }
|
||
if (b.dataset.primary) fetch('/api/primary', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ member: b.dataset.primary }) })
|
||
.then(function(){ loadState(); });
|
||
});
|
||
|
||
loadState(); stream(); renderJobs();
|
||
setInterval(function(){ fetch('/api/state').then(function(r){ return r.json(); }).then(function(s){
|
||
state.members = s.members; state.primary = s.primary; state.room = s.room; renderRail();
|
||
(s.jobs || []).forEach(function(j){ state.jobs[j.id] = j; });
|
||
}); }, 3000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
/* ------------------------------------------------------------ 启动 */
|
||
|
||
function start() {
|
||
if (!fs.existsSync(AIG)) {
|
||
process.stderr.write('aig-web: 找不到同目录的 aig.mjs——请把两个脚本放在一起。\n');
|
||
process.exit(1);
|
||
}
|
||
if (!listRooms().length) {
|
||
process.stderr.write('aig-web: 还没有任何房间,先跑一次:node "' + AIG + '" init\n');
|
||
process.exit(1);
|
||
}
|
||
// 从队尾开始跟踪:只在"启动之后到达的新消息"上触发自驱,
|
||
// 否则重启一次就会把整段历史重放、把所有成员重新唤醒一遍。
|
||
watchRoom();
|
||
const shown = HOST === '0.0.0.0' ? '(已绑定所有网卡,能被局域网访问,注意风险)' : '';
|
||
server.listen(PORT, HOST, () => {
|
||
process.stdout.write('AI 群聊台已启动\n');
|
||
process.stdout.write(' 地址 http://' + HOST + ':' + PORT + '/ ' + shown + '\n');
|
||
process.stdout.write(' 房间 ' + ROOM + '(' + roomDir(ROOM) + ')\n');
|
||
process.stdout.write(' 主代理 ' + primaryOf(ROOM) + '\n');
|
||
process.stdout.write(' 工作目录 ' + WORKSPACE + '\n');
|
||
});
|
||
}
|
||
|
||
start();
|