wezard 2.0.1 → 2.0.3

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/web/chat.js CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
24
24
  // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
25
- var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, stats: null };
25
+ var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, stats: null, winStats: null };
26
26
  // frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
27
27
  var S = { es: null, pinned: true, gen: 0, frags: {} };
28
28
  // 关系/日程两栏共用的世界快照。sel = 当前聚焦的节点, kinds = 边类型开关。
@@ -128,11 +128,23 @@
128
128
  return k.label || (n && n.label) || ({ human: '👤', task: '⏰', system: '🧙' })[kindOf(id)] || '🧙';
129
129
  };
130
130
  // `.name` 的点由 CSS 画 —— 人与定时任务没有地址, 不画点。
131
- var nm = function (id, name) {
132
- return '<span class="nm ' + kindOf(id) + '">' + esc(name || roleName(id)) + '</span>';
133
- };
134
131
  var nameOf = function (id) { return (kindOf(id) === 'wizard' ? '.' : '') + roleName(id); };
135
132
  var canSwitch = function (id) { return !!id && kindOf(id) !== 'task' && kindOf(id) !== 'system' && id !== 'human:'; };
133
+ // 头像与名字就是进入那个 role 视角的入口 —— 侧栏项、会话头、消息行同一种读法。
134
+ // 是 <span> 不是 <button>: 侧栏里它嵌在整行那颗按钮里。
135
+ var goSpan = function (cls, id, inner) {
136
+ var on = canSwitch(id) && id !== ROLE;
137
+ return '<span class="' + cls + (on ? ' go" data-r="' + esc(id) + '" title="' + esc('切到 ' + nameOf(id) + ' 的视角') : '') + '">' + inner + '</span>';
138
+ };
139
+ var nm = function (id, name, go) {
140
+ var cls = 'nm ' + kindOf(id), txt = esc(name || roleName(id));
141
+ return go ? goSpan(cls, id, txt) : '<span class="' + cls + '">' + txt + '</span>';
142
+ };
143
+ var bindGo = function (root, sel) {
144
+ root.querySelectorAll(sel || '.go[data-r]').forEach(function (b) {
145
+ b.onclick = function (e) { e.stopPropagation(); switchRole(b.getAttribute('data-r')); };
146
+ });
147
+ };
136
148
 
137
149
  // ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
138
150
  var md = null;
@@ -207,10 +219,35 @@
207
219
  var d = document.createElement('div'); d.innerHTML = html; return d;
208
220
  };
209
221
 
222
+ // ── 工具调用正文: 第二级数据, 展开那一刻才取 ──
223
+ // 片段里的 .tool-body 是个空壳 (data-lazy-turn / data-lazy-use), 首屏只带摘要行。
224
+ // toggle 不冒泡, 所以在线程根上用捕获听 —— 用户点开, 和 reconcile 换了新节点后
225
+ // restoreOpen 回填展开态, 走的是同一个事件: 后者正好把「展开着等结果」的那次调用
226
+ // 重取成带结果的正文。取到之后正文就留在节点上, 收起再展开不会重取。
227
+ var loadTool = function (d) {
228
+ var body = childBy(d, 'tool-body');
229
+ if (!body || !body.hasAttribute('data-lazy-turn') || body._busy || body.firstChild) return;
230
+ body._busy = true; body.removeAttribute('data-err');
231
+ api('api/tool', { turn: body.getAttribute('data-lazy-turn'), use: body.getAttribute('data-lazy-use') })
232
+ .then(function (r) {
233
+ if (!r.ok) throw new Error(r.error);
234
+ var stick = S.pinned;
235
+ body.innerHTML = r.html;
236
+ if (stick) toBottom(true);
237
+ })
238
+ // 失败留着空壳 (CSS 给出提示), 再展开一次就是重试。
239
+ .catch(function () { body.setAttribute('data-err', '1'); })
240
+ .then(function () { body._busy = false; });
241
+ };
242
+
210
243
  // ── 滚动: 默认到底; 用户手动上翻后解除吸附, 回到底部再吸附 ──
211
244
  var atBottom = function () { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80; };
212
245
  var toBottom = function (force) { if (force || S.pinned) thread.scrollTop = thread.scrollHeight; };
213
246
  thread.addEventListener('scroll', function () { S.pinned = atBottom(); });
247
+ inner.addEventListener('toggle', function (e) {
248
+ var d = e.target;
249
+ if (d.open && d.classList && d.classList.contains('tool-call')) loadTool(d);
250
+ }, true);
214
251
 
215
252
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
216
253
  var convOf = function (key) { return R.convs.filter(function (c) { return c.key === key; })[0]; };
@@ -223,63 +260,70 @@
223
260
  });
224
261
  return out;
225
262
  };
226
- var avatarOf = function (c) {
227
- if (c.kind !== 'group') return '<span class="av">' + esc(c.label) + '</span>';
263
+ // ── 状态: wizard 是否在执行中 ──
264
+ // 服务端只给事实 (busy / alive / runningUntil), 亮不亮在这里按本地时钟判 ——
265
+ // 到点没有新写入就自己熄 (同 expireRows), 不必等下一次推送。
266
+ var ST = { run: '执行中', idle: '空闲', off: '已关闭' };
267
+ var stateOf = function (s) { return wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
268
+ // 侧栏只在执行中时点一盏灯: 列表里安静是常态, 亮着的才值得看一眼。
269
+ var lampOf = function (s) {
270
+ return s && wRunning(s) ? '<i class="live" title="' + ST.run + '"></i>' : '';
271
+ };
272
+ var paintStatus = function () {
273
+ var el = $('#rb-st'), r = R.role;
274
+ if (!el || !r) return;
275
+ var k = stateOf(r);
276
+ el.className = 'st ' + k; el.textContent = ST[k];
277
+ };
278
+
279
+ var avatarOf = function (c, extra) {
280
+ if (c.kind !== 'group') return goSpan('av', c.peer, esc(c.label) + (extra || ''));
228
281
  var ms = membersOf(c).slice(0, 4);
229
282
  return '<span class="av mosaic n' + ms.length + '" aria-hidden="true">' +
230
283
  ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
231
284
  };
232
285
 
233
- // 选中的那一项若是个 role (私聊对端 / 群里的某人), 行内给一个换视角的开关。
234
- // 不能是 <button>: 它嵌在整行那颗按钮里。
235
- var SWAP = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3 5.5h9M9.5 3l2.5 2.5L9.5 8M13 10.5H4M6.5 8 4 10.5 6.5 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
236
- var swapOf = function (role) {
237
- return canSwitch(role) && role !== ROLE
238
- ? '<span class="sw" role="button" tabindex="0" data-sw="' + esc(role) + '" title="' + esc('切到 ' + nameOf(role) + ' 的视角') + '">' + SWAP + '</span>'
239
- : '';
240
- };
241
- var line = function (title, sw, ts, pv) {
242
- return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + sw +
286
+ var line = function (title, ts, pv) {
287
+ return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' +
243
288
  '<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
244
289
  '<span class="pv">' + esc(pv) + '</span></span>';
245
290
  };
246
291
 
247
292
  var convItem = function (c) {
248
293
  var on = c.key === CONV;
249
- var title = c.kind === 'wizard' ? nm(c.peer, c.name) : '<span class="nm chat">' + esc(c.name) + '</span>';
250
- var subs = on && c.subs.length
251
- ? '<div class="subs">' + c.subs.map(function (s) {
294
+ var title = c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>';
295
+ // 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
296
+ var talked = on ? c.subs.filter(function (s) { return s.count; }) : [];
297
+ var subs = talked.length
298
+ ? '<div class="subs">' + talked.map(function (s) {
252
299
  var sel = s.role === WITH;
253
- return '<button class="si' + (sel ? ' on' : '') + (s.count ? '' : ' quiet') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
254
- 'title="' + esc(s.count ? '我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来' : '在这里, 但还没和我说过话') + '">' +
255
- '<span class="av">' + esc(s.label) + '</span>' +
256
- line(nm(s.role, s.name), sel ? swapOf(s.role) : '', s.lastTs, s.preview) + '</button>';
300
+ return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
301
+ 'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
302
+ goSpan('av', s.role, esc(s.label) + lampOf(s.status)) +
303
+ line(nm(s.role, s.name, true), s.lastTs, s.preview) + '</button>';
257
304
  }).join('') + '</div>'
258
305
  : '';
259
306
  var sel = on && !WITH;
260
307
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
261
- avatarOf(c) + line(title, sel && c.kind === 'wizard' ? swapOf(c.peer) : '', c.lastTs, c.preview) +
308
+ avatarOf(c, lampOf(c.status)) + line(title, c.lastTs, c.preview) +
262
309
  '</button>' + subs;
263
310
  };
264
311
 
265
312
  var renderConvs = function () {
266
313
  var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
267
314
  var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
268
- var sec = function (title, list, none) {
269
- return '<h2>' + title + '<span>' + list.length + '</span></h2>' +
270
- (list.length ? list.map(convItem).join('') : '<div class="none">' + none + '</div>');
315
+ // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
316
+ var sec = function (title, list) {
317
+ return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
271
318
  };
272
- // 人没有私聊 —— wizard↔wizard 才有, 空着的那一栏也不画。
273
- convsEl.innerHTML = sec('群聊', groups, '还没在任何群里说过话') +
274
- (R.role && R.role.kind === 'human' ? '' : sec('私聊', dms, '没有和别的 wizard 私聊过'));
319
+ // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
320
+ var html = sec('群聊', groups) + sec('私聊', dms);
321
+ if (convsEl._html === html) return;
322
+ convsEl._html = html; convsEl.innerHTML = html;
275
323
  convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
276
324
  b.onclick = function () { selectConv(b.getAttribute('data-conv'), b.getAttribute('data-with') || ''); };
277
325
  });
278
- convsEl.querySelectorAll('[data-sw]').forEach(function (x) {
279
- var go = function (e) { e.stopPropagation(); switchRole(x.getAttribute('data-sw')); };
280
- x.onclick = go;
281
- x.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } };
282
- });
326
+ bindGo(convsEl);
283
327
  };
284
328
 
285
329
  var shortCwd = function (p) {
@@ -334,7 +378,7 @@
334
378
  if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
335
379
  });
336
380
 
337
- // 名片: 身份 · 出身 · cwd · 出生 · 关系 / 日程入口。
381
+ // 名片: 身份 · 出身 · cwd · 出生; 名片下面一排是关系 / 日程入口。
338
382
  var renderRole = function () {
339
383
  var r = R.role;
340
384
  if (!r) return;
@@ -358,17 +402,18 @@
358
402
  $('#rb-who').innerHTML =
359
403
  '<div class="id"><span class="av">' + esc(r.label) + '</span>' +
360
404
  '<span class="l">' + nm(r.id, r.name) +
361
- '<span class="k">' + kind + '</span></span></div>' +
405
+ '<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span></span></div>' +
362
406
  (r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
363
407
  '<div class="facts">' + facts.join('') + '</div>';
408
+ paintStatus();
364
409
  $('#rb-who').querySelectorAll('.go').forEach(function (g) {
365
410
  g.onclick = function () { switchRole(g.getAttribute('data-r')); };
366
411
  });
367
412
  $('#rb-sess').innerHTML = R.sessions.length > 1 ? sessPicker() : '';
413
+ $('#rb-foot').hidden = R.sessions.length < 2;
368
414
  bindSessPicker();
369
- syncFoot();
370
415
  var acts = [];
371
- // 入口只在有东西可看时出现, 且只是一枚徽标 —— 名片的主角是身份, 不是按钮。
416
+ // 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份。
372
417
  if (R.relations) acts.push('<button class="bd' + (VIEW === 'world' ? ' on' : '') + '" data-view="world">关系图</button>');
373
418
  if (R.schedules) acts.push('<button class="bd' + (VIEW === 'plan' ? ' on' : '') + '" data-view="plan">日程<b>' + R.schedules + '</b></button>');
374
419
  $('#rb-acts').innerHTML = acts.join('');
@@ -379,60 +424,64 @@
379
424
  document.title = nameOf(r.id) + ' 的视角';
380
425
  };
381
426
 
382
- // 左栏底: session 切换 + 用量总账, 都是「轮次」的事, 与上面的身份分开; 两样都空就整条收起。
383
- var syncFoot = function () {
384
- $('#rb-foot').hidden = !$('#rb-sess').innerHTML && !$('#rb-prof').innerHTML;
385
- };
386
-
387
- // ── profile: 当前 role 自己跑过的轮次的总账 (选了 session 就只算那一段) ──
388
- var COLORS = { input: '#0a7d6b', cacheRead: '#8250df', cacheWrite: '#953800', output: '#1a7f37' };
427
+ // ── 用量条: 一组轮次的总账 ──
428
+ // usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
429
+ var SEGS = [
430
+ ['input', '输入', '#0a7d6b'], ['cacheRead', '缓存读', '#8250df'],
431
+ ['cacheWrite', '缓存写', '#953800'], ['output', '输出', '#1a7f37'],
432
+ ];
389
433
  var TIP = {
390
434
  turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
391
- ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值',
392
- out: '累计输出 token', cache: '累计缓存 token (读 + 写)', time: '累计耗时',
435
+ ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值', time: '累计耗时',
393
436
  };
394
437
  var liveDur = function (t) {
395
438
  var d = (t.usage && t.usage.durationMs) || 0;
396
439
  if (!t.runningUntil) return d;
397
440
  return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - R.at);
398
441
  };
399
- var renderProfile = function () {
400
- var el = $('#rb-prof');
401
- var t = R.stats;
402
- if (!t) { el.innerHTML = ''; syncFoot(); return; }
442
+ var usageHTML = function (t) {
403
443
  var u = t.usage || {};
404
- var segs = [['input', '输入'], ['cacheRead', '缓存读'], ['cacheWrite', '缓存写'], ['output', '输出']]
405
- .filter(function (s) { return u[s[0]] > 0; });
444
+ var segs = SEGS.filter(function (s) { return u[s[0]] > 0; });
406
445
  var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
446
+ // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占位置。
447
+ var kv = function (k, n, text) {
448
+ return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '"><b>' + esc(text) + '</b>' + k + '</span>' : '';
449
+ };
450
+ // 输出 / 缓存不再单列成指标 —— 它们就是 I/O 图例里的那几格。
407
451
  var io = total > 0
408
- ? '<div class="io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
452
+ ? '<span class="u-io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
409
453
  '<span class="bar">' + segs.map(function (s) {
410
454
  return '<span class="seg" style="width:' + (u[s[0]] / total * 100).toFixed(2) + '%;background:' +
411
- COLORS[s[0]] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
455
+ s[2] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
412
456
  }).join('') + '</span>' +
413
457
  '<span class="leg">' + segs.map(function (s) {
414
- return '<span class="lg"><i style="background:' + COLORS[s[0]] + '"></i>' + s[1] +
458
+ return '<span class="lg"><i style="background:' + s[2] + '"></i>' + s[1] +
415
459
  '<b>' + fmtTok(u[s[0]]) + '</b></span>';
416
460
  }).join('') + '</span>' +
417
- '</div>'
461
+ '</span>'
418
462
  : '';
419
- // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占格子。
420
- var st = function (k, n, text) {
421
- return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '">' +
422
- '<span class="v">' + esc(text) + '</span><span class="k">' + k + '</span></span>' : '';
423
- };
424
- var cache = (u.cacheRead || 0) + (u.cacheWrite || 0);
425
- // 忙闲已在名片的状态里, 这里只留模型。
426
- el.innerHTML =
427
- '<div class="hd"><span class="lb">用量</span>' +
428
- (t.model ? '<span class="model" title="' + esc(t.model) + '">' + esc(t.model.replace(/^claude-/, '')) + '</span>' : '') +
429
- '</div>' +
430
- '<div class="grid">' +
431
- st('turns', t.turns, t.turns) + st('tools', u.tools, u.tools) + st('api', u.calls, u.calls) +
432
- st('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + st('out', u.output, fmtTok(u.output)) +
433
- st('cache', cache, fmtTok(cache)) + st('time', liveDur(t), fmtDur(liveDur(t))) +
434
- '</div>' + io;
435
- syncFoot();
463
+ return '<span class="u-lb">用量</span>' +
464
+ '<span class="u-kvs">' +
465
+ kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
466
+ kv('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + kv('time', liveDur(t), fmtDur(liveDur(t))) +
467
+ '</span>' + io +
468
+ (t.model ? '<span class="u-model" title="' + esc(t.model) + '">' + esc(t.model.replace(/^claude-/, '')) + '</span>' : '');
469
+ };
470
+ // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把悬停中的 tooltip 蹭掉。
471
+ var putUsage = function (el, t) {
472
+ var html = t ? usageHTML(t) : '';
473
+ // !el: web/ 是热更的, 外壳 HTML 要等 daemon 重启 —— 新脚本可能先遇上没有挂载点的旧外壳。
474
+ if (!el || el._html === html) return;
475
+ el._html = html; el.innerHTML = html; el.hidden = !html;
476
+ // 条的高度挤的是消息区 —— 原本贴底的继续贴底。
477
+ toBottom();
478
+ };
479
+ // wizard: 整页页脚, 它自己跑过的全部轮次 (选了 session 就只算那一段)。
480
+ // 人: 自己不跑轮次 —— 在群里点开某个 wizard 之后, 窗口底下是这段往来的账。
481
+ var renderUsage = function () {
482
+ var wiz = !!R.role && R.role.kind === 'wizard';
483
+ putUsage($('#pg-usage'), wiz ? R.stats : null);
484
+ putUsage($('#ch-usage'), !wiz && WITH && VIEW === 'msgs' ? R.winStats : null);
436
485
  };
437
486
 
438
487
  // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
@@ -448,7 +497,7 @@
448
497
  var c = convOf(CONV);
449
498
  if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
450
499
  if (c.kind === 'wizard') {
451
- who.innerHTML = avatarOf(c) + '<span class="l"><span class="t">' + nm(c.peer, c.name) + '</span>' +
500
+ who.innerHTML = avatarOf(c) + '<span class="l"><span class="t">' + nm(c.peer, c.name, true) + '</span>' +
452
501
  '<span class="sub">私聊 · ' + c.count + ' 条</span></span>';
453
502
  } else {
454
503
  var ms = membersOf(c);
@@ -458,8 +507,9 @@
458
507
  (ms.length > 8 ? '…' : '') + '</span></span>';
459
508
  }
460
509
  acts.innerHTML = WITH
461
- ? '<span class="with">只看我与 ' + nm(WITH) + '</span><button class="vb" id="ch-all">看全部</button>'
510
+ ? '<span class="with">只看我与 ' + nm(WITH, '', true) + '</span><button class="vb" id="ch-all">看全部</button>'
462
511
  : '';
512
+ bindGo(who); bindGo(acts);
463
513
  var x = $('#ch-all');
464
514
  if (x) x.onclick = function () { selectConv(CONV, ''); };
465
515
  };
@@ -480,11 +530,11 @@
480
530
  // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
481
531
  var group = CONV.indexOf('p:') !== 0;
482
532
  // 群里我不是收信方的那条 (X → Y), 头上写清是说给谁的。
483
- var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + nm(m.to, m.toName) + '</span>' : '';
533
+ var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + nm(m.to, m.toName, true) + '</span>' : '';
484
534
  var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
485
535
  var who = mine
486
- ? '<span class="to">' + (m.to && m.to !== 'human:' ? '→ ' + nm(m.to, m.toName) : '') + '</span>' + stamp(m.ts)
487
- : nm(m.from, m.fromName) + to + priv + stamp(m.ts);
536
+ ? '<span class="to">' + (m.to && m.to !== 'human:' ? '→ ' + nm(m.to, m.toName, true) : '') + '</span>' + stamp(m.ts)
537
+ : nm(m.from, m.fromName, true) + to + priv + stamp(m.ts);
488
538
  var sw = canSwitch(other);
489
539
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
490
540
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
@@ -541,9 +591,7 @@
541
591
  };
542
592
 
543
593
  var bindRow = function (el) {
544
- el.querySelectorAll('.flip[data-r]:not([disabled]), .av.go').forEach(function (b) {
545
- b.onclick = function () { switchRole(b.getAttribute('data-r')); };
546
- });
594
+ bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r]');
547
595
  };
548
596
  var rowNode = function (id) {
549
597
  var list = inner.querySelectorAll('.mrow');
@@ -625,7 +673,8 @@
625
673
  var applyRole = function (d) {
626
674
  R.at = d.at || Date.now(); R.recvAt = Date.now();
627
675
  R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
628
- R.relations = !!d.relations; R.schedules = d.schedules || 0; R.stats = d.stats || null;
676
+ R.relations = !!d.relations; R.schedules = d.schedules || 0;
677
+ R.stats = d.stats || null; R.winStats = d.winStats || null;
629
678
  ROLE = d.role.id;
630
679
  learn(d.role.id, d.role.name, d.role.label);
631
680
  R.convs.forEach(function (c) {
@@ -633,7 +682,7 @@
633
682
  c.subs.forEach(function (s) { learn(s.role, s.name, s.label); });
634
683
  });
635
684
  if (!CONV || !convOf(CONV)) { CONV = d.conv || ''; WITH = ''; }
636
- renderRole(); renderProfile(); renderConvs(); renderHead();
685
+ renderRole(); renderUsage(); renderConvs(); renderHead();
637
686
  };
638
687
 
639
688
  // ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
@@ -674,7 +723,9 @@
674
723
  CONV = key; WITH = withRole || '';
675
724
  if (VIEW !== 'msgs') setView('msgs');
676
725
  app.classList.add('reading');
677
- renderConvs(); renderHead();
726
+ // 上一个窗口的账不属于这个窗口 —— 先收起, 新的随 SSE 的首个 role 事件到。
727
+ R.winStats = null;
728
+ renderConvs(); renderHead(); renderUsage();
678
729
  S.gen++;
679
730
  syncUrl();
680
731
  inner.innerHTML = '<div class="empty">加载中…</div>';
@@ -1463,7 +1514,7 @@
1463
1514
  app.classList.toggle('outer', v !== 'msgs');
1464
1515
  // 手机上关系/日程占满主区 —— 进入即阅读态。
1465
1516
  if (v !== 'msgs') app.classList.add('reading');
1466
- renderRole(); renderHead();
1517
+ renderRole(); renderHead(); renderUsage();
1467
1518
  if (v === 'msgs') { fxStop(); toBottom(true); }
1468
1519
  else {
1469
1520
  // 关系图一打开就聚焦在当前 role 身上: 它的邻居亮着, 其余压暗。
@@ -1484,13 +1535,13 @@
1484
1535
  // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
1485
1536
  setInterval(function () {
1486
1537
  if (!R.role) return;
1487
- renderProfile();
1538
+ renderUsage();
1488
1539
  expireRows();
1540
+ // 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
1541
+ paintStatus(); renderConvs();
1489
1542
  if (VIEW === 'world') tickWorld();
1490
1543
  else if (VIEW === 'plan') renderPlan();
1491
1544
  }, TICK_MS);
1492
- // 侧栏的「几分钟前」一分钟刷一次就够 —— 每 3s 重建会把列表的滚动与焦点蹭掉。
1493
- setInterval(function () { if (R.role && VIEW === 'msgs') renderConvs(); }, 60000);
1494
1545
 
1495
1546
  // ── boot ──
1496
1547
  // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。