wezard 2.1.4 → 2.2.1

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