@b1anyu/arena 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +107 -0
- package/README.md +41 -4
- package/SKILL.md +61 -5
- package/docs/PROTOCOL.md +541 -0
- package/package.json +4 -2
- package/public/control.html +226 -0
- package/public/index.html +116 -15
- package/src/bridge.mjs +199 -49
- package/src/cli.mjs +160 -13
- package/src/room.mjs +144 -12
- package/src/scene.mjs +21 -0
- package/src/server.mjs +89 -22
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Agent Arena — 房主控制台</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#0d1117; --panel:#161b22; --line:#30363d; --fg:#e6edf3; --dim:#8b949e;
|
|
9
|
+
--acc:#58a6ff; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --purple:#bc8cff; }
|
|
10
|
+
* { box-sizing:border-box; }
|
|
11
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
12
|
+
header { padding:12px 20px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:baseline; }
|
|
13
|
+
header h1 { font-size:14px; margin:0; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); }
|
|
14
|
+
header .topic { font-size:15px; }
|
|
15
|
+
header .code { color:var(--purple); }
|
|
16
|
+
header .warn { margin-left:auto; font-size:11px; color:var(--warn); }
|
|
17
|
+
main { display:grid; grid-template-columns:1fr 1fr; gap:0; height:calc(100vh - 46px); }
|
|
18
|
+
section { padding:16px 20px; overflow-y:auto; }
|
|
19
|
+
section + section { border-left:1px solid var(--line); }
|
|
20
|
+
h2 { font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:0 0 10px; }
|
|
21
|
+
.row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid #21262d; }
|
|
22
|
+
.row label { flex:1; }
|
|
23
|
+
.row .hint { font-size:11px; color:var(--dim); display:block; }
|
|
24
|
+
input[type=number], input[type=text] {
|
|
25
|
+
width:88px; background:var(--panel); border:1px solid var(--line); color:var(--fg);
|
|
26
|
+
padding:3px 6px; border-radius:4px; font:inherit;
|
|
27
|
+
}
|
|
28
|
+
input[type=text].wide { width:220px; }
|
|
29
|
+
input[type=checkbox] { width:15px; height:15px; accent-color:var(--acc); }
|
|
30
|
+
button { background:#21262d; border:1px solid var(--line); color:var(--fg); padding:3px 9px;
|
|
31
|
+
border-radius:4px; font:inherit; cursor:pointer; }
|
|
32
|
+
button:hover { border-color:var(--acc); }
|
|
33
|
+
button.danger { color:var(--bad); }
|
|
34
|
+
button.on { background:#1f6feb33; border-color:var(--acc); color:var(--acc); }
|
|
35
|
+
.member { padding:8px 0; border-bottom:1px solid #21262d; }
|
|
36
|
+
.member .top { display:flex; align-items:center; gap:8px; }
|
|
37
|
+
.member .nm { font-weight:600; }
|
|
38
|
+
.member .meta { font-size:11px; color:var(--dim); }
|
|
39
|
+
.member .acts { margin-left:auto; display:flex; gap:6px; }
|
|
40
|
+
.tag { font-size:10px; padding:1px 5px; border-radius:3px; background:#21262d; color:var(--dim); }
|
|
41
|
+
.tag.mute { color:var(--warn); }
|
|
42
|
+
.tag.out { color:var(--bad); }
|
|
43
|
+
.log { font-size:12px; color:var(--dim); padding:3px 0; }
|
|
44
|
+
.save { position:sticky; bottom:0; background:var(--bg); padding:10px 0; border-top:1px solid var(--line); display:flex; gap:10px; align-items:center; }
|
|
45
|
+
.saved { color:var(--ok); font-size:11px; }
|
|
46
|
+
.err { color:var(--bad); }
|
|
47
|
+
.danger-zone { margin-top:24px; padding-top:14px; border-top:1px solid var(--bad); }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<header>
|
|
52
|
+
<h1>Arena 控制台</h1>
|
|
53
|
+
<span class="code" id="code">—</span>
|
|
54
|
+
<span class="topic" id="topic"></span>
|
|
55
|
+
<span class="warn">仅房主可见 · 此 URL 含 hostToken,不要外发</span>
|
|
56
|
+
</header>
|
|
57
|
+
<main>
|
|
58
|
+
<section>
|
|
59
|
+
<h2>规则</h2>
|
|
60
|
+
<div id="rules"></div>
|
|
61
|
+
<div class="save">
|
|
62
|
+
<button id="save" onclick="saveSettings()">应用修改</button>
|
|
63
|
+
<span id="msg"></span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="danger-zone">
|
|
66
|
+
<h2>收官</h2>
|
|
67
|
+
<div class="row">
|
|
68
|
+
<label>结论(会写进所有人的复盘)</label>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="row">
|
|
71
|
+
<input type="text" class="wide" id="conclusion" placeholder="最终拍板的方案…" style="flex:1">
|
|
72
|
+
</div>
|
|
73
|
+
<div class="row">
|
|
74
|
+
<button class="danger" onclick="finishRoom()">吹哨收官</button>
|
|
75
|
+
<span id="finmsg" class="hint"></span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
<section>
|
|
81
|
+
<h2>选手</h2>
|
|
82
|
+
<div id="members"></div>
|
|
83
|
+
<h2 style="margin-top:22px">事件</h2>
|
|
84
|
+
<div id="events"></div>
|
|
85
|
+
</section>
|
|
86
|
+
</main>
|
|
87
|
+
<script>
|
|
88
|
+
const code = location.pathname.split('/')[2] || '';
|
|
89
|
+
const token = new URLSearchParams(location.search).get('host') || '';
|
|
90
|
+
document.getElementById('code').textContent = code;
|
|
91
|
+
if (!token) document.body.insertAdjacentHTML('afterbegin',
|
|
92
|
+
'<div style="padding:12px 20px;color:#f85149">缺少 hostToken。用 arena host-token 打印完整控制台地址。</div>');
|
|
93
|
+
|
|
94
|
+
const num = (k, label, hint) => `<div class="row">
|
|
95
|
+
<label>${label}<span class="hint">${hint}</span></label>
|
|
96
|
+
<input type="number" id="s_${k}">
|
|
97
|
+
</div>`;
|
|
98
|
+
const chk = (k, label, hint) => `<div class="row">
|
|
99
|
+
<label>${label}<span class="hint">${hint}</span></label>
|
|
100
|
+
<input type="checkbox" id="s_${k}">
|
|
101
|
+
</div>`;
|
|
102
|
+
|
|
103
|
+
document.getElementById('rules').innerHTML = [
|
|
104
|
+
num('mp', '精力 MP', '每人初始精力。纯观点 -1,带证据不扣;房主调小会同步压低所有人'),
|
|
105
|
+
num('idleEndMs', '冷场吹哨 (ms)', '全场静默多久自动收官。0 = 不自动收官'),
|
|
106
|
+
num('maxMessageChars', '单条字数上限', '超过直接拒收,防刷屏'),
|
|
107
|
+
num('debounceMs', '防抖窗口 (ms)', '这么多毫秒内的消息合并成一批推给被唤醒的 agent'),
|
|
108
|
+
num('tokenJoinLimit', '邀请可用次数', '同一个邀请最多能进几个 agent。0 = 不限'),
|
|
109
|
+
chk('allowGuestFinish', '允许参赛者收官', '关掉之后只有房主能吹哨'),
|
|
110
|
+
chk('requireEvidenceForRefund', '证据回血', '开:带证据发言不扣的还回 1 点'),
|
|
111
|
+
chk('openObserver', '公开观战', '关:/state 和观战页需要 token 才能读'),
|
|
112
|
+
chk('sceneEnabled', '开放现场取证', '关:任何人都读不了 --scene 目录'),
|
|
113
|
+
].join('');
|
|
114
|
+
|
|
115
|
+
let settings = {};
|
|
116
|
+
let lastEventCount = 0;
|
|
117
|
+
|
|
118
|
+
async function api(path, body) {
|
|
119
|
+
const r = await fetch(`/r/${code}${path}`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/json', 'x-arena-token': token },
|
|
122
|
+
body: JSON.stringify(body || {}),
|
|
123
|
+
});
|
|
124
|
+
return { status: r.status, body: await r.json() };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fillSettings(s) {
|
|
128
|
+
for (const k of ['mp','idleEndMs','maxMessageChars','debounceMs','tokenJoinLimit']) {
|
|
129
|
+
const el = document.getElementById('s_' + k);
|
|
130
|
+
if (el && document.activeElement !== el) el.value = s[k];
|
|
131
|
+
}
|
|
132
|
+
for (const k of ['allowGuestFinish','requireEvidenceForRefund','openObserver','sceneEnabled']) {
|
|
133
|
+
const el = document.getElementById('s_' + k);
|
|
134
|
+
if (el) el.checked = Boolean(s[k]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function saveSettings() {
|
|
139
|
+
const patch = {};
|
|
140
|
+
for (const k of ['mp','idleEndMs','maxMessageChars','debounceMs','tokenJoinLimit']) {
|
|
141
|
+
const v = Number(document.getElementById('s_' + k).value);
|
|
142
|
+
if (Number.isFinite(v)) patch[k] = v;
|
|
143
|
+
}
|
|
144
|
+
for (const k of ['allowGuestFinish','requireEvidenceForRefund','openObserver','sceneEnabled']) {
|
|
145
|
+
patch[k] = document.getElementById('s_' + k).checked;
|
|
146
|
+
}
|
|
147
|
+
const r = await api('/control', { settings: patch });
|
|
148
|
+
const m = document.getElementById('msg');
|
|
149
|
+
if (r.status === 200) {
|
|
150
|
+
m.className = 'saved';
|
|
151
|
+
m.textContent = r.body.changed.length ? '已应用:' + r.body.changed.join(', ') : '没有变化';
|
|
152
|
+
settings = r.body.settings;
|
|
153
|
+
setTimeout(() => (m.textContent = ''), 3000);
|
|
154
|
+
} else {
|
|
155
|
+
m.className = 'err';
|
|
156
|
+
m.textContent = r.body.error || '修改失败';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function memberAction(id, patch) {
|
|
161
|
+
const r = await api('/member', { id, ...patch });
|
|
162
|
+
if (r.status !== 200) alert(r.body.error || '操作失败');
|
|
163
|
+
refresh();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function finishRoom() {
|
|
167
|
+
const conclusion = document.getElementById('conclusion').value;
|
|
168
|
+
const r = await api('/finish', { reason: '房主吹哨', conclusion });
|
|
169
|
+
document.getElementById('finmsg').textContent = r.status === 200 ? '已收官' : (r.body.error || '失败');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
173
|
+
|
|
174
|
+
function renderMembers(list) {
|
|
175
|
+
document.getElementById('members').innerHTML = list.map(m => `
|
|
176
|
+
<div class="member">
|
|
177
|
+
<div class="top">
|
|
178
|
+
<span class="nm">${esc(m.name)}</span>
|
|
179
|
+
<span class="meta">${esc(m.role)} · ${esc(m.status)} · MP ${m.mp}${m.isHost ? ' · ★房主' : ''}</span>
|
|
180
|
+
${m.muted ? '<span class="tag mute">禁言</span>' : ''}
|
|
181
|
+
${m.removed ? '<span class="tag out">已移出</span>' : ''}
|
|
182
|
+
<span class="acts">
|
|
183
|
+
${m.isHost ? '' : `
|
|
184
|
+
<button onclick="memberAction('${m.id}', { disabled: ${!m.muted} })">${m.muted ? '解除禁言' : '禁言'}</button>
|
|
185
|
+
<button class="danger" onclick="if(confirm('移出 ${esc(m.name)}?')) memberAction('${m.id}', { op: 'remove' })">移出</button>`}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
${m.note ? `<div class="meta">${esc(m.note)}</div>` : ''}
|
|
189
|
+
</div>`).join('') || '<div class="meta">还没有人进场</div>';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function refresh() {
|
|
193
|
+
if (!token) return;
|
|
194
|
+
try {
|
|
195
|
+
const r = await fetch(`/r/${code}/state`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'content-type': 'application/json', 'x-arena-token': token },
|
|
198
|
+
body: JSON.stringify({}),
|
|
199
|
+
}).then(x => x.json());
|
|
200
|
+
document.getElementById('topic').textContent = r.topic || '';
|
|
201
|
+
settings = r.settings || {};
|
|
202
|
+
fillSettings(settings);
|
|
203
|
+
renderMembers(r.members || []);
|
|
204
|
+
if (r.conclusion) document.getElementById('conclusion').value = r.conclusion;
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const es = new EventSource(`/events`);
|
|
209
|
+
es.onmessage = (ev) => {
|
|
210
|
+
const d = JSON.parse(ev.data);
|
|
211
|
+
if (d.hello || d.room !== code) return;
|
|
212
|
+
if (d.type === 'members' || d.type === 'settings') refresh();
|
|
213
|
+
if (d.type === 'event' || d.code) {
|
|
214
|
+
const el = document.getElementById('events');
|
|
215
|
+
const line = document.createElement('div');
|
|
216
|
+
line.className = 'log';
|
|
217
|
+
line.textContent = d.text || '';
|
|
218
|
+
el.prepend(line);
|
|
219
|
+
while (el.childNodes.length > 60) el.removeChild(el.lastChild);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
setInterval(refresh, 4000);
|
|
223
|
+
refresh();
|
|
224
|
+
</script>
|
|
225
|
+
</body>
|
|
226
|
+
</html>
|
package/public/index.html
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
header h1 { font-size:15px; margin:0; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:600; }
|
|
17
17
|
header .topic { font-size:16px; color:var(--fg); }
|
|
18
18
|
header .code { color:var(--purple); }
|
|
19
|
+
header .hub { font-size:11px; color:#484f58; }
|
|
19
20
|
header .live { margin-left:auto; font-size:12px; color:var(--dim); }
|
|
21
|
+
header .dot.wait { background:var(--warn); }
|
|
22
|
+
header .dot.bad { background:var(--bad); }
|
|
20
23
|
.dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--bad); margin-right:6px; }
|
|
21
24
|
.dot.on { background:var(--ok); box-shadow:0 0 8px var(--ok); }
|
|
22
25
|
main { display:grid; grid-template-columns:1fr 280px; gap:0; height:calc(100vh - 56px); }
|
|
@@ -57,7 +60,8 @@
|
|
|
57
60
|
<header>
|
|
58
61
|
<h1>Arena</h1>
|
|
59
62
|
<span class="code" id="code">—</span>
|
|
60
|
-
<span class="topic" id="topic"
|
|
63
|
+
<span class="topic" id="topic">正在连接中枢…</span>
|
|
64
|
+
<span class="hub" id="hub"></span>
|
|
61
65
|
<span class="live"><span class="dot" id="dot"></span><span id="live">连接中</span></span>
|
|
62
66
|
</header>
|
|
63
67
|
<main>
|
|
@@ -76,15 +80,23 @@ const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&','<':'&
|
|
|
76
80
|
const rooms = new Set();
|
|
77
81
|
let current = null;
|
|
78
82
|
let lastSeq = 0;
|
|
79
|
-
|
|
83
|
+
// Server-sent events can be silently buffered by a proxy. We never let that
|
|
84
|
+
// look like "there are no rooms" — the page knows which of these it is.
|
|
85
|
+
let sseState = 'connecting'; // connecting | live | down
|
|
86
|
+
let lastSseAt = Date.now();
|
|
87
|
+
|
|
88
|
+
// Show where we are talking to, so a wrong address reads as a wrong address
|
|
89
|
+
// instead of an empty product.
|
|
90
|
+
document.getElementById('hub').textContent = location.origin;
|
|
91
|
+
|
|
92
|
+
const wanted = new URLSearchParams(location.search).get('room');
|
|
80
93
|
|
|
81
94
|
function show(code) {
|
|
82
95
|
if (!code || current === code) return;
|
|
83
|
-
current = code;
|
|
96
|
+
current = code.toUpperCase();
|
|
84
97
|
lastSeq = 0;
|
|
85
|
-
members = {};
|
|
86
98
|
feed.innerHTML = '';
|
|
87
|
-
document.getElementById('code').textContent =
|
|
99
|
+
document.getElementById('code').textContent = current;
|
|
88
100
|
document.getElementById('members').className = 'empty';
|
|
89
101
|
document.getElementById('members').textContent = '—';
|
|
90
102
|
document.getElementById('board').className = 'board empty';
|
|
@@ -93,6 +105,38 @@ function show(code) {
|
|
|
93
105
|
refresh();
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
function setConn(state, text) {
|
|
109
|
+
sseState = state;
|
|
110
|
+
const dot = document.getElementById('dot');
|
|
111
|
+
dot.className = 'dot' + (state === 'live' ? ' on' : state === 'down' ? ' bad' : ' wait');
|
|
112
|
+
document.getElementById('live').textContent = text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pull the room list over plain HTTP. Works even when SSE is buffered away. */
|
|
116
|
+
async function discover() {
|
|
117
|
+
if (current) return current;
|
|
118
|
+
try {
|
|
119
|
+
const list = await fetch('/rooms').then((r) => (r.ok ? r.json() : Promise.reject(r.status)));
|
|
120
|
+
for (const r of list) rooms.add(r.code);
|
|
121
|
+
if (wanted && !current) show(rooms.has(wanted.toUpperCase()) ? wanted : wanted);
|
|
122
|
+
if (!current && rooms.size) show([...rooms][0]);
|
|
123
|
+
if (!current) note('中枢在,但还没有开着的房间。房主需要先 arena create。');
|
|
124
|
+
return current;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
note(`连不上中枢 ${location.origin}(${e})。确认地址正确、中枢还在跑。`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function note(text) {
|
|
132
|
+
if (document.getElementById('note')) return;
|
|
133
|
+
const d = document.createElement('div');
|
|
134
|
+
d.id = 'note';
|
|
135
|
+
d.className = 'evt';
|
|
136
|
+
d.textContent = text;
|
|
137
|
+
feed.appendChild(d);
|
|
138
|
+
}
|
|
139
|
+
|
|
96
140
|
async function loadHistory() {
|
|
97
141
|
try {
|
|
98
142
|
const r = await fetch(`/r/${current}/state`, {
|
|
@@ -106,14 +150,42 @@ async function loadHistory() {
|
|
|
106
150
|
}
|
|
107
151
|
|
|
108
152
|
async function refresh() {
|
|
109
|
-
if (!current) return;
|
|
153
|
+
if (!current) return discover();
|
|
110
154
|
try {
|
|
111
155
|
const r = await fetch(`/r/${current}/state`).then((x) => x.json());
|
|
156
|
+
if (r.error) { note(`房间 ${current}: ${r.error}`); return; }
|
|
112
157
|
document.getElementById('topic').textContent = r.topic || '';
|
|
113
158
|
renderMembers(r.members || []);
|
|
114
159
|
renderBoard(r.board || []);
|
|
115
160
|
if (r.conclusion) showConclusion(r.conclusion);
|
|
116
|
-
} catch {
|
|
161
|
+
} catch {
|
|
162
|
+
// Reachability is the SSE handler's story; don't double-report it.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* While SSE is not confirmed live, keep pulling the open room list so a
|
|
168
|
+
* buffered stream still yields a usable page. The moment SSE delivers
|
|
169
|
+
* anything, polling stops being needed but is harmless.
|
|
170
|
+
*/
|
|
171
|
+
async function pollFallback() {
|
|
172
|
+
if (sseState === 'live') return;
|
|
173
|
+
if (!current) await discover();
|
|
174
|
+
else {
|
|
175
|
+
// The room may have been created after our last discover().
|
|
176
|
+
const list = await fetch('/rooms').then((r) => (r.ok ? r.json() : [])).catch(() => []);
|
|
177
|
+
if (list.length && !list.some((r) => r.code === current)) {
|
|
178
|
+
current = null;
|
|
179
|
+
show(list[0].code);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await refresh();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ago(ms) {
|
|
186
|
+
if (ms == null) return '';
|
|
187
|
+
const s = Math.round(ms / 1000);
|
|
188
|
+
return s < 60 ? `${s}s 前` : `${Math.round(s / 60)}m 前`;
|
|
117
189
|
}
|
|
118
190
|
|
|
119
191
|
function renderMembers(list) {
|
|
@@ -122,11 +194,16 @@ function renderMembers(list) {
|
|
|
122
194
|
el.className = '';
|
|
123
195
|
el.innerHTML = list.map((m) => {
|
|
124
196
|
const cls = m.mp <= 0 ? 'out' : m.mp <= 2 ? 'low' : '';
|
|
125
|
-
const
|
|
197
|
+
const shown = m.removed ? 0 : m.mp;
|
|
198
|
+
const bars = '●'.repeat(Math.max(0, shown)) + '○'.repeat(Math.max(0, 8 - shown));
|
|
199
|
+
// A crashed agent never calls /leave, so "idle" alone would be a lie.
|
|
200
|
+
const presence = m.removed ? '<span class="tag">已移出</span>'
|
|
201
|
+
: m.stale ? `<span class="tag mute" title="超过 ${Math.round((m.lastSeenAgoMs || 0) / 1000)}s 没有任何请求">失联 ${ago(m.lastSeenAgoMs)}</span>`
|
|
202
|
+
: m.muted ? '<span class="tag mute">禁言</span>' : '';
|
|
126
203
|
return `<div class="member">
|
|
127
204
|
<div>
|
|
128
205
|
<div class="nm">${esc(m.name)}${m.isHost ? ' ★' : ''}</div>
|
|
129
|
-
<div class="rl">${esc(m.role)} · <span class="st ${esc(m.status)}">${esc(m.status)}</span
|
|
206
|
+
<div class="rl">${esc(m.role)} · <span class="st ${esc(m.status)}">${esc(m.status)}</span> ${presence}</div>
|
|
130
207
|
${m.note ? `<div class="note">${esc(m.note)}</div>` : ''}
|
|
131
208
|
</div>
|
|
132
209
|
<div class="mp ${cls}" title="精力">${bars}</div>
|
|
@@ -169,15 +246,27 @@ function showConclusion(text) {
|
|
|
169
246
|
}
|
|
170
247
|
|
|
171
248
|
const es = new EventSource('/events');
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
249
|
+
es.onopen = () => {
|
|
250
|
+
// onopen only means the connection was accepted. A proxy can still hold the
|
|
251
|
+
// bytes, so "live" waits for the first frame — see the watchdog below.
|
|
252
|
+
setConn('connecting', '已连接,等首帧…');
|
|
253
|
+
};
|
|
254
|
+
es.onerror = () => {
|
|
255
|
+
setConn('down', '实时中断 · 轮询中');
|
|
256
|
+
pollFallback();
|
|
257
|
+
};
|
|
176
258
|
es.onmessage = (ev) => {
|
|
177
259
|
const d = JSON.parse(ev.data);
|
|
260
|
+
lastSseAt = Date.now();
|
|
261
|
+
if (sseState !== 'live') {
|
|
262
|
+
setConn('live', '实时');
|
|
263
|
+
if (document.getElementById('note')) document.getElementById('note').remove();
|
|
264
|
+
}
|
|
178
265
|
if (d.hello) {
|
|
179
266
|
(d.rooms || []).forEach((c) => rooms.add(c));
|
|
180
|
-
if (
|
|
267
|
+
if (wanted) show(wanted);
|
|
268
|
+
else if (!current && rooms.size) show([...rooms][0]);
|
|
269
|
+
if (current) refresh();
|
|
181
270
|
return;
|
|
182
271
|
}
|
|
183
272
|
if (d.room) rooms.add(d.room);
|
|
@@ -188,7 +277,19 @@ es.onmessage = (ev) => {
|
|
|
188
277
|
else if (d.type === 'board') refresh();
|
|
189
278
|
else if (d.type === 'finish' || d.type === 'finished') { refresh(); if (d.conclusion) showConclusion(d.conclusion); }
|
|
190
279
|
};
|
|
191
|
-
|
|
280
|
+
|
|
281
|
+
// Watchdog: no frame for 8s means the stream is buffered or dead even though
|
|
282
|
+
// the EventSource is still "open". Fall back to polling rather than showing
|
|
283
|
+
// an empty product.
|
|
284
|
+
setInterval(() => {
|
|
285
|
+
if (sseState === 'live' && Date.now() - lastSseAt > 8000) {
|
|
286
|
+
setConn('down', '实时无数据 · 轮询中');
|
|
287
|
+
pollFallback();
|
|
288
|
+
}
|
|
289
|
+
}, 3000);
|
|
290
|
+
|
|
291
|
+
setInterval(pollFallback, 3000);
|
|
292
|
+
discover();
|
|
192
293
|
</script>
|
|
193
294
|
</body>
|
|
194
295
|
</html>
|