Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AI 群聊</title>
|
||||
<style>
|
||||
/* ============ Design tokens(linear 配方 → IM 语义) ============ */
|
||||
:root{
|
||||
/* 暗色但可读:所有正文/元信息都按 WCAG AA(4.5:1) 反推,不是凭感觉挑的 */
|
||||
--ground:#0B0D10; --s1:#15181D; --s2:#1C2027; --s3:#252932;
|
||||
--bubble:#242832; --bubble-line:rgba(255,255,255,.12);
|
||||
--hair:rgba(255,255,255,.10); --hair2:rgba(255,255,255,.17);
|
||||
--fg:#F5F7FA; /* ~13:1 */
|
||||
--fg2:#C3C9D4; /* ~11:1 */
|
||||
--fg3:#9AA3B0; /* ~7.6:1 —— 元信息用它,不用更暗的灰 */
|
||||
--accent:#5E6AD2; /* 只做填充/色条 */
|
||||
--accent-text:#AEB7FF; /* 需要当文字用时用它(~9:1) */
|
||||
--accent-soft:color-mix(in oklab, var(--accent) 22%, transparent);
|
||||
--accent-line:color-mix(in oklab, var(--accent) 52%, transparent);
|
||||
--ok:#6BD39E; --err:#FF8B80; --ok-text:#7ADFA9;
|
||||
--r1:6px; --r2:12px; --r3:16px;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.35);
|
||||
--mono:"Cascadia Mono",Consolas,"SFMono-Regular",ui-monospace,monospace;
|
||||
--ease:cubic-bezier(.22,1,.36,1);
|
||||
--t-fast:150ms; --t-med:350ms;
|
||||
}
|
||||
html[data-theme="light"]{
|
||||
--ground:#F4F5F7; --s1:#FFFFFF; --s2:#FFFFFF; --s3:#EEF0F3;
|
||||
--bubble:#FFFFFF; --bubble-line:rgba(0,0,0,.12);
|
||||
--hair:rgba(0,0,0,.12); --hair2:rgba(0,0,0,.2);
|
||||
--fg:#14171C; --fg2:#454C57; --fg3:#5F6772;
|
||||
--accent:#4F5AC2; --accent-text:#3B45A8;
|
||||
--accent-soft:color-mix(in oklab, var(--accent) 12%, transparent);
|
||||
--accent-line:color-mix(in oklab, var(--accent) 38%, transparent);
|
||||
--ok:#1F8A5B; --err:#C0392B; --ok-text:#146B44;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0}
|
||||
body{
|
||||
background:var(--ground); color:var(--fg); overflow:hidden;
|
||||
font:14.5px/1.65 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
|
||||
text-wrap:pretty;
|
||||
}
|
||||
button,select,textarea,input{
|
||||
font:inherit; color:var(--fg); background:var(--s3); border:1px solid var(--hair);
|
||||
border-radius:var(--r1); padding:6px 10px;
|
||||
transition:background-color var(--t-fast) ease-out,border-color var(--t-fast) ease-out,transform 80ms ease-out;
|
||||
}
|
||||
button{cursor:pointer}
|
||||
button:hover{background:color-mix(in oklab, var(--s3) 70%, var(--fg) 8%); border-color:var(--hair2)}
|
||||
button:active{transform:translateY(1px)}
|
||||
button:disabled{opacity:.45;cursor:default;transform:none}
|
||||
button:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--accent-line);outline-offset:1px}
|
||||
.btn-accent{background:color-mix(in oklab, var(--accent) 26%, var(--s3));border-color:var(--accent-line);color:var(--fg)}
|
||||
.btn-accent:hover{background:color-mix(in oklab, var(--accent) 38%, var(--s3));border-color:var(--accent-line)}
|
||||
.mono{font-family:var(--mono)}
|
||||
.chip{
|
||||
font:12px/1.7 var(--mono); color:var(--fg3); border:1px solid var(--hair);
|
||||
border-radius:var(--r1); padding:0 6px; white-space:nowrap;
|
||||
}
|
||||
.label{font:12px/1.6 var(--mono); color:var(--fg3); letter-spacing:.06em}
|
||||
|
||||
.app{display:flex;height:100%}
|
||||
|
||||
/* ============================== 左栏 ============================== */
|
||||
.side{width:276px;flex:0 0 276px;background:var(--s1);border-right:1px solid var(--hair);display:flex;flex-direction:column}
|
||||
.side-head{padding:14px 14px 12px;border-bottom:1px solid var(--hair)}
|
||||
.side-title{display:flex;align-items:center;gap:12px}
|
||||
.side-title h1{margin:0;font-size:15px;font-weight:600;letter-spacing:-.01em}
|
||||
.side-sub{margin-top:3px;font:12px/1.6 var(--mono);color:var(--fg3)}
|
||||
.side-tools{display:flex;gap:8px;margin-top:14px}
|
||||
.side-tools button,.side-tools select{flex:1;font-size:12px;padding:5px 8px}
|
||||
.side-list{flex:1;overflow:auto;padding:10px 8px}
|
||||
.sect{font:12px/1.6 var(--mono);color:var(--fg3);letter-spacing:.06em;padding:6px 8px}
|
||||
.mcard{display:flex;gap:11px;padding:10px 8px 10px 12px;border-radius:var(--r2);position:relative;
|
||||
transition:background-color var(--t-fast) ease-out}
|
||||
.mcard:hover{background:var(--s2)}
|
||||
.mcard[data-busy="1"]{background:color-mix(in oklab, var(--accent) 15%, var(--s2));box-shadow:inset 0 0 0 1px var(--accent-line)}
|
||||
.mcard[data-target="1"]{background:var(--s2)}
|
||||
.mcard[data-busy="1"]::before,.mcard[data-target="1"]::before{
|
||||
content:"";position:absolute;left:3px;top:9px;bottom:9px;width:3px;border-radius:3px;background:var(--accent)}
|
||||
.mcard[data-busy="1"] .mname{color:#fff}
|
||||
html[data-theme="light"] .mcard[data-busy="1"] .mname{color:var(--fg)}
|
||||
.mcard .body{flex:1;min-width:0}
|
||||
.line1{display:flex;align-items:center;gap:7px}
|
||||
.mname{font-weight:600;font-size:13.5px}
|
||||
.role{font:12px/1.7 var(--mono);color:var(--accent-text);border:1px solid var(--accent-line);border-radius:var(--r1);padding:0 5px}
|
||||
.mstat{font-size:12px;color:var(--fg3);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mstat.work{color:var(--fg);}
|
||||
.mstat .t{font-family:var(--mono);color:var(--accent-text)}
|
||||
.mmeta{font:12px/1.6 var(--mono);color:var(--fg3);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mact{display:flex;gap:6px;margin-top:8px;opacity:0;transition:opacity var(--t-fast) ease-out}
|
||||
.mcard:hover .mact,.mcard[data-busy="1"] .mact,.mcard[data-target="1"] .mact{opacity:1}
|
||||
.mact button{font-size:12px;padding:3px 8px;flex:1}
|
||||
.side-foot{padding:12px 16px;border-top:1px solid var(--hair);font:12px/1.7 var(--mono);color:var(--fg3);word-break:break-all}
|
||||
|
||||
/* ============================== 右栏 ============================== */
|
||||
.chat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
|
||||
/* 极克制的顶部环境光:给暗色界面一个焦点,不是渐变海报 */
|
||||
.chat::before{content:"";position:absolute;top:0;left:0;right:0;height:120px;z-index:0;pointer-events:none;
|
||||
background:radial-gradient(120% 130% at 18% 0%, color-mix(in oklab, var(--accent) 13%, transparent), transparent 68%)}
|
||||
.chat-head{position:relative;z-index:1;display:flex;align-items:center;gap:12px;padding:13px 24px;background:var(--s1);border-bottom:1px solid var(--hair)}
|
||||
.chat-head h2{margin:0;font-size:14.5px;font-weight:600;letter-spacing:-.01em;white-space:nowrap}
|
||||
.faces{display:flex;align-items:center;margin-left:2px}
|
||||
.faces .av{margin-left:-7px;box-shadow:0 0 0 2px var(--s1)}
|
||||
.faces .av:first-child{margin-left:0}
|
||||
.head-stat{font:12px/1.6 var(--mono);color:var(--fg3);white-space:nowrap}
|
||||
.grow{flex:1}
|
||||
.live{display:flex;align-items:center;gap:6px}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:var(--fg3);flex:0 0 7px}
|
||||
.dot.on{background:var(--ok)}
|
||||
|
||||
/* 工作状态条:只留"谁 · 在做什么 · 多久",长文本交给气泡,别堆在这儿 */
|
||||
.strip{position:relative;z-index:1;display:flex;gap:18px;flex-wrap:wrap;align-items:center;padding:10px 24px;
|
||||
border-bottom:1px solid var(--hair);background:var(--s1)}
|
||||
.strip:empty{display:none}
|
||||
.strip .who{display:flex;align-items:center;gap:8px;font:12px/1.6 var(--mono);color:var(--fg2)}
|
||||
.strip .who b{color:var(--fg);font-weight:600}
|
||||
.strip .who .t{color:var(--accent-text)}
|
||||
.strip .who .st{color:var(--fg3)}
|
||||
.bar{width:54px;height:2px;border-radius:2px;background:var(--s3);overflow:hidden;position:relative}
|
||||
.bar i{position:absolute;inset:0;width:40%;background:var(--accent);border-radius:2px;animation:slide 1.4s var(--ease) infinite}
|
||||
@keyframes slide{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}
|
||||
|
||||
.feed{flex:1;overflow:auto;padding:24px 24px 6px;scroll-behavior:auto;position:relative;z-index:1;background:var(--ground)}
|
||||
.day{text-align:center;margin:14px 0 10px}
|
||||
.day span{font:12px/1.6 var(--mono);color:var(--fg3);background:var(--s3);border:1px solid var(--hair);border-radius:var(--r1);padding:1px 8px}
|
||||
.sys{text-align:center;margin:10px 0}
|
||||
.sys span{display:inline-block;font:12px/1.7 var(--mono);color:var(--fg3);border:1px solid var(--hair);border-radius:var(--r1);padding:2px 10px;background:var(--s1)}
|
||||
.sys.err span{color:var(--err);border-color:color-mix(in oklab,var(--err) 30%,transparent)}
|
||||
|
||||
.row{display:flex;gap:12px;margin:19px 0;align-items:flex-start;animation:in 240ms var(--ease) both}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
.row.self{flex-direction:row-reverse}
|
||||
.bwrap{max-width:min(68%,720px);min-width:0;display:flex;flex-direction:column}
|
||||
.row.self .bwrap{align-items:flex-end}
|
||||
.bhead{display:flex;align-items:baseline;gap:8px;margin-bottom:5px;font:12px/1.6 var(--mono);color:var(--fg3)}
|
||||
.bhead .nm{color:var(--fg);font-weight:600;font-family:inherit;font-size:12.5px}
|
||||
.bubble{background:var(--bubble);border:1px solid var(--bubble-line);border-radius:var(--r2);padding:12px 15px;
|
||||
box-shadow:0 1px 2px rgba(0,0,0,.35);white-space:pre-wrap;word-break:break-word;line-height:1.72}
|
||||
.row.self .bubble{background:var(--accent-soft);border-color:var(--accent-line)}
|
||||
.row.err .bubble{border-color:color-mix(in oklab,var(--err) 32%,transparent);color:#FCA5A5}
|
||||
html[data-theme="light"] .row.err .bubble{color:#B42318}
|
||||
.quote{border-left:2px solid var(--hair2);padding:1px 0 1px 9px;margin-bottom:7px;font-size:12px;color:var(--fg3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.bmeta{display:flex;gap:6px;flex-wrap:wrap;margin-top:5px}
|
||||
.row.self .bmeta{justify-content:flex-end}
|
||||
.pill{font:12px/1.7 var(--mono);color:var(--fg3);border:1px solid var(--hair);border-radius:var(--r1);padding:0 5px}
|
||||
.pill.awake{color:var(--accent-text);border-color:var(--accent-line)}
|
||||
.pill.free{color:var(--ok-text);border-color:color-mix(in oklab,var(--ok) 34%,transparent)}
|
||||
.row.pending .bubble{border-style:dashed;opacity:.7}
|
||||
|
||||
/* 正在输入 */
|
||||
.row.typing .bubble{display:flex;align-items:center;gap:11px;background:var(--s3);min-width:240px}
|
||||
.dots{display:inline-flex;gap:4px}
|
||||
.dots i{width:5px;height:5px;border-radius:50%;background:var(--fg2);animation:bl 1.15s infinite}
|
||||
.dots i:nth-child(2){animation-delay:.14s}
|
||||
.dots i:nth-child(3){animation-delay:.28s}
|
||||
@keyframes bl{0%,60%,100%{opacity:.22;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
|
||||
.tstat{font:12px/1.6 var(--mono);color:var(--fg2)}
|
||||
.row.typing .stop{font-size:12px;padding:1px 7px;margin-left:auto;font-family:var(--mono)}
|
||||
|
||||
/* 头像 */
|
||||
.av{width:36px;height:36px;flex:0 0 36px;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||||
font-weight:600;font-size:13px;border:1px solid var(--hair2);user-select:none;position:relative}
|
||||
.av.sm{width:27px;height:27px;flex:0 0 27px;font-size:11.5px}
|
||||
.av .ring{position:absolute;inset:-3px;border-radius:50%;border:1.5px solid var(--accent);animation:ring 1.5s var(--ease) infinite}
|
||||
@keyframes ring{0%{transform:scale(.9);opacity:.85}80%{transform:scale(1.16);opacity:0}100%{opacity:0}}
|
||||
|
||||
/* 输入区 */
|
||||
.composer{background:var(--s1);border-top:1px solid var(--hair);padding:13px 20px 15px}
|
||||
.crow{display:flex;gap:10px;align-items:flex-end}
|
||||
.crow select{flex:0 0 152px;font-size:13px}
|
||||
#text{flex:1;min-height:84px;max-height:220px;resize:none;background:var(--s2);line-height:1.7}
|
||||
.cbtns{display:flex;flex-direction:column;gap:10px;flex:0 0 128px}
|
||||
.cbtns button{padding:10px}
|
||||
.cbtns button.quiet{background:transparent;border-color:transparent;color:var(--fg3);padding:4px}
|
||||
.cbtns button.quiet:hover{background:var(--s3);border-color:var(--hair);color:var(--fg2)}
|
||||
.chint{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:9px}
|
||||
.empty{color:var(--fg3);text-align:center;margin-top:44px}
|
||||
.jump{position:absolute;right:28px;bottom:132px;display:none;font:12px/1.7 var(--mono);border-radius:var(--r1);
|
||||
background:var(--s3);border:1px solid var(--hair2);padding:4px 11px;box-shadow:var(--shadow);color:var(--fg2)}
|
||||
@media (prefers-reduced-motion:reduce){*{animation-duration:.001ms !important;transition-duration:.001ms !important}}
|
||||
@media (max-width:900px){
|
||||
.app{flex-direction:column-reverse}
|
||||
.side{width:100%;flex:0 0 46%;border-right:0;border-top:1px solid var(--hair)}
|
||||
.bwrap{max-width:88%}
|
||||
.crow select{flex:0 0 116px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="side-head">
|
||||
<div class="side-title">
|
||||
<div class="av" id="groupAv">群</div>
|
||||
<div>
|
||||
<h1 id="groupName">AI 群聊</h1>
|
||||
<div class="side-sub" id="groupSub">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-tools">
|
||||
<button id="autoBtn" title="开启后你发的消息会自动唤醒被点名的成员">自驱:关</button>
|
||||
<select id="roomSel" title="切换房间"></select>
|
||||
<button id="themeBtn" title="明暗切换">亮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-list" id="rail"></div>
|
||||
<div class="side-foot" id="footHint"></div>
|
||||
</aside>
|
||||
|
||||
<section class="chat">
|
||||
<header class="chat-head">
|
||||
<h2 id="chatName">AI 群聊</h2>
|
||||
<div class="faces" id="faces"></div>
|
||||
<span class="head-stat" id="headStat"></span>
|
||||
<span class="grow"></span>
|
||||
<span class="live"><span class="dot" id="liveDot"></span><span class="head-stat" id="liveTxt">连接中</span></span>
|
||||
</header>
|
||||
<div class="strip" id="strip"></div>
|
||||
<div class="feed" id="feed"><div class="empty" id="emptyHint">还没有人说话。在下面直接说,默认发给主代理。</div></div>
|
||||
<button class="jump" id="jump">有新消息 ↓</button>
|
||||
<div class="composer">
|
||||
<div class="crow">
|
||||
<select id="target">
|
||||
<option value="__all__">发给全体(默认)</option>
|
||||
<option value="__primary__">只发给主代理</option>
|
||||
</select>
|
||||
<textarea id="text" placeholder="说点什么…(默认全体;大家会自动接话)"></textarea>
|
||||
<div class="cbtns">
|
||||
<button class="btn-accent" id="sendBtn">发送</button>
|
||||
<button class="quiet" id="sendOnlyBtn">静默发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chint">
|
||||
<span class="chip">Enter 发送</span>
|
||||
<span class="chip">Shift+Enter 换行</span>
|
||||
<span class="chip">Esc 清空</span>
|
||||
<span class="grow"></span>
|
||||
<span class="label" id="hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const feed = $('feed'), rail = $('rail'), strip = $('strip'), textEl = $('text'), targetEl = $('target');
|
||||
const sendBtn = $('sendBtn'), sendOnlyBtn = $('sendOnlyBtn'), autoBtn = $('autoBtn'), roomSel = $('roomSel');
|
||||
const liveTxt = $('liveTxt'), liveDot = $('liveDot'), jump = $('jump'), faces = $('faces'), themeBtn = $('themeBtn');
|
||||
|
||||
// 成员身份色:低彩度、按名字散列,避免彩虹
|
||||
const HUE = { dsh: 265, opencode: 158, workbuddy: 32, qoder: 208, human: 265 };
|
||||
const hueOf = (n) => (HUE[n] != null ? HUE[n] : (() => { let h = 0; for (const c of String(n)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; })());
|
||||
const S = { room: '', primary: 'dsh', head: 0, auto: true, budgetLeft: null, turnBudget: null, members: [], jobs: new Map(), seen: new Set(), pending: new Map(), quote: {}, lastTs: 0 };
|
||||
|
||||
const initialOf = (n) => (n === 'human' ? '我' : String(n).slice(0, 1).toUpperCase());
|
||||
const hhmm = (ts) => { const d = new Date(ts); const p = (n) => (n < 10 ? '0' : '') + n; return p(d.getHours()) + ':' + p(d.getMinutes()); };
|
||||
const setText = (n, t) => { if (n && n.textContent !== t) n.textContent = t; };
|
||||
const mk = (tag, cls, txt) => { const n = document.createElement(tag); if (cls) n.className = cls; if (txt != null) n.textContent = txt; return n; };
|
||||
const atBottom = () => feed.scrollHeight - feed.scrollTop - feed.clientHeight < 90;
|
||||
|
||||
function avatar(name, { small = false, busy = false } = {}) {
|
||||
const h = hueOf(name);
|
||||
const a = mk('div', 'av' + (small ? ' sm' : ''), initialOf(name));
|
||||
a.style.background = `oklch(0.32 0.035 ${h})`;
|
||||
a.style.color = `oklch(0.93 0.02 ${h})`;
|
||||
a.dataset.av = name;
|
||||
if (busy) a.appendChild(mk('span', 'ring'));
|
||||
return a;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 消息气泡 */
|
||||
function bubble(m) {
|
||||
const self = m.from === 'human';
|
||||
const row = mk('div', 'row' + (self ? ' self' : '') + (m._pending ? ' pending' : '') + (m.kind === 'dispatch-error' ? ' err' : ''));
|
||||
row.appendChild(avatar(m.from));
|
||||
const wrap = mk('div', 'bwrap');
|
||||
const head = mk('div', 'bhead');
|
||||
head.appendChild(mk('span', 'nm', self ? '我' : m.from));
|
||||
head.appendChild(mk('span', null, hhmm(m.ts)));
|
||||
wrap.appendChild(head);
|
||||
const b = mk('div', 'bubble');
|
||||
const q = m.reply_to && S.quote[m.reply_to];
|
||||
if (q) b.appendChild(mk('div', 'quote', '回复 ' + q.from + ':' + String(q.text).replace(/\s+/g, ' ').slice(0, 56)));
|
||||
b.appendChild(mk('div', 'text', m.text));
|
||||
wrap.appendChild(b);
|
||||
const meta = mk('div', 'bmeta');
|
||||
if (m.via === 'dispatch') meta.appendChild(mk('span', 'pill awake', '被叫醒'));
|
||||
if (m.meta && m.meta.model) meta.appendChild(mk('span', 'pill', m.meta.model));
|
||||
if (m.meta && typeof m.meta.cost === 'number') meta.appendChild(mk('span', 'pill free', '¥' + m.meta.cost));
|
||||
meta.appendChild(mk('span', 'pill', (m.to || []).length ? '→ ' + m.to.join(',') : '→ 全体'));
|
||||
if (m._pending) meta.appendChild(mk('span', 'pill', '发送中'));
|
||||
wrap.appendChild(meta);
|
||||
row.appendChild(wrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function pushMessage(m) {
|
||||
if (m.seq != null && S.seen.has(m.seq)) return;
|
||||
if (m.seq != null) { S.seen.add(m.seq); S.quote[m.seq] = m; }
|
||||
if (m.from === 'human' && S.pending.has(m.text)) {
|
||||
const node = S.pending.get(m.text); S.pending.delete(m.text);
|
||||
const stick = atBottom(); node.replaceWith(bubble(m));
|
||||
if (stick) feed.scrollTop = feed.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const stick = atBottom();
|
||||
const hint = $('emptyHint'); if (hint) hint.remove();
|
||||
if (m.kind === 'dispatch-error') {
|
||||
const d = mk('div', 'sys err');
|
||||
d.appendChild(mk('span', null, m.from + ' 唤醒失败 · ' + String(m.text).replace(/\s+/g, ' ').slice(0, 90)));
|
||||
feed.appendChild(d);
|
||||
} else {
|
||||
if (m.ts - S.lastTs > 6 * 60 * 1000) { const d = mk('div', 'day'); d.appendChild(mk('span', null, hhmm(m.ts))); feed.appendChild(d); }
|
||||
feed.appendChild(bubble(m));
|
||||
}
|
||||
S.lastTs = m.ts;
|
||||
if (stick) feed.scrollTop = feed.scrollHeight; else jump.style.display = 'block';
|
||||
}
|
||||
|
||||
function optimistic(text, to) {
|
||||
const hint = $('emptyHint'); if (hint) hint.remove();
|
||||
const node = bubble({ from: 'human', to: to || [], text, ts: Date.now(), kind: 'chat', _pending: true });
|
||||
feed.appendChild(node);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
S.pending.set(text, node);
|
||||
setTimeout(() => { if (S.pending.get(text) === node) { S.pending.delete(text); node.classList.remove('pending'); } }, 15000);
|
||||
}
|
||||
|
||||
jump.addEventListener('click', () => { feed.scrollTop = feed.scrollHeight; jump.style.display = 'none'; });
|
||||
feed.addEventListener('scroll', () => { if (atBottom()) jump.style.display = 'none'; });
|
||||
|
||||
/* ------------------------------------------------ 正在干什么(进行中) */
|
||||
function actText(job, secs) {
|
||||
const n = String(job.note || '').replace(/\s+/g, ' ');
|
||||
if (job.kind === 'ask') return { verb: '正在回答', what: n ? ':' + n.slice(0, 46) : '', secs };
|
||||
if (n.startsWith('auto:')) return { verb: '正在处理', what: ' ' + n.replace(/^auto:/, '').slice(0, 46), secs };
|
||||
return { verb: '正在处理未读', what: '', secs };
|
||||
}
|
||||
|
||||
function typingNode(job) {
|
||||
const row = mk('div', 'row typing'); row.dataset.job = job.id;
|
||||
row.appendChild(avatar(job.member, { busy: true }));
|
||||
const wrap = mk('div', 'bwrap');
|
||||
const head = mk('div', 'bhead');
|
||||
head.appendChild(mk('span', 'nm', job.member));
|
||||
wrap.appendChild(head);
|
||||
const b = mk('div', 'bubble');
|
||||
const dots = mk('span', 'dots'); dots.append(mk('i'), mk('i'), mk('i'));
|
||||
b.appendChild(dots);
|
||||
const a = actText(job, 0);
|
||||
const st = mk('span', 'tstat');
|
||||
const verb = mk('span', null, a.verb + a.what);
|
||||
const time = mk('span', 'mono', ' ' + a.secs + 's');
|
||||
st.append(verb, time);
|
||||
b.appendChild(st);
|
||||
const stop = mk('button', 'stop', '中止');
|
||||
const jid = job.id;
|
||||
stop.addEventListener('click', () => { stop.disabled = true; stop.textContent = '中止中'; post('/api/cancel', { id: jid }).catch(() => {}); });
|
||||
b.appendChild(stop);
|
||||
wrap.appendChild(b);
|
||||
row.appendChild(wrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function liveJobs() {
|
||||
const out = [];
|
||||
for (const [, j] of S.jobs) if (j.status === 'running' && j.member && j.member !== 'human') out.push(j);
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncLive() {
|
||||
const live = liveJobs();
|
||||
for (const node of [...feed.querySelectorAll('.row.typing')]) {
|
||||
const j = S.jobs.get(node.dataset.job);
|
||||
if (!j || j.status !== 'running') node.remove();
|
||||
}
|
||||
for (const j of live) {
|
||||
let node = feed.querySelector('.row.typing[data-job="' + j.id + '"]');
|
||||
if (!node) { node = typingNode(j); feed.appendChild(node); } else feed.appendChild(node);
|
||||
const a = actText(j, Math.round((Date.now() - j.startedAt) / 1000));
|
||||
const st = node.querySelector('.tstat');
|
||||
if (st) {
|
||||
setText(st.firstChild, a.verb + a.what);
|
||||
setText(st.lastChild, ' ' + a.secs + 's');
|
||||
}
|
||||
const av = node.querySelector('.av');
|
||||
if (av && !av.querySelector('.ring')) av.appendChild(mk('span', 'ring'));
|
||||
}
|
||||
// 顶部状态条:谁 · 在做什么 · 多久(长文本留在气泡里,别堆这儿)
|
||||
strip.textContent = '';
|
||||
for (const j of live) {
|
||||
const secs = Math.round((Date.now() - j.startedAt) / 1000);
|
||||
const w = mk('span', 'who');
|
||||
w.appendChild(mk('b', null, j.member));
|
||||
w.appendChild(mk('span', 'st', j.kind === 'ask' ? '回答中' : '处理中'));
|
||||
w.appendChild(mk('span', 't', secs + 's'));
|
||||
const bar = mk('span', 'bar'); bar.appendChild(mk('i'));
|
||||
w.appendChild(bar);
|
||||
strip.appendChild(w);
|
||||
}
|
||||
// 头像光环 + 侧栏状态
|
||||
for (const f of faces.querySelectorAll('.av')) {
|
||||
const busy = live.some((j) => j.member === f.dataset.av);
|
||||
const has = f.querySelector('.ring');
|
||||
if (busy && !has) f.appendChild(mk('span', 'ring'));
|
||||
if (!busy && has) has.remove();
|
||||
}
|
||||
for (const card of rail.querySelectorAll('.mcard')) {
|
||||
const j = live.find((x) => x.member === card.dataset.member);
|
||||
card.dataset.busy = j ? '1' : '0';
|
||||
const st = card.querySelector('.mstat');
|
||||
if (!j) continue;
|
||||
const a = actText(j, Math.round((Date.now() - j.startedAt) / 1000));
|
||||
st.textContent = '';
|
||||
st.appendChild(mk('span', null, a.verb + a.what));
|
||||
st.appendChild(mk('span', 't', ' ' + a.secs + 's'));
|
||||
st.classList.add('work');
|
||||
}
|
||||
}
|
||||
setInterval(syncLive, 300);
|
||||
|
||||
/* -------------------------------------------------------- 成员侧栏 */
|
||||
const mNodes = new Map();
|
||||
function memberCard(m) {
|
||||
const card = mk('div', 'mcard'); card.dataset.member = m.member;
|
||||
card.appendChild(avatar(m.member));
|
||||
const body = mk('div', 'body');
|
||||
const l1 = mk('div', 'line1');
|
||||
const name = mk('span', 'mname', m.member);
|
||||
const role = mk('span', 'role', '主代理'); role.style.display = 'none';
|
||||
l1.append(name, role);
|
||||
const stat = mk('div', 'mstat', '空闲');
|
||||
const meta = mk('div', 'mmeta', '');
|
||||
const act = mk('div', 'mact');
|
||||
const wakeBtn = mk('button', null, '唤醒');
|
||||
const atBtn = mk('button', null, '@');
|
||||
act.append(wakeBtn, atBtn);
|
||||
body.append(l1, stat, meta, act);
|
||||
card.appendChild(body);
|
||||
|
||||
atBtn.addEventListener('click', () => { targetEl.value = m.member; textEl.focus(); });
|
||||
wakeBtn.addEventListener('click', () => {
|
||||
wakeBtn.disabled = true; wakeBtn.textContent = '工作中';
|
||||
const localId = 'local-' + m.member + '-' + Date.now();
|
||||
upsertJob({ id: localId, member: m.member, kind: 'wake', status: 'running', startedAt: Date.now(), note: '手动唤醒', local: true });
|
||||
post('/api/wake', { target: m.member }).catch(() => {}).finally(() => { wakeBtn.disabled = false; wakeBtn.textContent = '唤醒'; });
|
||||
});
|
||||
return { card, name, role, stat, meta, wakeBtn, atBtn };
|
||||
}
|
||||
|
||||
/** 目标下拉按成员自动生成:加人不用改前端 */
|
||||
function syncTargets(list) {
|
||||
const key = list.map((m) => m.member).join(',');
|
||||
if (targetEl.dataset.members === key) return;
|
||||
targetEl.dataset.members = key;
|
||||
const keep = targetEl.value;
|
||||
targetEl.textContent = '';
|
||||
const all = mk('option', null, '发给全体(默认)'); all.value = '__all__';
|
||||
const prim = mk('option', null, '只发给主代理'); prim.value = '__primary__';
|
||||
targetEl.append(all, prim);
|
||||
for (const m of list) { const o = mk('option', null, '只 @ ' + m.member); o.value = m.member; targetEl.appendChild(o); }
|
||||
if ([...targetEl.options].some((o) => o.value === keep)) targetEl.value = keep;
|
||||
}
|
||||
|
||||
function syncMembers(list) {
|
||||
const names = new Set(list.map((m) => m.member));
|
||||
for (const [n, node] of mNodes) if (!names.has(n)) { node.card.remove(); mNodes.delete(n); }
|
||||
for (const m of list) {
|
||||
let n = mNodes.get(m.member);
|
||||
if (!n) { n = memberCard(m); mNodes.set(m.member, n); rail.appendChild(n.card); }
|
||||
n.card.style.order = m.primary ? -1 : 0;
|
||||
n.card.dataset.target = (targetEl.value === m.member) ? '1' : '0';
|
||||
setText(n.name, m.member);
|
||||
n.role.style.display = m.primary ? '' : 'none';
|
||||
n.card.dataset.busy = n.card.dataset.busy === '1' ? '1' : '0';
|
||||
if (n.card.dataset.busy !== '1') {
|
||||
setText(n.stat, m.ageSec == null ? '还没活动过' : (m.ageSec < 120 ? '空闲 · ' + m.ageSec + 's 前活动' : '空闲 · 很久没动作'));
|
||||
n.stat.classList.remove('work');
|
||||
}
|
||||
setText(n.meta, '游标 #' + m.cursor + ' · ' + (m.model || '默认模型') + (m.fallback ? ' / 备用 ' + m.fallback : ''));
|
||||
}
|
||||
faces.textContent = '';
|
||||
for (const m of list) {
|
||||
const a = avatar(m.member, { small: true });
|
||||
a.title = m.member + ' · ' + (m.model || '默认模型');
|
||||
faces.appendChild(a);
|
||||
}
|
||||
syncTargets(list);
|
||||
setText($('headStat'), list.length + ' 个成员 · 主代理 ' + S.primary);
|
||||
setText($('groupSub'), list.length + ' 个成员 · 主代理 ' + S.primary);
|
||||
setText($('chatName'), S.room);
|
||||
setText($('groupName'), S.room);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 任务 */
|
||||
function upsertJob(j) {
|
||||
const prev = S.jobs.get(j.id) || {};
|
||||
const cur = Object.assign({}, prev, j);
|
||||
S.jobs.set(j.id, cur);
|
||||
const mn = mNodes.get(cur.member);
|
||||
if (cur.status === 'running') {
|
||||
if (mn) { mn.wakeBtn.disabled = true; mn.wakeBtn.textContent = '工作中'; }
|
||||
} else {
|
||||
if (mn) {
|
||||
mn.wakeBtn.disabled = false; mn.wakeBtn.textContent = '唤醒';
|
||||
mn.card.dataset.busy = '0';
|
||||
const secs = Math.round(((cur.endedAt || Date.now()) - cur.startedAt) / 1000);
|
||||
setText(mn.stat, (cur.status === 'ok' ? '刚完成 · ' : cur.status === 'cancelled' ? '已中止 · ' : '刚失败 · ') + secs + 's');
|
||||
mn.stat.classList.remove('work');
|
||||
}
|
||||
for (const [id, other] of [...S.jobs]) if (other.local && other.member === cur.member) S.jobs.delete(id);
|
||||
}
|
||||
syncLive();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 网络 */
|
||||
function post(url, body) {
|
||||
return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json().catch(() => ({})));
|
||||
}
|
||||
function applyState(s) {
|
||||
S.room = s.room; S.primary = s.primary; S.head = s.head;
|
||||
if (typeof s.budgetLeft === 'number') S.budgetLeft = s.budgetLeft;
|
||||
if (typeof s.turnBudget === 'number') S.turnBudget = s.turnBudget;
|
||||
paintAuto(Boolean(s.auto));
|
||||
if (roomSel.dataset.rooms !== (s.rooms || []).join(',')) {
|
||||
roomSel.dataset.rooms = (s.rooms || []).join(',');
|
||||
roomSel.textContent = '';
|
||||
for (const r of s.rooms) { const o = mk('option', null, r); o.value = r; roomSel.appendChild(o); }
|
||||
roomSel.value = s.room;
|
||||
}
|
||||
(s.jobs || []).forEach(upsertJob);
|
||||
syncMembers((s.members || []).filter((m) => m.member !== 'human'));
|
||||
$('footHint').textContent = s.roomDir + '\n' + s.workspace + '\n只绑定本机';
|
||||
}
|
||||
const loadState = () => fetch('/api/state').then((r) => r.json()).then(applyState).catch(() => {});
|
||||
function paintAuto(on) {
|
||||
S.auto = Boolean(on);
|
||||
const left = S.budgetLeft;
|
||||
autoBtn.textContent = '自驱:' + (on ? '开' : '关') + (on && left != null ? (left > 0 ? ' · 剩 ' + left + ' 轮' : ' · 预算尽') : '');
|
||||
autoBtn.classList.toggle('btn-accent', Boolean(on) && !(left === 0));
|
||||
autoBtn.title = on
|
||||
? '新消息会自动唤醒相关成员(AI 之间也会互相接话)。你没有人工审批这一环,所以这里有轮次预算:你一发言就重置为 ' + (S.turnBudget || '—') + ' 轮。'
|
||||
: '已关闭自驱:消息只会落进群里,不会自动叫醒任何人';
|
||||
}
|
||||
function paintBudget(d) {
|
||||
if (typeof d.budgetLeft === 'number') S.budgetLeft = d.budgetLeft;
|
||||
if (typeof d.turnBudget === 'number') S.turnBudget = d.turnBudget;
|
||||
paintAuto(S.auto);
|
||||
if (d.exhausted) setText($('hint'), '本轮自动对话已到预算上限(等人说话自动重置)');
|
||||
}
|
||||
function loadMessages() {
|
||||
return fetch('/api/messages?since=0').then((r) => r.json()).then((d) => {
|
||||
feed.textContent = '';
|
||||
const day = mk('div', 'day'); day.appendChild(mk('span', null, hhmm(Date.now()))); feed.appendChild(day);
|
||||
S.seen.clear(); S.pending.clear(); S.quote = {}; S.lastTs = 0;
|
||||
if (!d.messages.length) { const e = mk('div', 'empty', '还没有人说话。在下面直接说,默认发给主代理。'); e.id = 'emptyHint'; feed.appendChild(e); }
|
||||
for (const m of d.messages) pushMessage(m);
|
||||
S.head = d.head;
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}).catch(() => {});
|
||||
}
|
||||
function stream() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('hello', (e) => {
|
||||
liveTxt.textContent = '已连接'; liveDot.className = 'dot on';
|
||||
const h = JSON.parse(e.data).head || 0;
|
||||
if (h > S.head) fetch('/api/messages?since=' + S.head).then((r) => r.json()).then((d) => { d.messages.forEach(pushMessage); S.head = d.head; }).catch(() => {});
|
||||
});
|
||||
es.addEventListener('messages', (e) => {
|
||||
const rows = JSON.parse(e.data);
|
||||
if (!rows.length) return;
|
||||
rows.forEach(pushMessage);
|
||||
S.head = rows[rows.length - 1].seq;
|
||||
loadState();
|
||||
});
|
||||
es.addEventListener('job', (e) => upsertJob(JSON.parse(e.data)));
|
||||
es.addEventListener('state', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof d.auto === 'boolean') { S.auto = d.auto; }
|
||||
if (typeof d.budgetLeft === 'number') S.budgetLeft = d.budgetLeft;
|
||||
paintAuto(S.auto);
|
||||
} catch { /* 忽略 */ }
|
||||
});
|
||||
es.addEventListener('budget', (e) => { try { paintBudget(JSON.parse(e.data)); } catch { /* 忽略 */ } });
|
||||
const down = () => { liveTxt.textContent = '重连中'; liveDot.className = 'dot'; };
|
||||
es.addEventListener('error', down); es.onerror = down;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 发送 */
|
||||
const target = () => { const v = targetEl.value; return v === '__primary__' ? (S.primary || 'dsh') : v === '__all__' ? '' : v; };
|
||||
function send(silent) {
|
||||
const text = textEl.value.trim();
|
||||
if (!text) { textEl.focus(); return; }
|
||||
const to = target();
|
||||
textEl.value = '';
|
||||
optimistic(text, to ? [to] : []);
|
||||
// 默认(全体)走普通发送,由自驱把大家都叫起来;点名某个成员则直接叫醒它
|
||||
if (!to || silent) {
|
||||
const btn = silent ? sendOnlyBtn : sendBtn;
|
||||
const label = silent ? '静默发送' : '发送';
|
||||
btn.disabled = true; btn.textContent = '发送中';
|
||||
post('/api/send', { text, to: to ? [to] : [], silent: Boolean(silent) }).catch(() => {})
|
||||
.finally(() => { btn.disabled = false; btn.textContent = label; });
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = true; sendBtn.textContent = '已叫醒';
|
||||
post('/api/ask', { text, target: to }).then((d) => { if (d.job) upsertJob(Object.assign({ local: true }, d.job)); })
|
||||
.catch(() => {}).finally(() => setTimeout(() => { sendBtn.disabled = false; sendBtn.textContent = '发送'; }, 700));
|
||||
}
|
||||
sendBtn.addEventListener('click', () => send(false));
|
||||
sendOnlyBtn.addEventListener('click', () => send(true));
|
||||
textEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(true); }
|
||||
if (e.key === 'Escape') textEl.value = '';
|
||||
});
|
||||
autoBtn.addEventListener('click', () => {
|
||||
// 先本地立刻翻转,别让用户等 5 秒轮询(上一版就是栽在这)
|
||||
const next = !autoBtn.classList.contains('btn-accent');
|
||||
paintAuto(next);
|
||||
autoBtn.disabled = true;
|
||||
post('/api/auto', {}).then((d) => { if (typeof d.auto === 'boolean') paintAuto(d.auto); })
|
||||
.catch(() => paintAuto(!next)).finally(() => { autoBtn.disabled = false; });
|
||||
});
|
||||
// 侧栏标出当前 @ 目标,一眼知道这条会发给谁
|
||||
targetEl.addEventListener('change', () => {
|
||||
for (const [name, n] of mNodes) n.card.dataset.target = (targetEl.value === name) ? '1' : '0';
|
||||
});
|
||||
roomSel.addEventListener('change', () => post('/api/room', { room: roomSel.value }).then(() => loadMessages().then(loadState)));
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadState(); });
|
||||
|
||||
const applyTheme = (t) => {
|
||||
document.documentElement.dataset.theme = t;
|
||||
themeBtn.textContent = t === 'dark' ? '亮' : '暗';
|
||||
try { localStorage.setItem('aig.theme', t); } catch { /* 忽略 */ }
|
||||
};
|
||||
applyTheme((() => { try { return localStorage.getItem('aig.theme') || 'dark'; } catch { return 'dark'; } })());
|
||||
themeBtn.addEventListener('click', () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
|
||||
|
||||
loadState().then(loadMessages).then(stream);
|
||||
setInterval(loadState, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user