wezard 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +113 -57
  3. package/cli/wezard.sh +2 -0
  4. package/config.example.jsonc +7 -4
  5. package/dist/cli/wx.js +78 -0
  6. package/dist/cli/wx.js.map +1 -0
  7. package/dist/daemon/aibot-media.js +62 -0
  8. package/dist/daemon/aibot-media.js.map +1 -0
  9. package/dist/daemon/approval.js +30 -20
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/ask.js +3 -2
  12. package/dist/daemon/ask.js.map +1 -1
  13. package/dist/daemon/cc-session.js +27 -4
  14. package/dist/daemon/cc-session.js.map +1 -1
  15. package/dist/daemon/chat-log.js +2 -2
  16. package/dist/daemon/chat-log.js.map +1 -1
  17. package/dist/daemon/chat-name.js +6 -2
  18. package/dist/daemon/chat-name.js.map +1 -1
  19. package/dist/daemon/config-api.js +25 -10
  20. package/dist/daemon/config-api.js.map +1 -1
  21. package/dist/daemon/direction.js +53 -0
  22. package/dist/daemon/direction.js.map +1 -0
  23. package/dist/daemon/ilink.js +162 -0
  24. package/dist/daemon/ilink.js.map +1 -0
  25. package/dist/daemon/inbound.js +64 -18
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +415 -109
  28. package/dist/daemon/index.js.map +1 -1
  29. package/dist/daemon/jobs.js +4 -3
  30. package/dist/daemon/jobs.js.map +1 -1
  31. package/dist/daemon/last-response.js +6 -3
  32. package/dist/daemon/last-response.js.map +1 -1
  33. package/dist/daemon/media.js +108 -0
  34. package/dist/daemon/media.js.map +1 -0
  35. package/dist/daemon/mirror-bridge.js +111 -93
  36. package/dist/daemon/mirror-bridge.js.map +1 -1
  37. package/dist/daemon/model-select.js +159 -54
  38. package/dist/daemon/model-select.js.map +1 -1
  39. package/dist/daemon/notices.js +59 -12
  40. package/dist/daemon/notices.js.map +1 -1
  41. package/dist/daemon/outbound.js +2 -1
  42. package/dist/daemon/outbound.js.map +1 -1
  43. package/dist/daemon/peers.js +46 -27
  44. package/dist/daemon/peers.js.map +1 -1
  45. package/dist/daemon/pending-items.js +1 -1
  46. package/dist/daemon/pending-items.js.map +1 -1
  47. package/dist/daemon/reach.js +6 -0
  48. package/dist/daemon/reach.js.map +1 -0
  49. package/dist/daemon/receipts.js +149 -7
  50. package/dist/daemon/receipts.js.map +1 -1
  51. package/dist/daemon/route.js +11 -4
  52. package/dist/daemon/route.js.map +1 -1
  53. package/dist/daemon/spawn-tmux.js +157 -60
  54. package/dist/daemon/spawn-tmux.js.map +1 -1
  55. package/dist/daemon/steward-guard.js +48 -7
  56. package/dist/daemon/steward-guard.js.map +1 -1
  57. package/dist/daemon/top-only-nudge.js +29 -0
  58. package/dist/daemon/top-only-nudge.js.map +1 -0
  59. package/dist/daemon/weixin-cmd.js +268 -0
  60. package/dist/daemon/weixin-cmd.js.map +1 -0
  61. package/dist/daemon/weixin-port.js +226 -0
  62. package/dist/daemon/weixin-port.js.map +1 -0
  63. package/dist/daemon/weixin.js +772 -0
  64. package/dist/daemon/weixin.js.map +1 -0
  65. package/dist/daemon/wizard.js +56 -18
  66. package/dist/daemon/wizard.js.map +1 -1
  67. package/dist/mcp/server.js +43 -23
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/shared/chat-http.js +61 -20
  70. package/dist/shared/chat-http.js.map +1 -1
  71. package/dist/shared/chat-render.js +9 -1
  72. package/dist/shared/chat-render.js.map +1 -1
  73. package/dist/shared/chat-view.js +16 -2
  74. package/dist/shared/chat-view.js.map +1 -1
  75. package/dist/shared/cli-backends.js +3 -0
  76. package/dist/shared/cli-backends.js.map +1 -1
  77. package/dist/shared/config.js +45 -10
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/json-map-store.js +1 -0
  80. package/dist/shared/json-map-store.js.map +1 -1
  81. package/dist/shared/reminder.js +1 -0
  82. package/dist/shared/reminder.js.map +1 -1
  83. package/dist/shared/role-render.js +7 -4
  84. package/dist/shared/role-render.js.map +1 -1
  85. package/dist/shared/role-view.js +116 -37
  86. package/dist/shared/role-view.js.map +1 -1
  87. package/dist/shared/seen-log.js +26 -0
  88. package/dist/shared/seen-log.js.map +1 -0
  89. package/dist/shared/session-label.js +84 -31
  90. package/dist/shared/session-label.js.map +1 -1
  91. package/dist/shared/turn-state.js +1 -1
  92. package/dist/shared/turn-state.js.map +1 -1
  93. package/dist/shared/world.js.map +1 -1
  94. package/dist/shared/wx-text.js +101 -0
  95. package/dist/shared/wx-text.js.map +1 -0
  96. package/dist/svr/index.js +5 -2
  97. package/dist/svr/index.js.map +1 -1
  98. package/hooks/pre-tool-use.sh +9 -6
  99. package/package.json +6 -2
  100. package/web/chat.css +32 -5
  101. package/web/chat.js +160 -92
package/web/chat.js CHANGED
@@ -35,14 +35,14 @@
35
35
  // 关系/日程两栏共用的世界快照。treeFor = 已把谁滚进过视野。
36
36
  var W = {
37
37
  at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
38
- degraded: false, loaded: false, treeFor: '', glance: {}, gKeys: '',
38
+ degraded: false, loaded: false, treeFor: '', glance: {}, gKeys: '', gRole: '',
39
39
  };
40
40
  var VIEW = 'msgs';
41
41
  // 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
42
42
  // URL 带了 side 以它为准; 没带 (新链接) 用本地记下的, 见 WORLD_KEY。
43
43
  var WORLD = qs.get('side') === 'world';
44
44
  var $ = function (s) { return document.querySelector(s); };
45
- var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
45
+ var app = $('#app'), main = $('.main'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
46
46
  var planEl = $('#plan-in');
47
47
 
48
48
  var srvNow = function () { return R.at ? R.at + (Date.now() - R.recvAt) : Date.now(); };
@@ -105,6 +105,12 @@
105
105
  Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
106
106
  return p;
107
107
  };
108
+ // 摘要另带未读基线 (定了才带): 服务端据此只给基线之后的他人未读账。不进 URL。
109
+ var roleParams = function (extra) {
110
+ var p = viewParams(extra);
111
+ if (BASE) p.since = BASE;
112
+ return p;
113
+ };
108
114
  // 视角状态回写 URL (replaceState: 不在历史里留一串中间态)。
109
115
  var syncUrl = function () {
110
116
  var p = new URLSearchParams({ id: TOKEN });
@@ -138,11 +144,11 @@
138
144
  };
139
145
  var roleLabel = function (id) {
140
146
  var k = names[id] || {}, n = nodeOf(id);
141
- return k.label || (n && n.label) || ({ human: '👤', task: '⏰', system: '🧙' })[kindOf(id)] || '🧙';
147
+ return k.label || (n && n.label) || ({ human: '👤', task: '⏰', system: '🧙‍♂️' })[kindOf(id)] || '🧙';
142
148
  };
143
149
  // `.name` 的点由 CSS 画 —— 人与定时任务没有地址, 不画点。
144
150
  var nameOf = function (id) { return (kindOf(id) === 'wizard' ? '.' : '') + roleName(id); };
145
- var canSwitch = function (id) { return !!id && kindOf(id) !== 'task' && kindOf(id) !== 'system' && id !== 'human:'; };
151
+ var canSwitch = function (id) { return !!id && kindOf(id) !== 'task' && kindOf(id) !== 'system'; };
146
152
  // 头像与名字就是进入那个 role 视角的入口 —— 侧栏项、会话头、消息行同一种读法。
147
153
  // 是 <span> 不是 <button>: 侧栏里它嵌在整行那颗按钮里。
148
154
  var goSpan = function (cls, id, inner) {
@@ -420,6 +426,10 @@
420
426
  var jobMark = function (ids) {
421
427
  return ids && ids.length ? '<span class="jmk" data-jobs="' + esc(ids.join(' ')) + '" title="' + esc((ids.length > 1 ? ids.length + ' 张工单: ' : '工单 ') + ids.join(' ') + ' —— 点开') + '">📋' + (ids.length > 1 ? '<i>' + ids.length + '</i>' : '') + '</span>' : '';
422
428
  };
429
+ // 微信通道群名字旁的状态: 判定与文字都是服务端 wxMark 给的, 这里只画。
430
+ var wxTag = function (w) {
431
+ return w ? '<span class="wxm ' + esc(w.state) + '" title="' + esc(w.tip) + '">' + esc(w.text) + '</span>' : '';
432
+ };
423
433
  // 视角不在里面的工单不在 R.convs 里: 按服务端的账 (R.jobIndex, 全部工单) 现造一项; 账里没有 = 已清掉 (job 为空)。
424
434
  var jobConv = function (key) {
425
435
  var id = key.slice(2), j = (R.jobIndex || {})[id];
@@ -506,52 +516,59 @@
506
516
  (unread ? '<b class="ub">' + (unread > 99 ? '99+' : unread) + '</b>' : '') + '</span></span>';
507
517
  };
508
518
 
509
- // ── 未读: 别人说完、我还没读到的话 ──
510
- // 服务端给每个会话一份 heard = [说完的时刻, 发话方, 收信方], 以及我在群里 / 每一对里
511
- // 最后开口的时刻 (mine)。说给我的那句属于「我与发话方」那一对。一句话的已读水位 = 它所在那一处
512
- // 「我最后开口」与「我看过」的较晚者: 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
513
- // 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 之后新说完的才累加,
514
- // 刷新即重置 —— 所以「看过」只活在内存里, 不落本地。按视角分账 (同一个群换个 role 看, 未读是另一回事)。
519
+ // ── 未读: 别人说完、还没在详情里读到的话 ──
520
+ // 服务端给每一行 (侧栏会话项 / 子项、关系图卡片) 一份账, 条目 = [说完的时刻, 发话方, 收信方, 消息 id], 按点开这一行
521
+ // 看到的消息 (与详情区取消息同一个 windowOf) 切成不相交的两份: heard = 这里看得见的未读 (右边红点) —— 页面视角的,
522
+ // 加上这一行主人的; theirs = 主人在这里看不见的未读 (名字旁) —— 切到它的视角才看得到。一句话在一行里最多数一次,
523
+ // 点开这一行读到的只从红点里减, 名字旁不因此变。
524
+ // 已读只有一份 (SEEN): 这句话说完的样子在详情里上过屏 (页面可见、正在看消息) 就记下它的 id ——
525
+ // 与页面视角、详情视角、侧栏 / 关系图无关, 哪一处读到, 所有视图里同一句话一起消失, 不必等下一份摘要。
526
+ // SEEN 落在 svr 上 (全局一份): 新看过的攒一小会儿批量写回, 服务端下发的账已经减掉记过的, 并带回基线 (seenBase) ——
527
+ // 刷新、另一个标签页 (下一次心跳) 都看得到。没落盘的服务端 (daemon 自己的页、老 svr) 或读写失败, 退回只活在内存里:
528
+ // 基线是「打开页面这一刻」(服务端时钟, 首次拿到快照时定), 刷新即重置。
515
529
  var BASE = 0;
516
- var READ = { at: {} };
517
- var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
518
- var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
519
- var unreadOf = function (c, withRole) {
520
- var g = seenAt(c.key);
521
- var mark = {};
522
- (c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, BASE, s.mine || 0, seenAt(c.key, s.role)); });
523
- var groupMark = Math.max(g, BASE, c.mine || 0);
524
- return (c.heard || []).filter(function (h) {
525
- var p = h[2] === ROLE ? h[1] : '';
526
- if (withRole && p !== withRole) return false;
527
- return h[0] > (p && mark[p] !== undefined ? mark[p] : groupMark);
528
- }).length;
529
- };
530
- var reading = function () { return VIEW === 'msgs' && !document.hidden; };
531
- // 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
532
- // 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
533
- var talkCovers = function (p, c) {
534
- var t = convOf(CONV);
535
- if (!t || t.kind !== 'all') return false;
536
- // 限定了频道的窗口 (某 role 在一个群里的全部记录) 只覆盖那个群里的往来。
537
- if (t.chat && (!c || c.base !== t.chat)) return false;
538
- var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
539
- return hit(ROLE, p) || hit(p, ROLE);
540
- };
541
- // 一处会话里这次读到了哪几对 (存水位用的 withRole): 侧栏选中的就是那一项; 关系图卡片打开的
542
- // 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
543
- var readPairs = function (c) {
544
- if (c.key === CONV) return [WITH];
545
- if (c.kind !== 'group') return c.peer && talkCovers(c.peer, c) ? [''] : [];
546
- return (c.subs || []).filter(function (s) { return talkCovers(s.role, c); }).map(function (s) { return s.role; });
547
- };
548
- // 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
549
- var markRead = function (c) {
530
+ var SEEN = {};
531
+ var OUTBOX = {}, OUT_T = 0;
532
+ var SEEN_URL = function () { return 'api/seen?id=' + encodeURIComponent(TOKEN); };
533
+ var flushSeen = function (leaving) {
534
+ clearTimeout(OUT_T); OUT_T = 0;
535
+ var ids = Object.keys(OUTBOX);
536
+ if (!ids.length) return;
537
+ var body = JSON.stringify(OUTBOX);
538
+ OUTBOX = {};
539
+ try {
540
+ if (leaving && navigator.sendBeacon) navigator.sendBeacon(SEEN_URL(), body);
541
+ else fetch(SEEN_URL(), { method: 'POST', body: body, keepalive: true }).catch(function () { });
542
+ } catch (e) { }
543
+ };
544
+ var queueSeen = function (rows) {
545
+ Object.keys(rows).forEach(function (id) { OUTBOX[id] = rows[id]; });
546
+ if (!OUT_T) OUT_T = setTimeout(flushSeen, 1500);
547
+ };
548
+ // 关页 / 切走时不等那 1.5s。
549
+ window.addEventListener('pagehide', function () { flushSeen(true); });
550
+ document.addEventListener('visibilitychange', function () { if (document.hidden) flushSeen(true); });
551
+ var unreadIn = function (hs) {
552
+ return (hs || []).filter(function (h) { return h[0] > BASE && !((SEEN[h[3]] || 0) >= h[0]); }).length;
553
+ };
554
+ var unreadOf = function (c) { return unreadIn(c.heard); };
555
+ // 一行的两枚未读, 侧栏会话项 / 子项与关系图卡片同一个: 红点 = 点开看得见的, 名字旁 = 主人在这里看不见的。
556
+ var tally = function (l) { return { unread: unreadIn(l && l.heard), theirs: unreadIn(l && l.theirs) }; };
557
+ var peerBadge = function (id, n) {
558
+ return n && id !== ROLE ? '<b class="pub" title="' + esc(nameOf(id) + ' 的视角下还有 ' + n + ' 条未读, 这一项里看不到') + '">' + (n > 99 ? '99+' : n) + '</b>' : '';
559
+ };
560
+ // 详情区真在屏上: 窄屏退回列表时它整个 display:none (offsetParent 为空)。
561
+ var reading = function () { return VIEW === 'msgs' && !document.hidden && thread.offsetParent !== null; };
562
+ // 上屏的片段记成已读 (正在看消息时): 记的是它说完的时刻, 还在吐字的那一版不算读到终句。
563
+ // 有新记的就重画侧栏 / 关系图 —— 两边从同一个 SEEN 派生, 同一拍变。
564
+ var see = function (frags) {
550
565
  if (!reading()) return;
551
- var h = c.heard && c.heard[c.heard.length - 1];
552
- var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
553
- hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
566
+ var hit = frags.filter(function (m) { return m && m.fin && !((SEEN[m.id] || 0) >= m.fin); });
567
+ hit.forEach(function (m) { SEEN[m.id] = m.fin; });
568
+ if (hit.length) queueSeen(hit.reduce(function (o, m) { o[m.id] = m.fin; return o; }, {}));
569
+ if (hit.length && R.convs) renderConvs();
554
570
  };
571
+ var seeAll = function () { see(Object.keys(S.frags || {}).map(function (k) { return S.frags[k]; })); };
555
572
 
556
573
  // 跨会话通用的个人偏好 (看全部、列表 / 关系、侧栏群节点的展开、session 选择): 记在本地, 刷新后还在。
557
574
  // 取不到 / 写不进 (隐私窗口、禁存) 就按默认值, 页面照常。
@@ -601,7 +618,7 @@
601
618
  var vp = viewpoint && viewpoint !== ROLE ? viewpoint : '';
602
619
  if (key !== CONV || (withRole || '') !== WITH || VIEW !== 'msgs' || VIEWPOINT !== vp) return selectConv(key, withRole, undefined, viewpoint);
603
620
  // 窄屏退回列表后再点它是要回去读, 不是要取消。
604
- if (!app.classList.contains('reading')) return app.classList.add('reading');
621
+ if (!app.classList.contains('reading')) { app.classList.add('reading'); return seeAll(); }
605
622
  var fold = !withRole && OPEN[key];
606
623
  if (fold) OPEN[key] = false;
607
624
  selectConv('a:' + ROLE, '', undefined, '');
@@ -609,10 +626,11 @@
609
626
  };
610
627
 
611
628
  // ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
612
- // glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
613
- var glance = function (c, s) {
614
- var x = s || c;
615
- return { lastTs: x.lastTs, preview: x.preview, unread: unreadOf(c, s ? s.role : undefined) };
629
+ // glance = 时刻 · 最近一句 (g) · 两枚未读 (l 的账, 见 tally): 会话项与成对的子项两样都取自己 (子项 = 我与它在这个群里的那一对),
630
+ // 与我无往来的子项时刻与预览取它在群里的全部记录 (whole)。名字旁: 子项是它, 私聊项是对端, 群项没有主人。
631
+ var glance = function (l, g) {
632
+ var t = tally(l);
633
+ return { lastTs: (g || l).lastTs, preview: (g || l).preview, unread: t.unread, theirs: t.theirs };
616
634
  };
617
635
  // 同层按最近活动排, 有新话就上浮。
618
636
  var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
@@ -621,7 +639,7 @@
621
639
  var SWAP = '<i class="sw" aria-hidden="true">⇄</i>';
622
640
  var roleRow = function (id, name, label, g, status, tail, badge) {
623
641
  return goSpan('av', id, esc(label)) +
624
- line(nm(id, name, true, SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
642
+ line(nm(id, name, true, peerBadge(id, g.theirs) + SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
625
643
  };
626
644
  // 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
627
645
  var moreTag = function (n, tip) {
@@ -639,17 +657,16 @@
639
657
  var convRow = function (c) {
640
658
  if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status, jobMark(c.jobs), otherChats(c.peer, dmKey(c.peer)));
641
659
  if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
642
- return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs), c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
660
+ return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>' + wxTag(c.wx) + jobMark(c.jobs), c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
643
661
  };
644
- var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status, jobMark(s.jobs), otherChats(s.role, c.key + '|' + pairKey(s.role))); };
662
+ var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(s), s.status, jobMark(s.jobs), otherChats(s.role, c.key + '|' + pairKey(s.role))); };
645
663
 
646
664
  // 「chat 内全部」下与我无往来的那几项: 一行的数据是它在群里的全部记录 (服务端 whole, 与关系图卡片同一个
647
- // glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限)。没有成对的往来, 也就没有我的未读 —— 同关系图里
648
- // 视角没有会话项的那张卡片。+N 数它的全部叶子, 这一项本身不是叶子。
665
+ // glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限), 未读账也按那一份切 (服务端 subs 的 heard / theirs)。
666
+ // +N 数它的全部叶子, 这一项本身不是叶子。
649
667
  var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
650
668
  var farRow = function (s) {
651
- var w = s.whole;
652
- return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0 }, s.status, '', otherChats(s.role, ''));
669
+ return roleRow(s.role, s.name, s.label, glance(s, s.whole), s.status, '', otherChats(s.role, ''));
653
670
  };
654
671
 
655
672
  var convItem = function (c) {
@@ -686,7 +703,7 @@
686
703
  if (c.kind === 'group' && c.subs && c.subs.length) {
687
704
  // 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
688
705
  var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
689
- var content = avatarOf(c) + '<span class="b"><span class="l1"><span class="t"><span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs) + '</span>' + chevron + '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
706
+ var content = avatarOf(c) + '<span class="b"><span class="l1"><span class="t"><span class="nm chat">' + esc(c.name) + '</span>' + wxTag(c.wx) + jobMark(c.jobs) + '</span>' + chevron + '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
690
707
  '<span class="l2"><span class="pv">' + esc(c.preview) + '</span>' + (unreadOf(c) ? '<b class="ub">' + (unreadOf(c) > 99 ? '99+' : unreadOf(c)) + '</b>' : '') + '</span></span>';
691
708
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
692
709
  }
@@ -709,7 +726,6 @@
709
726
  var renderConvs = function () {
710
727
  var groups = R.convs.filter(function (c) { return c.kind === 'group' && inWin(c.lastTs); }).sort(recentFirst);
711
728
  var dms = R.convs.filter(function (c) { return c.kind === 'wizard' && inWin(c.lastTs); }).sort(recentFirst);
712
- R.convs.forEach(markRead);
713
729
  if (WORLD) return renderWorld();
714
730
  // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
715
731
  var sec = function (title, list) {
@@ -848,7 +864,7 @@
848
864
  });
849
865
  };
850
866
  var worldToggle = function () {
851
- return '<button class="vt" data-vt title="' + (WORLD ? '换回会话列表' : '换成关系') + '">' + (WORLD ? LIST_SVG + '列表' : TREE_SVG + '关系') + '</button>';
867
+ return '<button class="vt" data-vt title="' + (WORLD ? '换回聊天' : '换成关系') + '">' + (WORLD ? LIST_SVG + '聊天' : TREE_SVG + '关系') + '</button>';
852
868
  };
853
869
  var bindWorldToggle = function (scope) {
854
870
  var b = scope.querySelector('[data-vt]');
@@ -975,11 +991,29 @@
975
991
  return n < .01 ? '<$0.01' : '$' + (n < 10 ? n.toFixed(2) : n < 1000 ? n.toFixed(1) : Math.round(n));
976
992
  };
977
993
  // 费用: 有认不出价格的模型就在数前加「≥」—— 实际只多不少; 一分钱都没算出来就不占位。
994
+ // 计算过程: 服务端按模型分好行 (bill, 单价取自计价用的同一份价格表), 这里逐项摆成「token × 单价 = 金额」。
995
+ var BILL = [['input', 'in', 'in'], ['output', 'out', 'out'], ['cacheWrite', 'cw', 'cacheWrite'], ['cacheRead', 'cr', 'cacheRead']];
996
+ var usd4 = function (n) { return '$' + (n >= 100 ? n.toFixed(2) : n.toFixed(4)); };
997
+ var billLines = function (b) {
998
+ var head = b.label + (b.model && b.model !== b.label ? ' (' + b.model + ')' : '') + ' · ' + b.calls + ' 次请求';
999
+ if (!b.price) return [head, ' 价格表里认不出, 未计入'];
1000
+ return [head, ' 单价 $/百万: ' + BILL.map(function (k) { return k[1] + ' ' + b.price[k[2]]; }).join(' · ')]
1001
+ .concat(BILL.filter(function (k) { return b[k[0]] > 0; }).map(function (k) {
1002
+ return ' ' + k[1] + ' ' + b[k[0]].toLocaleString('en-US') + ' × $' + b.price[k[2]] + ' / 1M = ' +
1003
+ usd4(b[k[0]] * b.price[k[2]] / 1e6);
1004
+ }))
1005
+ .concat([' 小计 ' + usd4(b.cost)]);
1006
+ };
978
1007
  var costKv = function (u) {
979
1008
  if (!u.cost) return '';
980
1009
  var txt = (u.unpriced ? '≥' : '') + fmtUsd(u.cost);
981
- var tip = '估算费用 — 每轮按模型单价 (LiteLLM 价格表快照) 计 input / output / 缓存写 / 缓存读' +
982
- (u.unpriced ? '\n另有 ' + u.unpriced + ' 次 API 请求的模型不在价格表里, 未计入' : '');
1010
+ var bill = u.bill || [];
1011
+ var priced = bill.filter(function (b) { return b.price; });
1012
+ var tip = ['估算费用 ' + usd4(u.cost) + ' — 按模型单价 (LiteLLM 价格表快照, USD / 百万 token) 逐项累加', '']
1013
+ .concat(bill.reduce(function (a, b) { return a.concat(billLines(b), ['']); }, []))
1014
+ .concat(priced.length > 1 ? ['合计 ' + priced.map(function (b) { return usd4(b.cost); }).join(' + ') + ' = ' + usd4(u.cost)] : [])
1015
+ .concat(u.unpriced ? ['另有 ' + u.unpriced + ' 次 API 请求的模型不在价格表里, 未计入'] : [])
1016
+ .join('\n').replace(/\n+$/, '');
983
1017
  return '<span class="kv kv-cost" title="' + esc(tip) + '" data-tip="' + esc(ICON.cost + ' ' + txt) +
984
1018
  '"><i class="u-ic">' + ICON.cost + '</i><b>' + esc(txt) + '</b></span>';
985
1019
  };
@@ -1200,7 +1234,7 @@
1200
1234
  var peer = pairPeer(c);
1201
1235
  var pairName = peer && pairTitle(peer);
1202
1236
  who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
1203
- : '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
1237
+ : '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>' + wxTag(c.wx)) + '</span>';
1204
1238
  acts.innerHTML = '';
1205
1239
  bindBack();
1206
1240
  bindGo(who);
@@ -1273,13 +1307,13 @@
1273
1307
  var c = convOf(CONV);
1274
1308
  var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1275
1309
  var dst = two ? '' : mine
1276
- ? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
1310
+ ? (m.to && m.to !== 'system:' && m.to !== peer ? m.to : '')
1277
1311
  : (m.to !== me && group ? m.to : '');
1278
1312
  // 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
1279
1313
  var toIn = dst
1280
1314
  ? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
1281
- '" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
1282
- esc(roleLabel(m.to)) + '</span><span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span></span>'
1315
+ '" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '">' +
1316
+ WZ.nameHead('<span class="av">' + esc(roleLabel(m.to)) + '</span>', '<span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span>') + '</span>'
1283
1317
  : '';
1284
1318
  var withTo = function (html) {
1285
1319
  if (!toIn) return html;
@@ -1298,7 +1332,7 @@
1298
1332
  var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
1299
1333
  var who = mine
1300
1334
  ? stamp(m.ts) + stat + avBtn(m.from)
1301
- : avBtn(m.from) + nm(m.from, '', true) + priv + stamp(m.ts) + stat;
1335
+ : '<span class="said">' + WZ.said(WZ.nameHead(avBtn(m.from), nm(m.from, '', true))) + '</span>' + priv + stamp(m.ts) + stat;
1302
1336
  var sw = canSwitch(other);
1303
1337
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
1304
1338
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
@@ -1424,6 +1458,7 @@
1424
1458
  foldPings(inner);
1425
1459
  expireRows();
1426
1460
  if (keep) keep();
1461
+ see(fresh);
1427
1462
  }).catch(retry);
1428
1463
  };
1429
1464
 
@@ -1431,9 +1466,11 @@
1431
1466
  // early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
1432
1467
  var loadMsgs = function (limit, land, early) {
1433
1468
  var gen = S.gen;
1434
- if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1469
+ if (!CONV) { unswitch(); inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1435
1470
  return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
1436
- if (!d.ok || gen !== S.gen) return;
1471
+ if (gen !== S.gen) return;
1472
+ if (!d.ok) { if (unswitch()) inner.innerHTML = '<div class="empty">载入失败</div>'; return; }
1473
+ unswitch();
1437
1474
  S.frags = {};
1438
1475
  d.msgs.forEach(function (m) { S.frags[m.id] = m; });
1439
1476
  if (!d.msgs.length) { inner.innerHTML = '<div class="empty">这里还没有消息</div>'; return; }
@@ -1445,6 +1482,7 @@
1445
1482
  expireRows();
1446
1483
  settleAbove();
1447
1484
  applyFold(inner);
1485
+ see(d.msgs);
1448
1486
  if (land && land()) return;
1449
1487
  S.pinned = true; toBottom(true);
1450
1488
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
@@ -1457,6 +1495,7 @@
1457
1495
  var stick = S.pinned;
1458
1496
  var cur = rowNode(m.id);
1459
1497
  S.frags[m.id] = m;
1498
+ see([m]);
1460
1499
  if (cur && cur.getAttribute('data-sig') === m.sig) return;
1461
1500
  // 折行会把 .mrow 收进 details, 而下面的按时间插位假定它们都是 inner 的直接子节点。
1462
1501
  unfoldPings(inner);
@@ -1508,7 +1547,7 @@
1508
1547
  // 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
1509
1548
  var takeRole = function (d) {
1510
1549
  R.at = d.at || Date.now(); R.recvAt = Date.now();
1511
- if (!BASE) BASE = R.at;
1550
+ if (d.seenBase) BASE = d.seenBase; else if (!BASE) BASE = R.at;
1512
1551
  R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys;
1513
1552
  SESSION = d.session || '';
1514
1553
  R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null; R.inflight = d.inflight || [];
@@ -1533,13 +1572,15 @@
1533
1572
  };
1534
1573
 
1535
1574
  // 切回标签页时正在读的那项就算读过了。
1536
- document.addEventListener('visibilitychange', function () { if (!document.hidden) renderConvs(); });
1575
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) seeAll(); });
1537
1576
 
1538
1577
  // ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
1539
1578
  var backoff = 1000;
1540
1579
  var connect = function () {
1541
1580
  if (S.es) { S.es.close(); S.es = null; }
1542
- var p = new URLSearchParams(viewParams({ id: TOKEN }));
1581
+ // 换窗口的新内容还没上屏: 新窗口的增量会落进旧内容里 —— 等 loadMsgs 替换完再连 (断线重连的定时器也在这儿挡下)。
1582
+ if (S.swapGen === S.gen) return;
1583
+ var p = new URLSearchParams(roleParams({ id: TOKEN }));
1543
1584
  var es = new EventSource('api/role-events?' + p.toString());
1544
1585
  S.es = es;
1545
1586
  es.addEventListener('role', function (e) { try { applyRole(JSON.parse(e.data)); } catch (err) { } });
@@ -1566,7 +1607,7 @@
1566
1607
  var want = hold || !CONV ? '' : winSig();
1567
1608
  var early = want ? api('api/msgs', viewParams()) : null;
1568
1609
  W.pickHold = hold;
1569
- return api('api/role', viewParams()).then(function (d) {
1610
+ return api('api/role', roleParams()).then(function (d) {
1570
1611
  if (!d.ok) {
1571
1612
  document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
1572
1613
  return;
@@ -1591,18 +1632,43 @@
1591
1632
  };
1592
1633
 
1593
1634
  // land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
1635
+ // 详情区正看着一个窗口时换窗口不清屏: 旧页头 / 正文 / 页脚原样留着 (只变暗、不接点击, 顶上一道细进度条),
1636
+ // 新消息到手的同一帧整块替换 —— 已读也在那一刻才记 (see 跟着替换走)。连点只画最后一次 (gen)。
1637
+ // 详情区原本不可见 (窄屏在列表、停在关系图 / 日程) 就没有可留的, 照旧先清成「加载中」。
1594
1638
  var selectConv = function (key, withRole, land, viewpoint) {
1639
+ var warm = VIEW === 'msgs' && thread.offsetParent !== null && !!inner.querySelector('.mrow, .empty');
1595
1640
  CONV = key; WITH = withRole || ''; VIEWPOINT = viewpoint && viewpoint !== ROLE ? viewpoint : '';
1596
1641
  reveal();
1597
1642
  if (VIEW !== 'msgs') setView('msgs');
1598
1643
  app.classList.add('reading');
1599
1644
  // 上一个窗口的账不属于这个窗口 —— 先收起, 新的随 SSE 的首个 role 事件到。
1600
1645
  R.winStats = null;
1601
- renderConvs(); renderHead(); renderUsage();
1602
1646
  S.gen++;
1603
1647
  syncUrl();
1604
- inner.innerHTML = '<div class="empty">加载中…</div>';
1605
- loadMsgs(undefined, land).then(connect);
1648
+ renderConvs();
1649
+ if (warm) {
1650
+ // 旧窗口的 SSE 先断: 它的增量 / 摘要不该再改留在屏上的旧内容, 新的等替换完 (connect 里挡着) 再连。
1651
+ if (S.es) { S.es.close(); S.es = null; }
1652
+ S.swapGen = S.gen;
1653
+ main.classList.add('switching');
1654
+ } else {
1655
+ renderHead(); renderUsage();
1656
+ inner.innerHTML = '<div class="empty">加载中…</div>';
1657
+ }
1658
+ var gen = S.gen;
1659
+ loadMsgs(undefined, land).catch(function () {
1660
+ if (gen !== S.gen) return;
1661
+ unswitch(); inner.innerHTML = '<div class="empty">载入失败</div>';
1662
+ }).then(function () { if (gen === S.gen) connect(); }); // 被后一次点击取代的由那一次去连
1663
+ };
1664
+ // 待替换的那次换窗口落地 (或被更新的一次取代): 收起加载态, 补画留着没动的页头页脚。返回它是否在等。
1665
+ var unswitch = function () {
1666
+ var was = S.swapGen != null;
1667
+ if (!was) return false;
1668
+ S.swapGen = null;
1669
+ main.classList.remove('switching');
1670
+ renderHead(); renderUsage();
1671
+ return true;
1606
1672
  };
1607
1673
 
1608
1674
  // 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
@@ -1616,7 +1682,7 @@
1616
1682
  // 群里「我与 X」时换到 X, 对面看到的是「X 与 from」—— 同一段往来, 选中这一对而不是整个群。
1617
1683
  var withBack = keep && WITH === id ? from : '';
1618
1684
  // 整个频道 / 两人私聊 / 群里的一对, 消息集合都与视角无关 —— 集合没变就就地翻面。
1619
- var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0);
1685
+ var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0) && S.swapGen !== S.gen;
1620
1686
  ROLE = id; WITH = withBack; SESSION = ''; VIEWPOINT = '';
1621
1687
  CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
1622
1688
  // 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
@@ -1757,7 +1823,7 @@
1757
1823
  S.gen++;
1758
1824
  syncUrl();
1759
1825
  var gen = S.gen;
1760
- api('api/role', viewParams()).then(function (d) {
1826
+ api('api/role', roleParams()).then(function (d) {
1761
1827
  if (!d.ok || gen !== S.gen) return;
1762
1828
  applyRole(d);
1763
1829
  // 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
@@ -1982,23 +2048,26 @@
1982
2048
  // 而不是节点自己最后的动静 —— 卡片承载的是边。
1983
2049
  // 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
1984
2050
  // (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
1985
- // 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
1986
- // 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
2051
+ // 时刻、最近一句与两枚未读取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
2052
+ // 也在那里定; 未读账与侧栏同一个 ledgerOf 按这一窗切, 主人 = 卡片上的 role)。窗口还没取到 (或第一遍量树时) 时刻与
2053
+ // 最近一句退回视角与对端的会话项 / 节点, 未读先不画 —— 那一项点开看的不是这一窗, 它的账不属于这张卡片。
1987
2054
  var cardGlance = function (F, t, e) {
2055
+ // 账随看的 role 而变 (红点里有视角的未读): 换了视角, 同一个窗口键下的旧账不能拿来画。
2056
+ var w = F.links && W.gRole === ROLE && W.glance[talkKey(F.links, t)];
2057
+ if (w) return glance(w);
1988
2058
  var hit = edgeConv(F, t, e), c = hit && convOf(hit[0]);
1989
2059
  var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
1990
2060
  var n = nodeOf(t) || {}, p = e || F.pp[t];
1991
- var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
1992
- var w = F.links && W.glance[talkKey(F.links, t)];
1993
- return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
2061
+ var g = s || c || { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '' };
2062
+ return { lastTs: g.lastTs, preview: g.preview, unread: 0, theirs: 0 };
1994
2063
  };
1995
2064
  // 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
1996
2065
  var loadGlance = function () {
1997
2066
  var keys = W.gKeys, role = ROLE;
1998
2067
  if (!keys) return;
1999
- api('api/glance', { role: role, keys: keys }).then(function (d) {
2068
+ api('api/glance', { role: role, keys: keys, since: BASE }).then(function (d) {
2000
2069
  if (!d.ok || role !== ROLE || keys !== W.gKeys) return;
2001
- W.glance = d.glances || {};
2070
+ W.glance = d.glances || {}; W.gRole = role;
2002
2071
  renderWorld();
2003
2072
  }).catch(function () { });
2004
2073
  };
@@ -2323,7 +2392,7 @@
2323
2392
  // 手机上关系/日程占满主区 —— 进入即阅读态。
2324
2393
  if (v !== 'msgs') app.classList.add('reading');
2325
2394
  renderRole(); renderHead(); renderUsage();
2326
- if (v === 'msgs') { toBottom(true); renderConvs(); }
2395
+ if (v === 'msgs') { toBottom(true); renderConvs(); seeAll(); }
2327
2396
  else if (v === 'charter') loadCharter();
2328
2397
  else if (v === 'jobs') renderJobList();
2329
2398
  else {
@@ -2424,7 +2493,7 @@
2424
2493
  all().forEach(function (x) { x.ontoggle = sync; });
2425
2494
  };
2426
2495
 
2427
- // 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
2496
+ // 侧栏换成关系图 / 换回聊天。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
2428
2497
  var setWorld = function (on) {
2429
2498
  WORLD = on; W.treeFor = ''; W.pickSelf = '';
2430
2499
  setPref(WORLD_KEY, on);
@@ -2521,12 +2590,11 @@
2521
2590
  };
2522
2591
  // 三层: 谁 → 对谁 · 何时 / 命中的那一截 / 在哪个会话。
2523
2592
  var msgItem = function (h, q) {
2524
- var to = h.to && h.to !== h.from && h.to !== 'human:'
2525
- ? '<span class="arr">→</span>' + nm(h.to, h.toName) : '';
2593
+ var to = h.to && h.to !== h.from && h.to !== 'system:' ? nm(h.to, h.toName) : undefined;
2526
2594
  return {
2527
2595
  kind: 'msg',
2528
2596
  html: '<span class="av">' + esc(h.fromLabel) + '</span><span class="b"><span class="l1">' +
2529
- nm(h.from, h.fromName) + to + skTs(h.ts) + '</span>' +
2597
+ '<span class="said">' + WZ.speakerHead(nm(h.from, h.fromName), to) + '</span>' + skTs(h.ts) + '</span>' +
2530
2598
  '<span class="sn">' + hl(h.snippet, q) + '</span>' +
2531
2599
  '<span class="l3">' + whereOf(h.convKind, h.convLabel, h.convName) +
2532
2600
  (h.hits > 1 ? '<span class="cnt">' + h.hits + ' 处命中</span>' : '') + '</span></span>',