Files

651 lines
34 KiB
HTML
Raw Permalink 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.
<!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>