wezard 2.1.3 → 2.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.
Files changed (54) hide show
  1. package/README.md +15 -8
  2. package/dist/daemon/cc-session.js +14 -1
  3. package/dist/daemon/cc-session.js.map +1 -1
  4. package/dist/daemon/detail.js +40 -7
  5. package/dist/daemon/detail.js.map +1 -1
  6. package/dist/daemon/handoff.js +132 -0
  7. package/dist/daemon/handoff.js.map +1 -0
  8. package/dist/daemon/inbound.js +13 -4
  9. package/dist/daemon/inbound.js.map +1 -1
  10. package/dist/daemon/index.js +191 -99
  11. package/dist/daemon/index.js.map +1 -1
  12. package/dist/daemon/mirror-bridge.js +161 -12
  13. package/dist/daemon/mirror-bridge.js.map +1 -1
  14. package/dist/daemon/peers.js +114 -34
  15. package/dist/daemon/peers.js.map +1 -1
  16. package/dist/daemon/receipts.js +190 -0
  17. package/dist/daemon/receipts.js.map +1 -0
  18. package/dist/daemon/route.js +92 -0
  19. package/dist/daemon/route.js.map +1 -0
  20. package/dist/daemon/wizard.js +38 -19
  21. package/dist/daemon/wizard.js.map +1 -1
  22. package/dist/daemon/ws.js +3 -0
  23. package/dist/daemon/ws.js.map +1 -1
  24. package/dist/mcp/server.js +60 -25
  25. package/dist/mcp/server.js.map +1 -1
  26. package/dist/shared/chat-http.js +101 -21
  27. package/dist/shared/chat-http.js.map +1 -1
  28. package/dist/shared/chat-render.js +0 -1
  29. package/dist/shared/chat-render.js.map +1 -1
  30. package/dist/shared/chat-view.js +2 -9
  31. package/dist/shared/chat-view.js.map +1 -1
  32. package/dist/shared/config.js +6 -0
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/detail-render.js +3 -2
  35. package/dist/shared/detail-render.js.map +1 -1
  36. package/dist/shared/detail-store.js +1 -1
  37. package/dist/shared/detail-store.js.map +1 -1
  38. package/dist/shared/keepalive.js +46 -0
  39. package/dist/shared/keepalive.js.map +1 -0
  40. package/dist/shared/reminder.js +13 -0
  41. package/dist/shared/reminder.js.map +1 -1
  42. package/dist/shared/role-render.js +2 -1
  43. package/dist/shared/role-render.js.map +1 -1
  44. package/dist/shared/role-search.js +100 -0
  45. package/dist/shared/role-search.js.map +1 -0
  46. package/dist/shared/role-view.js +85 -26
  47. package/dist/shared/role-view.js.map +1 -1
  48. package/dist/shared/world.js +38 -8
  49. package/dist/shared/world.js.map +1 -1
  50. package/dist/svr/index.js +1 -1
  51. package/dist/svr/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/web/chat.css +420 -327
  54. package/web/chat.js +924 -728
package/web/chat.js CHANGED
@@ -17,31 +17,26 @@
17
17
  var ROLE = qs.get('role') || qs.get('target') || '';
18
18
  var CONV = qs.get('conv') || '';
19
19
  var WITH = qs.get('with') || '';
20
+ // '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
20
21
  var SESSION = qs.get('session') || '';
21
22
  var TICK_MS = 3000;
22
23
 
23
24
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
24
25
  // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
25
- var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, stats: null, winStats: null };
26
+ var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, stats: null, winStats: null };
26
27
  // frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
27
28
  var S = { es: null, pinned: true, gen: 0, frags: {} };
28
- // 关系/日程两栏共用的世界快照。sel = 当前聚焦的节点, kinds = 边类型开关。
29
+ // 关系/日程两栏共用的世界快照。treeAll = 关系树展开全部; treeFor = 已把谁滚进过视野。
29
30
  var W = {
30
31
  at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
31
- degraded: false, loaded: false, sel: '', onlyRel: false, kinds: { clone: 1, spawn: 1, peer: 1, graph: 1 },
32
- layout: 'cards',
33
- };
34
- // 读法是个人偏好, 记在本地。存储不可用 (隐私窗口 / 老 webview) 就退回默认。
35
- var LAY_KEY = 'wezard.world.layout';
36
- try { if (localStorage.getItem(LAY_KEY) === 'force') W.layout = 'force'; } catch (e) { }
37
- var setLayout = function (v) {
38
- W.layout = v;
39
- try { localStorage.setItem(LAY_KEY, v); } catch (e) { }
32
+ degraded: false, loaded: false, treeAll: false, treeFor: '', glance: {}, gKeys: '',
40
33
  };
41
34
  var VIEW = 'msgs';
35
+ // 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
36
+ var WORLD = qs.get('side') === 'world';
42
37
  var $ = function (s) { return document.querySelector(s); };
43
38
  var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
44
- var wmapEl = $('#wmap'), wscrollEl = $('#wscroll'), wtoolsEl = $('#wtools'), planEl = $('#plan-in');
39
+ var planEl = $('#plan-in');
45
40
 
46
41
  var srvNow = function () { return R.at ? R.at + (Date.now() - R.recvAt) : Date.now(); };
47
42
 
@@ -98,6 +93,7 @@
98
93
  if (CONV) p.conv = CONV;
99
94
  if (WITH) p['with'] = WITH;
100
95
  if (SESSION) p.session = SESSION;
96
+ if (WORLD) p.side = 'world';
101
97
  Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
102
98
  return p;
103
99
  };
@@ -278,7 +274,27 @@
278
274
  });
279
275
 
280
276
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
281
- var convOf = function (key) { return R.convs.filter(function (c) { return c.key === key; })[0]; };
277
+ // `a:<x>` / `a:<x>|<p1>,<p2>` 不在列表里: x 参与的全部对话 / x 与这几个对端之间的, 按时间排开
278
+ // (关系图里点卡片看的就是它, 服务端同一个 talkOf) —— 现造一项, 落地时才不会被当成失效的会话退回默认。
279
+ var convOf = function (key) {
280
+ if (key && key.indexOf('a:') === 0) {
281
+ var part = key.slice(2).split('|'), who = part[0], peers = (part[1] || '').split(',').filter(Boolean);
282
+ var name = nameOf(who) + (peers.length ? ' 与 ' + peers.map(nameOf).join('、') + ' 的对话' : ' 的全部对话');
283
+ return { key: key, kind: 'all', who: who, peers: peers, name: name, subs: [] };
284
+ }
285
+ return R.convs.filter(function (c) { return c.key === key; })[0];
286
+ };
287
+ // 人的单聊 (user:…) 在数据上是频道, 但对端只有那一个人时同样是一对一。
288
+ var dmOf = function (c) {
289
+ return c && c.kind === 'group' && c.base.indexOf('user:') === 0 && (c.subs || []).length === 1 ? c.subs[0] : null;
290
+ };
291
+ // 一对一会话的对端 (wizard 私聊 / 人的单聊 / 「只看我与 X」); 不是一对一则空。
292
+ var pairPeer = function (c) {
293
+ if (!c) return WITH || '';
294
+ if (c.kind === 'all') return '';
295
+ var dm = dmOf(c);
296
+ return c.kind !== 'group' ? c.peer : WITH || (dm ? dm.role : '');
297
+ };
282
298
 
283
299
  // 群聊头像 = 参与者头像的拼图 (最多 4 格, 自己排第一)。私聊就是对端自己的头像。
284
300
  var membersOf = function (c) {
@@ -293,15 +309,17 @@
293
309
  // 到点没有新写入就自己熄 (同 expireRows), 不必等下一次推送。
294
310
  var ST = { run: '执行中', idle: '空闲', off: '已关闭' };
295
311
  var stateOf = function (s) { return wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
296
- // 侧栏只在执行中时点一盏灯: 列表里安静是常态, 亮着的才值得看一眼。
297
- var lampOf = function (s) {
298
- return s && wRunning(s) ? '<i class="live" title="' + ST.run + '"></i>' : '';
312
+ // 忙闲只有这一个渲染点, 永远挂在名字右边 —— 名片 / 关系树 / 侧栏共用。
313
+ // quiet: 只在执行中出现、只要灯不要字 (侧栏: 列表里安静是常态, 亮着的才值得看一眼)。
314
+ var stTag = function (s, quiet) {
315
+ if (!s) return '';
316
+ var k = stateOf(s);
317
+ if (quiet && k !== 'run') return '';
318
+ return '<span class="wst ' + k + '" title="' + ST[k] + '">' + (quiet ? '' : ST[k]) + '</span>';
299
319
  };
300
320
  var paintStatus = function () {
301
- var el = $('#rb-st'), r = R.role;
302
- if (!el || !r) return;
303
- var k = stateOf(r);
304
- el.className = 'st ' + k; el.textContent = ST[k];
321
+ var el = $('#rb-st');
322
+ if (el && R.role) el.innerHTML = stTag(R.role);
305
323
  };
306
324
 
307
325
  var avatarOf = function (c, extra) {
@@ -311,6 +329,11 @@
311
329
  ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
312
330
  };
313
331
 
332
+ // ends = [[id, 已知头像]]; 没带头像的按 role 查。
333
+ var pairOf = function (ends) {
334
+ return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
335
+ };
336
+
314
337
  var line = function (title, ts, pv, lamp, unread) {
315
338
  return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
316
339
  '<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
@@ -342,12 +365,28 @@
342
365
  }).length;
343
366
  };
344
367
  var reading = function () { return VIEW === 'msgs' && !document.hidden; };
368
+ // 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
369
+ // 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
370
+ var talkCovers = function (p) {
371
+ var t = convOf(CONV);
372
+ if (!t || t.kind !== 'all') return false;
373
+ var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
374
+ return hit(ROLE, p) || hit(p, ROLE);
375
+ };
376
+ // 一处会话里这次读到了哪几对 (存水位用的 withRole): 侧栏选中的就是那一项; 关系图卡片打开的
377
+ // 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
378
+ var readPairs = function (c) {
379
+ if (c.key === CONV) return [WITH];
380
+ if (c.kind !== 'group') return c.peer && talkCovers(c.peer) ? [''] : [];
381
+ return (c.subs || []).filter(function (s) { return talkCovers(s.role); }).map(function (s) { return s.role; });
382
+ };
345
383
  // 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
346
384
  var markRead = function (c) {
347
- if (!reading() || c.key !== CONV || !unreadOf(c, WITH)) return;
348
- var h = c.heard[c.heard.length - 1];
349
- READ.at[readKey(c.key, WITH)] = Math.max(R.at, h ? h[0] : 0);
350
- saveRead();
385
+ if (!reading()) return;
386
+ var h = c.heard && c.heard[c.heard.length - 1];
387
+ var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
388
+ hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
389
+ if (hit.length) saveRead();
351
390
  };
352
391
 
353
392
  // 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
@@ -369,11 +408,29 @@
369
408
  renderConvs();
370
409
  };
371
410
 
411
+ // ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
412
+ // glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
413
+ var glance = function (c, s) {
414
+ var x = s || c;
415
+ return { lastTs: x.lastTs, preview: x.preview, unread: unreadOf(c, s ? s.role : undefined) };
416
+ };
417
+ // 同层按最近活动排, 有新话就上浮。
418
+ var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
419
+ // 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
420
+ var roleRow = function (id, name, label, g, status, tail) {
421
+ return goSpan('av', id, esc(label)) +
422
+ line(nm(id, name, true) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
423
+ };
424
+ var convRow = function (c) {
425
+ if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status);
426
+ return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>', c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
427
+ };
428
+ var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status); };
429
+
372
430
  var convItem = function (c) {
373
431
  var on = c.key === CONV;
374
- var title = c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>';
375
432
  // 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
376
- var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }) : [];
433
+ var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }).sort(recentFirst) : [];
377
434
  var hidden = talked.length - SUB_FOLD;
378
435
  var bar = hidden > 0
379
436
  ? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
@@ -383,8 +440,7 @@
383
440
  var sel = on && s.role === WITH;
384
441
  return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
385
442
  'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
386
- goSpan('av', s.role, esc(s.label)) +
387
- line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status), unreadOf(c, s.role)) + '</button>';
443
+ subRow(c, s) + '</button>';
388
444
  };
389
445
  var rest = MORE[c.key] ? talked.slice(SUB_FOLD).map(sub).join('') : '';
390
446
  var subs = talked.length
@@ -392,14 +448,23 @@
392
448
  : '';
393
449
  var sel = on && !WITH;
394
450
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
395
- avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status), unreadOf(c)) +
396
- '</button>' + subs;
397
- };
398
-
451
+ convRow(c) + '</button>' + subs;
452
+ };
453
+
454
+ // 搜索入口挂在名片与关系/日程入口之间 —— 侧栏的公共区, 会话列表与关系图下都在。⌘K 见下方「搜索」。
455
+ var MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
456
+ var KBD = MAC ? '⌘K' : 'Ctrl K';
457
+ var SEARCH_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.6"/><path d="m10.4 10.4 3.6 3.6"/></svg>';
458
+ var sbox = document.createElement('button');
459
+ sbox.className = 'sbox'; sbox.type = 'button';
460
+ sbox.innerHTML = '<span class="ic">' + SEARCH_SVG + '</span><span class="lb">搜索 role、会话、消息</span><kbd>' + KBD + '</kbd>';
461
+ sbox.onclick = function () { openSearch(); };
462
+ $('#rb-acts').parentNode.insertBefore(sbox, $('#rb-acts'));
399
463
  var renderConvs = function () {
400
- var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
401
- var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
464
+ var groups = R.convs.filter(function (c) { return c.kind === 'group'; }).sort(recentFirst);
465
+ var dms = R.convs.filter(function (c) { return c.kind !== 'group'; }).sort(recentFirst);
402
466
  R.convs.forEach(markRead);
467
+ if (WORLD) return renderWorld();
403
468
  // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
404
469
  var sec = function (title, list) {
405
470
  return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
@@ -423,41 +488,41 @@
423
488
  var seg = String(p).replace(/\/+$/, '').split('/').filter(Boolean);
424
489
  return seg.length <= 2 ? p : '…/' + seg.slice(-2).join('/');
425
490
  };
426
- var sessLabel = function (s) {
427
- return (s.sessionId ? s.sessionId.slice(0, 8) : '(无 id)') + ' · ' + fmtAgo(s.start) + ' · ' + s.turns + ' 轮';
428
- };
491
+ // session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
492
+ var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
493
+ var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
429
494
 
430
- // session 切换: 原生 <select> 的弹层画不了样式, 换成按钮 + 列表。
495
+ // session 切换: name 右边一个小号文字触发器 + 浮层列表。
431
496
  // renderRole 每次刷新都重画, 展开态记在 SESS_OPEN 里才不会被轮询收起。
432
497
  var SESS_OPEN = false;
433
498
  var sessRow = function (s, on) {
434
- return '<button class="sp-it' + (on ? ' on' : '') + '" role="option" aria-selected="' + on + '" data-s="' + esc(s.sessionId || '') + '">' +
435
- '<span class="id">' + esc(s.sessionId ? s.sessionId.slice(0, 8) : '(无 id)') + '</span>' +
436
- '<span class="ago">' + esc(fmtAgo(s.start)) + '</span>' +
499
+ var last = s === latestSess();
500
+ return '<button class="sp-it' + (on ? ' on' : '') + '" role="option" aria-selected="' + on + '" data-s="' + esc(s.sessionId || '') + '" ' +
501
+ 'title="' + esc(s.sessionId || '') + '">' +
502
+ '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span>' +
437
503
  '<span class="n">' + s.turns + ' 轮</span></button>';
438
504
  };
439
505
  var sessPicker = function () {
440
506
  var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
441
- var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="">' +
442
- '<span class="id">全部 session</span><span class="n">' + R.sessions.length + ' 个</span></button>';
443
- return '<div class="sp' + (SESS_OPEN ? ' open' : '') + '">' +
444
- '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + SESS_OPEN + '">' +
445
- '<span class="lb">session</span>' +
446
- '<span class="v">' + esc(cur ? sessLabel(cur) : '全部 ' + R.sessions.length + ' 个') + '</span>' +
447
- '<span class="car" aria-hidden="true"></span></button>' +
448
- '<div class="sp-list" role="listbox">' + all +
507
+ var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="all">' +
508
+ '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span></button>';
509
+ var v = !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur);
510
+ return '<span class="sp' + (SESS_OPEN ? ' open' : '') + '">' +
511
+ '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + SESS_OPEN + '" title="切换 session">' +
512
+ esc(v) + '<span class="car" aria-hidden="true"></span></button>' +
513
+ '<span class="sp-list" role="listbox">' + all +
449
514
  R.sessions.slice().reverse().map(function (s) { return sessRow(s, s.sessionId === SESSION); }).join('') +
450
- '</div></div>';
515
+ '</span></span>';
451
516
  };
452
517
  var setSessOpen = function (open) {
453
518
  SESS_OPEN = open;
454
- var sp = $('#rb-sess .sp');
519
+ var sp = $('#rb-sp .sp');
455
520
  if (!sp) return;
456
521
  sp.classList.toggle('open', open);
457
522
  sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
458
523
  };
459
524
  var bindSessPicker = function () {
460
- var sp = $('#rb-sess .sp');
525
+ var sp = $('#rb-sp .sp');
461
526
  if (!sp) return;
462
527
  sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
463
528
  sp.querySelectorAll('.sp-it').forEach(function (it) {
@@ -465,12 +530,75 @@
465
530
  });
466
531
  };
467
532
  document.addEventListener('click', function (e) {
468
- if (SESS_OPEN && !e.target.closest('#rb-sess .sp')) setSessOpen(false);
533
+ if (SESS_OPEN && !e.target.closest('#rb-sp .sp')) setSessOpen(false);
469
534
  });
470
535
  document.addEventListener('keydown', function (e) {
471
536
  if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
472
537
  });
473
538
 
539
+ // ── 关系 / 日程入口: 名片下一行居中的文字链接, 「|」分隔 ──
540
+ // 副标题 (家谱计数 / 下一枪) 收进悬停提示; 打开着的染视角色, 定时出错的染红。
541
+ var tile = function (view, label, sub, tone) {
542
+ var on = view === 'world' ? WORLD : VIEW === view;
543
+ return '<button class="bd' + (on ? ' on' : '') + (tone ? ' ' + tone : '') + '" data-view="' + view + '"' +
544
+ (sub ? ' title="' + esc(sub) + '"' : '') + '>' + label + '</button>';
545
+ };
546
+ var relTile = function (r) {
547
+ var sub = [r.clones.length ? r.clones.length + ' 分身' : '', r.spawns.length ? r.spawns.length + ' 子' : ''].filter(Boolean);
548
+ return tile('world', '关系图', sub.join(' · '));
549
+ };
550
+ var planTile = function () {
551
+ var p = R.plan || {};
552
+ var sub = p.broken ? '⚠ ' + p.broken + ' 出错'
553
+ : p.nextAt ? fmtClock(p.nextAt).replace(/^今天 /, '')
554
+ : String(R.schedules);
555
+ return tile('plan', '日程', sub, p.broken ? 'bad' : '');
556
+ };
557
+
558
+ // 轻提示: 底部居中一枚, 新的顶掉旧的。
559
+ var toast = (function () {
560
+ var t, el;
561
+ return function (msg) {
562
+ el = el || document.body.appendChild(document.createElement('div'));
563
+ el.className = 'toast on'; el.textContent = msg;
564
+ clearTimeout(t); t = setTimeout(function () { el.className = 'toast'; }, 1600);
565
+ };
566
+ })();
567
+ // navigator.clipboard 只在安全上下文里有 —— 走 http 的远端页退回 execCommand。
568
+ var copyText = function (text) {
569
+ var legacy = function () {
570
+ var ta = document.body.appendChild(document.createElement('textarea'));
571
+ ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; ta.select();
572
+ var ok = document.execCommand('copy'); ta.remove();
573
+ return ok ? Promise.resolve() : Promise.reject();
574
+ };
575
+ (navigator.clipboard ? navigator.clipboard.writeText(text).catch(legacy) : legacy())
576
+ .then(function () { toast('已复制 ' + text); }, function () { toast('复制失败'); });
577
+ };
578
+
579
+ // 截断提示: 被省略号 (或 line-clamp) 截掉的文本, 悬停时给 title 看全文 —— 一处委托管全页,
580
+ // 不必每个渲染点各挂一份。只在真被截断时挂; 元素自带的 title (如「切到 X 的视角」) 不碰。
581
+ var isClipped = function (el, cs) {
582
+ return (cs.textOverflow === 'ellipsis' && el.scrollWidth > el.clientWidth + 1) ||
583
+ (cs.webkitLineClamp !== 'none' && cs.webkitLineClamp !== '' && el.scrollHeight > el.clientHeight + 1);
584
+ };
585
+ var clipTip = function (el) {
586
+ var cs = getComputedStyle(el);
587
+ if (cs.textOverflow !== 'ellipsis' && (cs.webkitLineClamp === 'none' || !cs.webkitLineClamp)) return false;
588
+ var ours = el.hasAttribute('data-cliptip');
589
+ if (el.hasAttribute('title') && !ours) return true;
590
+ if (isClipped(el, cs)) {
591
+ el.title = el.textContent.replace(/\s+/g, ' ').trim();
592
+ el.setAttribute('data-cliptip', '');
593
+ } else if (ours) {
594
+ el.removeAttribute('title'); el.removeAttribute('data-cliptip');
595
+ }
596
+ return true;
597
+ };
598
+ document.addEventListener('mouseover', function (e) {
599
+ for (var el = e.target; el && el.nodeType === 1 && el !== document.body && !clipTip(el); el = el.parentElement);
600
+ });
601
+
474
602
  // 名片: 身份 · 出身 · cwd · 出生; 名片下面一排是关系 / 日程入口。
475
603
  var renderRole = function () {
476
604
  var r = R.role;
@@ -487,32 +615,35 @@
487
615
  var facts = [];
488
616
  if (r.cwd) facts.push('<span title="' + esc(r.cwd) + '">📁 ' + esc(shortCwd(r.cwd)) + '</span>');
489
617
  // 生在哪个群、什么时候 —— 一件事, 一行。
490
- var born = [r.chat, r.bornAt ? fmtAgo(r.bornAt) : ''].filter(Boolean);
491
- if (born.length) facts.push('<span title="' + esc('创建于' + (r.chat ? ' ' + r.chat : '') + (r.bornAt ? ' · ' + fmtDay(r.bornAt) : '')) + '">🐣 ' + esc(born.join(' · ')) + '</span>');
492
- var kin = function (xs, what) {
493
- return xs.length ? '<span title="' + esc(xs.map(function (x) { return '.' + x.name; }).join(' ')) + '">' + xs.length + ' 个' + what + '</span>' : '';
494
- };
495
- facts.push(kin(r.clones, '分身'), kin(r.spawns, '子 wizard'));
618
+ // 时刻跟着选中的 session 走: 选了一段就是那一段的开始与轮数, 全部就是最早那段起、共几轮。
619
+ var cur = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
620
+ var span = cur ? [cur] : R.sessions;
621
+ var t0 = span.reduce(function (m, x) { return x.start && (!m || x.start < m) ? x.start : m; }, 0);
622
+ var turns = span.reduce(function (n, x) { return n + (x.turns || 0); }, 0);
623
+ var when = [r.chat, t0 ? (cur ? '' : '自 ') + fmtClock(t0) : '', turns ? turns + ' 轮' : ''].filter(Boolean);
624
+ if (when.length) facts.push('<span title="' + esc((cur ? 'session ' + cur.sessionId : '全部 ' + R.sessions.length + ' 段 session') +
625
+ (t0 ? ' · 开始于 ' + fmtDay(t0) : '')) + '">🕘 ' + esc(when.join(' · ')) + '</span>');
496
626
  $('#rb-who').innerHTML =
497
627
  '<div class="id"><span class="av">' + esc(r.label) + '</span>' +
498
- '<span class="l">' + nm(r.id, r.name) +
499
- (kind ? '<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span>' : '') + '</span></div>' +
628
+ '<span class="l"><span class="nl"><span class="cp" title="' + esc('复制 ' + nameOf(r.id)) + '">' + nm(r.id, r.name) + '</span>' + (r.kind === 'wizard' ? '<span id="rb-st"></span>' : '') +
629
+ '<span id="rb-sp">' + (R.sessions.length > 1 ? sessPicker() : '') + '</span></span>' +
630
+ (kind ? '<span class="k">' + kind + '</span>' : '') + '</span></div>' +
500
631
  (r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
501
632
  '<div class="facts">' + facts.join('') + '</div>';
502
633
  paintStatus();
634
+ $('#rb-who').querySelector('.cp').onclick = function () { copyText(nameOf(r.id)); };
503
635
  $('#rb-who').querySelectorAll('.go').forEach(function (g) {
504
636
  g.onclick = function () { switchRole(g.getAttribute('data-r')); };
505
637
  });
506
- $('#rb-sess').innerHTML = R.sessions.length > 1 ? sessPicker() : '';
507
- $('#rb-foot').hidden = R.sessions.length < 2;
638
+ // 老外壳的底栏选择框已并进 name 行。
639
+ if ($('#rb-foot')) $('#rb-foot').hidden = true;
508
640
  bindSessPicker();
509
- var acts = [];
510
- // 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份。
511
- if (R.relations) acts.push('<button class="bd' + (VIEW === 'world' ? ' on' : '') + '" data-view="world">关系图</button>');
512
- if (R.schedules) acts.push('<button class="bd' + (VIEW === 'plan' ? ' on' : '') + '" data-view="plan">日程<b>' + R.schedules + '</b></button>');
513
- $('#rb-acts').innerHTML = acts.join('');
641
+ // 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份,
642
+ // 家谱计数 (几个分身 / 子 wizard) 属于关系, 写在关系图入口上。
643
+ $('#rb-acts').innerHTML = [R.relations && relTile(r), R.schedules && planTile()].filter(Boolean).join('<span class="sep" aria-hidden="true">|</span>');
514
644
  $('#rb-acts').querySelectorAll('.bd').forEach(function (b) {
515
- b.onclick = function () { setView(VIEW === b.getAttribute('data-view') ? 'msgs' : b.getAttribute('data-view')); };
645
+ var v = b.getAttribute('data-view');
646
+ b.onclick = function () { v === 'world' ? setWorld(!WORLD) : setView(VIEW === v ? 'msgs' : v); };
516
647
  });
517
648
  // 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题跟着换。
518
649
  document.title = nameOf(r.id) + ' 的视角';
@@ -520,14 +651,17 @@
520
651
 
521
652
  // ── 用量条: 一组轮次的总账 ──
522
653
  // usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
654
+ // 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 退成浅灰蓝不抢眼 →
655
+ // 输入 (1×) 沉稳蓝 → 缓存写 (1.25×) 琥珀提醒 → 输出 (约 5×, 最贵) 珊瑚红警示。注意力留给后三段。
523
656
  var SEGS = [
524
- ['input', '输入', '#0a7d6b'], ['cacheRead', '缓存读', '#8250df'],
525
- ['cacheWrite', '缓存写', '#953800'], ['output', '输出', '#1a7f37'],
657
+ ['cacheRead', '缓存读', '#aab7c8', 'cr'], ['input', '输入', '#4c6fd6', 'in'],
658
+ ['cacheWrite', '缓存写', '#e0a030', 'cw'], ['output', '输出', '#e4572e', 'out'],
526
659
  ];
527
660
  var TIP = {
528
661
  turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
529
662
  ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值', time: '累计耗时',
530
663
  };
664
+ var ICON = { turns: '💬', tools: '🛠️', api: '📡', ctx: '🧠', time: '⏱️' };
531
665
  var liveDur = function (t) {
532
666
  var d = (t.usage && t.usage.durationMs) || 0;
533
667
  if (!t.runningUntil) return d;
@@ -538,60 +672,112 @@
538
672
  var segs = SEGS.filter(function (s) { return u[s[0]] > 0; });
539
673
  var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
540
674
  // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占位置。
675
+ // 指标一律 emoji 在前、数字在后, 不挂英文名 (含义在 title 里)。
676
+ // data-tip: 被 fitUsage 藏起来时, 它在整条 bar 的 title 里怎么读。
541
677
  var kv = function (k, n, text) {
542
- return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '"><b>' + esc(text) + '</b>' + k + '</span>' : '';
678
+ return n ? '<span class="kv kv-' + k + '" title="' + esc(TIP[k] || k) + '" data-tip="' + esc(ICON[k] + ' ' + text) +
679
+ '"><i class="u-ic">' + ICON[k] + '</i><b>' + esc(text) + '</b></span>' : '';
680
+ };
681
+ // 输出 / 缓存不再单列成指标 —— 它们就是底边色带的那几段。
682
+ // I/O 分布: 贴着整条 bar 的底边画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
683
+ // 两头留一位小数: 99.9% 不该被四舍五入成 100%, 一丁点也不该成 0%。
684
+ var pct = function (n) {
685
+ var p = n / total * 100;
686
+ return p < .1 ? '<0.1%' : (p < 1 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%';
543
687
  };
544
- // 输出 / 缓存不再单列成指标 —— 它们就是 I/O 图例里的那几格。
545
688
  var io = total > 0
546
- ? '<span class="u-io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
547
- '<span class="bar">' + segs.map(function (s) {
548
- return '<span class="seg" style="width:' + (u[s[0]] / total * 100).toFixed(2) + '%;background:' +
549
- s[2] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
550
- }).join('') + '</span>' +
551
- '<span class="leg">' + segs.map(function (s) {
552
- return '<span class="lg"><i style="background:' + s[2] + '"></i>' + s[1] +
553
- '<b>' + fmtTok(u[s[0]]) + '</b></span>';
554
- }).join('') + '</span>' +
555
- '</span>'
689
+ ? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total)].concat(segs.map(function (s) {
690
+ return s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]);
691
+ })).join('\n')) + '">' + segs.map(function (s) {
692
+ return '<span class="seg" style="flex-grow:' + u[s[0]] + ';background:' + s[2] + '" title="' +
693
+ s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"></span>';
694
+ }).join('') + '</span>' +
695
+ '<span class="leg" data-tip="' + esc(segs.map(function (s) { return s[3] + ' ' + fmtTok(u[s[0]]); }).join(' · ')) + '">' +
696
+ segs.map(function (s) {
697
+ // 常显的是缩写, 全称与占比在 title 里。
698
+ return '<span class="lg" title="' + s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"><i style="background:' +
699
+ s[2] + '"></i>' + s[3] + '<b>' + fmtTok(u[s[0]]) + '</b></span>';
700
+ }).join('') + '</span>'
556
701
  : '';
557
- // 条首的标签就是模型名: 这本账是拿什么跑出来的。没有模型记录 (老轮次) 才退回「用量」。
558
- return '<span class="u-lb" title="' + esc(t.model || '') + '">' +
702
+ // 条首先是账的主人 (rolepage 里它的头像 + .名字), 再是模型名: 这本账是谁、拿什么跑出来的。
703
+ // 没有模型记录 (老轮次) 才退回「用量」。
704
+ return '<span class="u-who" title="' + esc(nameOf(t.target)) + '">' + esc(roleLabel(t.target) || t.label) +
705
+ '<span class="u-nm" data-tip="' + esc(nameOf(t.target)) + '">' + esc(nameOf(t.target)) + '</span></span>' +
706
+ '<span class="u-lb" title="' + esc(t.model || '') + '">' +
559
707
  esc(t.model ? t.model.replace(/^claude-/, '') : '用量') + '</span>' +
560
708
  '<span class="u-kvs">' +
561
709
  kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
562
710
  kv('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + kv('time', liveDur(t), fmtDur(liveDur(t))) +
563
711
  '</span>' + io;
564
712
  };
713
+ // 永远单行: 排不下就按 FOLD 的顺序一级级藏 (分布文字 → 名字 → 耗时), 宽度回来再按反序放出来。
714
+ // 判定看的是容器自己溢没溢出, 不看视口 —— 同一个组件挂在整页页脚和右栏底, 宽度各不相同。
715
+ var FOLD = ['f-leg', 'f-nm', 'f-time'];
716
+ var FOLDED = { 'f-leg': '.leg', 'f-nm': '.u-nm', 'f-time': '.kv-time' };
717
+ var fitUsage = function (el) {
718
+ if (el.hidden) return;
719
+ var over = function () { return el.scrollWidth > el.clientWidth; };
720
+ FOLD.forEach(function (c) { el.classList.remove(c); });
721
+ var n = 0;
722
+ while (n < FOLD.length && over()) el.classList.add(FOLD[n++]);
723
+ // 藏起来的那几样进整条 bar 的 title, hover 照样读得到。
724
+ el.title = FOLD.slice(0, n).map(function (c) {
725
+ var x = el.querySelector(FOLDED[c]);
726
+ return x ? x.getAttribute('data-tip') : '';
727
+ }).filter(Boolean).join('\n');
728
+ };
729
+ var watchUsage = function (el) {
730
+ if (el._ro || typeof ResizeObserver === 'undefined') return;
731
+ // 只在宽度变了时重算: 自己增删 class 不改宽度, 不会自激。
732
+ var w = -1;
733
+ el._ro = new ResizeObserver(function (es) {
734
+ var cw = Math.round(es[0].contentRect.width);
735
+ if (cw !== w) { w = cw; fitUsage(el); }
736
+ });
737
+ el._ro.observe(el);
738
+ };
565
739
  // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把悬停中的 tooltip 蹭掉。
566
740
  var putUsage = function (el, t) {
567
741
  var html = t ? usageHTML(t) : '';
568
742
  // !el: web/ 是热更的, 外壳 HTML 要等 daemon 重启 —— 新脚本可能先遇上没有挂载点的旧外壳。
569
743
  if (!el || el._html === html) return;
570
744
  el._html = html; el.innerHTML = html; el.hidden = !html;
745
+ watchUsage(el); fitUsage(el);
571
746
  // 条的高度挤的是消息区 —— 原本贴底的继续贴底。
572
747
  toBottom();
573
748
  };
574
749
  // wizard: 整页页脚, 它自己跑过的全部轮次 (选了 session 就只算那一段)。
575
- // 人: 自己不跑轮次 —— 在群里点开某个 wizard 之后, 窗口底下是这段往来的账。
750
+ // 窗口: 对端恰好是一个 wizard 时 (服务端按这个窗口的 talkOf 入参判定, 给不给 winStats 就是答案),
751
+ // 窗口底下是那个 wizard 在这段往来里的账 —— 视角是人还是 wizard 都一样。
576
752
  var renderUsage = function () {
577
753
  var wiz = !!R.role && R.role.kind === 'wizard';
578
754
  putUsage($('#pg-usage'), wiz ? R.stats : null);
579
- putUsage($('#ch-usage'), !wiz && WITH && VIEW === 'msgs' ? R.winStats : null);
755
+ putUsage($('#ch-usage'), VIEW === 'msgs' ? R.winStats : null);
580
756
  };
581
757
 
582
758
  // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
583
759
  var renderHead = function () {
584
760
  var who = $('#ch-who'), acts = $('#ch-acts');
585
761
  if (VIEW !== 'msgs') {
586
- who.innerHTML = '<span class="t">' + (VIEW === 'world' ? '关系图' : '日程') + '</span>' +
587
- '<span class="sub">' + esc(nameOf(ROLE)) + (VIEW === 'world' ? ' 的家谱与协作网' : ' 名下的定时任务与工单') + '</span>';
762
+ who.innerHTML = '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务与工单</span>';
588
763
  acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
589
764
  $('#ch-back').onclick = function () { setView('msgs'); };
590
765
  return;
591
766
  }
592
767
  var c = convOf(CONV);
593
768
  if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
594
- who.innerHTML = '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
769
+ if (c.kind === 'all') {
770
+ who.innerHTML = pairOf([[c.who]]) + '<span class="t">' + esc(c.name) + '</span>';
771
+ acts.innerHTML = '';
772
+ bindGo(who);
773
+ return;
774
+ }
775
+ // 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
776
+ var dm = dmOf(c);
777
+ var peer = pairPeer(c);
778
+ var peerLabel = c.kind !== 'group' ? c.label : dm && peer === dm.role ? dm.label : '';
779
+ who.innerHTML = (peer ? pairOf([[ROLE, R.role && R.role.label], [peer, peerLabel]]) : '') +
780
+ '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
595
781
  acts.innerHTML = WITH
596
782
  ? '<span class="with">只看我与 ' + nm(WITH, '', true) + '</span><button class="vb" id="ch-all">看全部</button>'
597
783
  : '';
@@ -605,32 +791,54 @@
605
791
  // 箭头朝外 = 这条气泡换视角后要去的那一侧; mine 行靠 CSS 翻转。
606
792
  var CHEVRON = '<svg class="fc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
607
793
  // 行的另一侧是 .flip: 点它 = 换成这条消息的对端 (我发的 → 收信方; 别人发的 → 发话方)。
794
+ // 消息头上的角色头像 (发话方 / 收信方同一种画法), 点它切到那个角色的视角。
795
+ var avBtn = function (id, label) {
796
+ var sw = canSwitch(id) && id !== ROLE;
797
+ return '<button class="av' + (sw ? ' go' : '') + '" data-r="' + esc(id) + '" title="' + esc(nameOf(id)) + '"' + (sw ? '' : ' disabled') + '>' + esc(label || roleLabel(id)) + '</button>';
798
+ };
799
+ // 断点 (/clear /new 轮换) 是某一个 role 的 —— 群窗里几个 wizard 交错时, 不署名就认不出是谁断的。
800
+ // 片段由服务端渲染、不知视角, 名字 (及能否点) 在这里按视角补进断点条的标签前。
801
+ var CUT_L = /(<div class="tg-(?:cut|mark)\b[^>]*>)(<span class="l">)/;
802
+ var signCut = function (html, id, name) {
803
+ return html.replace(CUT_L, function (_, open, l) { return open + nm(id, name, true) + l; });
804
+ };
608
805
  var rowHTML = function (m) {
609
806
  learn(m.from, m.fromName, m.fromLabel);
610
807
  learn(m.to, m.toName, m.toLabel);
611
808
  if (m.dir === 'mark') {
612
- return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + m.html + '</div>';
809
+ return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + signCut(m.html, m.from, m.fromName) + '</div>';
613
810
  }
614
811
  var mine = m.from === ROLE;
615
812
  var other = mine ? m.to : m.from;
616
813
  // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
617
814
  var group = CONV.indexOf('p:') !== 0;
618
- // 群里我不是收信方的那条 (X → Y), 头上写清是说给谁的。
619
- var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + nm(m.to, m.toName, true) + '</span>' : '';
815
+ // 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
816
+ // (一对一里收信方就是顶栏那个对端, 不再重复写)。
817
+ var dst = mine
818
+ ? (m.to && m.to !== 'human:' && m.to !== (group ? pairPeer(convOf(CONV)) : CONV.slice(2)) ? m.to : '')
819
+ : (m.to !== ROLE && group ? m.to : '');
820
+ // 宽屏写在气泡对面的空白里 (.dest, 在换视角的门里但不吃点击); 窄屏空白太窄, 退回消息头 (.to)。
821
+ var to = dst ? '<span class="to">→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) + '</span>' : '';
822
+ // 箭头顺着「发话人 → 收件人」: 气泡是发话人, 描述在哪一侧箭头就背着气泡指向哪一侧 ——
823
+ // 我的消息描述在左 (🦉 .x ←), 别人的在右 (→ 🦉 .x)。
824
+ var dest = dst
825
+ ? '<span class="dest" aria-hidden="true">' + (mine ? '' : '<i class="arr">→</i>') +
826
+ '<span class="dav">' + esc(m.toLabel || roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
827
+ (mine ? '<i class="arr">←</i>' : '') + '</span>'
828
+ : '';
620
829
  var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
621
830
  // 本轮的账 (呼吸点 + 模型 / token / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
622
831
  var stat = m.meta ? '<span class="mstat">' + m.meta + '</span>' : '';
623
832
  var who = mine
624
- ? '<span class="to">' + (m.to && m.to !== 'human:' ? '→ ' + nm(m.to, m.toName, true) : '') + '</span>' + stamp(m.ts) + stat
625
- : nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
833
+ ? to + stamp(m.ts) + stat + avBtn(m.from, m.fromLabel)
834
+ : avBtn(m.from, m.fromLabel) + nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
626
835
  var sw = canSwitch(other);
627
836
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
628
837
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
629
- (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
630
- var av = mine ? '' : '<button class="av' + (canSwitch(m.from) ? ' go' : '') + '" data-r="' + esc(m.from) + '" title="' + esc(nameOf(m.from)) + '"' + (canSwitch(m.from) ? '' : ' disabled') + '>' + esc(m.fromLabel || roleLabel(m.from)) + '</button>';
838
+ dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
631
839
  return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
632
840
  (m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
633
- '<div class="mcol"><div class="mwho">' + av + who + '</div><div class="mb">' + m.html + '</div></div>' +
841
+ '<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + (m.dir === 'out' ? signCut(m.html, m.from, m.fromName) : m.html) + '</div></div>' +
634
842
  flip +
635
843
  '</div>';
636
844
  };
@@ -685,7 +893,7 @@
685
893
  };
686
894
 
687
895
  var bindRow = function (el) {
688
- bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r]');
896
+ bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r]');
689
897
  };
690
898
  var rowNode = function (id) {
691
899
  var list = inner.querySelectorAll('.mrow');
@@ -693,6 +901,21 @@
693
901
  return null;
694
902
  };
695
903
 
904
+ // 载入更早的消息: 锚住此刻视口顶上的那一行, 重绘后原位不动 —— 不吸底。
905
+ var keepView = function () {
906
+ var top = thread.getBoundingClientRect().top;
907
+ var ref = [].filter.call(inner.querySelectorAll('.mrow'), function (r) { return r.getBoundingClientRect().bottom > top; })[0];
908
+ if (!ref) return null;
909
+ var id = ref.getAttribute('data-id'), y = ref.getBoundingClientRect().top;
910
+ return function () {
911
+ var el = rowNode(id);
912
+ if (!el) return false;
913
+ S.pinned = false;
914
+ thread.scrollTop += el.getBoundingClientRect().top - y;
915
+ return true;
916
+ };
917
+ };
918
+
696
919
  // land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
697
920
  var loadMsgs = function (limit, land) {
698
921
  var gen = S.gen;
@@ -707,7 +930,7 @@
707
930
  : '';
708
931
  inner.innerHTML = more + d.msgs.map(rowHTML).join('');
709
932
  var btn = $('#more');
710
- if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
933
+ if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0', keepView()); };
711
934
  bindRow(inner);
712
935
  render(inner);
713
936
  foldPings(inner);
@@ -771,7 +994,8 @@
771
994
  var takeRole = function (d) {
772
995
  R.at = d.at || Date.now(); R.recvAt = Date.now();
773
996
  R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
774
- R.relations = !!d.relations; R.schedules = d.schedules || 0;
997
+ SESSION = d.session || '';
998
+ R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null;
775
999
  R.stats = d.stats || null; R.winStats = d.winStats || null;
776
1000
  ROLE = d.role.id;
777
1001
  learn(d.role.id, d.role.name, d.role.label);
@@ -822,7 +1046,7 @@
822
1046
  // 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
823
1047
  if (land) takeRole(d); else applyRole(d);
824
1048
  syncUrl();
825
- if (VIEW === 'world' || VIEW === 'plan') { W.sel = ROLE; loadWorld(); }
1049
+ if (WORLD || VIEW === 'plan') loadWorld();
826
1050
  return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
827
1051
  if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
828
1052
  connect();
@@ -830,7 +1054,8 @@
830
1054
  });
831
1055
  };
832
1056
 
833
- var selectConv = function (key, withRole) {
1057
+ // land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
1058
+ var selectConv = function (key, withRole, land) {
834
1059
  CONV = key; WITH = withRole || '';
835
1060
  reveal();
836
1061
  if (VIEW !== 'msgs') setView('msgs');
@@ -841,7 +1066,7 @@
841
1066
  S.gen++;
842
1067
  syncUrl();
843
1068
  inner.innerHTML = '<div class="empty">加载中…</div>';
844
- loadMsgs().then(connect);
1069
+ loadMsgs(undefined, land).then(connect);
845
1070
  };
846
1071
 
847
1072
  // 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
@@ -849,6 +1074,7 @@
849
1074
  // at: 被点的那条消息 —— 换视角后它留在指针底下, 其余的相对它滑动。
850
1075
  var switchRole = function (id, at) {
851
1076
  if (!canSwitch(id) || id === ROLE) return;
1077
+ W.treeFor = '';
852
1078
  var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
853
1079
  var from = ROLE;
854
1080
  // 整个频道 / 两人私聊的消息集合与视角无关 —— 只有 with 与 session 按 role 过滤。
@@ -857,8 +1083,11 @@
857
1083
  var withBack = keep && WITH ? from : '';
858
1084
  ROLE = id; WITH = withBack; SESSION = '';
859
1085
  CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
1086
+ // 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
1087
+ // 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
1088
+ if (WORLD) { CONV = 'a:' + id; WITH = ''; same = false; W.pickSelf = id; }
860
1089
  var snap = VIEW === 'msgs' && !calm() ? snapRows(at) : null;
861
- if (same) { flipWindow(snap); return; }
1090
+ if (same) { if (WORLD) loadWorld(); flipWindow(snap); return; }
862
1091
  var kept = VIEW === 'msgs' ? keepBodies() : {};
863
1092
  refresh(function () {
864
1093
  adoptBodies(kept);
@@ -1016,592 +1245,307 @@
1016
1245
  $('#tb-back').onclick = function () { app.classList.remove('reading'); };
1017
1246
 
1018
1247
  // ══ 关系视图 ═══════════════════════════════════════════════════════
1019
- // 三层叠在一起:
1020
- // 1. 聊天卡片 (HTML) —— 归属; 一张卡 = 一个群
1021
- // 2. 家谱缩进 (HTML) —— 层级; 分身 / 子 wizard 缩在父亲下面, 左侧一道折线
1022
- // 3. 连线 (SVG) —— 其余的关系; 布局表达不了的那些 (跨卡片的家谱、
1023
- // 同群与跨群的派活、流水线的一步)
1024
- // 前两层撑起版面 (所以文字永远可读), 第三层才是"图"。
1025
- var EDGE = {
1026
- // 分身: 从父亲某个 session 节点 fork, 带着上下文; 子 wizard: 父亲 spawn 的白板。
1027
- clone: { c: '#8250df', label: '分身', dash: '', tip: '从父亲的某个 session 节点 fork 出来, 开局带着那一刻的上下文' },
1028
- spawn: { c: '#bc4c00', label: '子 wizard', dash: '9 3', tip: '父亲 spawn 的白板 wizard, 只有出身、没有继承上下文' },
1029
- peer: { c: '#0969da', label: '派活', dash: '5 4' },
1030
- graph: { c: '#0a7d6b', label: '流水线', dash: '2 3' },
1031
- };
1032
-
1033
- // 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器
1034
- // 直接抛异常, 整张图就白了。
1248
+ // 家谱 (clone / spawn) 与派活 (send_peer · 工单 · 流水线) 是同一种东西: 一条「谁把谁拉进这件事」的
1249
+ // 有向关系。一对 wizard 之间的全部关系合成一条边, 沿方向长成一棵树:
1250
+ // · 每个节点只认一个主父亲 —— 家谱父亲优先, 否则派活次数最多的那位; 其余入边记成节点上的引用
1251
+ // · 环 (互相派活) 在建树时截断: 哪个根都走不到的环, 挑最近活跃的那个当根
1252
+ // · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
1253
+ // 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
1254
+ var KIND = {
1255
+ spawn: { mark: '子', tip: 'spawn 的白板 wizard, 只有出身、没有继承上下文' },
1256
+ clone: { mark: '分身', tip: 'fork 自父亲的 session, 开局带着那一刻的上下文' },
1257
+ peer: { mark: '对话', tip: 'send_peer 发起的对话' },
1258
+ job: { mark: '工单', tip: '它开的工单里有这位成员' },
1259
+ graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
1260
+ };
1261
+ var LINEAGE = { spawn: 1, clone: 1 };
1262
+
1263
+ // 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
1035
1264
  var cssEsc = function (v) {
1036
1265
  return window.CSS && CSS.escape
1037
1266
  ? CSS.escape(v)
1038
1267
  : String(v).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c; });
1039
1268
  };
1040
-
1041
- var shortPath = function (p, keep) {
1042
- var seg = String(p || '').replace(/\/+$/, '').split('/').filter(Boolean);
1043
- return seg.length <= keep ? p : '…/' + seg.slice(-keep).join('/');
1044
- };
1045
-
1046
1269
  var wRunning = function (n) { return n.busy || (!!n.runningUntil && srvNow() < n.runningUntil); };
1047
-
1048
- // ── 走进一个 wizard ──
1049
- // 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁 —— 不再整页跳。
1050
- var chatOf = function (base) {
1051
- return (W.chats || []).filter(function (c) { return c.base === base; })[0];
1052
- };
1270
+ // 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁。
1053
1271
  var canOpen = function (n) { return !!n; };
1054
1272
  var openNode = function (target) { if (target) switchRole(target); };
1055
1273
 
1056
- // 名字全局唯一, 卡片头写的是聊天名, 节点上只写它自己的 `.name`。
1057
- var shortName = function (n) { return '.' + (n.name || n.tag || n.target); };
1058
-
1059
- // 与 sel 相连的一切 (含 sel 自己)。聚焦时其余的压暗而不是移除 —— 位置稳定,
1060
- // 反复点不同节点时版面不会跳。
1061
- var neighborhood = function (target) {
1062
- var set = {}; set[target] = 1;
1063
- W.edges.forEach(function (e) {
1064
- if (e.from === target) set[e.to] = 1;
1065
- if (e.to === target) set[e.from] = 1;
1066
- });
1067
- return set;
1068
- };
1069
-
1070
- var edgeOn = function (e) { return !!W.kinds[e.kind]; };
1071
-
1072
- // 一个节点身上挂了几条 (当前开着的) 边。0 = 它此刻不属于任何协作关系 ——
1073
- // 「只看有关系的」就是把这些收起来, 剩下的才是真正的那张网。
1074
- var degree = function (target) {
1075
- return W.edges.filter(edgeOn).filter(function (e) { return e.from === target || e.to === target; }).length;
1076
- };
1077
-
1078
- var renderWTools = function () {
1079
- var counts = { clone: 0, spawn: 0, peer: 0, graph: 0 };
1080
- W.edges.forEach(function (e) { counts[e.kind] = (counts[e.kind] || 0) + 1; });
1081
- var cross = W.edges.filter(function (e) { return e.cross; }).length;
1082
- var chips = Object.keys(EDGE).map(function (k) {
1083
- return '<button class="chip' + (W.kinds[k] ? ' on' : '') + '" data-k="' + k + '" ' +
1084
- 'style="--c:' + EDGE[k].c + '"' + (EDGE[k].tip ? ' title="' + EDGE[k].tip + '"' : '') + '><i></i>' + EDGE[k].label +
1085
- '<b>' + (counts[k] || 0) + '</b></button>';
1086
- }).join('');
1087
- var force = W.layout === 'force';
1088
- wtoolsEl.innerHTML =
1089
- '<div class="wlegend">' + chips + '</div>' +
1090
- '<div class="wlay">' +
1091
- '<button class="lb' + (force ? '' : ' on') + '" data-lay="cards" ' +
1092
- 'title="按聊天分卡, 分身 / 子 wizard 缩在父亲下面 —— 归属与层级最准">▦ 卡片</button>' +
1093
- '<button class="lb' + (force ? ' on' : '') + '" data-lay="force" ' +
1094
- 'title="wizard 之间的力导向图, 聊天收进节点 —— 协作关系最直观">🕸 关系网</button>' +
1095
- '</div>' +
1096
- '<div class="wstat">' +
1097
- W.nodes.length + ' 个 wizard · ' + W.chats.length + ' 个聊天' +
1098
- (cross ? ' · <b>' + cross + '</b> 条跨聊天关系' : '') +
1099
- (W.degraded ? ' · <span class="warn" title="注册表不可达 (独立 svr 部署), 只画观测到的往来">名册缺席</span>' : '') +
1100
- '<span class="hint">单击聚焦 · 双击进入' + (force ? ' · 拖动钉住' : ' (外聊天整页跳过去)') + '</span>' +
1101
- '</div>' +
1102
- (force ? '<button class="chip" id="wre" title="松开所有钉住的节点, 重新排一遍">↻ 重排</button>' : '') +
1103
- '<button class="chip only' + (W.onlyRel ? ' on' : '') + '" id="wonly" ' +
1104
- 'title="把此刻不属于任何关系的 wizard 收起来 —— 剩下的就是这张协作网本身">' +
1105
- (W.onlyRel ? '☑' : '☐') + ' 只看有关系的</button>' +
1106
- (W.sel ? '<button class="chip clear" id="wclear">✕ 取消聚焦</button>' : '');
1107
- wtoolsEl.querySelectorAll('.lb').forEach(function (b) {
1108
- b.onclick = function () {
1109
- var v = b.getAttribute('data-lay');
1110
- if (v === W.layout) return;
1111
- setLayout(v);
1112
- renderWorld();
1113
- };
1114
- });
1115
- var re = $('#wre');
1116
- if (re) re.onclick = function () { FX.pos = {}; FX.sig = ''; renderForce(); };
1117
- wtoolsEl.querySelectorAll('.chip[data-k]').forEach(function (b) {
1118
- b.onclick = function () {
1119
- var k = b.getAttribute('data-k');
1120
- W.kinds[k] = W.kinds[k] ? 0 : 1;
1121
- renderWTools();
1122
- if (W.layout === 'force') renderForce(); else drawEdges();
1123
- };
1124
- });
1125
- var only = $('#wonly');
1126
- if (only) only.onclick = function () { W.onlyRel = !W.onlyRel; renderWorld(); };
1127
- var cl = $('#wclear');
1128
- if (cl) cl.onclick = function () { W.sel = ''; renderWorld(); };
1129
- };
1130
-
1131
- var nodeHTML = function (n, depth) {
1132
- var run = wRunning(n), nm = shortName(n);
1133
- var bits = [];
1134
- if (n.model) bits.push(n.model.replace(/^claude-/, ''));
1135
- if (n.cwd) bits.push('📁 ' + shortPath(n.cwd, 1));
1136
- if (n.turns) bits.push(n.turns + ' 轮');
1137
- if (n.taskTurns) bits.push('⏰ ' + n.taskTurns);
1138
- if (n.peerTurns) bits.push('✉ ' + n.peerTurns);
1139
- return '<div class="wnode' + (n.self ? ' self' : '') + (run ? ' run' : '') +
1140
- (n.alive ? '' : ' cold') + (n.target === ROLE ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
1141
- '" data-t="' + esc(n.target) + '" style="margin-left:' + (depth * 16) + 'px">' +
1142
- (depth ? (n.inherited ? '<span class="lin" title="分身"></span>' : '<span class="lin sp" title="子 wizard"></span>') : '') +
1143
- '<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
1144
- '<span class="wbody">' +
1145
- '<span class="wl1">' +
1146
- '<b class="wname" title="' + esc(n.name || n.target) + '">' + esc(nm) + '</b>' +
1147
- (n.inherited ? '<span class="wih" title="分身: 从父亲的 session 节点 fork, 开局带着那一刻的上下文">⧉</span>' : '') +
1148
- '<span class="wts">' + esc(fmtAgo(n.lastTs)) + '</span>' +
1149
- '</span>' +
1150
- (n.description ? '<span class="wjob">' + esc(n.description) + '</span>' : '') +
1151
- (n.preview ? '<span class="wprev">' + esc(n.preview) + '</span>' : '') +
1152
- (bits.length ? '<span class="wmeta">' + bits.map(esc).join('<span class="sep">·</span>') + '</span>' : '') +
1153
- '</span>' +
1154
- '</div>';
1274
+ // ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
1275
+ var rangeOf = function () {
1276
+ var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
1277
+ return s ? { from: s.start, to: s.end || Infinity, s: s } : null;
1155
1278
  };
1279
+ var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
1156
1280
 
1157
- var renderWMap = function () {
1158
- if (!W.chats.length) {
1159
- wmapEl.innerHTML = '<div class="empty">' + (W.loaded ? '还没有任何 wizard 记录' : '加载中…') + '</div>';
1160
- return;
1161
- }
1162
- var hood = W.sel ? neighborhood(W.sel) : null;
1163
- var cards = W.chats.map(function (c) {
1164
- var shown = c.members.filter(function (mm) { return !W.onlyRel || degree(mm.target); });
1165
- var live = shown.filter(function (mm) {
1166
- var n = nodeOf(mm.target); return n && wRunning(n);
1167
- }).length;
1168
- var rows = shown.map(function (mm) {
1169
- var n = nodeOf(mm.target);
1170
- // 「只看有关系的」会把父亲筛掉而留下孩子 —— 那时的缩进没有参照物, 拉平。
1171
- var d = W.onlyRel ? 0 : mm.depth;
1172
- return n ? nodeHTML(n, d) : '';
1173
- }).join('');
1174
- if (!shown.length) return '';
1175
- return '<section class="wchat' + (c.self ? ' self' : '') + '" data-base="' + esc(c.base) + '">' +
1176
- '<header class="wch">' +
1177
- '<span class="nm">' + esc(c.name || c.base) + '</span>' +
1178
- (c.self ? '<span class="here">当前</span>' : '') +
1179
- '<span class="ct">' + shown.length + (live ? ' · <em>' + live + ' 在跑</em>' : '') + '</span>' +
1180
- '</header>' +
1181
- '<div class="wrows">' + rows + '</div>' +
1182
- (c.hidden || shown.length < c.members.length
1183
- ? '<div class="wmore" title="不在图上的会话 —— 它们还在, 只是此刻既没在跑也没有关系">另有 ' +
1184
- (c.hidden + (c.members.length - shown.length)) + ' 个未显示</div>'
1185
- : '') +
1186
- '</section>';
1187
- }).filter(Boolean).join('');
1188
- wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg><div class="wgrid">' +
1189
- (cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身 / 生子 wizard 之后这里就有边了</div>') + '</div>';
1190
- wmapEl.querySelectorAll('.wnode').forEach(function (el) {
1191
- var t = el.getAttribute('data-t');
1192
- if (hood && !hood[t]) el.classList.add('dim');
1193
- if (t === W.sel) el.classList.add('sel');
1194
- if (canOpen(nodeOf(t))) el.classList.add('go');
1195
- el.onclick = function () { W.sel = (W.sel === t ? '' : t); renderWorld(); };
1196
- // 双击 = 走进它: 视角切成它 (见 openNode)。
1197
- el.ondblclick = function () { openNode(t); };
1198
- });
1199
- // 卡片头 = 那个群本身。点它进那个群里最近活跃的那一栏 —— 图上找不到哪个
1200
- // 节点该双击的时候, 这是最直觉的入口。
1201
- wmapEl.querySelectorAll('.wch').forEach(function (h) {
1202
- var c = chatOf(h.parentNode.getAttribute('data-base'));
1203
- var first = c && c.members.filter(function (mm) { return canOpen(nodeOf(mm.target)); })[0];
1204
- if (!first) return;
1205
- h.classList.add('go');
1206
- h.onclick = function () { openNode(first.target); };
1207
- });
1281
+ // 工单归属也是一条关系: 开单的人 → 每个成员。
1282
+ var jobEdges = function () {
1283
+ return (W.jobs || []).reduce(function (acc, j) {
1284
+ var o = nodeOf(j.owner);
1285
+ return !o ? acc : acc.concat(j.members.filter(function (mm) { return mm.target !== j.owner && nodeOf(mm.target); }).map(function (mm) {
1286
+ return { kind: 'job', from: j.owner, to: mm.target, cross: o.base !== nodeOf(mm.target).base, jobs: [j.id], ts: [j.openedAt] };
1287
+ }));
1288
+ }, []);
1208
1289
  };
1209
1290
 
1210
- // ── 连线 ──
1211
- // 端点在布局之后才知道 (卡片会换行、文字会折行), 所以连线是一个纯粹的
1212
- // "读版面 → 画路径" 的过程, 每次重排都重来一遍, 不维护任何位置状态。
1213
- var anchorsOf = function (a, b, base) {
1214
- var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
1215
- var ox = base.left, oy = base.top;
1216
- var A = { l: ra.left - ox, r: ra.right - ox, cy: ra.top - oy + ra.height / 2 };
1217
- var B = { l: rb.left - ox, r: rb.right - ox, cy: rb.top - oy + rb.height / 2 };
1218
- // 同一列 (同一张卡片里) → 两端都走左侧, 从左边的空白处绕出去, 像 git 图的
1219
- // 那条 gutter。左右分列 → 从近的一侧出、近的一侧进。
1220
- if (Math.abs(A.l - B.l) < 60) {
1221
- // 同卡片内的边走卡片自己的左内边距 (.wrows 的 padding-left) —— 那条车道
1222
- // 就是为它留的。拐到卡片外面既会被滚动容器裁掉, 也读不出"这两个是一个群
1223
- // 里的"。room 按最近的卡片左沿算, 没有卡片 (理论上不会) 才退回画布左沿。
1224
- var card = a.closest('.wchat');
1225
- var lane = card ? card.getBoundingClientRect().left - ox + 6 : 4;
1226
- var room = Math.max(6, Math.min(A.l, B.l) - lane);
1227
- var d = Math.min(room, 14 + Math.abs(A.cy - B.cy) * 0.22);
1228
- return {
1229
- d: 'M' + A.l + ',' + A.cy + ' C' + (A.l - d) + ',' + A.cy + ' ' + (B.l - d) + ',' + B.cy + ' ' + B.l + ',' + B.cy,
1230
- head: 'start',
1231
- };
1232
- }
1233
- var right = B.l > A.l;
1234
- var ax = right ? A.r : A.l, bx = right ? B.l : B.r;
1235
- var k = right ? 1 : -1, dd = Math.max(46, Math.abs(bx - ax) * 0.42);
1236
- return {
1237
- d: 'M' + ax + ',' + A.cy + ' C' + (ax + dd * k) + ',' + A.cy + ' ' + (bx - dd * k) + ',' + B.cy + ' ' + bx + ',' + B.cy,
1238
- head: right ? 'end' : 'start',
1291
+ /** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
1292
+ var relations = function (rg) {
1293
+ var pairs = W.edges.map(function (e) {
1294
+ return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs] };
1295
+ }).concat(jobEdges()).reduce(function (m, e) {
1296
+ // 家谱是身份, 不是发生在某段 session 里的事 —— 不受时间窗裁剪, 否则切到子 wizard 时
1297
+ // 它的出生早于自己的 session, 父亲那条边就被裁掉了。
1298
+ var ts = LINEAGE[e.kind] ? e.ts : e.ts.filter(function (t) { return inRange(rg, t); });
1299
+ if (!ts.length) return m;
1300
+ var k = e.from + '\u0000' + e.to;
1301
+ var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
1302
+ p.kinds[e.kind] = (p.kinds[e.kind] || 0) + ts.length;
1303
+ if (!LINEAGE[e.kind]) p.n += ts.length;
1304
+ p.first = Math.min(p.first, Math.min.apply(null, ts));
1305
+ p.last = Math.max(p.last, Math.max.apply(null, ts));
1306
+ e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
1307
+ return m;
1308
+ }, {});
1309
+ // 派活本身带着工单号时, 「工单」标记就是重复的。
1310
+ Object.keys(pairs).forEach(function (k) { if (pairs[k].kinds.peer) delete pairs[k].kinds.job; });
1311
+ return pairs;
1312
+ };
1313
+
1314
+ /** 关系 → 森林: pp = 主父亲那条边, kids = 主孩子, roots = 森林的根。 */
1315
+ var forestOf = function (pairs) {
1316
+ var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
1317
+ var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
1318
+ var weight = function (p) { return (p.kinds.spawn ? 3e6 : p.kinds.clone ? 2e6 : 0) + p.n; };
1319
+ var pp = Object.keys(inc).reduce(function (m, t) {
1320
+ m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
1321
+ return m;
1322
+ }, {});
1323
+ var ends = list.reduce(function (m, p) { m[p.from] = 1; m[p.to] = 1; return m; }, {});
1324
+ var kidsIn = function () {
1325
+ return Object.keys(pp).reduce(function (m, t) { (m[pp[t].from] = m[pp[t].from] || []).push(t); return m; }, {});
1239
1326
  };
1327
+ var reach = function (kids, roots) {
1328
+ var seen = {};
1329
+ var walk = function (t) { if (seen[t]) return; seen[t] = 1; (kids[t] || []).forEach(walk); };
1330
+ roots.forEach(walk);
1331
+ return seen;
1332
+ };
1333
+ var roots = Object.keys(ends).filter(function (t) { return !pp[t]; });
1334
+ var recent = function (a, b) { return ((nodeOf(b) || {}).lastTs || 0) - ((nodeOf(a) || {}).lastTs || 0); };
1335
+ // 从 t 沿主父亲往上走, 直到撞回走过的点 —— 撞上的那一圈就是环。
1336
+ var loopFrom = function (t, path) {
1337
+ var at = path.indexOf(t);
1338
+ return at >= 0 ? path.slice(at) : loopFrom(pp[t].from, path.concat([t]));
1339
+ };
1340
+ // 环: 只断环上的派活边 (家谱无环, 断它会让子 wizard 丢了父亲), 挑最近活跃的那个当根 —— 直到都走得到。
1341
+ var close = function () {
1342
+ var seen = reach(kidsIn(), roots);
1343
+ var left = Object.keys(ends).filter(function (t) { return !seen[t]; });
1344
+ if (!left.length) return;
1345
+ var ring = loopFrom(left[0], []);
1346
+ var cut = ring.filter(function (t) { return !LINEAGE[domKind(pp[t])]; });
1347
+ var pick = (cut.length ? cut : ring).sort(recent)[0];
1348
+ delete pp[pick]; roots.push(pick);
1349
+ close();
1350
+ };
1351
+ close();
1352
+ // 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
1353
+ var kids = kidsIn();
1354
+ return { pairs: pairs, inc: inc, pp: pp, kids: kids, roots: roots, ends: ends };
1355
+ };
1356
+
1357
+ // F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
1358
+ var shows = function (F, t) { return !F.vis || !!F.vis[t]; };
1359
+ var countSub = function (F, t) {
1360
+ return (F.kids[t] || []).filter(function (k) { return shows(F, k); })
1361
+ .reduce(function (n, k) { return n + 1 + countSub(F, k); }, 0);
1362
+ };
1363
+ var chainUp = function (F, t, seen) {
1364
+ seen = seen || {};
1365
+ var p = F.pp[t];
1366
+ if (!p || seen[p.from]) return [t];
1367
+ seen[t] = 1;
1368
+ return chainUp(F, p.from, seen).concat([t]);
1369
+ };
1370
+
1371
+ // 线接入卡片处的 label: 种类 (家谱不计次, 对话等一律写次数, ×1 也写) + 跨群; 经手的工单号放进悬停提示。
1372
+ var labelHTML = function (p) {
1373
+ if (!p) return '';
1374
+ return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
1375
+ Object.keys(KIND).filter(function (k) { return p.kinds[k]; }).map(function (k) {
1376
+ return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
1377
+ (!LINEAGE[k] ? ' ×' + p.kinds[k] : '') + '</span>';
1378
+ }).join('') +
1379
+ (p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') + '</span>';
1380
+ };
1381
+ // 一条边的主色: 家谱优先, 决定树上那道线的颜色。
1382
+ var domKind = function (p) {
1383
+ return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
1384
+ };
1385
+
1386
+ // ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
1387
+ var edgeEnds = function (F, t) { var p = F.pp[t]; return p ? [p.from, t] : [t]; };
1388
+ // 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
1389
+ var pairConv = function (other) {
1390
+ if (convOf('p:' + other)) return ['p:' + other, ''];
1391
+ var hit = R.convs.filter(function (c) { return c.kind === 'group'; }).map(function (c) {
1392
+ return [c, (c.subs || []).filter(function (x) { return x.role === other && x.count; })[0]];
1393
+ }).filter(function (x) { return x[1]; }).sort(function (a, b) { return b[1].lastTs - a[1].lastTs; })[0];
1394
+ return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
1395
+ };
1396
+ // 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
1397
+ var edgeConv = function (F, t) {
1398
+ var ends = edgeEnds(F, t);
1399
+ var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
1400
+ return other ? pairConv(other) : null;
1401
+ };
1402
+ // 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话, 不换视角。
1403
+ // links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
1404
+ var talkKey = function (links, t) {
1405
+ var ls = links[t] || [];
1406
+ return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
1407
+ };
1408
+ // 图上相连 = 画出来的主父亲边: 它的父亲 + 它的孩子, 两端都画出来了才算。
1409
+ var linksOf = function (F, shown) {
1410
+ var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
1411
+ return shown.reduce(function (m, n) {
1412
+ var t = n.target, p = F.pp[t];
1413
+ m[t] = (p && on[p.from] ? [p.from] : []).concat((F.kids[t] || []).filter(function (k) { return on[k]; }));
1414
+ return m;
1415
+ }, {});
1240
1416
  };
1241
1417
 
1242
- var drawEdges = function () {
1243
- var svg = $('#wedges');
1244
- if (!svg) return;
1245
- var base = wmapEl.getBoundingClientRect();
1246
- svg.setAttribute('width', wmapEl.scrollWidth);
1247
- svg.setAttribute('height', wmapEl.scrollHeight);
1248
- svg.setAttribute('viewBox', '0 0 ' + wmapEl.scrollWidth + ' ' + wmapEl.scrollHeight);
1249
- var defs = Object.keys(EDGE).map(function (k) {
1250
- return ['', '-d'].map(function (sfx) {
1251
- return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
1252
- 'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
1253
- 'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
1254
- }).join('');
1255
- }).join('');
1256
- var hood = W.sel ? neighborhood(W.sel) : null;
1257
- var paths = W.edges.filter(edgeOn).map(function (e) {
1258
- var a = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.from) + '"]');
1259
- var b = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.to) + '"]');
1260
- if (!a || !b) return '';
1261
- var p = anchorsOf(a, b, base);
1262
- // 聚焦时: 不碰 sel 的边压到近乎不可见 —— 删掉它们会让"这张图本来有多密"
1263
- // 这个信息消失, 而那恰恰是判断要不要收几个 wizard 的依据。
1264
- var off = hood && !(e.from === W.sel || e.to === W.sel);
1265
- var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
1266
- return '<path d="' + p.d + '" fill="none" stroke="' + EDGE[e.kind].c + '" ' +
1267
- 'stroke-width="' + (off ? 1 : w).toFixed(2) + '" ' +
1268
- 'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '" ' +
1269
- 'opacity="' + (off ? 0.14 : (e.cross ? 0.95 : 0.6)) + '" ' +
1270
- 'marker-' + p.head + '="url(#ah-' + e.kind + (off ? '-d' : '') + ')">' +
1271
- '<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
1272
- (e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
1273
- (e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
1274
- }).join('');
1275
- svg.innerHTML = '<defs>' + defs + '</defs>' + paths;
1276
- };
1277
-
1278
- // ── 力导向布局 ────────────────────────────────────────────────────
1279
- // 卡片布局把「归属」和「层级」交给版面, 代价是聊天必须张张画出来: 一个只出了
1280
- // 一个分身的群也占一整张卡, 而跨聊天那条边得横穿半张图去找对面。
1281
- // 换一种折法 —— 只画 wizard, 聊天收进节点自己 (左侧色条 + 群名一行), 位置交给
1282
- // 力: 同群相吸自然成簇, 家谱 (分身 / 子 wizard) 的边比派活的边短, 家谱于是仍然读得出来。
1283
- // 两种读法各有盲区 (力导向读不出精确的父子层级), 所以是开关不是替换。
1284
- // 绿在这一页已经有主 (在跑), 所以群色不用绿 —— 一道绿色条会被读成「它活着」。
1285
- var CHAT_C = ['#0969da', '#8250df', '#bc4c00', '#bf3989', '#0a7d6b', '#cf222e', '#9a6700', '#4338ca'];
1286
- var hashOf = function (s) {
1287
- var v = String(s), h = 0;
1288
- for (var i = 0; i < v.length; i++) h = (h * 31 + v.charCodeAt(i)) >>> 0;
1289
- return h;
1290
- };
1291
- // 颜色按 base **排序**取位, 而不是按它在列表里的次序取位 —— 列表次序随最近活动
1292
- // 变, 颜色跟着变就没人记得住哪个色是哪个群; 排序是稳定的, 顺带保证 8 个群之内
1293
- // 一个不撞。超过 8 个才退回 hash。
1294
- var chatColor = function (base) {
1295
- return (FX.color || {})[base] || CHAT_C[hashOf(base) % CHAT_C.length];
1296
- };
1297
- // 取位用的是**全部**节点而不是画在图上的那些 —— 拿筛过的集合分色, 一按「只看
1298
- // 有关系的」每个群的颜色就换一轮。
1299
- var fxColors = function () {
1300
- var bases = [];
1301
- W.nodes.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
1302
- return bases.sort().reduce(function (m, b, i) { m[b] = CHAT_C[i % CHAT_C.length]; return m; }, {});
1303
- };
1304
-
1305
- // pos 按 target 存活: 6 秒一次的轮询重画 DOM, 但坐标沿用, 图不会每轮跳一次。
1306
- var FX = { pos: {}, size: {}, els: {}, paths: [], color: {}, nodes: [], edges: [], w: 0, h: 0, alpha: 0, raf: 0, drag: null, sig: '' };
1307
-
1308
- var fxNodes = function () {
1309
- return W.nodes.filter(function (n) { return !W.onlyRel || degree(n.target); });
1310
- };
1311
-
1312
- /** 种子位置: 聊天摆一圈, 成员散在自己那个群周围。确定性 (hash 而不是 random)
1313
- * —— 同一批 wizard 每次打开这一页, 图的大致形状是同一个。 */
1314
- var fxSeed = function (ns) {
1315
- var bases = [];
1316
- ns.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
1317
- var cx = FX.w / 2, cy = FX.h / 2, R = Math.min(FX.w, FX.h) * 0.28;
1318
- var on = {};
1319
- ns.forEach(function (n) { on[n.target] = 1; });
1320
- Object.keys(FX.pos).forEach(function (t) { if (!on[t]) delete FX.pos[t]; });
1321
- ns.forEach(function (n) {
1322
- if (FX.pos[n.target]) return;
1323
- var i = bases.indexOf(n.base);
1324
- var a = bases.length > 1 ? (i / bases.length) * Math.PI * 2 : 0;
1325
- var bx = bases.length > 1 ? cx + Math.cos(a) * R : cx;
1326
- var by = bases.length > 1 ? cy + Math.sin(a) * R : cy;
1327
- var t = (hashOf(n.target) % 360) * Math.PI / 180;
1328
- FX.pos[n.target] = { x: bx + Math.cos(t) * 46, y: by + Math.sin(t) * 46, vx: 0, vy: 0, pin: 0 };
1329
- });
1330
- };
1331
-
1332
- var fxTick = function () {
1333
- var ns = FX.nodes, P = FX.pos, n = ns.length;
1334
- if (!n) return;
1335
- var L = Math.max(86, Math.min(190, Math.sqrt(FX.w * FX.h / n) * 0.66));
1336
- var rep = L * L * 1.15;
1337
- var i, j, a, b, dx, dy, d, ux, uy, f;
1338
- // 斥力 —— O(n²), 但这张图按设计就不超过几十个节点 (见 shared/world 的相关性)。
1339
- for (i = 0; i < n; i++) {
1340
- a = P[ns[i].target];
1341
- for (j = i + 1; j < n; j++) {
1342
- b = P[ns[j].target];
1343
- dx = b.x - a.x; dy = b.y - a.y;
1344
- d = Math.sqrt(dx * dx + dy * dy) || 0.01;
1345
- f = rep / (d * d); ux = dx / d; uy = dy / d;
1346
- a.vx -= ux * f; a.vy -= uy * f; b.vx += ux * f; b.vy += uy * f;
1347
- }
1348
- }
1349
- // 弹簧 —— 家谱边的静息长度短于派活的, 于是家谱天然抱成一小团。
1350
- // 静息长度有个下限: 节点是一张一百多像素宽的卡片, 不是一个点。两端贴到一起
1351
- // 时连线被两张卡各自吃掉一半, 剩下的那截连箭头都放不下 —— 图上就成了一条
1352
- // 看不见的边。下限按两张卡的半宽算, 横着摆也留得出一段看得见的线。
1353
- FX.edges.forEach(function (e) {
1354
- var pa = P[e.from], pb = P[e.to];
1355
- if (!pa || !pb) return;
1356
- var za = FX.size[e.from] || { w: 150, h: 36 }, zb = FX.size[e.to] || { w: 150, h: 36 };
1357
- var ex = pb.x - pa.x, ey = pb.y - pa.y;
1358
- var ed = Math.sqrt(ex * ex + ey * ey) || 0.01;
1359
- var kin = e.kind === 'clone' || e.kind === 'spawn';
1360
- var rest = Math.max((za.w + zb.w) / 2 + 30, kin ? L * 0.8 : L * 1.35);
1361
- var k = (kin ? 0.06 : 0.032) * Math.min(2.2, 1 + Math.log(1 + e.count) * 0.45);
1362
- var g = (ed - rest) * k, gx = ex / ed, gy = ey / ed;
1363
- pa.vx += gx * g; pa.vy += gy * g; pb.vx -= gx * g; pb.vy -= gy * g;
1364
- });
1365
- // 同群相吸 + 朝心收 —— 归属在卡片布局里由版面表达, 这里由一股弱引力表达。
1366
- var cen = {};
1367
- ns.forEach(function (nd) {
1368
- var p = P[nd.target], c = cen[nd.base] || (cen[nd.base] = { x: 0, y: 0, n: 0 });
1369
- c.x += p.x; c.y += p.y; c.n++;
1370
- });
1371
- ns.forEach(function (nd) {
1372
- var p = P[nd.target], c = cen[nd.base];
1373
- p.vx += (c.x / c.n - p.x) * 0.022 + (FX.w / 2 - p.x) * 0.006;
1374
- p.vy += (c.y / c.n - p.y) * 0.022 + (FX.h / 2 - p.y) * 0.006;
1375
- });
1376
- // 积分。alpha 同时当步长, 于是"冷却"就是自然停住。
1377
- ns.forEach(function (nd) {
1378
- var p = P[nd.target];
1379
- if (p.pin) { p.vx = 0; p.vy = 0; return; }
1380
- p.vx *= 0.8; p.vy *= 0.8;
1381
- var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy), cap = 26;
1382
- if (sp > cap) { p.vx *= cap / sp; p.vy *= cap / sp; }
1383
- p.x += p.vx * FX.alpha; p.y += p.vy * FX.alpha;
1384
- });
1385
- // 矩形避让 —— 节点是一行字, 不是一个点。圆形斥力挡不住两张卡横向叠在一起,
1386
- // 而这张图的全部价值就在于那行字读得出来。
1387
- // 它排在积分之后, 所以收边必须排在它之后 —— 反过来的话, 被挤到边上的节点会
1388
- // 被推出画布再也回不来 (弱引力拉不过避让)。
1389
- var pass, pa, pb, sa, sb, mx, my, ox, oy, sh;
1390
- for (pass = 0; pass < 2; pass++) {
1391
- for (i = 0; i < n; i++) {
1392
- for (j = i + 1; j < n; j++) {
1393
- pa = P[ns[i].target]; pb = P[ns[j].target];
1394
- sa = FX.size[ns[i].target]; sb = FX.size[ns[j].target];
1395
- if (!sa || !sb) continue;
1396
- mx = (sa.w + sb.w) / 2 + 18; my = (sa.h + sb.h) / 2 + 10;
1397
- dx = pb.x - pa.x; dy = pb.y - pa.y;
1398
- ox = mx - Math.abs(dx); oy = my - Math.abs(dy);
1399
- if (ox <= 0 || oy <= 0) continue;
1400
- if (ox / mx < oy / my) {
1401
- sh = (dx < 0 ? -1 : 1) * ox;
1402
- if (pa.pin) pb.x += sh; else if (pb.pin) pa.x -= sh;
1403
- else { pa.x -= sh / 2; pb.x += sh / 2; }
1404
- } else {
1405
- sh = (dy < 0 ? -1 : 1) * oy;
1406
- if (pa.pin) pb.y += sh; else if (pb.pin) pa.y -= sh;
1407
- else { pa.y -= sh / 2; pb.y += sh / 2; }
1408
- }
1409
- }
1410
- }
1411
- }
1412
- // 收进画布 —— 钉住的也收, 窗口缩小之后那些被人摆在外面的节点得跟着回来。
1413
- ns.forEach(function (nd) {
1414
- var p = P[nd.target], z = FX.size[nd.target] || { w: 150, h: 36 };
1415
- p.x = Math.max(z.w / 2 + 6, Math.min(FX.w - z.w / 2 - 6, p.x));
1416
- p.y = Math.max(z.h / 2 + 6, Math.min(FX.h - z.h / 2 - 6, p.y));
1417
- });
1418
- };
1419
-
1420
- /** 中心沿 (ux,uy) 走到卡片矩形的边 —— 箭头要落在卡片外面才看得见。 */
1421
- var fxRim = function (p, s, ux, uy) {
1422
- var hw = (s ? s.w : 150) / 2 + 3, hh = (s ? s.h : 36) / 2 + 3;
1423
- var t = Math.min(ux ? hw / Math.abs(ux) : 1e9, uy ? hh / Math.abs(uy) : 1e9);
1424
- return { x: p.x + ux * t, y: p.y + uy * t };
1425
- };
1426
-
1427
- /** 连线只在重排时建一次 —— 逐帧重写 innerHTML 会把一张 50 条边的图拖成幻灯片。
1428
- * 之后每帧只改 `d`, 聚焦只改描边 (见 fxClasses)。 */
1429
- var fxEdgesDOM = function () {
1430
- var svg = $('#wedges');
1431
- if (!svg) return;
1432
- var defs = Object.keys(EDGE).map(function (k) {
1433
- return ['', '-d'].map(function (sfx) {
1434
- return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
1435
- 'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
1436
- 'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
1437
- }).join('');
1438
- }).join('');
1439
- svg.innerHTML = '<defs>' + defs + '</defs>' + FX.edges.map(function (e) {
1440
- return '<path fill="none" stroke="' + EDGE[e.kind].c + '" ' +
1441
- 'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '">' +
1442
- '<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
1443
- (e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
1444
- (e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
1445
- }).join('');
1446
- FX.paths = Array.prototype.slice.call(svg.querySelectorAll('path[stroke]'));
1447
- };
1448
-
1449
- var fxPaint = function () {
1450
- FX.nodes.forEach(function (n) {
1451
- var p = FX.pos[n.target], el = FX.els[n.target];
1452
- if (el && p) el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) translate(-50%,-50%)';
1453
- });
1454
- FX.edges.forEach(function (e, i) {
1455
- var path = FX.paths[i], pa = FX.pos[e.from], pb = FX.pos[e.to];
1456
- if (!path || !pa || !pb) return;
1457
- var dx = pb.x - pa.x, dy = pb.y - pa.y;
1458
- var d = Math.sqrt(dx * dx + dy * dy) || 0.01, ux = dx / d, uy = dy / d;
1459
- var A = fxRim(pa, FX.size[e.from], ux, uy), B = fxRim(pb, FX.size[e.to], -ux, -uy);
1460
- // 恒向左弓 —— 互相派活的两个 wizard 有两条方向相反的边, 直线会重合成一条。
1461
- var bow = Math.min(30, d * 0.14);
1462
- var qx = (A.x + B.x) / 2 - uy * bow, qy = (A.y + B.y) / 2 + ux * bow;
1463
- path.setAttribute('d', 'M' + A.x.toFixed(1) + ',' + A.y.toFixed(1) +
1464
- ' Q' + qx.toFixed(1) + ',' + qy.toFixed(1) + ' ' + B.x.toFixed(1) + ',' + B.y.toFixed(1));
1465
- });
1466
- };
1467
-
1468
- var fxLoop = function () {
1469
- FX.raf = 0;
1470
- if (W.layout !== 'force' || VIEW !== 'world') return;
1471
- fxTick();
1472
- fxPaint();
1473
- if (!FX.drag) FX.alpha *= 0.972;
1474
- if (FX.alpha > 0.02 || FX.drag) FX.raf = requestAnimationFrame(fxLoop);
1475
- };
1476
-
1477
- var fxHeat = function (a) {
1478
- FX.alpha = Math.max(FX.alpha, a === undefined ? 0.9 : a);
1479
- if (!FX.raf) FX.raf = requestAnimationFrame(fxLoop);
1418
+ // 卡片与侧栏会话项同一套 (glance / roleRow / recentFirst)。
1419
+ // 视角与对端有会话项 → 一行的数据就是那一项的 glance; 没有 → 取节点, 时刻取这条边最近一次发生,
1420
+ // 而不是节点自己最后的动静 —— 卡片承载的是边。
1421
+ // 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
1422
+ // (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
1423
+ // 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
1424
+ // 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
1425
+ var cardGlance = function (F, t) {
1426
+ var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
1427
+ var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
1428
+ var n = nodeOf(t) || {}, p = F.pp[t];
1429
+ var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
1430
+ var w = F.links && W.glance[talkKey(F.links, t)];
1431
+ return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
1432
+ };
1433
+ // 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
1434
+ var loadGlance = function () {
1435
+ var keys = W.gKeys, role = ROLE;
1436
+ if (!keys) return;
1437
+ api('api/glance', { role: role, keys: keys }).then(function (d) {
1438
+ if (!d.ok || role !== ROLE || keys !== W.gKeys) return;
1439
+ W.glance = d.glances || {};
1440
+ renderWorld();
1441
+ }).catch(function () { });
1480
1442
  };
1481
-
1482
- var fxStop = function () {
1483
- if (FX.raf) cancelAnimationFrame(FX.raf);
1484
- FX.raf = 0; FX.drag = null; FX.alpha = 0;
1485
- wscrollEl.classList.remove('fx');
1486
- wmapEl.classList.remove('force');
1443
+ var byCard = function (F) {
1444
+ return function (a, b) { return recentFirst(cardGlance(F, a), cardGlance(F, b)); };
1445
+ };
1446
+ var tnodeHTML = function (F, n, folded) {
1447
+ var me = n.target === ROLE;
1448
+ var tail = folded ? '<span class="tfold" title="它下面还有 ' + folded + ' 个, 切到它的视角可见">+' + folded + '</span>' : '';
1449
+ var p = F.pp[n.target];
1450
+ var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, tail);
1451
+ var via = F.vis && F.vis[n.target] === 'via';
1452
+ return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
1453
+ (via ? ' title="' + esc(nameOf(n.target) + ' 没和 ' + nameOf(ROLE) + ' 对过话, 留着是为了连到它下面对过话的') + '"' : '') + '>' +
1454
+ labelHTML(p) + row + '</button>';
1455
+ };
1456
+
1457
+ // keep(父, 孩子) → 'full' 整枝 / 'leaf' 只画它自己 / '' 不画; 不给 keep = 全画。shown 收集画出来的节点。
1458
+ var treeHTML = function (F, t, depth, keep, leaf, shown, seen, z) {
1459
+ var n = nodeOf(t);
1460
+ if (!n || seen[t] || depth > 32) return '';
1461
+ seen[t] = 1;
1462
+ shown.push(n);
1463
+ var kids = leaf ? [] : (F.kids[t] || []).filter(function (k) { return shows(F, k) && (!keep || keep(t, k)); }).sort(byCard(F));
1464
+ return '<li class="' + domKind(F.pp[t]) + '"' + (z ? ' style="z-index:' + z + '"' : '') + '>' + tnodeHTML(F, n, leaf ? countSub(F, t) : 0) +
1465
+ // 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
1466
+ (kids.length ? '<ul>' + kids.map(function (k, i) {
1467
+ return treeHTML(F, k, depth + 1, keep, keep && keep(t, k) === 'leaf', shown, seen, kids.length - i);
1468
+ }).join('') + '</ul>' : '') +
1469
+ '</li>';
1470
+ };
1471
+ // 只看相关: 主父亲链一条直线下来, 到它自己再整枝展开; 同一个主父亲下的兄弟只画本人。
1472
+ var focusKeep = function (path) {
1473
+ var on = path.reduce(function (m, t) { m[t] = 1; return m; }, {});
1474
+ var parent = path[path.length - 2];
1475
+ return function (t, k) {
1476
+ if (!on[t] || t === ROLE) return 'full';
1477
+ return on[k] ? 'full' : t === parent ? 'leaf' : '';
1478
+ };
1487
1479
  };
1488
1480
 
1489
- var fxClasses = function () {
1490
- var hood = W.sel ? neighborhood(W.sel) : null;
1491
- FX.nodes.forEach(function (n) {
1492
- var el = FX.els[n.target];
1493
- if (!el) return;
1494
- el.classList.toggle('dim', !!(hood && !hood[n.target]));
1495
- el.classList.toggle('sel', n.target === W.sel);
1496
- el.classList.toggle('pin', !!(FX.pos[n.target] || {}).pin);
1497
- });
1498
- // 聚焦时无关的边压到近乎不可见而不是删掉 —— "这张图本来有多密"是判断要不要
1499
- // 收几个 wizard 的依据 (同 drawEdges)。
1500
- FX.edges.forEach(function (e, i) {
1501
- var path = FX.paths[i];
1502
- if (!path) return;
1503
- var off = hood && !(e.from === W.sel || e.to === W.sel);
1504
- var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
1505
- path.setAttribute('stroke-width', (off ? 1 : w).toFixed(2));
1506
- path.setAttribute('opacity', off ? 0.12 : (e.cross ? 0.95 : 0.55));
1507
- path.setAttribute('marker-end', 'url(#ah-' + e.kind + (off ? '-d' : '') + ')');
1508
- });
1481
+ // 关系图替换的是侧栏的会话列表 —— 右边照旧是选中的那段对话。
1482
+ // 只看相关时画谁: 视角自己 + 和视角对过话的 ('talk'); 它们通向根的主父亲链上没对过话的祖先
1483
+ // 留作连接 ('via', 画淡) —— 抹掉它, 有对话的后代就从家谱上脱开, 成了一棵来历不明的孤树。
1484
+ var talks = function (F, a, b) {
1485
+ var p = F.pairs[a + '\u0000' + b];
1486
+ return !!p && !!(p.kinds.peer || p.kinds.graph);
1509
1487
  };
1510
-
1511
- var fxNodeHTML = function (n) {
1512
- var run = wRunning(n);
1513
- var tip = [n.name, n.description, n.preview, n.cwd ? '📁 ' + n.cwd : '',
1514
- [n.model && n.model.replace(/^claude-/, ''), n.turns ? n.turns + ' 轮' : ''].filter(Boolean).join(' · ')]
1515
- .filter(Boolean).join('\n');
1516
- return '<div class="fxn' + (n.self ? ' self' : '') + (run ? ' run' : '') + (n.alive ? '' : ' cold') +
1517
- (canOpen(n) ? ' go' : '') + '" data-t="' + esc(n.target) + '" style="--c:' + chatColor(n.base) + '" ' +
1518
- 'title="' + esc(tip) + '">' +
1519
- '<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
1520
- '<span class="fxb">' +
1521
- '<b class="wname">' + esc(shortName(n)) + '</b>' +
1522
- '<span class="fxc">' + esc(n.chat || n.base) + (n.self ? ' · 本会话' : '') + '</span>' +
1523
- '</span>' +
1524
- '</div>';
1488
+ var visibleOf = function (F) {
1489
+ var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || talks(F, ROLE, t) || talks(F, t, ROLE); });
1490
+ var vis = keep.reduce(function (m, t) { m[t] = 'talk'; return m; }, {});
1491
+ if (!vis[ROLE]) vis[ROLE] = 'talk';
1492
+ keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
1493
+ return vis;
1525
1494
  };
1526
1495
 
1527
- // 拖动 = 钉住。点一下 (没挪动) 仍然是聚焦 —— 所以判据是位移而不是事件类型。
1528
- var fxBind = function (el, t) {
1529
- el.onpointerdown = function (ev) {
1530
- if (ev.button) return;
1531
- var p = FX.pos[t];
1532
- if (!p) return;
1533
- FX.drag = { t: t, dx: p.x - ev.clientX, dy: p.y - ev.clientY, moved: 0 };
1534
- el.classList.add('drag');
1535
- if (el.setPointerCapture) el.setPointerCapture(ev.pointerId);
1536
- fxHeat(0.3);
1537
- ev.preventDefault();
1538
- };
1539
- el.onpointermove = function (ev) {
1540
- if (!FX.drag || FX.drag.t !== t) return;
1541
- var p = FX.pos[t], nx = ev.clientX + FX.drag.dx, ny = ev.clientY + FX.drag.dy;
1542
- FX.drag.moved += Math.abs(nx - p.x) + Math.abs(ny - p.y);
1543
- p.x = nx; p.y = ny; p.vx = 0; p.vy = 0; p.pin = 1;
1544
- };
1545
- el.onpointerup = function () {
1546
- if (!FX.drag || FX.drag.t !== t) return;
1547
- var moved = FX.drag.moved;
1548
- FX.drag = null;
1549
- el.classList.remove('drag');
1550
- if (moved < 5) {
1551
- FX.pos[t].pin = 0;
1552
- W.sel = (W.sel === t ? '' : t);
1553
- renderWTools(); fxClasses(); fxHeat(0.12);
1554
- } else { fxClasses(); fxHeat(0.2); }
1496
+ var renderWorld = function () {
1497
+ if (!WORLD) return;
1498
+ if (!W.loaded) { convsEl._tree = ''; convsEl.innerHTML = '<div class="empty">加载中…</div>'; return; }
1499
+ var rg = rangeOf();
1500
+ var F = forestOf(relations(rg));
1501
+ var me = nodeOf(ROLE);
1502
+ var all = W.treeAll || !me;
1503
+ var shown = [];
1504
+ var path = me ? chainUp(F, ROLE) : [];
1505
+ // 和视角之间没有对话的关系只在「看全部」里画。
1506
+ F.vis = all ? null : visibleOf(F);
1507
+ var draw = function (into) {
1508
+ return all
1509
+ ? F.roots.slice().sort(function (a, b) {
1510
+ var mine = path[0];
1511
+ return (b === mine) - (a === mine) || byCard(F)(a, b);
1512
+ }).map(function (r) { return treeHTML(F, r, 0, null, false, into, {}); }).join('')
1513
+ : treeHTML(F, path[0], 0, focusKeep(path), false, into, {});
1555
1514
  };
1556
- el.ondblclick = function () { openNode(t); };
1557
- };
1558
-
1559
- var renderForce = function () {
1560
- var ns = fxNodes();
1561
- wscrollEl.classList.add('fx');
1562
- wmapEl.classList.add('force');
1563
- if (!ns.length) {
1564
- wmapEl.innerHTML = '<div class="empty">' + (W.loaded ? '此刻没有任何 wizard 在图上' : '加载中…') + '</div>';
1565
- return;
1515
+ // 两遍: 先量出画了谁 (卡片的窗口要知道它在图上连着谁), 再带着连线画 —— 选中态也就进了 html。
1516
+ draw(shown);
1517
+ F.links = linksOf(F, shown);
1518
+ var keys = shown.map(function (n) { return talkKey(F.links, n.target); }).join('\n');
1519
+ if (keys !== W.gKeys) { W.gKeys = keys; loadGlance(); }
1520
+ // 换视角后的第一张新树: 世界快照与摘要都已是新视角的, 才知道它自己那张卡片连着谁。
1521
+ if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
1522
+ W.pickSelf = '';
1523
+ var self = talkKey(F.links, ROLE);
1524
+ if (CONV !== self) return selectConv(self, '');
1566
1525
  }
1567
- var box = wscrollEl.getBoundingClientRect();
1568
- FX.w = Math.max(320, box.width); FX.h = Math.max(300, box.height);
1569
- FX.nodes = ns;
1570
- FX.color = fxColors();
1571
- fxSeed(ns);
1572
- // 端点被「只看有关系的」筛掉的边没有落脚处 —— 画一根悬空的线只会让人以为漏了谁。
1573
- var on = {};
1574
- ns.forEach(function (n) { on[n.target] = 1; });
1575
- FX.edges = W.edges.filter(edgeOn).filter(function (e) { return on[e.from] && on[e.to]; });
1576
- // 轮询回来的快照通常一模一样 —— 那就只重画文字, 不重新点火, 免得这张图每 6
1577
- // 秒自己抖一下。
1578
- var sig = ns.map(function (n) { return n.target; }).join(',') + '|' +
1579
- FX.edges.map(function (e) { return e.kind + e.from + '>' + e.to; }).join(',');
1580
- var fresh = sig !== FX.sig;
1581
- FX.sig = sig;
1582
- wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg>' + ns.map(fxNodeHTML).join('');
1583
- FX.els = {};
1584
- wmapEl.querySelectorAll('.fxn').forEach(function (el) {
1585
- var t = el.getAttribute('data-t');
1586
- FX.els[t] = el;
1587
- FX.size[t] = { w: el.offsetWidth, h: el.offsetHeight };
1588
- fxBind(el, t);
1526
+ var trees = draw([]);
1527
+ var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
1528
+ var alone = !all && shown.length < 2;
1529
+ var html = '<div class="tview">' +
1530
+ '<h2>关系图<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
1531
+ (W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
1532
+ (me ? '<button class="tall" title="' + (all ? '只留它的上游链、它自己、它的下游与同源兄弟' : '画出范围内所有有关系的 wizard') + '">' +
1533
+ (all ? '只看相关' : '看全部') + '</button>' : '') + '</h2>' +
1534
+ (alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁对过话</div>' : '') +
1535
+ (shown.length ? '<ul class="tree' + (all ? ' all' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
1536
+ '</div>';
1537
+ // 心跳每 3s 重算一次 (状态灯 / 几分钟前) —— 没变就不碰 DOM, 免得蹭掉悬停与滚动。
1538
+ if (convsEl._tree === html && convsEl.querySelector('.tview')) return;
1539
+ convsEl._tree = html; convsEl._html = '';
1540
+ convsEl.innerHTML = html;
1541
+ convsEl.querySelectorAll('.tci').forEach(function (el) {
1542
+ el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
1589
1543
  });
1590
- var svg = $('#wedges');
1591
- svg.setAttribute('viewBox', '0 0 ' + FX.w + ' ' + FX.h);
1592
- fxEdgesDOM();
1593
- fxClasses();
1594
- fxPaint();
1595
- fxHeat(fresh ? 0.9 : 0.05);
1596
- };
1597
-
1598
- var renderWorld = function () {
1599
- renderWTools();
1600
- if (W.layout === 'force') { renderForce(); return; }
1601
- fxStop();
1602
- renderWMap();
1603
- // 连线要等浏览器把卡片排好 —— 同一帧里量到的是上一次的版面。
1604
- requestAnimationFrame(drawEdges);
1544
+ bindGo(convsEl);
1545
+ var tall = convsEl.querySelector('.tall');
1546
+ if (tall) tall.onclick = function () { W.treeAll = !W.treeAll; W.treeFor = ''; renderWorld(); };
1547
+ var cur = convsEl.querySelector('.tci.me');
1548
+ if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
1605
1549
  };
1606
1550
 
1607
1551
  // ══ 日程视图 ═══════════════════════════════════════════════════════
@@ -1632,6 +1576,7 @@
1632
1576
  // 未来 12 小时的刻度条: 哪几个钟点会有任务醒来, 一眼看完。超出 12h 的排进下面
1633
1577
  // 的列表但不占刻度 —— 把 24h 压进这么窄的一条, 刻度会密到读不出。
1634
1578
  var HORIZON_H = 12;
1579
+ var broken = function (x) { return x.lastGate === 'error' || !!x.loadError; };
1635
1580
  var renderStrip = function (list) {
1636
1581
  var now = srvNow(), span = HORIZON_H * 3600000;
1637
1582
  var soon = list.filter(function (x) { return x.nextAt - now < span; });
@@ -1639,16 +1584,66 @@
1639
1584
  for (var i = 0; i <= HORIZON_H; i += 3) {
1640
1585
  var t = new Date(now + i * 3600000);
1641
1586
  ticks.push('<span class="tk" style="left:' + (i / HORIZON_H * 100) + '%">' +
1642
- (i ? (t.getHours() < 10 ? '0' : '') + t.getHours() + ':00' : '现在') + '</span>');
1587
+ (i ? pad(t.getHours()) + ':00' : '现在') + '</span>');
1643
1588
  }
1644
1589
  var pins = soon.map(function (x) {
1645
1590
  var pct = Math.max(0, Math.min(100, (x.nextAt - now) / span * 100));
1646
- return '<span class="pin" style="left:' + pct.toFixed(2) + '%" data-t="' + esc(x.target) + '" ' +
1647
- 'title="' + esc(fmtClock(x.nextAt) + ' · ' + nameOf(x.target) + ' · ' + x.when) + '"></span>';
1591
+ return '<button class="pin' + (broken(x) ? ' bad' : '') + '" style="left:' + pct.toFixed(2) + '%" data-id="' + esc(x.id) + '" ' +
1592
+ 'title="' + esc(fmtClock(x.nextAt) + ' · ' + (x.note || x.id) + ' · ' + x.when) + '"><span>' + esc(fmtHM(x.nextAt)) + '</span></button>';
1648
1593
  }).join('');
1649
1594
  return '<div class="strip"><div class="axis">' + ticks.join('') + '</div>' +
1650
1595
  '<div class="rail">' + pins + '</div>' +
1651
- '<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
1596
+ '<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发 · 点刻度跳到那条' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
1597
+ };
1598
+
1599
+ // 触发规则 = describeTrigger 的人话, 各段以 ` · ` 相接: 第一段是时钟 (什么时候响), 其余是闸 (什么时候不响)。
1600
+ var ruleHTML = function (when) {
1601
+ return String(when || '').split(' · ').filter(Boolean).map(function (part, i) {
1602
+ return '<span class="rl' + (i ? ' guard' : ' clock') + '">' + (i ? '' : '⏱ ') + esc(part) + '</span>';
1603
+ }).join('');
1604
+ };
1605
+ var gateHTML = function (x) {
1606
+ if (!x.hasGate) return '<span class="gt none" title="没有 gate: 到点就放">无 gate</span>';
1607
+ var at = x.lastGateAt ? ' · ' + fmtAgo(x.lastGateAt) : '';
1608
+ return x.lastGate === 'go' ? '<span class="gt go" title="上一轮 gate 放行">gate 放行' + esc(at) + '</span>'
1609
+ : x.lastGate === 'skip' ? '<span class="gt skip" title="上一轮 gate 判定没活, 没放枪 (不出声)">gate 挡下' + esc(at) + '</span>'
1610
+ : x.lastGate === 'error' ? '<span class="gt err">gate 出错' + esc(at) + '</span>'
1611
+ : '<span class="gt none">gate 未跑过</span>';
1612
+ };
1613
+ var flowHTML = function (x) {
1614
+ var own = x.owner || x.createdBy;
1615
+ return '<div class="pflow">' +
1616
+ (own && own !== x.target ? '<span class="fl">排班</span>' + wizChip(own) + '<span class="ar">→</span>' : '') +
1617
+ (x.fresh
1618
+ ? '<span class="fl">执行</span><span class="fresh" title="每次到点新起一个白板 wizard, 跑完自动回收">✦ 新起白板</span><span class="fl">在</span>' + wizChip(x.target) + '<span class="fl">名下</span>'
1619
+ : '<span class="fl">执行</span>' + wizChip(x.target) + '<span class="fl">注入已有会话</span>') +
1620
+ '</div>';
1621
+ };
1622
+ var PLAN_OPEN = {};
1623
+ var taskHTML = function (x) {
1624
+ var lines = String(x.prompt || '').split('\n');
1625
+ var head = x.note || lines[0];
1626
+ var errs = [
1627
+ x.loadError ? '<div class="perr"><b>任务文件加载失败</b> · 仍在跑上一版<code>' + esc(x.loadError.split('\n')[0].slice(0, 240)) + '</code></div>' : '',
1628
+ x.lastGate === 'error' ? '<div class="perr"><b>gate 出错</b>' + (x.lastGateAt ? ' · ' + esc(fmtAgo(x.lastGateAt)) : '') +
1629
+ ' · 本轮未放枪, 下次照常重试' + (x.lastError ? '<code>' + esc(x.lastError.split('\n')[0].slice(0, 240)) + '</code>' : '') + '</div>' : '',
1630
+ ].join('');
1631
+ return '<article class="tcard' + (broken(x) ? ' bad' : '') + '" data-id="' + esc(x.id) + '">' +
1632
+ '<div class="tc-time"><b>' + esc(fmtHM(x.nextAt)) + '</b><span>' + esc(fmtClock(x.nextAt).replace(/ \d\d:\d\d$/, '')) + '</span>' +
1633
+ '<em>' + esc(fmtIn(x.nextAt)) + '</em></div>' +
1634
+ '<div class="tc-main">' +
1635
+ errs +
1636
+ '<div class="prule">' + ruleHTML(x.when) + '</div>' +
1637
+ '<div class="ptitle">' + esc(head.slice(0, 120)) + '</div>' +
1638
+ flowHTML(x) +
1639
+ '<details class="pprompt" data-id="' + esc(x.id) + '"' + (PLAN_OPEN[x.id] ? ' open' : '') + '>' +
1640
+ '<summary>prompt<span>' + esc((x.note ? lines[0] : lines.slice(1).join(' ')).slice(0, 90)) + '</span></summary>' +
1641
+ '<pre>' + esc(x.prompt) + '</pre></details>' +
1642
+ '<div class="pfoot">' + gateHTML(x) +
1643
+ '<span>' + (x.lastFired ? '上次触发 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</span>' +
1644
+ '<span class="pid" title="' + esc(x.file || '') + '">' + esc(x.id) + '</span></div>' +
1645
+ '</div>' +
1646
+ '</article>';
1652
1647
  };
1653
1648
 
1654
1649
  // 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
@@ -1659,67 +1654,63 @@
1659
1654
  });
1660
1655
  var open = js.filter(function (j) { return j.status === 'open'; });
1661
1656
  var closed = js.filter(function (j) { return j.status !== 'open'; });
1662
- var schedRows = ss.length
1663
- ? ss.map(function (x) {
1664
- return '<div class="prow">' +
1665
- '<div class="pl">' +
1666
- '<div class="pwhen">⏰ ' + esc(x.when) + '</div>' +
1667
- '<div class="pnext"><b>' + esc(fmtClock(x.nextAt)) + '</b><span>' + esc(fmtIn(x.nextAt)) + '</span></div>' +
1668
- '</div>' +
1669
- '<div class="pr">' +
1670
- '<div class="ph">' + wizChip(x.target) + (x.note ? '<span class="note">' + esc(x.note) + '</span>' : '') +
1671
- '<span class="pid">' + esc(x.id) + '</span></div>' +
1672
- '<div class="ptext">' + esc(x.prompt.split('\n')[0].slice(0, 160)) + '</div>' +
1673
- '<div class="pfoot">' + (x.lastFired ? '上次 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</div>' +
1674
- '</div>' +
1675
- '</div>';
1676
- }).join('')
1677
- : '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>';
1657
+ var bad = ss.filter(broken);
1658
+ var next = ss[0];
1659
+ var stat = function (k, v, sub, tone) {
1660
+ return '<div class="pst' + (tone ? ' ' + tone : '') + '"><span class="k">' + k + '</span><b>' + v + '</b><span class="s">' + sub + '</span></div>';
1661
+ };
1662
+ var stats = '<div class="pstats">' +
1663
+ stat('下一次', next ? esc(fmtHM(next.nextAt)) : '—', next ? esc(fmtIn(next.nextAt) + ' · ' + (next.note || next.id)) : '没有排期') +
1664
+ stat('定时任务', ss.length, ss.length ? esc(ss.filter(function (x) { return x.hasGate; }).length + ' 条带 gate') : '—') +
1665
+ stat('工单', open.length + '<small> / ' + js.length + '</small>', '进行中 / 全部') +
1666
+ (bad.length ? stat('出错', bad.length, '见下方红色卡片', 'bad') : '') +
1667
+ '</div>';
1678
1668
 
1679
1669
  var jobRow = function (j) {
1680
1670
  return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
1681
- '<div class="jh"><span class="jid">' + esc(j.id) + '</span>' +
1671
+ '<div class="jh"><span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
1682
1672
  '<span class="jt">' + esc(j.title) + '</span>' +
1683
- '<span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
1673
+ '<span class="jid">' + esc(j.id) + '</span>' +
1684
1674
  '<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
1685
1675
  '<div class="jm">' + (j.members.length
1686
1676
  ? j.members.map(function (mm) {
1687
- return '<span class="jmm">' + wizChip(mm.target) +
1677
+ var n = nodeOf(mm.target);
1678
+ return '<span class="jmm">' + wizChip(mm.target) + stTag(n) +
1688
1679
  (mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
1689
- '<em>' + esc((mm.task || '').split('\n')[0].slice(0, 70)) + '</em></span>';
1680
+ '<em>' + esc((mm.task || '').split('\n')[0].slice(0, 90)) + '</em></span>';
1690
1681
  }).join('')
1691
1682
  : '<span class="jmm none">还没有成员</span>') + '</div>' +
1692
1683
  (j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
1693
1684
  '</div>';
1694
1685
  };
1695
1686
 
1696
- planEl.innerHTML =
1687
+ var html = stats +
1697
1688
  '<section class="psec">' +
1698
- '<h3>⏰ 定时任务<span>' + ss.length + '</span></h3>' +
1699
- renderStrip(ss) + schedRows +
1689
+ '<h3>定时任务<span>' + ss.length + '</span></h3>' +
1690
+ (ss.length ? renderStrip(ss) + bad.concat(ss.filter(function (x) { return !broken(x); })).map(taskHTML).join('')
1691
+ : '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>') +
1700
1692
  '</section>' +
1701
1693
  '<section class="psec">' +
1702
- '<h3>📋 工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
1694
+ '<h3>工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
1703
1695
  (js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
1704
1696
  '</section>';
1697
+ // 心跳每 3s 重画一次 —— 没变就不碰 DOM (展开着的 prompt、悬停的提示都留着)。
1698
+ if (planEl._html === html) return;
1699
+ planEl._html = html;
1700
+ planEl.innerHTML = html;
1705
1701
  planEl.querySelectorAll('.wchip.go').forEach(function (c) {
1706
1702
  c.onclick = function () { openNode(c.getAttribute('data-t')); };
1707
1703
  });
1708
- };
1709
-
1710
- // 关系图上随时间变化的只有两样: 呼吸灯该不该亮、"几分钟前"该写几。重建整张图
1711
- // 会把滚动位置、聚焦态和连线一起抖掉, 所以这里只原地改这两处。
1712
- var tickWorld = function () {
1713
- wmapEl.querySelectorAll('.wnode').forEach(function (el) {
1714
- var n = nodeOf(el.getAttribute('data-t'));
1715
- if (!n) return;
1716
- var run = wRunning(n);
1717
- el.classList.toggle('run', run);
1718
- var av = el.querySelector('.wav'), dot = el.querySelector('.wav .live');
1719
- if (run && !dot && av) av.insertAdjacentHTML('beforeend', '<i class="live"></i>');
1720
- if (!run && dot) dot.remove();
1721
- var ts = el.querySelector('.wts');
1722
- if (ts) ts.textContent = fmtAgo(n.lastTs);
1704
+ planEl.querySelectorAll('.pprompt').forEach(function (d) {
1705
+ d.ontoggle = function () { PLAN_OPEN[d.getAttribute('data-id')] = d.open; planEl._html = ''; };
1706
+ });
1707
+ planEl.querySelectorAll('.strip .pin').forEach(function (p) {
1708
+ p.onclick = function () {
1709
+ var card = planEl.querySelector('.tcard[data-id="' + cssEsc(p.getAttribute('data-id')) + '"]');
1710
+ if (!card) return;
1711
+ card.scrollIntoView({ block: 'center', behavior: 'smooth' });
1712
+ card.classList.remove('flash'); void card.offsetWidth; card.classList.add('flash');
1713
+ };
1723
1714
  });
1724
1715
  };
1725
1716
 
@@ -1729,18 +1720,20 @@
1729
1720
  var WORLD_MS = 6000;
1730
1721
  var worldTimer = null;
1731
1722
  var loadWorld = function () {
1732
- return api('api/world', { role: ROLE }).then(function (d) {
1723
+ var asked = ROLE;
1724
+ return api('api/world', { role: asked }).then(function (d) {
1733
1725
  if (!d.ok) return;
1734
- W.at = d.at; W.loaded = true;
1726
+ W.at = d.at; W.loaded = true; W.role = asked;
1735
1727
  W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
1736
1728
  W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
1737
- if (VIEW === 'world') renderWorld();
1729
+ renderWorld();
1730
+ loadGlance();
1738
1731
  if (VIEW === 'plan') renderPlan();
1739
1732
  }).catch(function () { });
1740
1733
  };
1741
1734
  var pollWorld = function () {
1742
1735
  if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
1743
- if (VIEW === 'msgs') return;
1736
+ if (VIEW === 'msgs' && !WORLD) return;
1744
1737
  worldTimer = setInterval(function () {
1745
1738
  if (!document.hidden) loadWorld();
1746
1739
  }, WORLD_MS);
@@ -1749,29 +1742,42 @@
1749
1742
  var setView = function (v) {
1750
1743
  VIEW = v;
1751
1744
  thread.hidden = v !== 'msgs';
1752
- $('#pane-world').hidden = v !== 'world';
1753
1745
  $('#pane-plan').hidden = v !== 'plan';
1754
1746
  app.classList.toggle('outer', v !== 'msgs');
1755
1747
  // 手机上关系/日程占满主区 —— 进入即阅读态。
1756
1748
  if (v !== 'msgs') app.classList.add('reading');
1757
1749
  renderRole(); renderHead(); renderUsage();
1758
- if (v === 'msgs') { fxStop(); toBottom(true); renderConvs(); }
1750
+ if (v === 'msgs') { toBottom(true); renderConvs(); }
1759
1751
  else {
1760
- // 关系图一打开就聚焦在当前 role 身上: 它的邻居亮着, 其余压暗。
1761
- if (v === 'world') W.sel = ROLE;
1762
- if (!W.loaded) { (v === 'world' ? wmapEl : planEl).innerHTML = '<div class="empty">加载中…</div>'; }
1763
- else if (v === 'world') renderWorld();
1752
+ if (!W.loaded) { planEl._html = ''; planEl.innerHTML = '<div class="empty">加载中…</div>'; }
1764
1753
  else renderPlan();
1765
1754
  loadWorld();
1766
1755
  }
1767
1756
  pollWorld();
1768
1757
  };
1769
- // 卡片换行会改变每个节点的坐标 —— 连线必须跟着重画。
1770
- window.addEventListener('resize', function () {
1771
- if (VIEW !== 'world') return;
1772
- if (W.layout === 'force') renderForce(); else requestAnimationFrame(drawEdges);
1773
- });
1774
-
1758
+ // 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
1759
+ var setWorld = function (on) {
1760
+ WORLD = on; W.treeFor = ''; W.pickSelf = '';
1761
+ convsEl._html = convsEl._tree = '';
1762
+ syncUrl();
1763
+ // 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
1764
+ if (on) app.classList.remove('reading');
1765
+ renderRole();
1766
+ if (on) { renderWorld(); loadWorld(); }
1767
+ else {
1768
+ // 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
1769
+ var c = convOf(CONV);
1770
+ if (c) {
1771
+ OPEN[CONV] = true; OPEN_AT = CONV;
1772
+ var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
1773
+ if (rank >= SUB_FOLD) MORE[CONV] = true;
1774
+ }
1775
+ renderConvs();
1776
+ var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
1777
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
1778
+ }
1779
+ pollWorld();
1780
+ };
1775
1781
  // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
1776
1782
  setInterval(function () {
1777
1783
  if (!R.role) return;
@@ -1779,12 +1785,202 @@
1779
1785
  expireRows();
1780
1786
  // 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
1781
1787
  paintStatus(); renderConvs();
1782
- if (VIEW === 'world') tickWorld();
1783
- else if (VIEW === 'plan') renderPlan();
1788
+ if (VIEW === 'plan') renderPlan();
1784
1789
  }, TICK_MS);
1785
1790
 
1791
+ // ══ 搜索 (⌘K / Ctrl+K) ═══════════════════════════════════════════════
1792
+ // 从当前视角搜三样: role 名字 (→ 切视角)、会话名 (→ 打开)、消息正文 (→ 打开会话并定位到那一句)。
1793
+ // 服务端搜全部时间; 那一句不在当前 session 段里就放宽成「全部」再跳。空查询时列出最近的会话,
1794
+ // 面板本身就是一个键盘快速切换器。面板由脚本挂载 —— 外壳 HTML 要等 daemon 重启才换。
1795
+ var Q = { open: false, q: '', done: null, enter: false, items: [], sel: 0, gen: 0, timer: 0 };
1796
+ var sk = document.body.appendChild(document.createElement('div'));
1797
+ sk.className = 'sk'; sk.hidden = true;
1798
+ sk.innerHTML = '<div class="sk-panel" role="dialog" aria-label="搜索">' +
1799
+ '<div class="sk-in"><span class="ic">' + SEARCH_SVG + '</span>' +
1800
+ '<input id="sk-q" type="search" autocomplete="off" spellcheck="false" placeholder="搜索 role、会话、消息…" aria-controls="sk-list">' +
1801
+ '<kbd>esc</kbd></div>' +
1802
+ '<div class="sk-list" id="sk-list" role="listbox"></div>' +
1803
+ '<div class="sk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> 选择</span><span><kbd>↵</kbd> 打开</span><span><kbd>esc</kbd> 关闭</span></div>' +
1804
+ '</div>';
1805
+ var skQ = sk.querySelector('#sk-q'), skList = sk.querySelector('#sk-list');
1806
+
1807
+ var reEsc = function (s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); };
1808
+ // 先切再转义: 命中段包 <mark>, 其余照常 esc。
1809
+ var hl = function (text, q) {
1810
+ var ts = q.toLowerCase().split(/\s+/).filter(Boolean);
1811
+ if (!ts.length) return esc(text);
1812
+ var re = new RegExp('(' + ts.map(reEsc).join('|') + ')', 'ig');
1813
+ return String(text).split(re).map(function (p, i) { return i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p); }).join('');
1814
+ };
1815
+
1816
+ // 一项 = { kind, html, go }: 画什么、回车做什么。
1817
+ var roleItem = function (r, q) {
1818
+ return {
1819
+ kind: 'role',
1820
+ html: '<span class="av">' + esc(r.label) + '</span><span class="b"><span class="l1">' +
1821
+ '<span class="nm ' + (r.wizard ? 'wizard' : 'human') + '">' + hl(r.name, q) + '</span><span class="k">切到视角</span></span>' +
1822
+ (r.description ? '<span class="pv">' + hl(r.description, q) + '</span>' : '') + '</span>',
1823
+ go: function () { switchRole(r.id); },
1824
+ };
1825
+ };
1826
+ // 时刻: 列表里写相对的, 悬停给完整的。
1827
+ var skTs = function (ts) { return '<time class="ts" title="' + esc(fmtFull(ts)) + '">' + esc(fmtAgo(ts)) + '</time>'; };
1828
+ // 会话归属: 群聊带群名, 私聊写明是私聊 —— 结果项第三层, 比发话人与正文都淡。
1829
+ var whereOf = function (kind, label, name, q) {
1830
+ return '<span class="where' + (kind === 'wizard' ? ' priv' : '') + '"><i>' + esc(label || '💬') + '</i>' +
1831
+ (kind === 'wizard' ? '私聊 · ' : '') + hl(name, q || '') + '</span>';
1832
+ };
1833
+ var convItem_ = function (c, q) {
1834
+ var title = c.kind === 'wizard' ? '<span class="nm wizard">' + hl(c.name, q) + '</span>' : '<span class="nm chat">' + hl(c.name, q) + '</span>';
1835
+ return {
1836
+ kind: 'conv',
1837
+ html: '<span class="av">' + esc(c.label) + '</span><span class="b"><span class="l1">' + title +
1838
+ '<span class="tag">' + (c.kind === 'wizard' ? '私聊' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
1839
+ (c.preview ? '<span class="pv">' + esc(c.preview) + '</span>' : '') + '</span>',
1840
+ go: function () { selectConv(c.key, ''); },
1841
+ };
1842
+ };
1843
+ // 三层: 谁 → 对谁 · 何时 / 命中的那一截 / 在哪个会话。
1844
+ var msgItem = function (h, q) {
1845
+ var to = h.to && h.to !== h.from && h.to !== 'human:'
1846
+ ? '<span class="arr">→</span>' + nm(h.to, h.toName) : '';
1847
+ return {
1848
+ kind: 'msg',
1849
+ html: '<span class="av">' + esc(h.fromLabel) + '</span><span class="b"><span class="l1">' +
1850
+ nm(h.from, h.fromName) + to + skTs(h.ts) + '</span>' +
1851
+ '<span class="sn">' + hl(h.snippet, q) + '</span>' +
1852
+ '<span class="l3">' + whereOf(h.convKind, h.convLabel, h.convName) +
1853
+ (h.hits > 1 ? '<span class="cnt">' + h.hits + ' 处命中</span>' : '') + '</span></span>',
1854
+ go: function () { jumpMsg(h); },
1855
+ };
1856
+ };
1857
+
1858
+ // 一句消息落在当前 session 段里没有 —— 不在就得放宽成全部才看得见。
1859
+ var inSession = function (ts) {
1860
+ var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
1861
+ return !s || (ts >= s.start && (!s.end || ts < s.end));
1862
+ };
1863
+ // 定位一行: 滚到视口中间, 闪一下。窗口只上屏最近一截, 不在就整窗重取一次再找。
1864
+ var focusRow = function (id) {
1865
+ var row = rowNode(id);
1866
+ if (!row) return false;
1867
+ row.scrollIntoView({ block: 'center' });
1868
+ S.pinned = atBottom();
1869
+ row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
1870
+ return true;
1871
+ };
1872
+ var landOn = function (id) {
1873
+ return function () {
1874
+ if (focusRow(id)) return true;
1875
+ loadMsgs('0', function () { return focusRow(id); });
1876
+ return true;
1877
+ };
1878
+ };
1879
+ var jumpMsg = function (h) {
1880
+ if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, '', landOn(h.id));
1881
+ SESSION = 'all'; CONV = h.conv; WITH = '';
1882
+ if (VIEW !== 'msgs') setView('msgs');
1883
+ app.classList.add('reading');
1884
+ reveal();
1885
+ refresh(landOn(h.id));
1886
+ };
1887
+
1888
+ var paintSearch = function (sections) {
1889
+ Q.items = sections.reduce(function (a, s) { return a.concat(s.items); }, []);
1890
+ Q.sel = Math.min(Q.sel, Math.max(0, Q.items.length - 1));
1891
+ var n = 0;
1892
+ skList.innerHTML = sections.filter(function (s) { return s.items.length || s.empty; }).map(function (s) {
1893
+ return '<h3>' + esc(s.title) + (s.count ? '<span>' + s.count + '</span>' : '') + '</h3>' +
1894
+ (s.items.length
1895
+ ? s.items.map(function (it) {
1896
+ var i = n++;
1897
+ return '<button class="sk-it ' + it.kind + (i === Q.sel ? ' on' : '') + '" role="option" data-i="' + i + '" aria-selected="' + (i === Q.sel) + '">' + it.html + '</button>';
1898
+ }).join('')
1899
+ : '<div class="sk-empty">' + esc(s.empty) + '</div>');
1900
+ }).join('');
1901
+ };
1902
+ var moveSel = function (d) {
1903
+ if (!Q.items.length) return;
1904
+ Q.sel = (Q.sel + d + Q.items.length) % Q.items.length;
1905
+ skList.querySelectorAll('.sk-it').forEach(function (b) {
1906
+ var on = Number(b.getAttribute('data-i')) === Q.sel;
1907
+ b.classList.toggle('on', on); b.setAttribute('aria-selected', on);
1908
+ if (on) b.scrollIntoView({ block: 'nearest' });
1909
+ });
1910
+ };
1911
+ var pick = function (i) {
1912
+ var it = Q.items[i];
1913
+ if (!it) return;
1914
+ closeSearch();
1915
+ it.go();
1916
+ };
1917
+
1918
+ var recent = function () {
1919
+ Q.done = '';
1920
+ paintSearch([{ title: '最近的会话', items: R.convs.slice(0, 8).map(function (c) { return convItem_(c, ''); }), empty: '还没有会话' }]);
1921
+ };
1922
+ var runSearch = function () {
1923
+ var q = skQ.value.trim(), gen = ++Q.gen;
1924
+ Q.q = q; Q.sel = 0; Q.done = null;
1925
+ if (!q) return recent();
1926
+ api('api/search', { role: ROLE, q: q }).then(function (d) {
1927
+ if (!d.ok || gen !== Q.gen) return;
1928
+ var sections = [
1929
+ { title: 'Role', items: d.roles.map(function (r) { return roleItem(r, q); }) },
1930
+ { title: '会话', items: d.convs.map(function (c) { return convItem_(c, q); }) },
1931
+ { title: '消息', count: d.msgTotal > d.msgs.length ? d.msgs.length + ' / ' + d.msgTotal : d.msgs.length,
1932
+ items: d.msgs.map(function (h) { return msgItem(h, q); }) },
1933
+ ];
1934
+ if (!d.roles.length && !d.convs.length && !d.msgs.length) sections = [{ title: '结果', items: [], empty: '没有找到「' + q + '」' }];
1935
+ paintSearch(sections);
1936
+ Q.done = q;
1937
+ // 结果还没回来时按下的回车, 落在回来的第一项上。
1938
+ if (Q.enter) { Q.enter = false; pick(Q.sel); }
1939
+ }).catch(function () { });
1940
+ };
1941
+
1942
+ var openSearch = function () {
1943
+ if (Q.open) { skQ.select(); return; }
1944
+ Q.open = true; sk.hidden = false;
1945
+ skQ.value = Q.q; skQ.focus(); skQ.select();
1946
+ runSearch();
1947
+ };
1948
+ var closeSearch = function () {
1949
+ if (!Q.open) return;
1950
+ Q.open = false; sk.hidden = true; Q.enter = false;
1951
+ clearTimeout(Q.timer);
1952
+ };
1953
+ skQ.addEventListener('input', function () { clearTimeout(Q.timer); Q.timer = setTimeout(runSearch, 140); });
1954
+ skQ.addEventListener('keydown', function (e) {
1955
+ if (e.isComposing) return; // 输入法选词时的回车 / 方向键归输入法
1956
+ if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { e.preventDefault(); moveSel(1); }
1957
+ else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) { e.preventDefault(); moveSel(-1); }
1958
+ else if (e.key === 'Enter') {
1959
+ e.preventDefault();
1960
+ var v = skQ.value.trim();
1961
+ if (Q.done === v) return pick(Q.sel);
1962
+ Q.enter = true;
1963
+ if (Q.q !== v) { clearTimeout(Q.timer); runSearch(); }
1964
+ }
1965
+ });
1966
+ skList.addEventListener('click', function (e) {
1967
+ var b = e.target.closest('.sk-it');
1968
+ if (b) pick(Number(b.getAttribute('data-i')));
1969
+ });
1970
+ skList.addEventListener('mousemove', function (e) {
1971
+ var b = e.target.closest('.sk-it');
1972
+ if (b && Number(b.getAttribute('data-i')) !== Q.sel) moveSel(Number(b.getAttribute('data-i')) - Q.sel);
1973
+ });
1974
+ sk.addEventListener('mousedown', function (e) { if (e.target === sk) closeSearch(); });
1975
+ document.addEventListener('keydown', function (e) {
1976
+ if ((MAC ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
1977
+ e.preventDefault();
1978
+ Q.open ? closeSearch() : openSearch();
1979
+ } else if (Q.open && e.key === 'Escape') { e.preventDefault(); closeSearch(); }
1980
+ });
1981
+
1786
1982
  // ── boot ──
1787
1983
  // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
1788
- if (ROLE || CONV) app.classList.add('reading');
1984
+ if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
1789
1985
  refresh();
1790
1986
  })();