wezard 2.4.1 → 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 (98) 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 +11 -7
  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 +5 -6
  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 +57 -14
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +297 -65
  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 +109 -92
  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 +35 -15
  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 +29 -23
  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 +145 -3
  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/weixin-cmd.js +268 -0
  58. package/dist/daemon/weixin-cmd.js.map +1 -0
  59. package/dist/daemon/weixin-port.js +226 -0
  60. package/dist/daemon/weixin-port.js.map +1 -0
  61. package/dist/daemon/weixin.js +772 -0
  62. package/dist/daemon/weixin.js.map +1 -0
  63. package/dist/daemon/wizard.js +47 -19
  64. package/dist/daemon/wizard.js.map +1 -1
  65. package/dist/mcp/server.js +43 -23
  66. package/dist/mcp/server.js.map +1 -1
  67. package/dist/shared/chat-http.js +59 -28
  68. package/dist/shared/chat-http.js.map +1 -1
  69. package/dist/shared/chat-render.js +9 -1
  70. package/dist/shared/chat-render.js.map +1 -1
  71. package/dist/shared/chat-view.js +16 -2
  72. package/dist/shared/chat-view.js.map +1 -1
  73. package/dist/shared/cli-backends.js +3 -0
  74. package/dist/shared/cli-backends.js.map +1 -1
  75. package/dist/shared/config.js +35 -10
  76. package/dist/shared/config.js.map +1 -1
  77. package/dist/shared/json-map-store.js +1 -0
  78. package/dist/shared/json-map-store.js.map +1 -1
  79. package/dist/shared/reminder.js +1 -0
  80. package/dist/shared/reminder.js.map +1 -1
  81. package/dist/shared/role-render.js +4 -3
  82. package/dist/shared/role-render.js.map +1 -1
  83. package/dist/shared/role-view.js +97 -62
  84. package/dist/shared/role-view.js.map +1 -1
  85. package/dist/shared/seen-log.js +26 -0
  86. package/dist/shared/seen-log.js.map +1 -0
  87. package/dist/shared/session-label.js +84 -31
  88. package/dist/shared/session-label.js.map +1 -1
  89. package/dist/shared/turn-state.js +1 -1
  90. package/dist/shared/turn-state.js.map +1 -1
  91. package/dist/shared/world.js.map +1 -1
  92. package/dist/shared/wx-text.js +101 -0
  93. package/dist/shared/wx-text.js.map +1 -0
  94. package/dist/svr/index.js +5 -2
  95. package/dist/svr/index.js.map +1 -1
  96. package/package.json +6 -2
  97. package/web/chat.css +26 -5
  98. package/web/chat.js +128 -63
package/web/chat.css CHANGED
@@ -20,6 +20,8 @@
20
20
  --priv: #9a5b10; /* 私聊 */
21
21
  --human: #12715a; /* 人名 —— 青绿, 与 wizard (墨色) 和群名 (暗金) 三分 */
22
22
  --chat: #7d6410; /* 群名 —— 暗金, 与 role 名 (墨色 + 视角色的点) 分开: 群是频道不是 role */
23
+ --wezard: #6b3fa0; /* 系统 wezard (守护进程与认不出的发话方) —— 紫金: 紫起金收, 两端都压暗到正文对比度 */
24
+ --wezard-gold: #9a6a00;
23
25
  /* 头像底: 不透明, 且由 card/accent 推出 —— 叠在选中/高亮/关系图任何底色上都不变色, 换主题时随 token 走。 */
24
26
  --av-bg: var(--card);
25
27
  --av-line: var(--line);
@@ -46,6 +48,11 @@ button { font: inherit; color: inherit }
46
48
  .nm.human::before, .nm.task::before, .nm.system::before, .nm.chat::before { content: none }
47
49
  .nm.chat, .ch-who .sub .up { color: var(--chat) }
48
50
  .nm.human { color: var(--human) }
51
+ .nm.system {
52
+ color: var(--wezard);
53
+ background: linear-gradient(90deg, var(--wezard), var(--wezard-gold));
54
+ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
55
+ }
49
56
 
50
57
  /* ── 侧栏: 我 + 会话 ─────────────────────────────────────────────── */
51
58
  .side {
@@ -330,6 +337,14 @@ span.av.go:hover { background: var(--av-hot); box-shadow: 0 0 0 3px var(--av-hot
330
337
  padding: 16px 20px 28px; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
331
338
  }
332
339
  .thread-in { display: flex; flex-direction: column; gap: 14px }
340
+ /* 换窗口时旧内容留着等新内容: 不接点击, 稍候才变暗 (快的切换看不出闪), 页头底边一道细进度条。 */
341
+ .main.switching .thread-in, .main.switching .chathead > :not(.back) { pointer-events: none }
342
+ .main.switching .thread-in { opacity: .6; transition: opacity .2s .15s }
343
+ .main.switching .chathead {
344
+ background-image: linear-gradient(var(--accent), var(--accent)); background-repeat: no-repeat;
345
+ background-size: 40% 2px; animation: swbar 1s ease-in-out infinite;
346
+ }
347
+ @keyframes swbar { from { background-position: -67% 100% } to { background-position: 167% 100% } }
333
348
  .empty { color: var(--faint); text-align: center; padding: 60px 0; font-size: 13px }
334
349
  .more-btn {
335
350
  align-self: center; background: var(--card); border: 1px solid var(--line); border-radius: 16px;
@@ -351,6 +366,9 @@ span.av.go:hover { background: var(--av-hot); box-shadow: 0 0 0 3px var(--av-hot
351
366
  .mrow.mine .mcol { align-items: flex-end }
352
367
  .mwho { font-size: 11.5px; color: var(--mute); display: flex; gap: 6px; align-items: center; flex-wrap: wrap }
353
368
  .mwho .nm { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
369
+ /* 发言头 `🦊.a:` (WZ.said): 头像贴着名字、冒号贴着名字, 不吃 .mwho 的 gap。 */
370
+ .mwho .said { display: inline-flex; align-items: center; min-width: 0; max-width: 100% }
371
+ .mwho .said .av { margin-right: 2px }
354
372
  .mwho .ch { font-size: 10.5px; border-radius: 4px; padding: 0 4px }
355
373
  .mwho .ch.priv { color: var(--priv); background: #9a5b1014 }
356
374
  /* 时刻只此一处 —— 气泡外, 跟着名字走。等宽数字让一列时间对得齐。 */
@@ -382,16 +400,16 @@ button.mchip.rcpt:hover { text-decoration: underline }
382
400
  .mchip.rcpt:is(.st-timeout, .st-silent, .st-dead, .st-canceled) { color: #cf222e; background: #cf222e10 }
383
401
  /* 说给谁 (inline): 塞进正文第一段开头的头像 + 名字, 像行内 mention; 小圆角底色, 点它走移交行同一套跳转。 */
384
402
  .to-in {
385
- display: inline-flex; align-items: center; gap: 3px; vertical-align: baseline; padding: 1px 6px; border-radius: 5px;
403
+ display: inline-flex; align-items: center; gap: 0; vertical-align: baseline; padding: 1px 6px; border-radius: 5px;
386
404
  line-height: 18px; background: color-mix(in srgb, #0969da 10%, transparent); color: var(--ink);
387
405
  cursor: pointer; transition: background .12s;
388
406
  }
389
407
  .to-in:hover { background: color-mix(in srgb, #0969da 20%, transparent) }
390
- .to-in .av { width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--line); background: var(--card); font-size: 10px; line-height: 14px; text-align: center; align-self: center }
408
+ .to-in .av { margin-right: 2px; width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--line); background: var(--card); font-size: 10px; line-height: 14px; text-align: center; align-self: center }
391
409
  .to-in .nm { font-weight: 500; font-size: 13px }
392
410
  .to-in .nm::before { content: none }
393
411
  /* 移交行里的对方: 小头像 + 名字。整行是入口 (开双方的往来), 悬停时整行提亮。 */
394
- .handoff .ho-who { display: inline-flex; align-items: center; gap: 3px; color: var(--ink) }
412
+ .handoff .ho-who { display: inline-flex; align-items: center; gap: 2px; color: var(--ink) }
395
413
  .handoff .ho-line[data-hto]:hover { background: var(--av-hot); border-radius: 6px }
396
414
  .handoff .ho-who .av { width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--line); background: var(--card); font-size: 10px; line-height: 14px; text-align: center }
397
415
  .handoff .ho-who .nm { font-weight: 500 }
@@ -906,8 +924,8 @@ button.mchip.rcpt:hover { text-decoration: underline }
906
924
  .sk-it.on .k { opacity: 1; color: var(--accent) }
907
925
  .sk-it .ts { margin-left: auto; flex: none; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums }
908
926
  .sk-it .pv { display: block; font-size: 12px; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
909
- .sk-it .l1 .nm + .nm, .sk-it .arr + .nm { font-weight: 500; color: var(--mute) }
910
- .sk-it .arr { flex: none; color: var(--faint); font-size: 11.5px }
927
+ .sk-it .said { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
928
+ .sk-it .said .nm + .nm { font-weight: 500; color: var(--mute) }
911
929
  .sk-it .tag { flex: none; font-size: 10.5px; color: var(--mute); background: var(--paper); border-radius: 4px; padding: 1px 5px }
912
930
  /* 命中的那一截: 正文色, 最多两行; 命中词是这一页的视角色淡底 —— 与选中态一个色系。 */
913
931
  .sk-it .sn {
@@ -949,6 +967,9 @@ button.mchip.rcpt:hover { text-decoration: underline }
949
967
  .jtag { flex: none; margin-left: 6px; font-size: 10.5px; line-height: 15px; padding: 0 5px; border-radius: 4px; color: var(--faint); background: #f1f3f6; font-weight: 500 }
950
968
  .jtag.open { color: #94741f; background: #bf870014 }
951
969
  .jstuck { flex: none; margin-left: 4px; font-size: 10.5px; line-height: 15px; padding: 0 5px; border-radius: 4px; color: #8250df; background: #fbefff; font-weight: 500 }
970
+ .wxm { flex: none; margin-left: 4px; padding: 0 4px; border-radius: 4px; font-size: 10.5px; white-space: nowrap; color: #2f7d32; background: #2f7d3214 }
971
+ .wxm.paused { color: #94741f; background: #bf870014 }
972
+ .wxm.down { color: #c62828; background: #c6282814 }
952
973
  .jmk { flex: none; margin-left: 4px; padding: 0 3px; border-radius: 4px; font-size: 10.5px; opacity: .8; cursor: pointer }
953
974
  .jmk:hover { opacity: 1; background: #bf870022 }
954
975
  .jmk i { font-style: normal; margin-left: 1px; color: #94741f; font-size: 10px }
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(); };
@@ -144,11 +144,11 @@
144
144
  };
145
145
  var roleLabel = function (id) {
146
146
  var k = names[id] || {}, n = nodeOf(id);
147
- return k.label || (n && n.label) || ({ human: '👤', task: '⏰', system: '🧙' })[kindOf(id)] || '🧙';
147
+ return k.label || (n && n.label) || ({ human: '👤', task: '⏰', system: '🧙‍♂️' })[kindOf(id)] || '🧙';
148
148
  };
149
149
  // `.name` 的点由 CSS 画 —— 人与定时任务没有地址, 不画点。
150
150
  var nameOf = function (id) { return (kindOf(id) === 'wizard' ? '.' : '') + roleName(id); };
151
- 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'; };
152
152
  // 头像与名字就是进入那个 role 视角的入口 —— 侧栏项、会话头、消息行同一种读法。
153
153
  // 是 <span> 不是 <button>: 侧栏里它嵌在整行那颗按钮里。
154
154
  var goSpan = function (cls, id, inner) {
@@ -426,6 +426,10 @@
426
426
  var jobMark = function (ids) {
427
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>' : '';
428
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
+ };
429
433
  // 视角不在里面的工单不在 R.convs 里: 按服务端的账 (R.jobIndex, 全部工单) 现造一项; 账里没有 = 已清掉 (job 为空)。
430
434
  var jobConv = function (key) {
431
435
  var id = key.slice(2), j = (R.jobIndex || {})[id];
@@ -513,30 +517,45 @@
513
517
  };
514
518
 
515
519
  // ── 未读: 别人说完、还没在详情里读到的话 ──
516
- // 服务端给每个窗口 (侧栏会话项 / 子项、关系图卡片) 一份 heard = [说完的时刻, 发话方, 收信方, 消息 id]:
517
- // 看的那个 role 在点开这一项会看到的消息里听得到、且晚于它在那一处最后开口的 (回过话 = 读到了那里)。
520
+ // 服务端给每一行 (侧栏会话项 / 子项、关系图卡片) 一份账, 条目 = [说完的时刻, 发话方, 收信方, 消息 id], 按点开这一行
521
+ // 看到的消息 (与详情区取消息同一个 windowOf) 切成不相交的两份: heard = 这里看得见的未读 (右边红点) —— 页面视角的,
522
+ // 加上这一行主人的; theirs = 主人在这里看不见的未读 (名字旁) —— 切到它的视角才看得到。一句话在一行里最多数一次,
523
+ // 点开这一行读到的只从红点里减, 名字旁不因此变。
518
524
  // 已读只有一份 (SEEN): 这句话说完的样子在详情里上过屏 (页面可见、正在看消息) 就记下它的 id ——
519
525
  // 与页面视角、详情视角、侧栏 / 关系图无关, 哪一处读到, 所有视图里同一句话一起消失, 不必等下一份摘要。
520
- // 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 刷新即重置 ——
521
- // 所以 SEEN 只活在内存里, 不落本地。
526
+ // SEEN 落在 svr 上 (全局一份): 新看过的攒一小会儿批量写回, 服务端下发的账已经减掉记过的, 并带回基线 (seenBase) ——
527
+ // 刷新、另一个标签页 (下一次心跳) 都看得到。没落盘的服务端 (daemon 自己的页、老 svr) 或读写失败, 退回只活在内存里:
528
+ // 基线是「打开页面这一刻」(服务端时钟, 首次拿到快照时定), 刷新即重置。
522
529
  var BASE = 0;
523
530
  var SEEN = {};
524
- var unreadIn = function (hs, skip) {
525
- return (hs || []).filter(function (h) { return h[0] > BASE && !((SEEN[h[3]] || 0) >= h[0]) && !(skip && skip[h[3]]); });
526
- };
527
- // 群里的成对子项 = 说给视角的、出自它的那几句; 不给 withRole = 整个会话。
528
- var unreadHeard = function (c, withRole) {
529
- return unreadIn(withRole ? (c.heard || []).filter(function (h) { return h[1] === withRole && h[2] === ROLE; }) : c.heard);
530
- };
531
- var unreadOf = function (c, withRole) { return unreadHeard(c, withRole).length; };
532
- // 名字旁的他人未读: 点名字切到 id 的视角会看到的未读 (它全部会话项的, 服务端 peerUnread 按同一口径给),
533
- // 减去这一行右边红点已在数的那几句。已读同样取自 SEEN —— 哪一处读到, 这里一起消。
534
- var idsOf = function (hs) { return hs.reduce(function (m, h) { m[h[3]] = 1; return m; }, {}); };
535
- var theirsOf = function (id, counted) {
536
- return id && id !== ROLE ? unreadIn((R.peerUnread || {})[id], idsOf(counted || [])).length : 0;
537
- };
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) }; };
538
557
  var peerBadge = function (id, n) {
539
- return n && id !== ROLE ? '<b class="pub" title="' + esc(nameOf(id) + ' 的视角下还有 ' + n + ' 条未读') + '">' + (n > 99 ? '99+' : n) + '</b>' : '';
558
+ return n && id !== ROLE ? '<b class="pub" title="' + esc(nameOf(id) + ' 的视角下还有 ' + n + ' 条未读, 这一项里看不到') + '">' + (n > 99 ? '99+' : n) + '</b>' : '';
540
559
  };
541
560
  // 详情区真在屏上: 窄屏退回列表时它整个 display:none (offsetParent 为空)。
542
561
  var reading = function () { return VIEW === 'msgs' && !document.hidden && thread.offsetParent !== null; };
@@ -546,6 +565,7 @@
546
565
  if (!reading()) return;
547
566
  var hit = frags.filter(function (m) { return m && m.fin && !((SEEN[m.id] || 0) >= m.fin); });
548
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; }, {}));
549
569
  if (hit.length && R.convs) renderConvs();
550
570
  };
551
571
  var seeAll = function () { see(Object.keys(S.frags || {}).map(function (k) { return S.frags[k]; })); };
@@ -606,11 +626,11 @@
606
626
  };
607
627
 
608
628
  // ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
609
- // glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
610
- // theirs = 这一行主人的未读 (名字旁): 子项是它, 私聊项是对端, 群项没有。
611
- var glance = function (c, s) {
612
- var x = s || c, mine = unreadHeard(c, s ? s.role : undefined);
613
- return { lastTs: x.lastTs, preview: x.preview, unread: mine.length, theirs: theirsOf(s ? s.role : c.peer, mine) };
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 };
614
634
  };
615
635
  // 同层按最近活动排, 有新话就上浮。
616
636
  var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
@@ -637,17 +657,16 @@
637
657
  var convRow = function (c) {
638
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)));
639
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));
640
- 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));
641
661
  };
642
- 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))); };
643
663
 
644
664
  // 「chat 内全部」下与我无往来的那几项: 一行的数据是它在群里的全部记录 (服务端 whole, 与关系图卡片同一个
645
- // glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限)。没有成对的往来, 也就没有我的未读 —— 同关系图里
646
- // 视角没有会话项的那张卡片。+N 数它的全部叶子, 这一项本身不是叶子。
665
+ // glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限), 未读账也按那一份切 (服务端 subs 的 heard / theirs)。
666
+ // +N 数它的全部叶子, 这一项本身不是叶子。
647
667
  var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
648
668
  var farRow = function (s) {
649
- var w = s.whole;
650
- return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0, theirs: theirsOf(s.role) }, s.status, '', otherChats(s.role, ''));
669
+ return roleRow(s.role, s.name, s.label, glance(s, s.whole), s.status, '', otherChats(s.role, ''));
651
670
  };
652
671
 
653
672
  var convItem = function (c) {
@@ -684,7 +703,7 @@
684
703
  if (c.kind === 'group' && c.subs && c.subs.length) {
685
704
  // 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
686
705
  var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
687
- 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>' +
688
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>';
689
708
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
690
709
  }
@@ -972,11 +991,29 @@
972
991
  return n < .01 ? '<$0.01' : '$' + (n < 10 ? n.toFixed(2) : n < 1000 ? n.toFixed(1) : Math.round(n));
973
992
  };
974
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
+ };
975
1007
  var costKv = function (u) {
976
1008
  if (!u.cost) return '';
977
1009
  var txt = (u.unpriced ? '≥' : '') + fmtUsd(u.cost);
978
- var tip = '估算费用 — 每轮按模型单价 (LiteLLM 价格表快照) 计 input / output / 缓存写 / 缓存读' +
979
- (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+$/, '');
980
1017
  return '<span class="kv kv-cost" title="' + esc(tip) + '" data-tip="' + esc(ICON.cost + ' ' + txt) +
981
1018
  '"><i class="u-ic">' + ICON.cost + '</i><b>' + esc(txt) + '</b></span>';
982
1019
  };
@@ -1197,7 +1234,7 @@
1197
1234
  var peer = pairPeer(c);
1198
1235
  var pairName = peer && pairTitle(peer);
1199
1236
  who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
1200
- : '<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>';
1201
1238
  acts.innerHTML = '';
1202
1239
  bindBack();
1203
1240
  bindGo(who);
@@ -1270,13 +1307,13 @@
1270
1307
  var c = convOf(CONV);
1271
1308
  var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1272
1309
  var dst = two ? '' : mine
1273
- ? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
1310
+ ? (m.to && m.to !== 'system:' && m.to !== peer ? m.to : '')
1274
1311
  : (m.to !== me && group ? m.to : '');
1275
1312
  // 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
1276
1313
  var toIn = dst
1277
1314
  ? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
1278
- '" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
1279
- 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>'
1280
1317
  : '';
1281
1318
  var withTo = function (html) {
1282
1319
  if (!toIn) return html;
@@ -1295,7 +1332,7 @@
1295
1332
  var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
1296
1333
  var who = mine
1297
1334
  ? stamp(m.ts) + stat + avBtn(m.from)
1298
- : 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;
1299
1336
  var sw = canSwitch(other);
1300
1337
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
1301
1338
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
@@ -1429,9 +1466,11 @@
1429
1466
  // early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
1430
1467
  var loadMsgs = function (limit, land, early) {
1431
1468
  var gen = S.gen;
1432
- if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1469
+ if (!CONV) { unswitch(); inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1433
1470
  return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
1434
- 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();
1435
1474
  S.frags = {};
1436
1475
  d.msgs.forEach(function (m) { S.frags[m.id] = m; });
1437
1476
  if (!d.msgs.length) { inner.innerHTML = '<div class="empty">这里还没有消息</div>'; return; }
@@ -1508,8 +1547,8 @@
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;
1512
- R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys; R.peerUnread = d.peerUnread;
1550
+ if (d.seenBase) BASE = d.seenBase; else if (!BASE) BASE = R.at;
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 || [];
1515
1554
  // 工单的账变了 (或比消息晚到): 已上屏的工单 badge 按新账重填一遍 —— 片段里只写了工单号。
@@ -1539,6 +1578,8 @@
1539
1578
  var backoff = 1000;
1540
1579
  var connect = function () {
1541
1580
  if (S.es) { S.es.close(); S.es = null; }
1581
+ // 换窗口的新内容还没上屏: 新窗口的增量会落进旧内容里 —— 等 loadMsgs 替换完再连 (断线重连的定时器也在这儿挡下)。
1582
+ if (S.swapGen === S.gen) return;
1542
1583
  var p = new URLSearchParams(roleParams({ id: TOKEN }));
1543
1584
  var es = new EventSource('api/role-events?' + p.toString());
1544
1585
  S.es = es;
@@ -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
  // 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
@@ -1982,26 +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, theirs: theirsOf(t) };
1992
- var w = F.links && W.glance[talkKey(F.links, t)];
1993
- if (!w) return base;
1994
- // 未读也取自卡片自己的窗口 (与侧栏同一个 unreadIn): 点开它看得到的才数; 名字旁的是卡片主人整个视角的 (theirsOf)。
1995
- var mine = unreadIn(w.mine);
1996
- return { lastTs: w.lastTs, preview: w.preview, unread: mine.length, theirs: theirsOf(t, mine) };
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 };
1997
2063
  };
1998
2064
  // 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
1999
2065
  var loadGlance = function () {
2000
2066
  var keys = W.gKeys, role = ROLE;
2001
2067
  if (!keys) return;
2002
- api('api/glance', { role: role, keys: keys }).then(function (d) {
2068
+ api('api/glance', { role: role, keys: keys, since: BASE }).then(function (d) {
2003
2069
  if (!d.ok || role !== ROLE || keys !== W.gKeys) return;
2004
- W.glance = d.glances || {};
2070
+ W.glance = d.glances || {}; W.gRole = role;
2005
2071
  renderWorld();
2006
2072
  }).catch(function () { });
2007
2073
  };
@@ -2524,12 +2590,11 @@
2524
2590
  };
2525
2591
  // 三层: 谁 → 对谁 · 何时 / 命中的那一截 / 在哪个会话。
2526
2592
  var msgItem = function (h, q) {
2527
- var to = h.to && h.to !== h.from && h.to !== 'human:'
2528
- ? '<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;
2529
2594
  return {
2530
2595
  kind: 'msg',
2531
2596
  html: '<span class="av">' + esc(h.fromLabel) + '</span><span class="b"><span class="l1">' +
2532
- 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>' +
2533
2598
  '<span class="sn">' + hl(h.snippet, q) + '</span>' +
2534
2599
  '<span class="l3">' + whereOf(h.convKind, h.convLabel, h.convName) +
2535
2600
  (h.hits > 1 ? '<span class="cnt">' + h.hits + ' 处命中</span>' : '') + '</span></span>',