wezard 2.2.1 → 2.3.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 (95) hide show
  1. package/CLAUDE.md +7 -4
  2. package/README.md +1 -1
  3. package/dist/cli/init.js +2 -1
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +6 -4
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/chat-log.js +6 -1
  8. package/dist/daemon/chat-log.js.map +1 -1
  9. package/dist/daemon/danger.js +17 -0
  10. package/dist/daemon/danger.js.map +1 -1
  11. package/dist/daemon/detail.js +41 -0
  12. package/dist/daemon/detail.js.map +1 -1
  13. package/dist/daemon/handoff.js +8 -2
  14. package/dist/daemon/handoff.js.map +1 -1
  15. package/dist/daemon/inbound.js +9 -4
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +744 -152
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +62 -23
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +15 -0
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/memory-steward.js +124 -68
  24. package/dist/daemon/memory-steward.js.map +1 -1
  25. package/dist/daemon/mirror-bridge.js +461 -91
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/mirror-store.js.map +1 -1
  28. package/dist/daemon/model-select.js +99 -0
  29. package/dist/daemon/model-select.js.map +1 -1
  30. package/dist/daemon/notices.js +7 -5
  31. package/dist/daemon/notices.js.map +1 -1
  32. package/dist/daemon/peers.js +183 -60
  33. package/dist/daemon/peers.js.map +1 -1
  34. package/dist/daemon/receipts.js +391 -77
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +63 -10
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +24 -13
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/task-registry.js +1 -1
  41. package/dist/daemon/task-registry.js.map +1 -1
  42. package/dist/daemon/tasks.js +12 -4
  43. package/dist/daemon/tasks.js.map +1 -1
  44. package/dist/daemon/usage.js +4 -11
  45. package/dist/daemon/usage.js.map +1 -1
  46. package/dist/daemon/wizard-memory.js +36 -4
  47. package/dist/daemon/wizard-memory.js.map +1 -1
  48. package/dist/daemon/wizard.js +102 -58
  49. package/dist/daemon/wizard.js.map +1 -1
  50. package/dist/mcp/server.js +138 -111
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/shared/charter-view.js +35 -0
  53. package/dist/shared/charter-view.js.map +1 -0
  54. package/dist/shared/chat-http.js +133 -31
  55. package/dist/shared/chat-http.js.map +1 -1
  56. package/dist/shared/chat-render.js +6 -3
  57. package/dist/shared/chat-render.js.map +1 -1
  58. package/dist/shared/chat-view.js +17 -6
  59. package/dist/shared/chat-view.js.map +1 -1
  60. package/dist/shared/cli-backends.js +2 -0
  61. package/dist/shared/cli-backends.js.map +1 -1
  62. package/dist/shared/config.js +9 -5
  63. package/dist/shared/config.js.map +1 -1
  64. package/dist/shared/daemon-token.js +18 -0
  65. package/dist/shared/daemon-token.js.map +1 -0
  66. package/dist/shared/detail-render.js +140 -16
  67. package/dist/shared/detail-render.js.map +1 -1
  68. package/dist/shared/detail-store.js +33 -12
  69. package/dist/shared/detail-store.js.map +1 -1
  70. package/dist/shared/effort.js +11 -0
  71. package/dist/shared/effort.js.map +1 -0
  72. package/dist/shared/model-prices.js +258 -0
  73. package/dist/shared/model-prices.js.map +1 -0
  74. package/dist/shared/pricing.js +55 -0
  75. package/dist/shared/pricing.js.map +1 -0
  76. package/dist/shared/reminder.js +18 -4
  77. package/dist/shared/reminder.js.map +1 -1
  78. package/dist/shared/role-render.js +172 -8
  79. package/dist/shared/role-render.js.map +1 -1
  80. package/dist/shared/role-view.js +89 -25
  81. package/dist/shared/role-view.js.map +1 -1
  82. package/dist/shared/session-label.js +4 -0
  83. package/dist/shared/session-label.js.map +1 -1
  84. package/dist/shared/task-file.js +16 -1
  85. package/dist/shared/task-file.js.map +1 -1
  86. package/dist/shared/turn-state.js +24 -0
  87. package/dist/shared/turn-state.js.map +1 -0
  88. package/dist/shared/world.js +37 -3
  89. package/dist/shared/world.js.map +1 -1
  90. package/dist/svr/index.js +2 -1
  91. package/dist/svr/index.js.map +1 -1
  92. package/hooks/pre-tool-use.sh +16 -3
  93. package/package.json +1 -1
  94. package/web/chat.css +173 -41
  95. package/web/chat.js +724 -245
package/web/chat.js CHANGED
@@ -19,11 +19,15 @@
19
19
  var WITH = qs.get('with') || '';
20
20
  // '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
21
21
  var SESSION = qs.get('session') || '';
22
+ // 消息区的视角 (谁算「自己」靠右、描述行以谁为准): 只在看移交详情时才有 —— 移交人 (派活那一方);
23
+ // 平时不存在, 消息区就用 ROLE。侧栏与名片永远是 ROLE 的。
24
+ var EYE = qs.get('eye') || '';
25
+ var eyeOf = function () { return EYE || ROLE; };
22
26
  var TICK_MS = 3000;
23
27
 
24
28
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
25
29
  // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
26
- var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, stats: null, winStats: null };
30
+ var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, charter: null, stats: null, winStats: null };
27
31
  // frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
28
32
  var S = { es: null, pinned: true, gen: 0, frags: {} };
29
33
  // 关系/日程两栏共用的世界快照。treeAll = 关系树展开全部; treeFor = 已把谁滚进过视野。
@@ -46,13 +50,13 @@
46
50
  if (n < 1e6) { var v = n / 1000; return (v >= 10 ? v.toFixed(0) : v.toFixed(1).replace(/\.0$/, '')) + 'k'; }
47
51
  return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
48
52
  };
53
+ // 与服务端 detail-render 的 fmtDuration 同一写法: 走表的那一轮收口后换成服务端写死的值, 不该跳格式。
49
54
  var fmtDur = function (ms) {
50
55
  if (ms < 1000) return ms + 'ms';
51
- var s = Math.round(ms / 1000);
56
+ var s = Math.round(ms / 100) / 10;
52
57
  if (s < 60) return s + 's';
53
58
  var m = Math.floor(s / 60);
54
- if (m < 60) return m + 'm' + (s - m * 60) + 's';
55
- return Math.floor(m / 60) + 'h' + (m % 60) + 'm';
59
+ return m + 'm' + Math.round(s - m * 60) + 's';
56
60
  };
57
61
  var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
58
62
  var fmtDay = function (ts) {
@@ -93,6 +97,7 @@
93
97
  if (CONV) p.conv = CONV;
94
98
  if (WITH) p['with'] = WITH;
95
99
  if (SESSION) p.session = SESSION;
100
+ if (EYE) p.eye = EYE;
96
101
  if (WORLD) p.side = 'world';
97
102
  Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
98
103
  return p;
@@ -106,9 +111,18 @@
106
111
  };
107
112
 
108
113
  // ── 名字 ──
109
- // 一个 role 的名字与头像: 消息片段自带 (fromName/fromLabel), 会话列表与名册兜底。
110
- var names = {};
111
- var learn = function (id, name, label) { if (id && name) names[id] = { name: name, label: label || '' }; };
114
+ // 一个 role 的名字与头像, 两路来: 摘要 (名册, 每拍刷新, 为准) 与消息片段 (渲染那一刻的名册, 只在摘要没给时兜底)。
115
+ // svr 刚重启、还没收到 daemon 推来的名册那几秒, 两路回的「名字」都只是 id 本身 (头像也是按 id 算的) ——
116
+ // id 不是名字, 不记; 否则它会钉在已上屏的行里, 名册到了也不改。返回: 这一次记的有没有变。
117
+ var names = {}, fromDir = {};
118
+ var learn = function (id, name, label, dir) {
119
+ if (!id || !name || name === id || (!dir && fromDir[id])) return false;
120
+ if (dir) fromDir[id] = 1;
121
+ var k = names[id];
122
+ if (k && k.name === name && k.label === (label || '')) return false;
123
+ names[id] = { name: name, label: label || '' };
124
+ return true;
125
+ };
112
126
  var kindOf = function (id) {
113
127
  return /^task:/.test(id) ? 'task' : id === 'system:' ? 'system' : /^human:/.test(id) ? 'human' : 'wizard';
114
128
  };
@@ -132,8 +146,9 @@
132
146
  var on = canSwitch(id) && id !== ROLE;
133
147
  return '<span class="' + cls + (on ? ' go" data-r="' + esc(id) + '" title="' + esc('切到 ' + nameOf(id) + ' 的视角') : '') + '">' + inner + '</span>';
134
148
  };
135
- var nm = function (id, name, go) {
136
- var cls = 'nm ' + kindOf(id), txt = esc(name || roleName(id));
149
+ // extra = 跟在名字后、同属一个点击范围的徽标 (原样 html)。
150
+ var nm = function (id, name, go, extra) {
151
+ var cls = 'nm ' + kindOf(id), txt = esc(name || roleName(id)) + (extra || '');
137
152
  return go ? goSpan(cls, id, txt) : '<span class="' + cls + '">' + txt + '</span>';
138
153
  };
139
154
  var bindGo = function (root, sel) {
@@ -148,8 +163,8 @@
148
163
 
149
164
  // ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
150
165
  var md = null;
151
- var render = function (scope) {
152
- if (!window.markdownit) return;
166
+ var mdReady = function () {
167
+ if (!window.markdownit) return false;
153
168
  if (!md) {
154
169
  md = window.markdownit({
155
170
  html: false, linkify: true, breaks: true, highlight: function (str, lang) {
@@ -161,7 +176,12 @@
161
176
  }
162
177
  });
163
178
  }
164
- scope.querySelectorAll('.bubble').forEach(function (b) {
179
+ return true;
180
+ };
181
+ var render = function (scope) {
182
+ if (!mdReady()) return;
183
+ // 工单页的开工 / 收工两行不是气泡, 计划与结论同样是 markdown。
184
+ scope.querySelectorAll('.bubble, .tg-job').forEach(function (b) {
165
185
  var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
166
186
  if (src && body && body.dataset.rendered !== '1') {
167
187
  body.innerHTML = md.render(src.textContent || '');
@@ -176,15 +196,18 @@
176
196
  var m = {};
177
197
  scope.querySelectorAll('[data-key]').forEach(function (b) {
178
198
  var k = b.getAttribute('data-key');
179
- b.querySelectorAll('details').forEach(function (d, i) { m[k + '#' + i] = d.open; });
199
+ b.querySelectorAll('details').forEach(function (d, i) { m[openKey(k, d, i)] = d.open; });
180
200
  });
181
201
  return m;
182
202
  };
203
+ // 展开态按位置记; 移交行单独记一格 —— 它是后来才挂上去的 (公开移交从气泡变成可开合),
204
+ // 跟着位置走会把旁边那个框的开合套到它身上, 默认展开的公开移交被收起、箭头朝右。
205
+ var openKey = function (k, d, i) { return k + '#' + (d.classList.contains('handoff') ? 'ho' : i); };
183
206
  var restoreOpen = function (scope, m) {
184
207
  scope.querySelectorAll('[data-key]').forEach(function (b) {
185
208
  var k = b.getAttribute('data-key');
186
209
  b.querySelectorAll('details').forEach(function (d, i) {
187
- var v = m[k + '#' + i]; if (v !== undefined) d.open = v;
210
+ var v = m[openKey(k, d, i)]; if (v !== undefined) d.open = v;
188
211
  });
189
212
  });
190
213
  };
@@ -210,11 +233,8 @@
210
233
  var eb = childBy(ex, 'bubbles'), nb = childBy(nc, 'bubbles');
211
234
  if (eb && nb) {
212
235
  swapHead(ex, nc);
213
- // 终句刚到: 过程框收起一次。标记留在节点上, 用户之后再点开不会被下一帧收回去。
214
- if (nc.hasAttribute('data-fold') && !ex.hasAttribute('data-fold')) {
215
- ex.setAttribute('data-fold', '');
216
- ex.classList.add('folded');
217
- }
236
+ // 下面有了新东西 / 这一轮结束: 过程框收起一次。标记留在节点上, 用户之后再点开不会被下一帧收回去。
237
+ if (nc.hasAttribute('data-fold')) settle(ex);
218
238
  reconcile(eb, nb);
219
239
  ex.setAttribute('data-sig', nc.getAttribute('data-sig') || '');
220
240
  return ex;
@@ -225,6 +245,28 @@
225
245
  restoreOpen(cur, open);
226
246
  render(cur);
227
247
  };
248
+ // 人手点过的过程框: data-key → 是否收起。只由点击写入, 每次换节点 (增量、翻页、换视角
249
+ // 整窗重拉) 之后按它回填 —— 默认态归服务端, 人的选择压过默认态。
250
+ var FOLD = {};
251
+ var settle = function (s) {
252
+ if (s.hasAttribute('data-fold')) return;
253
+ s.setAttribute('data-fold', '');
254
+ s.classList.add('folded');
255
+ };
256
+ // 一轮还在跑, 但线程里它下面已经有了别的消息 —— 它也不再是「最末尾那个」。
257
+ // 这件事只有整条线程知道 (片段按轮缓存, 服务端不看邻居), 所以在客户端补。
258
+ var settleAbove = function () {
259
+ var rows = inner.querySelectorAll('.mrow');
260
+ Array.prototype.slice.call(rows, 0, -1).forEach(function (r) {
261
+ r.querySelectorAll('.steps').forEach(settle);
262
+ });
263
+ };
264
+ var applyFold = function (root) {
265
+ root.querySelectorAll('.steps[data-key]').forEach(function (s) {
266
+ var v = FOLD[s.getAttribute('data-key')];
267
+ if (v !== undefined) s.classList.toggle('folded', v);
268
+ });
269
+ };
228
270
  var frag = function (html) {
229
271
  var d = document.createElement('div'); d.innerHTML = html; return d;
230
272
  };
@@ -264,7 +306,9 @@
264
306
  inner.addEventListener('click', function (e) {
265
307
  var s = e.target.closest && e.target.closest('.steps');
266
308
  if (!s) return;
267
- if (e.target.closest('.steps-head') || e.target === s || e.target === childBy(s, 'bubbles')) s.classList.toggle('folded');
309
+ if (e.target.closest('.steps-head') || e.target === s || e.target === childBy(s, 'bubbles')) {
310
+ FOLD[s.getAttribute('data-key')] = s.classList.toggle('folded');
311
+ }
268
312
  });
269
313
  // reminder 块: 摘要 ⇄ 原文。刚划选了一段原文的那次松手不算点击。
270
314
  inner.addEventListener('click', function (e) {
@@ -272,17 +316,91 @@
272
316
  if (!r || String(window.getSelection() || '')) return;
273
317
  r.classList.toggle('raw');
274
318
  });
319
+ inner.addEventListener('click', function (e) {
320
+ var c = e.target.closest && e.target.closest('.mchip.job[data-job]');
321
+ if (c) openJob(c.getAttribute('data-job'));
322
+ });
323
+ // 回执 chip / 移交行的活号: 跳到派活那句 (同一个活号的入消息)。先在眼前这个窗口里找;
324
+ // 不在就按它带来的坐标切到它所在的会话再落点, 同搜索结果 —— 派活那句常在另一个窗口
325
+ // (私聊派的活, 回执落在群里)。会话键随视角: 公开的在那个群; 私聊在视角与对端的私聊里,
326
+ // 视角不是两端之一就开「这两方之间」的往来。服务端找不到那句时不会画成可点的。
327
+ inner.addEventListener('click', function (e) {
328
+ var c = e.target.closest && e.target.closest('[data-goto][data-gid]');
329
+ if (!c) return;
330
+ var q = inner.querySelector('[data-pturn="' + cssEsc(c.getAttribute('data-goto')) + '"]');
331
+ var row = q && q.closest('.mrow');
332
+ if (row && focusRow(row.getAttribute('data-id'))) return;
333
+ var ch = c.getAttribute('data-gch'), from = c.getAttribute('data-gfrom'), to = c.getAttribute('data-gto');
334
+ var conv = ch ? 'c:' + ch : ROLE === from ? 'p:' + to : ROLE === to ? 'p:' + from : 'a:' + from + '|' + to;
335
+ jumpMsg({ conv: conv, id: c.getAttribute('data-gid'), ts: Number(c.getAttribute('data-gts')) });
336
+ });
337
+ // 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 视角不是两端之一就开
338
+ // 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。不换视角 (节点随 reconcile 换, 所以在根上委托)。
339
+ // 行尾的 chevron 只管展开原文, 走 <summary> 自己的开合。
340
+ inner.addEventListener('click', function (e) {
341
+ var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto]');
342
+ if (!b || e.target.closest('.ho-chev')) return;
343
+ e.preventDefault(); // 行是 <summary>: 不顺带展开原文
344
+ var from = b.getAttribute('data-hfrom'), to = b.getAttribute('data-hto'), ch = b.getAttribute('data-hch');
345
+ var peer = ROLE === from ? to : ROLE === to ? from : '';
346
+ var conv = !peer ? 'a:' + from + '|' + to : ch ? 'c:' + ch : 'p:' + peer;
347
+ var w = peer && ch ? peer : '';
348
+ var id = b.getAttribute('data-gid');
349
+ markBack(function () { return VIEW === 'msgs' && CONV === conv && WITH === w; });
350
+ // 消息区换成移交人 (派活那一方) 的视角 (侧栏、名片不动; 视角就是它时不带)。
351
+ if (id) return jumpMsg({ conv: conv, with: w, id: id, ts: Number(b.getAttribute('data-gts')), eye: from });
352
+ selectConv(conv, w, undefined, from);
353
+ });
275
354
 
276
355
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
277
356
  // `a:<x>` / `a:<x>|<p1>,<p2>` 不在列表里: x 参与的全部对话 / x 与这几个对端之间的, 按时间排开
278
357
  // (关系图里点卡片看的就是它, 服务端同一个 talkOf) —— 现造一项, 落地时才不会被当成失效的会话退回默认。
358
+ // 工单的进度: 开着 = 已落定几份 / 一共几份, 收了 = 收工。
359
+ var jobTag = function (j) {
360
+ return j ? '<span class="jtag' + (j.status === 'open' ? ' open' : '') + '">' + (j.status === 'open' ? j.done + '/' + j.total : '收工') + '</span>' : '';
361
+ };
362
+ // 工单里有人卡住 (停在审批卡 / 反问待答 / 报错停了) 时标出几份卡着 —— 要人或开单者动手的。
363
+ var stuckTag = function (j) {
364
+ var n = j && j.status === 'open' ? (R.inflight || []).filter(function (x) {
365
+ return x.job === j.id && (x.state === 'blocked' || x.state === 'needs-input' || x.state === 'errored');
366
+ }).length : 0;
367
+ return n ? '<span class="jstuck" title="' + n + ' 份停在审批 / 反问 / 报错上">' + n + ' 卡住</span>' : '';
368
+ };
369
+ // 侧栏一行 / 关系图卡片上的小标记: 这处往来里有归在工单名下的话。
370
+ var jobMark = function (ids) {
371
+ 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>' : '';
372
+ };
373
+ // 视角不在里面的工单不在 R.convs 里: 按服务端的账 (R.jobIndex, 全部工单) 现造一项; 账里没有 = 已清掉 (job 为空)。
374
+ var jobConv = function (key) {
375
+ var id = key.slice(2), j = (R.jobIndex || {})[id];
376
+ return {
377
+ key: key, kind: 'job', name: j ? j.title : id, label: '📋', base: j ? j.base : '', subs: [], heard: [],
378
+ lastTs: j ? j.closedAt || j.openedAt : 0, preview: '',
379
+ job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total },
380
+ };
381
+ };
382
+ /** 一个工单号 → 它的一行: 视角有份的取会话项 (带预览), 其余按账现造。 */
383
+ var jobRowOf = function (id) {
384
+ return jobConvs().filter(function (c) { return c.job.id === id; })[0] || jobConv('j:' + id);
385
+ };
386
+ /** 工单列表此刻列哪些: 从 📋 进来 = 入口带的那几张 (按 id, 不按视角筛); 否则视角开的或参与的。标题的数与列表同出这一份。 */
387
+ var jobListRows = function () {
388
+ return JOB_ONLY ? JOB_ONLY.map(jobRowOf).filter(function (c) { return c.job; }) : jobConvs();
389
+ };
390
+ var jobConvs = function () { return R.convs.filter(function (c) { return c.kind === 'job' && c.job; }); };
279
391
  var convOf = function (key) {
280
392
  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: [] };
393
+ var part = key.slice(2).split('|'), who = part[0], peers = (part[1] || '').split(',').filter(Boolean), chat = part[2] || '';
394
+ var name = chat ? nameOf(who) + ' 在 ' + chatTitle(chat) + ' 的全部记录'
395
+ : nameOf(who) + (peers.length ? ' 与 ' + peers.map(nameOf).join('、') + ' 的对话' : ' 的全部对话');
396
+ return { key: key, kind: 'all', who: who, peers: peers, chat: chat, name: name, subs: [] };
284
397
  }
285
- return R.convs.filter(function (c) { return c.key === key; })[0];
398
+ var hit = R.convs.filter(function (c) { return c.key === key; })[0];
399
+ return hit || (key && key.indexOf('j:') === 0 ? jobConv(key) : undefined);
400
+ };
401
+ var chatTitle = function (base) {
402
+ var c = R.convs.filter(function (x) { return x.key === 'c:' + base; })[0];
403
+ return c ? c.name : base;
286
404
  };
287
405
  // 人的单聊 (user:…) 在数据上是频道, 但对端只有那一个人时同样是一对一。
288
406
  var dmOf = function (c) {
@@ -307,15 +425,17 @@
307
425
  // ── 状态: wizard 是否在执行中 ──
308
426
  // 服务端只给事实 (busy / alive / runningUntil), 亮不亮在这里按本地时钟判 ——
309
427
  // 到点没有新写入就自己熄 (同 expireRows), 不必等下一次推送。
310
- var ST = { run: '执行中', idle: '空闲', off: '已关闭' };
311
- var stateOf = function (s) { return wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
428
+ var ST = { wait: '等人点', run: '执行中', idle: '空闲', off: '已关闭' };
429
+ // 等人点 (停在审批卡 / 提问卡上) 先于执行中: 审批长轮询期间 pane 照样在转圈。
430
+ var stateOf = function (s) { return s.waiting && s.waiting.length ? 'wait' : wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
312
431
  // 忙闲只有这一个渲染点, 永远挂在名字右边 —— 名片 / 关系树 / 侧栏共用。
313
- // quiet: 只在执行中出现、只要灯不要字 (侧栏: 列表里安静是常态, 亮着的才值得看一眼)。
432
+ // quiet: 只在执行中 / 等人点时出现、只要灯不要字 (侧栏: 列表里安静是常态, 亮着的才值得看一眼)。
314
433
  var stTag = function (s, quiet) {
315
434
  if (!s) return '';
316
435
  var k = stateOf(s);
317
- if (quiet && k !== 'run') return '';
318
- return '<span class="wst ' + k + '" title="' + ST[k] + '">' + (quiet ? '' : ST[k]) + '</span>';
436
+ if (quiet && k !== 'run' && k !== 'wait') return '';
437
+ var tip = k === 'wait' ? '停在 ' + s.waiting.join(' / ') + ' 上等人点 (审批卡 / 提问卡)' : ST[k];
438
+ return '<span class="wst ' + k + '" title="' + esc(tip) + '">' + (quiet ? '' : esc(k === 'wait' ? ST[k] + ' · ' + s.waiting[0] : ST[k])) + '</span>';
319
439
  };
320
440
  var paintStatus = function () {
321
441
  var el = $('#rb-st');
@@ -342,9 +462,10 @@
342
462
  };
343
463
 
344
464
  // ── 未读: 别人说完、我还没读到的话 ──
345
- // 服务端给每个会话一份 heard = [说完的时刻, 属于我与谁的那一对], 以及我在群里 / 每一对里
346
- // 最后开口的时刻 (mine)。一句话的已读水位 = 它所在那一处「我最后开口」与「我看过」的较晚者:
347
- // 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
465
+ // 服务端给每个会话一份 heard = [说完的时刻, 发话方, 收信方], 以及我在群里 / 每一对里
466
+ // 最后开口的时刻 (mine)。说给我的那句属于「我与发话方」那一对。一句话的已读水位 = 它所在那一处
467
+ // 「我最后开口」与「我看过」的较晚者: 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
468
+ // 已读记录 `视角|会话|对端` 的键一直没变: 子项曾短暂是「对端在群里的全部记录」, 那时看过的覆盖了这一对, 照旧算数。
348
469
  // 「看过」记在本地 (刷新 / 换视角都还在), 按视角分账 —— 同一个群换个 role 看, 未读是另一回事。
349
470
  var READ_KEY = 'wezard.role.read';
350
471
  var READ = (function () {
@@ -360,16 +481,19 @@
360
481
  (c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, s.mine || 0, seenAt(c.key, s.role)); });
361
482
  var groupMark = Math.max(g, c.mine || 0);
362
483
  return (c.heard || []).filter(function (h) {
363
- if (withRole && h[1] !== withRole) return false;
364
- return h[0] > (h[1] ? (mark[h[1]] !== undefined ? mark[h[1]] : groupMark) : groupMark);
484
+ var p = h[2] === ROLE ? h[1] : '';
485
+ if (withRole && p !== withRole) return false;
486
+ return h[0] > (p && mark[p] !== undefined ? mark[p] : groupMark);
365
487
  }).length;
366
488
  };
367
489
  var reading = function () { return VIEW === 'msgs' && !document.hidden; };
368
490
  // 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
369
491
  // 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
370
- var talkCovers = function (p) {
492
+ var talkCovers = function (p, c) {
371
493
  var t = convOf(CONV);
372
494
  if (!t || t.kind !== 'all') return false;
495
+ // 限定了频道的窗口 (某 role 在一个群里的全部记录) 只覆盖那个群里的往来。
496
+ if (t.chat && (!c || c.base !== t.chat)) return false;
373
497
  var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
374
498
  return hit(ROLE, p) || hit(p, ROLE);
375
499
  };
@@ -377,8 +501,8 @@
377
501
  // 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
378
502
  var readPairs = function (c) {
379
503
  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; });
504
+ if (c.kind !== 'group') return c.peer && talkCovers(c.peer, c) ? [''] : [];
505
+ return (c.subs || []).filter(function (s) { return talkCovers(s.role, c); }).map(function (s) { return s.role; });
382
506
  };
383
507
  // 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
384
508
  var markRead = function (c) {
@@ -395,12 +519,16 @@
395
519
  // 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按会话各记各的。
396
520
  var SUB_FOLD = 5, MORE = {};
397
521
  var reveal = function () {
398
- if (CONV && CONV !== OPEN_AT) { OPEN[CONV] = true; OPEN_AT = CONV; }
522
+ if (CONV && CONV !== OPEN_AT) {
523
+ // 某 role 在一个群里的全部记录挂在那个群下: 展开的是那个群。
524
+ var t = convOf(CONV);
525
+ OPEN[t && t.chat ? 'c:' + t.chat : CONV] = true; OPEN_AT = CONV;
526
+ }
399
527
  };
400
528
  // 点侧栏项: 没选中它 → 选中 (会话顺带展开); 已选中 → 不再选一遍, 右边的正文一个字不动 ——
401
529
  // 会话只折叠/展开, 子项什么都不做。
402
530
  var clickItem = function (key, withRole) {
403
- if (key !== CONV || withRole !== WITH || VIEW !== 'msgs') return selectConv(key, withRole);
531
+ if (key !== CONV || withRole !== WITH || VIEW !== 'msgs' || EYE) return selectConv(key, withRole);
404
532
  // 窄屏退回列表后再点它是要回去读, 不是要折叠。
405
533
  if (!app.classList.contains('reading')) return app.classList.add('reading');
406
534
  if (withRole) return;
@@ -417,26 +545,60 @@
417
545
  // 同层按最近活动排, 有新话就上浮。
418
546
  var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
419
547
  // 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
420
- var roleRow = function (id, name, label, g, status, tail) {
548
+ var roleRow = function (id, name, label, g, status, tail, badge) {
421
549
  return goSpan('av', id, esc(label)) +
422
- line(nm(id, name, true) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
550
+ line(nm(id, name, true, badge) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
551
+ };
552
+ // 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
553
+ var moreTag = function (n, tip) {
554
+ return n ? '<span class="oc" title="' + esc(tip) + '">+' + n + '</span>' : '';
423
555
  };
556
+ // 会话列表: 除了这一项 (here = 它的绝对 key), 它还对应 N 个叶子项 (各群里它参与的成对子项 + 它的私聊)。服务端没给这份 = 不知道, 不画。
557
+ var otherChats = function (id, here) {
558
+ var ks = (R.chatKeys || {})[id];
559
+ var n = ks ? ks.filter(function (k) { return k !== here; }).length : 0;
560
+ return moreTag(n, nameOf(id) + ' 在另外 ' + n + ' 处 (群里的一对 / 私聊) 还有往来, 切到它的视角可见');
561
+ };
562
+ // 侧栏的 key 是相对视角的 (p:<对端> / 群 + 选中的对端), 服务端那份是绝对的 (两端排序)。
563
+ var pairKey = function (peer) { return [ROLE, peer].sort().join('|'); };
564
+ var dmKey = function (peer) { return 'p:' + pairKey(peer); };
424
565
  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));
566
+ 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)));
567
+ if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
568
+ 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));
569
+ };
570
+ 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))); };
571
+
572
+ // 「chat 内全部」下与我无往来的那几项: 一行的数据是它在群里的全部记录 (服务端 whole, 与关系图卡片同一个
573
+ // glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限)。没有成对的往来, 也就没有我的未读 —— 同关系图里
574
+ // 视角没有会话项的那张卡片。+N 数它的全部叶子, 这一项本身不是叶子。
575
+ var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
576
+ var farRow = function (s) {
577
+ var w = s.whole;
578
+ return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0 }, s.status, '', otherChats(s.role, ''));
427
579
  };
428
- var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status); };
429
580
 
430
581
  var convItem = function (c) {
431
582
  var on = c.key === CONV;
432
- // 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
433
- var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }).sort(recentFirst) : [];
583
+ // 成对: 只列与我有往来的 —— 在群里但没和我说过话的人, 点进去也是空的。
584
+ // 「chat 内全部」再把其余有记录的接在成对的后面 (.far), 各自按最近排。
585
+ var pairs = c.subs.filter(function (s) { return s.count; }).sort(recentFirst);
586
+ var fars = SCOPE === 'all'
587
+ ? c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count; })
588
+ .sort(function (a, b) { return recentFirst(a.whole, b.whole); })
589
+ : [];
590
+ var talked = OPEN[c.key] ? pairs.concat(fars) : [];
434
591
  var hidden = talked.length - SUB_FOLD;
435
592
  var bar = hidden > 0
436
593
  ? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
437
594
  : '';
438
595
  // 展开条钉在第 SUB_FOLD+1 位, 展开与折叠都不挪: 其余子项展开后接在它下面。
439
596
  var sub = function (s) {
597
+ if (!s.count) {
598
+ var k = farKey(c, s);
599
+ return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" ' +
600
+ 'title="' + esc(nameOf(s.role) + ' 在这里的 ' + s.whole.count + ' 条记录 (与我无往来)') + '">' + farRow(s) + '</button>';
601
+ }
440
602
  var sel = on && s.role === WITH;
441
603
  return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
442
604
  'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
@@ -451,7 +613,7 @@
451
613
  convRow(c) + '</button>' + subs;
452
614
  };
453
615
 
454
- // 搜索入口挂在名片与关系/日程入口之间 —— 侧栏的公共区, 会话列表与关系图下都在。⌘K 见下方「搜索」。
616
+ // 搜索入口挂在名片下 —— 侧栏的公共区, 会话列表与关系图下都在。⌘K 见下方「搜索」。
455
617
  var MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
456
618
  var KBD = MAC ? '⌘K' : 'Ctrl K';
457
619
  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>';
@@ -459,10 +621,12 @@
459
621
  sbox.className = 'sbox'; sbox.type = 'button';
460
622
  sbox.innerHTML = '<span class="ic">' + SEARCH_SVG + '</span><span class="lb">搜索 role、会话、消息</span><kbd>' + KBD + '</kbd>';
461
623
  sbox.onclick = function () { openSearch(); };
462
- $('#rb-acts').parentNode.insertBefore(sbox, $('#rb-acts'));
624
+ $('#rolebar').parentNode.insertBefore(sbox, $('#rolebar').nextSibling);
625
+ // 老外壳 (daemon 未重启) 里还留着名片下那条入口栏 —— 日程已并进名片, 它整条不要了。
626
+ if ($('#rb-acts')) $('#rb-acts').remove();
463
627
  var renderConvs = function () {
464
628
  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);
629
+ var dms = R.convs.filter(function (c) { return c.kind === 'wizard'; }).sort(recentFirst);
466
630
  R.convs.forEach(markRead);
467
631
  if (WORLD) return renderWorld();
468
632
  // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
@@ -470,7 +634,8 @@
470
634
  return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
471
635
  };
472
636
  // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
473
- var html = sec('群聊', groups) + sec('私聊', dms);
637
+ // 开关挂在第一个标题行的右端; 一个会话都没有也留一行标题给它。
638
+ var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', scopeToggle() + worldToggle() + '</h2>');
474
639
  if (convsEl._html === html) return;
475
640
  convsEl._html = html; convsEl.innerHTML = html;
476
641
  convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
@@ -482,6 +647,10 @@
482
647
  b.onclick = function () { MORE[key] = !MORE[key]; renderConvs(); };
483
648
  });
484
649
  bindGo(convsEl);
650
+ bindWorldToggle(convsEl);
651
+ convsEl.querySelectorAll('[data-scope]').forEach(function (b) {
652
+ b.onclick = function () { setScope(b.getAttribute('data-scope')); };
653
+ });
485
654
  };
486
655
 
487
656
  var shortCwd = function (p) {
@@ -536,23 +705,31 @@
536
705
  if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
537
706
  });
538
707
 
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>';
708
+ // 会话列表 ↔ 关系图: 侧栏第一行右上角同一个小开关, 标的是「点了去哪」。
709
+ var LIST_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
710
+ var TREE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 3v10M4 6h5M4 11h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="11" cy="6" r="1.6" fill="currentColor"/><circle cx="11" cy="11" r="1.6" fill="currentColor"/></svg>';
711
+ // 群下子项的范围: 「仅与我有往来」(成对) / 「chat 内全部」(再列出群里与我无往来的 role)。
712
+ // 只是个人的看法偏好 —— 记在本地, 取不到就按成对。
713
+ var SCOPE_KEY = 'wezard.role.subScope';
714
+ var SCOPE = (function () { try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'pair'; } catch (e) { return 'pair'; } })();
715
+ var setScope = function (v) {
716
+ if (v === SCOPE) return;
717
+ SCOPE = v;
718
+ try { localStorage.setItem(SCOPE_KEY, v); } catch (e) { }
719
+ renderConvs();
545
720
  };
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(' · '));
721
+ // 单个文字按钮: 字是当前范围, 点一下切到另一种 (样式与旁边的「关系图」按钮同一个 .vt)。
722
+ var scopeToggle = function () {
723
+ var pair = SCOPE === 'pair';
724
+ return '<button class="vt" data-scope="' + (pair ? 'all' : 'pair') + '" title="' +
725
+ (pair ? '群下只列与我有往来的 role (点击改为列出 chat 内全部)' : '群下列出 chat 内全部 role (点击改为只列有往来的)') + '">' + (pair ? '往来' : '全部') + '</button>';
549
726
  };
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' : '');
727
+ var worldToggle = function () {
728
+ return '<button class="vt" data-vt title="' + (WORLD ? '换回会话列表' : '换成关系') + '">' + (WORLD ? LIST_SVG + '列表' : TREE_SVG + '关系') + '</button>';
729
+ };
730
+ var bindWorldToggle = function (scope) {
731
+ var b = scope.querySelector('[data-vt]');
732
+ if (b) b.onclick = function () { setWorld(!WORLD); };
556
733
  };
557
734
 
558
735
  // 轻提示: 底部居中一枚, 新的顶掉旧的。
@@ -599,60 +776,89 @@
599
776
  for (var el = e.target; el && el.nodeType === 1 && el !== document.body && !clipTip(el); el = el.parentElement);
600
777
  });
601
778
 
602
- // 名片: 头像 · 名字 (忙闲 / session) · 名字下一行低调的描述 (📁 cwd · 🏠 home · 🐣 出生) · 职责;
603
- // 名片下面一排是关系 / 日程入口。身份与出身 (谁的分身 / 子 wizard) 交给关系图, 名片不写。
779
+ // 名片: 头像 · 名字 (忙闲 / session) · 名字下一行低调的描述 (🏠 home · 🐣 出生 · 📜 宪章大小 · 🗂️ cwd · 📅 日程) · 职责。
780
+ // 身份与出身 (谁的分身 / 子 wizard) 交给关系图 (侧栏标题右端的开关), 名片不写。
781
+ // 日程: 下一枪几点, 定时出错时染红改写出错数; 没有排期就只写条数 (定时 + 工单)。
782
+ var planFact = function () {
783
+ var p = R.plan || {};
784
+ var txt = p.broken ? '日程 ⚠ ' + p.broken + ' 出错' : p.nextAt ? '日程 ' + fmtClock(p.nextAt).replace(/^今天 /, '') : '日程 ' + R.schedules;
785
+ return ['📅', txt, (p.tasks ? p.tasks + ' 条定时 · ' : '') + '点开看日程', 'plan', p.broken ? 'bad' : ''];
786
+ };
604
787
  var renderRole = function () {
605
788
  var r = R.role;
606
789
  if (!r) return;
607
790
  // 出生 = 注册表记的 bornAt; 没记 (老 wizard / 人) 就退到最早一段 session 的开始。
608
791
  var born = r.bornAt || R.sessions.reduce(function (m, x) { return x.start && (!m || x.start < m) ? x.start : m; }, 0);
609
792
  var facts = [
610
- r.cwd ? ['📁', shortCwd(r.cwd), r.cwd] : null,
611
793
  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>'; });
794
+ born ? ['🐣', fmtAgo(born), '出生于 ' + fmtDay(born)] : null,
795
+ // 宪章紧跟出生: 出生时被交代了多少 (≈token), 只写大小; 可点开看全文。
796
+ R.charter ? ['📜', fmtTok(R.charter.tokens), '宪章 ≈' + fmtTok(R.charter.tokens) + ' token · ' + fmtClock(R.charter.at) + ' 压进系统提示 · 点开看全文', 'charter'] : null,
797
+ r.cwd ? ['🗂️', shortCwd(r.cwd), r.cwd] : null,
798
+ // 日程排最后, 可点: 名下排了什么。
799
+ R.schedules ? planFact() : null
800
+ ].filter(Boolean).map(function (f) {
801
+ return '<span' + (f[3] ? ' class="fx' + (VIEW === f[3] ? ' on' : '') + (f[4] ? ' ' + f[4] : '') + '" data-view="' + f[3] + '"' : '') + ' title="' + esc(f[2]) + '">' + f[0] + ' ' + esc(f[1]) + '</span>';
802
+ });
803
+ // 只有一段 session 就没什么可选, 不挂选择器。
614
804
  $('#rb-who').innerHTML =
615
805
  '<div class="id"><span class="av">' + esc(r.label) + '</span>' +
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>' : '') +
806
+ '<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
807
  '<span id="rb-sp">' + (R.sessions.length > 1 ? sessPicker() : '') + '</span></span>' +
618
808
  (facts.length ? '<span class="facts">' + facts.join('') + '</span>' : '') + '</span></div>' +
619
809
  (r.description ? '<p class="job">' + esc(r.description) + '</p>' : '');
620
810
  paintStatus();
621
811
  $('#rb-who').querySelector('.cp').onclick = function () { copyText(nameOf(r.id)); };
812
+ $('#rb-who').querySelectorAll('.fx').forEach(function (x) {
813
+ x.onclick = function () { var v = x.getAttribute('data-view'); setView(VIEW === v ? 'msgs' : v); };
814
+ });
622
815
  $('#rb-who').querySelectorAll('.go').forEach(function (g) {
623
816
  g.onclick = function () { switchRole(g.getAttribute('data-r')); };
624
817
  });
625
818
  // 老外壳的底栏选择框已并进 name 行。
626
819
  if ($('#rb-foot')) $('#rb-foot').hidden = true;
627
820
  bindSessPicker();
628
- // 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份,
629
- // 家谱计数 (几个分身 / 子 wizard) 属于关系, 写在关系图入口上。
630
- $('#rb-acts').innerHTML = [R.relations && relTile(r), R.schedules && planTile()].filter(Boolean).join('<span class="sep" aria-hidden="true">|</span>');
631
- $('#rb-acts').querySelectorAll('.bd').forEach(function (b) {
632
- var v = b.getAttribute('data-view');
633
- b.onclick = function () { v === 'world' ? setWorld(!WORLD) : setView(VIEW === v ? 'msgs' : v); };
634
- });
635
- // 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题跟着换。
636
- document.title = nameOf(r.id) + ' 的视角';
821
+ // 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题与 favicon 跟着换成它的头像。
822
+ document.title = (r.label ? r.label + ' ' : '') + nameOf(r.id) + ' 的视角';
823
+ setFavicon(r.label);
824
+ };
825
+
826
+ // favicon = 头像 emoji 画成的 SVG data URI; 没有头像就不动。
827
+ var setFavicon = function (emoji) {
828
+ if (!emoji) return;
829
+ var link = document.querySelector('link[rel="icon"]') || document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }));
830
+ link.type = 'image/svg+xml';
831
+ link.href = 'data:image/svg+xml,' + encodeURIComponent(
832
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y="50" font-size="86" text-anchor="middle" dominant-baseline="central">' +
833
+ esc(emoji) + '</text></svg>');
637
834
  };
638
835
 
639
836
  // ── 用量条: 一组轮次的总账 ──
640
837
  // usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
641
- // 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 退成浅灰蓝不抢眼 →
642
- // 输入 (1×) 沉稳蓝 → 缓存写 (1.25×) 琥珀提醒 → 输出 (约 5×, 最贵) 珊瑚红警示。注意力留给后三段。
838
+ // 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 直接用边线色, 色带贴顶时这段就像普通上边线 →
839
+ // 输入 (1×) 沉稳蓝 → 输出 (约 5×, 最贵) 绿 → 缓存写 (1.25×) 红, 与蓝/绿都拉开。注意力留给后三段。
643
840
  var SEGS = [
644
- ['cacheRead', '缓存读', '#aab7c8', 'cr'], ['input', '输入', '#4c6fd6', 'in'],
645
- ['cacheWrite', '缓存写', '#e0a030', 'cw'], ['output', '输出', '#e4572e', 'out'],
841
+ // in = API 的 input_tokens: 只是没命中缓存、也没写进缓存的那点新输入 (Claude 上每次请求常只有个位数),
842
+ // 不是「送进模型的输入」—— 那是 in + cr + cw, 在 bar 的悬停提示里单列。
843
+ ['cacheRead', '缓存读', 'var(--line)', 'cr'], ['input', '新输入 (未走缓存)', '#4c6fd6', 'in'],
844
+ ['output', '输出', '#2f9e5b', 'out'], ['cacheWrite', '缓存写', '#d64545', 'cw'],
646
845
  ];
647
846
  var TIP = {
648
847
  turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
649
- ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值', time: '累计耗时',
848
+ ctx: '上下文 — 最近一次请求送入的 input + 缓存',
650
849
  };
651
- var ICON = { turns: '💬', tools: '🛠️', api: '📡', ctx: '🧠', time: '⏱️' };
652
- var liveDur = function (t) {
653
- var d = (t.usage && t.usage.durationMs) || 0;
654
- if (!t.runningUntil) return d;
655
- return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - R.at);
850
+ var ICON = { cost: '💰', turns: '💬', tools: '🛠️', api: '⇄', ctx: '📄' };
851
+ var fmtUsd = function (n) {
852
+ return n < .01 ? '<$0.01' : '$' + (n < 10 ? n.toFixed(2) : n < 1000 ? n.toFixed(1) : Math.round(n));
853
+ };
854
+ // 费用: 有认不出价格的模型就在数前加「≥」—— 实际只多不少; 一分钱都没算出来就不占位。
855
+ var costKv = function (u) {
856
+ if (!u.cost) return '';
857
+ var txt = (u.unpriced ? '≥' : '') + fmtUsd(u.cost);
858
+ var tip = '估算费用 — 每轮按模型单价 (LiteLLM 价格表快照) 计 input / output / 缓存写 / 缓存读' +
859
+ (u.unpriced ? '\n另有 ' + u.unpriced + ' 次 API 请求的模型不在价格表里, 未计入' : '');
860
+ return '<span class="kv kv-cost" title="' + esc(tip) + '" data-tip="' + esc(ICON.cost + ' ' + txt) +
861
+ '"><i class="u-ic">' + ICON.cost + '</i><b>' + esc(txt) + '</b></span>';
656
862
  };
657
863
  var usageHTML = function (t) {
658
864
  var u = t.usage || {};
@@ -665,15 +871,16 @@
665
871
  return n ? '<span class="kv kv-' + k + '" title="' + esc(TIP[k] || k) + '" data-tip="' + esc(ICON[k] + ' ' + text) +
666
872
  '"><i class="u-ic">' + ICON[k] + '</i><b>' + esc(text) + '</b></span>' : '';
667
873
  };
668
- // 输出 / 缓存不再单列成指标 —— 它们就是底边色带的那几段。
669
- // I/O 分布: 贴着整条 bar 的底边画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
874
+ // 输出 / 缓存不再单列成指标 —— 它们就是顶边色带的那几段。
875
+ // I/O 分布: 贴着整条 bar 的顶边 (替代上边线) 画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
670
876
  // 两头留一位小数: 99.9% 不该被四舍五入成 100%, 一丁点也不该成 0%。
671
877
  var pct = function (n) {
672
878
  var p = n / total * 100;
673
879
  return p < .1 ? '<0.1%' : (p < 1 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%';
674
880
  };
675
881
  var io = total > 0
676
- ? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total)].concat(segs.map(function (s) {
882
+ ? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total),
883
+ '送入合计 (in + cr + cw) ' + fmtTok((u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0))].concat(segs.map(function (s) {
677
884
  return s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]);
678
885
  })).join('\n')) + '">' + segs.map(function (s) {
679
886
  return '<span class="seg" style="flex-grow:' + u[s[0]] + ';background:' + s[2] + '" title="' +
@@ -691,16 +898,17 @@
691
898
  return '<span class="u-who" title="' + esc(nameOf(t.target)) + '">' + esc(roleLabel(t.target) || t.label) +
692
899
  '<span class="u-nm" data-tip="' + esc(nameOf(t.target)) + '">' + esc(nameOf(t.target)) + '</span></span>' +
693
900
  '<span class="u-lb" title="' + esc(t.model || '') + '">' +
694
- esc(t.model ? t.model.replace(/^claude-/, '') : '用量') + '</span>' +
901
+ esc(t.modelLabel || t.model || '用量') + '</span>' +
902
+ (t.effort ? '<span class="u-ef" title="effort 档位">' + esc(t.effort) + '</span>' : '') +
695
903
  '<span class="u-kvs">' +
696
- kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
697
- kv('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + kv('time', liveDur(t), fmtDur(liveDur(t))) +
904
+ costKv(u) + kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
905
+ kv('ctx', u.ctx, fmtTok(u.ctx)) +
698
906
  '</span>' + io;
699
907
  };
700
- // 永远单行: 排不下就按 FOLD 的顺序一级级藏 (分布文字 → 名字 → 耗时), 宽度回来再按反序放出来。
908
+ // 永远单行: 排不下就按 FOLD 的顺序一级级藏 (消息/工具/API 计数 → 分布文字 → 名字), 宽度回来再按反序放出来。
701
909
  // 判定看的是容器自己溢没溢出, 不看视口 —— 同一个组件挂在整页页脚和右栏底, 宽度各不相同。
702
- var FOLD = ['f-leg', 'f-nm', 'f-time'];
703
- var FOLDED = { 'f-leg': '.leg', 'f-nm': '.u-nm', 'f-time': '.kv-time' };
910
+ var FOLD = ['f-cnt', 'f-leg', 'f-nm'];
911
+ var FOLDED = { 'f-cnt': '.kv-turns, .kv-tools, .kv-api', 'f-leg': '.leg', 'f-nm': '.u-nm' };
704
912
  var fitUsage = function (el) {
705
913
  if (el.hidden) return;
706
914
  var over = function () { return el.scrollWidth > el.clientWidth; };
@@ -708,10 +916,9 @@
708
916
  var n = 0;
709
917
  while (n < FOLD.length && over()) el.classList.add(FOLD[n++]);
710
918
  // 藏起来的那几样进整条 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');
919
+ el.title = FOLD.slice(0, n).reduce(function (a, c) {
920
+ return a.concat([].map.call(el.querySelectorAll(FOLDED[c]), function (x) { return x.getAttribute('data-tip'); }));
921
+ }, []).filter(Boolean).join('\n');
715
922
  };
716
923
  var watchUsage = function (el) {
717
924
  if (el._ro || typeof ResizeObserver === 'undefined') return;
@@ -743,19 +950,113 @@
743
950
  };
744
951
 
745
952
  // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
953
+ // 跳走之前在看的那段会话 —— 顶栏的「‹ 回到 …」回这里。on: 在哪些窗口上露出这个按钮;
954
+ // at: 跳走时点的那条消息, 回来落在它上面。进工单页 (只在从非工单的窗口进来时记) 与
955
+ // 移交行点对方都记它, 同一个按钮。
956
+ var BACK = null;
957
+ var inJobs = function () { return VIEW === 'jobs' || CONV.indexOf('j:') === 0; };
958
+ // 回来 = 原样恢复那一页: 会话、视角、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
959
+ var markBack = function (on) {
960
+ var r = !atBottom() && firstShown();
961
+ var at = r && { id: r.getAttribute('data-id'), off: r.getBoundingClientRect().top - thread.getBoundingClientRect().top };
962
+ BACK = { conv: CONV, with: WITH, session: SESSION, eye: EYE, at: at || null, on: on };
963
+ };
964
+ var restoreAt = function (at) {
965
+ if (!at) return undefined;
966
+ var put = function () {
967
+ var row = rowNode(at.id);
968
+ if (!row) return false;
969
+ thread.scrollTop += row.getBoundingClientRect().top - thread.getBoundingClientRect().top - at.off;
970
+ S.pinned = atBottom();
971
+ return true;
972
+ };
973
+ return function () { if (!put()) loadMsgs('0', put); return true; };
974
+ };
975
+ var openJob = function (id) {
976
+ if (CONV.indexOf('j:') !== 0) markBack(inJobs);
977
+ selectConv('j:' + id, '');
978
+ };
979
+ // 一处标记背后可能有好几张: 一张直接进那张, 多张进工单列表且只列这几张 (JOB_ONLY)。
980
+ var JOB_ONLY = null;
981
+ var openJobs = function (ids) {
982
+ if (ids.length === 1) return openJob(ids[0]);
983
+ if (CONV.indexOf('j:') !== 0) markBack(inJobs);
984
+ JOB_ONLY = ids;
985
+ if (VIEW === 'jobs') renderJobList(); else setView('jobs');
986
+ renderHead();
987
+ };
988
+ // 标记长在会话项 / 关系图卡片这些按钮里: 捕获阶段截下来, 不让外层按钮再打开那段对话。
989
+ document.addEventListener('click', function (e) {
990
+ var m = e.target.closest && e.target.closest('.jmk[data-jobs]');
991
+ if (!m) return;
992
+ e.stopPropagation(); e.preventDefault();
993
+ openJobs(m.getAttribute('data-jobs').split(' ').filter(Boolean));
994
+ }, true);
995
+ var backBtn = function () {
996
+ var c = BACK && BACK.on() && convOf(BACK.conv);
997
+ return c ? '<button class="back jb" id="ch-jback" title="' + esc('回到 ' + (BACK.with ? nameOf(BACK.with) : c.name)) + '" aria-label="返回">‹</button>' : '';
998
+ };
999
+ // 返回钮钉在标题栏最左 (标题前面), 不进右侧的按钮组 —— 标题写好之后再插进去。
1000
+ var bindBack = function () {
1001
+ var h = backBtn();
1002
+ if (h) $('#ch-who').insertAdjacentHTML('afterbegin', h);
1003
+ var b = $('#ch-jback');
1004
+ if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.eye); };
1005
+ };
746
1006
  var renderHead = function () {
747
1007
  var who = $('#ch-who'), acts = $('#ch-acts');
1008
+ if (VIEW === 'jobs') {
1009
+ who.innerHTML = '<span class="t">工单</span><span class="sub">' + (JOB_ONLY ? '这一处经手的 ' + jobListRows().length + ' 张' : esc(nameOf(ROLE)) + ' 开的或参与的') + ' · 进行中在前</span>';
1010
+ acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
1011
+ bindBack();
1012
+ $('#ch-back').onclick = function () { setView('msgs'); };
1013
+ return;
1014
+ }
748
1015
  if (VIEW !== 'msgs') {
749
- who.innerHTML = '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务与工单</span>';
1016
+ who.innerHTML = VIEW === 'charter'
1017
+ ? '<span class="t">宪章</span><span class="sub">wezard 出生时压进 ' + esc(nameOf(ROLE)) + ' 系统提示的那份身份</span>'
1018
+ : '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务</span>';
750
1019
  acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
751
1020
  $('#ch-back').onclick = function () { setView('msgs'); };
752
1021
  return;
753
1022
  }
754
1023
  var c = convOf(CONV);
755
1024
  if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
1025
+ // 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏与成对子项同一种写法 —— 只写它,
1026
+ // 「在 <群>」点回整个群。
1027
+ if (c.kind === 'all' && c.chat) {
1028
+ who.innerHTML = '<span class="t">' + nm(c.who, '', true) + '</span>' +
1029
+ '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button></span>';
1030
+ acts.innerHTML = '';
1031
+ bindGo(who);
1032
+ $('#ch-up').onclick = function () { selectConv('c:' + c.chat, ''); };
1033
+ return;
1034
+ }
756
1035
  if (c.kind === 'all') {
757
- who.innerHTML = pairOf([[c.who]]) + '<span class="t">' + esc(c.name) + '</span>';
1036
+ who.innerHTML = pairOf([[c.who]]) + '<span class="t" title="' + esc(c.name) + '"></span>';
758
1037
  acts.innerHTML = '';
1038
+ bindBack();
1039
+ fitTalk(who.querySelector('.t'), c);
1040
+ bindGo(who);
1041
+ return;
1042
+ }
1043
+ // 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏只写对方 (视角自己左栏名片上已有), 不画头像;
1044
+ // 群名退到副标题, 点它 = 选回这个群本身 (整个群的视图)。
1045
+ if (WITH && c.kind === 'group') {
1046
+ var s = (c.subs || []).filter(function (x) { return x.role === WITH; })[0];
1047
+ who.innerHTML = '<span class="t">' + nm(WITH, s && s.name, true) + '</span>' +
1048
+ '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + c.name + ' 的全部记录') + '">' + esc(c.name) + '</button></span>';
1049
+ acts.innerHTML = '';
1050
+ bindBack();
1051
+ bindGo(who);
1052
+ $('#ch-up').onclick = function () { selectConv(CONV, ''); };
1053
+ return;
1054
+ }
1055
+ if (c.kind === 'job') {
1056
+ who.innerHTML = '<span class="av">📋</span><span class="t">' + esc(c.name) + '</span>' +
1057
+ '<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + jobTag(c.job) + stuckTag(c.job) + '</span>';
1058
+ acts.innerHTML = '';
1059
+ bindBack();
759
1060
  bindGo(who);
760
1061
  return;
761
1062
  }
@@ -764,14 +1065,27 @@
764
1065
  var peer = pairPeer(c);
765
1066
  var peerLabel = c.kind !== 'group' ? c.label : dm && peer === dm.role ? dm.label : '';
766
1067
  who.innerHTML = (peer ? pairOf([[ROLE, R.role && R.role.label], [peer, peerLabel]]) : '') +
767
- '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
768
- acts.innerHTML = WITH
769
- ? '<span class="with">只看我与 ' + nm(WITH, '', true) + '</span><button class="vb" id="ch-all">看全部</button>'
770
- : '';
771
- bindGo(who); bindGo(acts);
772
- var x = $('#ch-all');
773
- if (x) x.onclick = function () { selectConv(CONV, ''); };
774
- };
1068
+ '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
1069
+ acts.innerHTML = '';
1070
+ bindBack();
1071
+ bindGo(who);
1072
+ };
1073
+ // 多人对话的标题放不下时, 收成「X 与 .a、.b、.c 等 N 个 role 之间的对话」—— 放得下几个列几个, 至少一个。
1074
+ // 量的是真宽度: 没上屏 (窄屏还停在列表) 量不出来, 就先给全称, 等 resize / 下一次重画。
1075
+ var fitTalk = function (el, c) {
1076
+ var n = c.peers.length;
1077
+ var at = function (k) {
1078
+ return k >= n ? c.name : nameOf(c.who) + ' 与 ' + c.peers.slice(0, k).map(nameOf).join('、') + ' 等 ' + n + ' 个 role 之间的对话';
1079
+ };
1080
+ var k = n;
1081
+ el.textContent = at(k);
1082
+ if (!el.clientWidth) return;
1083
+ while (k > 1 && el.scrollWidth > el.clientWidth + 1) el.textContent = at(--k);
1084
+ };
1085
+ window.addEventListener('resize', function () {
1086
+ var c = VIEW === 'msgs' && convOf(CONV);
1087
+ if (c && c.kind === 'all') renderHead();
1088
+ });
775
1089
 
776
1090
  // ── 消息行 ──
777
1091
  // 同一条消息从发话方看靠右、从收信方看靠左 —— 片段不带方向, 由这里按视角包装。
@@ -789,43 +1103,66 @@
789
1103
  var signCut = function (html, id, name) {
790
1104
  return html.replace(CUT_L, function (_, open, l) { return open + nm(id, name, true) + l; });
791
1105
  };
1106
+ // 消息上的工单 badge (服务端只写 data-job): 按此刻的账填成 📋 标题 + 进度, 点了进那张工单页。
1107
+ // 工单页自己的消息不挂 —— 点了还是这一页。
1108
+ var JOB_CHIP = /<button class="mchip job" data-job="([^"]*)"><\/button>/g;
1109
+ var jobChips = function (html) {
1110
+ return html.replace(JOB_CHIP, function (_, id) {
1111
+ if (CONV === 'j:' + id) return '';
1112
+ var c = jobRowOf(id);
1113
+ return '<button class="mchip job" data-job="' + id + '" title="' + esc(id + ' · ' + c.name + ' —— 进工单页') + '">📋 <span class="jt">' + esc(c.name) + '</span>' + jobTag(c.job) + '</button>';
1114
+ });
1115
+ };
1116
+ var refillJobChips = function () {
1117
+ inner.querySelectorAll('.mchip.job[data-job]').forEach(function (b) {
1118
+ b.outerHTML = jobChips('<button class="mchip job" data-job="' + b.getAttribute('data-job') + '"></button>');
1119
+ });
1120
+ };
792
1121
  var rowHTML = function (m) {
793
1122
  learn(m.from, m.fromName, m.fromLabel);
794
1123
  learn(m.to, m.toName, m.toLabel);
795
1124
  if (m.dir === 'mark') {
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>';
1125
+ 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) + '</div>';
797
1126
  }
798
- var mine = m.from === ROLE;
1127
+ var me = eyeOf();
1128
+ var mine = m.from === me;
799
1129
  var other = mine ? m.to : m.from;
800
1130
  // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
801
1131
  var group = CONV.indexOf('p:') !== 0;
1132
+ // 顶栏那个对端是相对 ROLE 的; 消息区换成了它 (EYE) 的视角时, 它的对端就是 ROLE。
1133
+ var peer = group ? pairPeer(convOf(CONV)) : CONV.slice(2);
1134
+ if (EYE && peer === EYE) peer = ROLE;
802
1135
  // 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
803
1136
  // (一对一里收信方就是顶栏那个对端, 不再重复写)。
804
1137
  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 : '');
1138
+ ? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
1139
+ : (m.to !== me && group ? m.to : '');
807
1140
  // 宽屏写在气泡对面的空白里 (.dest, 在换视角的门里但不吃点击); 窄屏空白太窄, 退回消息头 (.to)。
808
- var to = dst ? '<span class="to">→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) + '</span>' : '';
1141
+ var to = dst ? '<span class="to">→ ' + avBtn(m.to) + nm(m.to, '', true) + '</span>' : '';
809
1142
  // 箭头顺着「发话人 → 收件人」: 气泡是发话人, 描述在哪一侧箭头就背着气泡指向哪一侧 ——
810
1143
  // 我的消息描述在左 (🦉 .x ←), 别人的在右 (→ 🦉 .x)。
811
1144
  var dest = dst
812
1145
  ? '<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>' +
1146
+ '<span class="dav">' + esc(roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
814
1147
  (mine ? '<i class="arr">←</i>' : '') + '</span>'
815
1148
  : '';
816
1149
  var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
817
- // 本轮的账 (呼吸点 + 模型 / token / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
818
- var stat = m.meta ? '<span class="mstat">' + m.meta + '</span>' : '';
1150
+ // 本轮的账 (呼吸点 + 模型 / 上下文 / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
1151
+ // 模型 / ctx 只在多方会话里有用 (分得清谁跑的什么模型); 两个 role 之间的对话里整行挂 .two, 头像行 (.mstat)、
1152
+ // 终句气泡的时间行 (.say-cap) 与移交行的账 (.ho-acct) 里藏掉这两样, 耗时、呼吸点与回执留着。
1153
+ var c = convOf(CONV);
1154
+ var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1155
+ var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
819
1156
  var who = mine
820
- ? to + stamp(m.ts) + stat + avBtn(m.from, m.fromLabel)
821
- : avBtn(m.from, m.fromLabel) + nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
1157
+ ? to + stamp(m.ts) + stat + avBtn(m.from)
1158
+ : avBtn(m.from) + nm(m.from, '', true) + to + priv + stamp(m.ts) + stat;
822
1159
  var sw = canSwitch(other);
823
1160
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
824
1161
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
825
1162
  dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
826
- return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
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) + '">' +
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>' +
1163
+ return '<div class="mrow ' + (mine ? 'mine' : 'them') + (two ? ' two' : '') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
1164
+ (m.ping ? ' data-ping="1" data-ping-who="' + esc(roleName(m.dir === 'in' ? m.to : m.from)) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
1165
+ '<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(m.dir === 'out' ? signCut(m.html, m.from) : m.html) + '</div></div>' +
829
1166
  flip +
830
1167
  '</div>';
831
1168
  };
@@ -880,7 +1217,7 @@
880
1217
  };
881
1218
 
882
1219
  var bindRow = function (el) {
883
- bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r]');
1220
+ bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r], .tg-job .go[data-r]');
884
1221
  };
885
1222
  var rowNode = function (id) {
886
1223
  var list = inner.querySelectorAll('.mrow');
@@ -903,6 +1240,43 @@
903
1240
  };
904
1241
  };
905
1242
 
1243
+ var moreBtn = function (d) {
1244
+ return d.truncated ? '<button class="more-btn" id="more">载入更早的 ' + (d.older != null ? d.older : d.total - d.msgs.length) + ' 条</button>' : '';
1245
+ };
1246
+ var bindMore = function () {
1247
+ var btn = $('#more');
1248
+ if (btn) btn.onclick = function () { btn.textContent = '载入中…'; btn.disabled = true; loadOlder(); };
1249
+ };
1250
+ // 载入更早: 以此刻最早那一行为游标往前取一页, 插在最前面; 视口锚住原来顶上那一行。
1251
+ var loadOlder = function () {
1252
+ var gen = S.gen, first = inner.querySelector('.mrow');
1253
+ if (!first) return Promise.resolve();
1254
+ var cursor = { before: first.getAttribute('data-id'), beforeTs: first.getAttribute('data-ts') };
1255
+ // 失败就把按钮还原, 别让它永远停在「载入中…」; 换了窗口 (gen 变了) 按钮已随旧内容消失。
1256
+ var retry = function () {
1257
+ var b = $('#more'); if (gen === S.gen && b) { b.disabled = false; b.textContent = '载入失败, 点此重试'; }
1258
+ };
1259
+ return api('api/msgs', viewParams(cursor)).then(function (d) {
1260
+ if (gen !== S.gen) return;
1261
+ if (!d.ok) return retry();
1262
+ var keep = keepView();
1263
+ // 游标切不出「之前」(服务端回 reset): 退回整窗全量重载, 视口照旧锚住。
1264
+ if (d.reset) return loadMsgs('0', keep);
1265
+ var old = $('#more'); if (old) old.remove();
1266
+ // 按时刻兜底的那一页可能带回同一毫秒、已经画着的几条 —— 按 id 去重。
1267
+ var fresh = d.msgs.filter(function (m) { return !rowNode(m.id); });
1268
+ fresh.forEach(function (m) { S.frags[m.id] = m; });
1269
+ unfoldPings(inner);
1270
+ var page = frag(moreBtn(d) + fresh.map(rowHTML).join(''));
1271
+ bindRow(page); render(page);
1272
+ while (page.lastChild) inner.insertBefore(page.lastChild, inner.firstChild);
1273
+ bindMore();
1274
+ foldPings(inner);
1275
+ expireRows();
1276
+ if (keep) keep();
1277
+ }).catch(retry);
1278
+ };
1279
+
906
1280
  // land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
907
1281
  var loadMsgs = function (limit, land) {
908
1282
  var gen = S.gen;
@@ -912,16 +1286,14 @@
912
1286
  S.frags = {};
913
1287
  d.msgs.forEach(function (m) { S.frags[m.id] = m; });
914
1288
  if (!d.msgs.length) { inner.innerHTML = '<div class="empty">这里还没有消息</div>'; return; }
915
- var more = d.truncated
916
- ? '<button class="more-btn" id="more">载入更早的 ' + (d.total - d.msgs.length) + ' 条</button>'
917
- : '';
918
- inner.innerHTML = more + d.msgs.map(rowHTML).join('');
919
- var btn = $('#more');
920
- if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0', keepView()); };
1289
+ inner.innerHTML = moreBtn(d) + d.msgs.map(rowHTML).join('');
1290
+ bindMore();
921
1291
  bindRow(inner);
922
1292
  render(inner);
923
1293
  foldPings(inner);
924
1294
  expireRows();
1295
+ settleAbove();
1296
+ applyFold(inner);
925
1297
  if (land && land()) return;
926
1298
  S.pinned = true; toBottom(true);
927
1299
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
@@ -960,6 +1332,8 @@
960
1332
  }
961
1333
  }
962
1334
  foldPings(inner);
1335
+ settleAbove();
1336
+ applyFold(inner);
963
1337
  if (stick) toBottom(true);
964
1338
  };
965
1339
 
@@ -972,6 +1346,9 @@
972
1346
  g.setAttribute('data-stale-at', '0');
973
1347
  g.querySelectorAll('.tg-dot').forEach(function (d) { d.classList.remove('live'); });
974
1348
  g.querySelectorAll('.typing').forEach(function (t) { t.remove(); });
1349
+ // 静默到点 = 这一轮结束, 末尾那个过程框也不再是「进行中」。
1350
+ g.querySelectorAll('.steps').forEach(settle);
1351
+ applyFold(g);
975
1352
  });
976
1353
  };
977
1354
 
@@ -980,16 +1357,20 @@
980
1357
  // 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
981
1358
  var takeRole = function (d) {
982
1359
  R.at = d.at || Date.now(); R.recvAt = Date.now();
983
- R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
1360
+ R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys;
984
1361
  SESSION = d.session || '';
985
- R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null;
1362
+ R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null; R.inflight = d.inflight || [];
1363
+ // 工单的账变了 (或比消息晚到): 已上屏的工单 badge 按新账重填一遍 —— 片段里只写了工单号。
1364
+ var jx = JSON.stringify(d.jobIndex || {});
1365
+ if (jx !== R.jobIndexSig) { R.jobIndexSig = jx; R.jobIndex = d.jobIndex || {}; refillJobChips(); }
1366
+ R.charter = d.charter || null;
986
1367
  R.stats = d.stats || null; R.winStats = d.winStats || null;
987
1368
  ROLE = d.role.id;
988
- learn(d.role.id, d.role.name, d.role.label);
989
- R.convs.forEach(function (c) {
990
- if (c.peer) learn(c.peer, c.name, c.label);
991
- c.subs.forEach(function (s) { learn(s.role, s.name, s.label); });
992
- });
1369
+ var renamed = [learn(d.role.id, d.role.name, d.role.label, true)].concat.apply([], R.convs.map(function (c) {
1370
+ return [c.peer ? learn(c.peer, c.name, c.label, true) : false].concat(c.subs.map(function (s) { return learn(s.role, s.name, s.label, true); }));
1371
+ })).some(Boolean);
1372
+ // 名册晚到 (svr 刚起) 或有人改了名: 已上屏的行按新名字就地重包, 正文不动。
1373
+ if (renamed && inner.querySelector('.mrow')) rewrapRows(false);
993
1374
  // 没带 conv 的链接 (群里点名字进来) 落在服务端挑的窗口上, 连同它挑的「只看我与谁」;
994
1375
  // 手里的 conv 失效则只退回整个会话。
995
1376
  if (!CONV || !convOf(CONV)) { WITH = CONV ? '' : d['with'] || ''; CONV = d.conv || ''; }
@@ -1034,6 +1415,7 @@
1034
1415
  if (land) takeRole(d); else applyRole(d);
1035
1416
  syncUrl();
1036
1417
  if (WORLD || VIEW === 'plan') loadWorld();
1418
+ if (VIEW === 'charter') loadCharter();
1037
1419
  return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
1038
1420
  if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
1039
1421
  connect();
@@ -1042,8 +1424,8 @@
1042
1424
  };
1043
1425
 
1044
1426
  // land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
1045
- var selectConv = function (key, withRole, land) {
1046
- CONV = key; WITH = withRole || '';
1427
+ var selectConv = function (key, withRole, land, eye) {
1428
+ CONV = key; WITH = withRole || ''; EYE = eye && eye !== ROLE ? eye : '';
1047
1429
  reveal();
1048
1430
  if (VIEW !== 'msgs') setView('msgs');
1049
1431
  app.classList.add('reading');
@@ -1064,11 +1446,11 @@
1064
1446
  W.treeFor = '';
1065
1447
  var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
1066
1448
  var from = ROLE;
1067
- // 整个频道 / 两人私聊的消息集合与视角无关 —— 只有 with 与 session 按 role 过滤。
1068
- var same = VIEW === 'msgs' && !WITH && !SESSION && (keep || CONV.indexOf('p:') === 0);
1069
- // 群里「只看我与 X」时换过去, 对面看到的是「只看我与 from」—— 同一段往来, 选中只看而不是整个群。
1070
- var withBack = keep && WITH ? from : '';
1071
- ROLE = id; WITH = withBack; SESSION = '';
1449
+ // 群里「我与 X」时换到 X, 对面看到的是「X 与 from」—— 同一段往来, 选中这一对而不是整个群。
1450
+ var withBack = keep && WITH === id ? from : '';
1451
+ // 整个频道 / 两人私聊 / 群里的一对, 消息集合都与视角无关 —— 集合没变就就地翻面。
1452
+ var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0);
1453
+ ROLE = id; WITH = withBack; SESSION = ''; EYE = '';
1072
1454
  CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
1073
1455
  // 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
1074
1456
  // 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
@@ -1186,6 +1568,21 @@
1186
1568
  if (nb) nb.replaceWith(k.mb);
1187
1569
  });
1188
1570
  };
1571
+ /** 已上屏的行拿手里的片段就地重包 (左右、署名), 已渲染的正文整块搬过去。dropMarks: 断点一并撤下。 */
1572
+ var rewrapRows = function (dropMarks) {
1573
+ unfoldPings(inner);
1574
+ [].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
1575
+ var m = S.frags[row.getAttribute('data-id')];
1576
+ if (!m || (dropMarks && m.dir === 'mark')) { if (dropMarks) row.remove(); return; }
1577
+ var next = frag(rowHTML(m)).firstElementChild;
1578
+ var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
1579
+ if (ob && nb) nb.replaceWith(ob);
1580
+ row.replaceWith(next);
1581
+ bindRow(next);
1582
+ });
1583
+ foldPings(inner);
1584
+ expireRows();
1585
+ };
1189
1586
  /** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去。
1190
1587
  * 先等 role 摘要到手、页头侧栏按新视角画完再重包 —— 页头换了高度 (人没有职责行、有的 wizard 有),
1191
1588
  * 在动画起跑之后才变, 整列就会在半路被顶一下。旧行在这几十毫秒里原样留着。 */
@@ -1196,19 +1593,8 @@
1196
1593
  api('api/role', viewParams()).then(function (d) {
1197
1594
  if (!d.ok || gen !== S.gen) return;
1198
1595
  applyRole(d);
1199
- unfoldPings(inner);
1200
- [].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
1201
- var m = S.frags[row.getAttribute('data-id')];
1202
- // 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
1203
- if (!m || m.dir === 'mark') { row.remove(); return; }
1204
- var next = frag(rowHTML(m)).firstElementChild;
1205
- var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
1206
- if (ob && nb) nb.replaceWith(ob);
1207
- row.replaceWith(next);
1208
- bindRow(next);
1209
- });
1210
- foldPings(inner);
1211
- expireRows();
1596
+ // 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
1597
+ rewrapRows(true);
1212
1598
  landFlip(snap);
1213
1599
  connect();
1214
1600
  return api('api/msgs', viewParams());
@@ -1343,10 +1729,6 @@
1343
1729
 
1344
1730
  // F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
1345
1731
  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
1732
  var chainUp = function (F, t, seen) {
1351
1733
  seen = seen || {};
1352
1734
  var p = F.pp[t];
@@ -1359,11 +1741,13 @@
1359
1741
  var labelHTML = function (p) {
1360
1742
  if (!p) return '';
1361
1743
  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) {
1744
+ // 工单这一种关系由 📋 那一枚说 (可点, 列的就是这几张), 不再另挂「工单 ×N」重复一遍。
1745
+ Object.keys(KIND).filter(function (k) { return p.kinds[k] && !(k === 'job' && p.jobs.length); }).map(function (k) {
1363
1746
  return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
1364
1747
  (!LINEAGE[k] ? ' ×' + p.kinds[k] : '') + '</span>';
1365
1748
  }).join('') +
1366
- (p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') + '</span>';
1749
+ (p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') +
1750
+ (p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? ' ×' + p.jobs.length : '') + '</span>' : '') + '</span>';
1367
1751
  };
1368
1752
  // 一条边的主色: 家谱优先, 决定树上那道线的颜色。
1369
1753
  var domKind = function (p) {
@@ -1430,78 +1814,86 @@
1430
1814
  var byCard = function (F) {
1431
1815
  return function (a, b) { return recentFirst(cardGlance(F, a), cardGlance(F, b)); };
1432
1816
  };
1433
- var tnodeHTML = function (F, n, folded) {
1817
+ // 关系图: 它与 N 个 role 有关系, 但它们的卡片不在这一档画出来的图上 (画着就不算, 连没连线都一样)。
1818
+ // 关系 = 服务端裁节点之前的全部对端 (node.rels) ∪ 这段范围里画得出的边 —— 前者补上没下发的节点,
1819
+ // 后者兜住老 svr 没给 rels 的情况。F.on = 这一档画出来的卡片, 「相关 | 全部」各算各的。
1820
+ var otherRels = function (F, t) {
1821
+ var on = F.on || {};
1822
+ var all = Object.keys(F.pairs).reduce(function (m, k) {
1823
+ var p = F.pairs[k], o = p.from === t ? p.to : p.to === t ? p.from : '';
1824
+ if (o) m[o] = 1;
1825
+ return m;
1826
+ }, ((nodeOf(t) || {}).rels || []).reduce(function (m, o) { m[o] = 1; return m; }, {}));
1827
+ var n = Object.keys(all).filter(function (o) { return o !== t && !on[o]; }).length;
1828
+ return moreTag(n, nameOf(t) + ' 还和 ' + n + ' 个 role 有关系, 不在这张图上, 切到它的视角可见');
1829
+ };
1830
+ var tnodeHTML = function (F, n) {
1434
1831
  var me = n.target === ROLE;
1435
- var tail = folded ? '<span class="tfold" title="它下面还有 ' + folded + ' 个, 切到它的视角可见">+' + folded + '</span>' : '';
1436
1832
  var p = F.pp[n.target];
1437
- var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, tail);
1833
+ var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, '', otherRels(F, n.target));
1438
1834
  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) + ' 对过话, 留着是为了连到它下面对过话的') + '"' : '') + '>' +
1835
+ var jin = F.job && (F.job.in[n.target] ? ' jin' : ' jout');
1836
+ return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
1837
+ (via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
1441
1838
  labelHTML(p) + row + '</button>';
1442
1839
  };
1443
1840
 
1444
- // keep(父, 孩子) → 'full' 整枝 / 'leaf' 只画它自己 / '' 不画; 不给 keep = 全画。shown 收集画出来的节点。
1445
- var treeHTML = function (F, t, depth, keep, leaf, shown, seen, z) {
1841
+ // 只画 F.vis 里的 (没有 = 全画)。shown 收集画出来的节点。
1842
+ var treeHTML = function (F, t, depth, shown, seen, z) {
1446
1843
  var n = nodeOf(t);
1447
1844
  if (!n || seen[t] || depth > 32) return '';
1448
1845
  seen[t] = 1;
1449
1846
  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) +
1847
+ var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).sort(byCard(F));
1848
+ // 选中工单时: 两端都是它的当事人 (含开单者) 的那道线才亮。
1849
+ var jl = F.job ? (F.job.in[t] && F.pp[t] && F.job.in[F.pp[t].from] ? ' jline' : ' jdim') : '';
1850
+ return '<li class="' + domKind(F.pp[t]) + jl + '"' + (z ? ' style="z-index:' + z + '"' : '') + '>' + tnodeHTML(F, n) +
1452
1851
  // 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
1453
1852
  (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);
1853
+ return treeHTML(F, k, depth + 1, shown, seen, kids.length - i);
1455
1854
  }).join('') + '</ul>' : '') +
1456
1855
  '</li>';
1457
1856
  };
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
- };
1466
- };
1467
-
1468
1857
  // 关系图替换的是侧栏的会话列表 —— 右边照旧是选中的那段对话。
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);
1474
- };
1858
+ // 只看相关时画谁: 视角自己 + 和视角有直接关系的 (任一种, 任一方向; 'rel'); 它们通向根的主父亲链上
1859
+ // 与视角无关的祖先留作连接 ('via', 画淡) —— 抹掉它, 有关系的后代就从家谱上脱开, 成了一棵来历不明的孤树。
1475
1860
  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';
1861
+ var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || F.pairs[ROLE + '\u0000' + t] || F.pairs[t + '\u0000' + ROLE]; });
1862
+ var vis = keep.reduce(function (m, t) { m[t] = 'rel'; return m; }, {});
1863
+ if (!vis[ROLE]) vis[ROLE] = 'rel';
1479
1864
  keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
1480
1865
  return vis;
1481
1866
  };
1482
1867
 
1868
+ // 正在看一张工单 (CONV = j:<id>) 时, 它的当事人; 选中态就是 CONV 本身, 不另记一份。
1869
+ var jobOfConv = function () {
1870
+ var id = CONV.indexOf('j:') === 0 ? CONV.slice(2) : '';
1871
+ var j = id && (W.jobs || []).filter(function (x) { return x.id === id; })[0];
1872
+ return j ? { id: id, in: j.members.reduce(function (m, mm) { m[mm.target] = 1; return m; }, (function (o) { o[j.owner] = 1; return o; })({})) } : null;
1873
+ };
1874
+
1483
1875
  var renderWorld = function () {
1484
1876
  if (!WORLD) return;
1485
1877
  if (!W.loaded) { convsEl._tree = ''; convsEl.innerHTML = '<div class="empty">加载中…</div>'; return; }
1486
1878
  var rg = rangeOf();
1487
1879
  var F = forestOf(relations(rg));
1880
+ F.job = jobOfConv();
1488
1881
  var me = nodeOf(ROLE);
1489
1882
  var all = W.treeAll || !me;
1490
1883
  var shown = [];
1491
1884
  var path = me ? chainUp(F, ROLE) : [];
1492
- // 和视角之间没有对话的关系只在「看全部」里画。
1885
+ // 和视角没有直接关系的只在「看全部」里画。
1493
1886
  F.vis = all ? null : visibleOf(F);
1494
1887
  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, {});
1888
+ return F.roots.filter(function (r) { return shows(F, r); }).sort(function (a, b) {
1889
+ var mine = path[0];
1890
+ return (b === mine) - (a === mine) || byCard(F)(a, b);
1891
+ }).map(function (r) { return treeHTML(F, r, 0, into, {}); }).join('');
1501
1892
  };
1502
1893
  // 两遍: 先量出画了谁 (卡片的窗口要知道它在图上连着谁), 再带着连线画 —— 选中态也就进了 html。
1503
1894
  draw(shown);
1504
1895
  F.links = linksOf(F, shown);
1896
+ F.on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
1505
1897
  var keys = shown.map(function (n) { return talkKey(F.links, n.target); }).join('\n');
1506
1898
  if (keys !== W.gKeys) { W.gKeys = keys; loadGlance(); }
1507
1899
  // 换视角后的第一张新树: 世界快照与摘要都已是新视角的, 才知道它自己那张卡片连着谁。
@@ -1514,12 +1906,12 @@
1514
1906
  var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
1515
1907
  var alone = !all && shown.length < 2;
1516
1908
  var html = '<div class="tview">' +
1517
- '<h2>关系图<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
1909
+ '<h2>关系<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
1518
1910
  (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>') +
1911
+ (me ? '<button class="vt" data-tall="' + (all ? 'rel' : 'all') + '" title="' +
1912
+ (all ? '画出范围内所有有关系的 wizard (点击改为只画相关的)' : '只画它自己、和它有直接关系的, 以及连到它们的上游链 (点击改为画全部)') + '">' + (all ? '全部' : '相关') + '</button>' : '') + worldToggle() + '</h2>' +
1913
+ (alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁有关系</div>' : '') +
1914
+ (shown.length ? '<ul class="tree' + (all ? ' all' : '') + (F.job ? ' jsel' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
1523
1915
  '</div>';
1524
1916
  // 心跳每 3s 重算一次 (状态灯 / 几分钟前) —— 没变就不碰 DOM, 免得蹭掉悬停与滚动。
1525
1917
  if (convsEl._tree === html && convsEl.querySelector('.tview')) return;
@@ -1529,8 +1921,14 @@
1529
1921
  el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
1530
1922
  });
1531
1923
  bindGo(convsEl);
1532
- var tall = convsEl.querySelector('.tall');
1533
- if (tall) tall.onclick = function () { W.treeAll = !W.treeAll; W.treeFor = ''; renderWorld(); };
1924
+ convsEl.querySelectorAll('[data-tall]').forEach(function (b) {
1925
+ b.onclick = function () {
1926
+ var on = b.getAttribute('data-tall') === 'all';
1927
+ if (on === !!W.treeAll) return;
1928
+ W.treeAll = on; W.treeFor = ''; renderWorld();
1929
+ };
1930
+ });
1931
+ bindWorldToggle(convsEl);
1534
1932
  var cur = convsEl.querySelector('.tci.me');
1535
1933
  if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
1536
1934
  };
@@ -1636,11 +2034,6 @@
1636
2034
  // 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
1637
2035
  var renderPlan = function () {
1638
2036
  var ss = (W.schedules || []).filter(function (x) { return (x.owner || x.createdBy || x.target) === ROLE; });
1639
- var js = (W.jobs || []).filter(function (j) {
1640
- return j.owner === ROLE || j.members.some(function (mm) { return mm.target === ROLE; });
1641
- });
1642
- var open = js.filter(function (j) { return j.status === 'open'; });
1643
- var closed = js.filter(function (j) { return j.status !== 'open'; });
1644
2037
  var bad = ss.filter(broken);
1645
2038
  var next = ss[0];
1646
2039
  var stat = function (k, v, sub, tone) {
@@ -1649,37 +2042,14 @@
1649
2042
  var stats = '<div class="pstats">' +
1650
2043
  stat('下一次', next ? esc(fmtHM(next.nextAt)) : '—', next ? esc(fmtIn(next.nextAt) + ' · ' + (next.note || next.id)) : '没有排期') +
1651
2044
  stat('定时任务', ss.length, ss.length ? esc(ss.filter(function (x) { return x.hasGate; }).length + ' 条带 gate') : '—') +
1652
- stat('工单', open.length + '<small> / ' + js.length + '</small>', '进行中 / 全部') +
1653
2045
  (bad.length ? stat('出错', bad.length, '见下方红色卡片', 'bad') : '') +
1654
2046
  '</div>';
1655
2047
 
1656
- var jobRow = function (j) {
1657
- return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
1658
- '<div class="jh"><span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
1659
- '<span class="jt">' + esc(j.title) + '</span>' +
1660
- '<span class="jid">' + esc(j.id) + '</span>' +
1661
- '<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
1662
- '<div class="jm">' + (j.members.length
1663
- ? j.members.map(function (mm) {
1664
- var n = nodeOf(mm.target);
1665
- return '<span class="jmm">' + wizChip(mm.target) + stTag(n) +
1666
- (mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
1667
- '<em>' + esc((mm.task || '').split('\n')[0].slice(0, 90)) + '</em></span>';
1668
- }).join('')
1669
- : '<span class="jmm none">还没有成员</span>') + '</div>' +
1670
- (j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
1671
- '</div>';
1672
- };
1673
-
1674
2048
  var html = stats +
1675
2049
  '<section class="psec">' +
1676
2050
  '<h3>定时任务<span>' + ss.length + '</span></h3>' +
1677
2051
  (ss.length ? renderStrip(ss) + bad.concat(ss.filter(function (x) { return !broken(x); })).map(taskHTML).join('')
1678
2052
  : '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>') +
1679
- '</section>' +
1680
- '<section class="psec">' +
1681
- '<h3>工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
1682
- (js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
1683
2053
  '</section>';
1684
2054
  // 心跳每 3s 重画一次 —— 没变就不碰 DOM (展开着的 prompt、悬停的提示都留着)。
1685
2055
  if (planEl._html === html) return;
@@ -1706,11 +2076,20 @@
1706
2076
  // 不经过 detail store。改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
1707
2077
  var WORLD_MS = 6000;
1708
2078
  var worldTimer = null;
2079
+ // 带上次的 ETag 问: 304 = 名册没变, 不重画 (卡片窗口的 glance 照样顺带刷新)。
1709
2080
  var loadWorld = function () {
1710
2081
  var asked = ROLE;
1711
- return api('api/world', { role: asked }).then(function (d) {
1712
- if (!d.ok) return;
1713
- W.at = d.at; W.loaded = true; W.role = asked;
2082
+ var p = new URLSearchParams({ role: asked, id: TOKEN });
2083
+ var tag = W.role === asked ? W.etag : '';
2084
+ var etag = '';
2085
+ return fetch('api/world?' + p.toString(), { cache: 'no-store', headers: tag ? { 'If-None-Match': tag } : {} }).then(function (r) {
2086
+ if (r.status === 304) { if (asked === ROLE) loadGlance(); return null; }
2087
+ etag = r.headers.get('etag') || '';
2088
+ return r.json();
2089
+ }).then(function (d) {
2090
+ // 迟到的回包 (这期间已换了视角) 丢掉, 免得盖掉新视角的图; etag 与 role 成对记。
2091
+ if (!d || !d.ok || asked !== ROLE) return;
2092
+ W.at = d.at; W.loaded = true; W.role = asked; W.etag = etag;
1714
2093
  W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
1715
2094
  W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
1716
2095
  renderWorld();
@@ -1720,7 +2099,7 @@
1720
2099
  };
1721
2100
  var pollWorld = function () {
1722
2101
  if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
1723
- if (VIEW === 'msgs' && !WORLD) return;
2102
+ if (VIEW !== 'plan' && !WORLD) return;
1724
2103
  worldTimer = setInterval(function () {
1725
2104
  if (!document.hidden) loadWorld();
1726
2105
  }, WORLD_MS);
@@ -1730,11 +2109,15 @@
1730
2109
  VIEW = v;
1731
2110
  thread.hidden = v !== 'msgs';
1732
2111
  $('#pane-plan').hidden = v !== 'plan';
2112
+ charterPane().hidden = v !== 'charter';
2113
+ jobsPane().hidden = v !== 'jobs';
1733
2114
  app.classList.toggle('outer', v !== 'msgs');
1734
2115
  // 手机上关系/日程占满主区 —— 进入即阅读态。
1735
2116
  if (v !== 'msgs') app.classList.add('reading');
1736
2117
  renderRole(); renderHead(); renderUsage();
1737
2118
  if (v === 'msgs') { toBottom(true); renderConvs(); }
2119
+ else if (v === 'charter') loadCharter();
2120
+ else if (v === 'jobs') renderJobList();
1738
2121
  else {
1739
2122
  if (!W.loaded) { planEl._html = ''; planEl.innerHTML = '<div class="empty">加载中…</div>'; }
1740
2123
  else renderPlan();
@@ -1742,6 +2125,93 @@
1742
2125
  }
1743
2126
  pollWorld();
1744
2127
  };
2128
+ // ── 宪章: 出生快照, 不轮询 —— 换视角或重新点开时取一次。 ──
2129
+ // 面板由脚本挂载: 外壳 HTML 要等服务重启才换, 老外壳里没有这一格。
2130
+ var charterPane = function () {
2131
+ var p = $('#pane-charter');
2132
+ if (p) return p;
2133
+ p = document.createElement('div');
2134
+ p.className = 'pane'; p.id = 'pane-charter'; p.hidden = true;
2135
+ p.innerHTML = '<div class="plan-in" id="charter-in"></div>';
2136
+ $('#pane-plan').after(p);
2137
+ return p;
2138
+ };
2139
+ var jobsPane = function () {
2140
+ var p = $('#pane-jobs');
2141
+ if (p) return p;
2142
+ p = document.createElement('div');
2143
+ p.className = 'pane'; p.id = 'pane-jobs'; p.hidden = true;
2144
+ p.innerHTML = '<div class="plan-in jlist" id="jobs-in"></div>';
2145
+ $('#pane-plan').after(p);
2146
+ return p;
2147
+ };
2148
+ // 视角开的或在里面的工单: 进行中 (最近有动静的在前) / 已收工 (淡一档, 最近收的在前)。一行与会话列表同一个 convItem。
2149
+ var renderJobList = function () {
2150
+ var el = jobsPane().firstChild;
2151
+ var js = jobListRows();
2152
+ var open = js.filter(function (c) { return c.job.status === 'open'; }).sort(recentFirst);
2153
+ var closed = js.filter(function (c) { return c.job.status !== 'open'; }).sort(recentFirst);
2154
+ var sec = function (title, list, cls) {
2155
+ return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + list.map(convItem).join('') + '</div>';
2156
+ };
2157
+ var html = js.length
2158
+ ? sec('进行中', open, 'jopen') + (closed.length ? sec('已收工', closed, 'jclosed') : '')
2159
+ : JOB_ONLY
2160
+ ? '<div class="pempty">这几张工单已经不在账上了 (收工满 24 小时会清掉) —— ' + esc(JOB_ONLY.join(' ')) + '</div>'
2161
+ : '<div class="pempty">' + esc(nameOf(ROLE)) + ' 没有开过、也不在任何工单里 —— 一次派出两个以上 wizard 时 open_job 开一个 (收工满 24 小时的不再列出)</div>';
2162
+ if (el._html === html) return;
2163
+ el._html = html; el.innerHTML = html;
2164
+ el.querySelectorAll('[data-conv]').forEach(function (b) { b.onclick = function () { selectConv(b.getAttribute('data-conv'), ''); }; });
2165
+ };
2166
+ var loadCharter = function () {
2167
+ var el = charterPane().firstChild, asked = ROLE;
2168
+ el.innerHTML = '<div class="empty">加载中…</div>';
2169
+ return api('api/charter', { role: asked }).then(function (d) {
2170
+ if (asked !== ROLE || VIEW !== 'charter') return;
2171
+ el.innerHTML = d.ok && !d.none ? charterHTML(d) : '<div class="empty">' + esc(roleName(asked)) + ' 没有宪章记录 (人, 或在这项记录出现前出生、还没重生过的 wizard)</div>';
2172
+ bindCharter(el);
2173
+ }).catch(function () { el.innerHTML = '<div class="empty">载入失败</div>'; });
2174
+ };
2175
+ var charterHTML = function (d) {
2176
+ var stat = function (k, v, sub) {
2177
+ return '<div class="pst"><span class="k">' + k + '</span><b>' + v + '</b><span class="s" title="' + esc(sub) + '">' + esc(sub) + '</span></div>';
2178
+ };
2179
+ var b = d.baseline;
2180
+ var max = Math.max.apply(null, d.sections.map(function (x) { return x.tokens; }));
2181
+ var big = d.sections.filter(function (x) { return x.tokens === max; })[0];
2182
+ var pct = function (n) { return Math.round(100 * n / d.tokens) + '%'; };
2183
+ var mdOk = mdReady();
2184
+ // 组成条: 一节一段, 宽度按体量 —— 一眼看出谁在吃宪章; 最大那节加深, 与下面的行同色。
2185
+ var mix = '<div class="cmix">' + d.sections.map(function (x) {
2186
+ return '<i class="' + (x === big ? 'top' : '') + '" style="flex:' + x.tokens + '" title="' + esc(x.title + ' ≈' + fmtTok(x.tokens) + ' · ' + pct(x.tokens)) + '"></i>';
2187
+ }).join('') + '</div>';
2188
+ var sec = function (x) {
2189
+ return '<details class="csec' + (x === big ? ' top' : '') + '"><summary><span class="ct">' + esc(x.title) + '</span>' +
2190
+ '<span class="cbar"><i style="width:' + Math.round(100 * x.tokens / max) + '%"></i></span>' +
2191
+ '<span class="cn">≈' + fmtTok(x.tokens) + '<small>' + pct(x.tokens) + '</small></span></summary>' +
2192
+ '<div class="md-body">' + (mdOk ? md.render(x.body) : '<pre>' + esc(x.body) + '</pre>') + '</div></details>';
2193
+ };
2194
+ // 没有实测底座就不摆一张「—」: 换成最大的那一节, 同样回答「钱花在哪」。
2195
+ var right = b
2196
+ ? stat('开局实测', fmtTok(b.ctx), (b.resumed ? '续接的老会话, 含此前对话 · ' : '') + '此后首轮第一次调用送入的上下文') +
2197
+ stat('宪章占开局', Math.round(100 * d.tokens / b.ctx) + '%', '其余是 CLI 系统提示、工具、CLAUDE.md、skills 与第一句话')
2198
+ : stat('最大一节', pct(max), big.title + ' ≈' + fmtTok(max));
2199
+ return '<div class="pstats">' +
2200
+ stat('宪章', '≈' + fmtTok(d.tokens) + '<small> tokens</small>', fmtClock(d.at) + ' 压进系统提示 · 随进程终身不变') + right +
2201
+ '</div>' +
2202
+ '<section class="psec"><h3>按节<span>' + d.sections.length + ' 节</span>' +
2203
+ '<button class="cx" id="charter-all">全部展开</button></h3>' +
2204
+ mix + d.sections.map(sec).join('') + '</section>';
2205
+ };
2206
+ var bindCharter = function (el) {
2207
+ var btn = el.querySelector('#charter-all');
2208
+ if (!btn) return;
2209
+ var all = function () { return el.querySelectorAll('.csec'); };
2210
+ var sync = function () { btn.textContent = [].every.call(all(), function (x) { return x.open; }) ? '全部收起' : '全部展开'; };
2211
+ btn.onclick = function () { var open = btn.textContent === '全部展开'; all().forEach(function (x) { x.open = open; }); sync(); };
2212
+ all().forEach(function (x) { x.ontoggle = sync; });
2213
+ };
2214
+
1745
2215
  // 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
1746
2216
  var setWorld = function (on) {
1747
2217
  WORLD = on; W.treeFor = ''; W.pickSelf = '';
@@ -1749,7 +2219,7 @@
1749
2219
  syncUrl();
1750
2220
  // 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
1751
2221
  if (on) app.classList.remove('reading');
1752
- renderRole();
2222
+ renderRole(); renderHead();
1753
2223
  if (on) { renderWorld(); loadWorld(); }
1754
2224
  else {
1755
2225
  // 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
@@ -1765,6 +2235,14 @@
1765
2235
  }
1766
2236
  pollWorld();
1767
2237
  };
2238
+ // 进行中那一轮的耗时: 服务端只给开始时刻, 这里每秒按它走表 (取整秒, 免得小数位抖)。
2239
+ var tickDur = function () {
2240
+ document.querySelectorAll('.tg-dur.live[data-since]').forEach(function (el) {
2241
+ var ms = Math.max(0, srvNow() - Number(el.getAttribute('data-since')));
2242
+ el.textContent = fmtDur(Math.floor(ms / 1000) * 1000);
2243
+ });
2244
+ };
2245
+ setInterval(tickDur, 1000);
1768
2246
  // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
1769
2247
  setInterval(function () {
1770
2248
  if (!R.role) return;
@@ -1773,6 +2251,7 @@
1773
2251
  // 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
1774
2252
  paintStatus(); renderConvs();
1775
2253
  if (VIEW === 'plan') renderPlan();
2254
+ if (VIEW === 'jobs') renderJobList();
1776
2255
  }, TICK_MS);
1777
2256
 
1778
2257
  // ══ 搜索 (⌘K / Ctrl+K) ═══════════════════════════════════════════════
@@ -1822,7 +2301,7 @@
1822
2301
  return {
1823
2302
  kind: 'conv',
1824
2303
  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>' +
2304
+ '<span class="tag">' + (c.kind === 'wizard' ? '私聊' : c.kind === 'job' ? '工单' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
1826
2305
  (c.preview ? '<span class="pv">' + esc(c.preview) + '</span>' : '') + '</span>',
1827
2306
  go: function () { selectConv(c.key, ''); },
1828
2307
  };
@@ -1864,8 +2343,8 @@
1864
2343
  };
1865
2344
  };
1866
2345
  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 = '';
2346
+ if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, h.with || '', landOn(h.id), h.eye);
2347
+ SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; EYE = h.eye && h.eye !== ROLE ? h.eye : '';
1869
2348
  if (VIEW !== 'msgs') setView('msgs');
1870
2349
  app.classList.add('reading');
1871
2350
  reveal();