wezard 1.4.1 → 2.0.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 (71) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +227 -112
  3. package/cli/wezard.sh +6 -3
  4. package/dist/daemon/approval.js +9 -3
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/cc-bridge.js.map +1 -1
  7. package/dist/daemon/chat-name.js +8 -14
  8. package/dist/daemon/chat-name.js.map +1 -1
  9. package/dist/daemon/detail.js +94 -9
  10. package/dist/daemon/detail.js.map +1 -1
  11. package/dist/daemon/graph.js +5 -7
  12. package/dist/daemon/graph.js.map +1 -1
  13. package/dist/daemon/inbound.js +135 -158
  14. package/dist/daemon/inbound.js.map +1 -1
  15. package/dist/daemon/index.js +452 -156
  16. package/dist/daemon/index.js.map +1 -1
  17. package/dist/daemon/jobs.js +2 -2
  18. package/dist/daemon/mirror-bridge.js +284 -140
  19. package/dist/daemon/mirror-bridge.js.map +1 -1
  20. package/dist/daemon/mirror-store.js.map +1 -1
  21. package/dist/daemon/model-select.js +74 -0
  22. package/dist/daemon/model-select.js.map +1 -0
  23. package/dist/daemon/peers.js +10 -16
  24. package/dist/daemon/peers.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +27 -4
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/task-gate.js +97 -0
  28. package/dist/daemon/task-gate.js.map +1 -0
  29. package/dist/daemon/task-registry.js +132 -0
  30. package/dist/daemon/task-registry.js.map +1 -0
  31. package/dist/daemon/tasks.js +126 -54
  32. package/dist/daemon/tasks.js.map +1 -1
  33. package/dist/daemon/wizard-memory.js +57 -0
  34. package/dist/daemon/wizard-memory.js.map +1 -0
  35. package/dist/daemon/wizard.js +84 -36
  36. package/dist/daemon/wizard.js.map +1 -1
  37. package/dist/mcp/server.js +138 -76
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/shared/chat-http.js +193 -96
  40. package/dist/shared/chat-http.js.map +1 -1
  41. package/dist/shared/chat-render.js +31 -24
  42. package/dist/shared/chat-render.js.map +1 -1
  43. package/dist/shared/chat-view.js +14 -2
  44. package/dist/shared/chat-view.js.map +1 -1
  45. package/dist/shared/config.js +23 -4
  46. package/dist/shared/config.js.map +1 -1
  47. package/dist/shared/detail-render.js +14 -7
  48. package/dist/shared/detail-render.js.map +1 -1
  49. package/dist/shared/detail-store.js +12 -5
  50. package/dist/shared/detail-store.js.map +1 -1
  51. package/dist/shared/role-render.js +52 -0
  52. package/dist/shared/role-render.js.map +1 -0
  53. package/dist/shared/role-view.js +210 -0
  54. package/dist/shared/role-view.js.map +1 -0
  55. package/dist/shared/schedule-spec.js +92 -116
  56. package/dist/shared/schedule-spec.js.map +1 -1
  57. package/dist/shared/session-label.js +103 -72
  58. package/dist/shared/session-label.js.map +1 -1
  59. package/dist/shared/std.js +20 -0
  60. package/dist/shared/std.js.map +1 -1
  61. package/dist/shared/task-file.js +120 -0
  62. package/dist/shared/task-file.js.map +1 -0
  63. package/dist/shared/trigger.js +232 -0
  64. package/dist/shared/trigger.js.map +1 -0
  65. package/dist/shared/world.js +193 -0
  66. package/dist/shared/world.js.map +1 -0
  67. package/dist/svr/index.js +68 -3
  68. package/dist/svr/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/web/chat.css +609 -147
  71. package/web/chat.js +1274 -215
package/web/chat.js CHANGED
@@ -1,20 +1,50 @@
1
- // Chat 详情 SPA。数据全部来自 /api/chat (侧栏 + 总账) 与 /api/thread (线程),
2
- // 增量走 /api/events (SSE)。turn 正文是服务端渲染好的 HTML 片段 —— diff / ANSI /
3
- // 语法高亮只在 shared/detail-render 实现一次, 这里只做 DOM 增量 reconcile。
1
+ // Rolepage SPA —— 从**一个 role 的视角**看它的 IM: 它参与的群聊与单聊, 每一处
2
+ // 它说了什么 (靠右)、听到了什么 (靠左)。点气泡对面那片空白就换成对方的视角。
3
+ //
4
+ // 对话 /api/role (身份 + 会话列表 + session) + /api/msgs (窗口正文) + /api/role-events (SSE)
5
+ // 关系 /api/world —— 聚焦当前 role 的家谱与协作网 (轮询)
6
+ // 日程 同一份 /api/world, 只列归当前 role 的定时任务与工单
7
+ //
8
+ // 视角状态 (role / conv / with / session) 全在 URL 里 —— 刷新、转发、从别的聊天点
9
+ // 进来, 落点都一样。消息正文是服务端渲染好的 HTML 片段 (diff / ANSI / 高亮只在
10
+ // shared/detail-render 实现一次), 这里只包左右与头像, 再做 DOM 增量 reconcile。
4
11
  //
5
12
  // 无构建步骤: 保持 var / function 写法, 直接被浏览器加载。
6
13
  (function () {
7
14
  var qs = new URLSearchParams(location.search);
8
15
  var TOKEN = qs.get('id') || '';
16
+ // 老链接只有 `target=` —— 服务端两个都认, 这里统一成 role。
17
+ var ROLE = qs.get('role') || qs.get('target') || '';
18
+ var CONV = qs.get('conv') || '';
19
+ var WITH = qs.get('with') || '';
20
+ var SESSION = qs.get('session') || '';
9
21
  var TICK_MS = 3000;
10
22
 
11
23
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
12
24
  // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
13
- var S = { base: '', target: '', tags: [], graphs: [], at: 0, recvAt: 0, es: null, pinned: true };
25
+ var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, stats: null };
26
+ // frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
27
+ var S = { es: null, pinned: true, gen: 0, frags: {} };
28
+ // 关系/日程两栏共用的世界快照。sel = 当前聚焦的节点, kinds = 边类型开关。
29
+ var W = {
30
+ at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
31
+ degraded: false, loaded: false, sel: '', onlyRel: false, kinds: { clone: 1, spawn: 1, peer: 1, graph: 1 },
32
+ layout: 'cards',
33
+ };
34
+ // 读法是个人偏好, 记在本地。存储不可用 (隐私窗口 / 老 webview) 就退回默认。
35
+ var LAY_KEY = 'wezard.world.layout';
36
+ try { if (localStorage.getItem(LAY_KEY) === 'force') W.layout = 'force'; } catch (e) { }
37
+ var setLayout = function (v) {
38
+ W.layout = v;
39
+ try { localStorage.setItem(LAY_KEY, v); } catch (e) { }
40
+ };
41
+ var VIEW = 'msgs';
14
42
  var $ = function (s) { return document.querySelector(s); };
15
- var thread = $('#thread'), tagsEl = $('#tags'), sbEl = $('#sb'), connEl = $('#conn'), gbarEl = $('#gbar');
43
+ var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
44
+ var connEl = $('#conn');
45
+ var wmapEl = $('#wmap'), wscrollEl = $('#wscroll'), wtoolsEl = $('#wtools'), planEl = $('#plan-in');
16
46
 
17
- var srvNow = function () { return S.at ? S.at + (Date.now() - S.recvAt) : Date.now(); };
47
+ var srvNow = function () { return R.at ? R.at + (Date.now() - R.recvAt) : Date.now(); };
18
48
 
19
49
  var fmtTok = function (n) {
20
50
  if (!n) return '0';
@@ -30,15 +60,30 @@
30
60
  if (m < 60) return m + 'm' + (s - m * 60) + 's';
31
61
  return Math.floor(m / 60) + 'h' + (m % 60) + 'm';
32
62
  };
33
- // 聊天列表用的相对时间 — 一眼看出"刚刚 / 5 分钟前"。
63
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
64
+ var fmtDay = function (ts) {
65
+ var x = new Date(ts);
66
+ return x.getFullYear() + '-' + pad(x.getMonth() + 1) + '-' + pad(x.getDate()) + ' ' + pad(x.getHours()) + ':' + pad(x.getMinutes());
67
+ };
68
+ // 列表用的相对时间 — 一眼看出"刚刚 / 5 分钟前"。
34
69
  var fmtAgo = function (ts) {
35
70
  if (!ts) return '';
36
71
  var d = srvNow() - ts;
37
72
  if (d < 60000) return '刚刚';
38
73
  if (d < 3600000) return Math.floor(d / 60000) + '分钟前';
39
74
  if (d < 86400000) return Math.floor(d / 3600000) + '小时前';
40
- var x = new Date(ts), p = function (n) { return n < 10 ? '0' + n : '' + n; };
41
- return p(x.getMonth() + 1) + '-' + p(x.getDate()) + ' ' + p(x.getHours()) + ':' + p(x.getMinutes());
75
+ var x = new Date(ts);
76
+ return pad(x.getMonth() + 1) + '-' + pad(x.getDate()) + ' ' + pad(x.getHours()) + ':' + pad(x.getMinutes());
77
+ };
78
+ var fmtHM = function (ts) { var x = new Date(ts); return pad(x.getHours()) + ':' + pad(x.getMinutes()); };
79
+ var fmtFull = function (ts) {
80
+ var x = new Date(ts);
81
+ return (x.getMonth() + 1) + '-' + pad(x.getDate()) + ' ' + pad(x.getHours()) + ':' +
82
+ pad(x.getMinutes()) + ':' + pad(x.getSeconds());
83
+ };
84
+ /** 一条消息的时刻 —— 只写在气泡外面这一处, 卡片里不再重复 (见 chat.css 的 .mb .tg-time)。 */
85
+ var stamp = function (ts) {
86
+ return '<time class="mt" title="' + esc(fmtFull(ts)) + '">' + esc(fmtHM(ts)) + '</time>';
42
87
  };
43
88
  var esc = function (s) {
44
89
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@@ -49,14 +94,46 @@
49
94
  var p = new URLSearchParams(params || {}); p.set('id', TOKEN);
50
95
  return fetch(path + '?' + p.toString(), { cache: 'no-store' }).then(function (r) { return r.json(); });
51
96
  };
97
+ var viewParams = function (extra) {
98
+ var p = { role: ROLE };
99
+ if (CONV) p.conv = CONV;
100
+ if (WITH) p['with'] = WITH;
101
+ if (SESSION) p.session = SESSION;
102
+ Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
103
+ return p;
104
+ };
105
+ // 视角状态回写 URL (replaceState: 不在历史里留一串中间态)。
106
+ var syncUrl = function () {
107
+ var p = new URLSearchParams({ id: TOKEN });
108
+ Object.keys(viewParams()).forEach(function (k) { p.set(k, viewParams()[k]); });
109
+ ['forceInnerBrowser', 'ww_vw', 'ww_vh', 'ww_uniq'].forEach(function (k) { if (qs.get(k)) p.set(k, qs.get(k)); });
110
+ try { history.replaceState(null, '', location.pathname + '?' + p.toString()); } catch (e) { }
111
+ };
52
112
 
53
- // ── 运行状态: 服务端只给"到这个时刻自动算结束", 由客户端定时判定 ──
54
- // SSE 只在有写入时推送, 一个真正停下来的 turn 不会再产生任何事件 —— 没有本地
55
- // 判定, 页面上的「运行中 / 正在思考」就永远熄不掉。
56
- var isRunning = function (t) { return !!t.runningUntil && srvNow() < t.runningUntil; };
57
- var curTag = function () {
58
- return S.tags.filter(function (x) { return x.target === S.target; })[0];
113
+ // ── 名字 ──
114
+ // 一个 role 的名字与头像: 消息片段自带 (fromName/fromLabel), 会话列表与名册兜底。
115
+ var names = {};
116
+ var learn = function (id, name, label) { if (id && name) names[id] = { name: name, label: label || '' }; };
117
+ var kindOf = function (id) {
118
+ return /^task:/.test(id) ? 'task' : /^human:/.test(id) ? 'human' : 'wizard';
119
+ };
120
+ var nodeOf = function (target) {
121
+ return W.nodes.filter(function (n) { return n.target === target; })[0];
122
+ };
123
+ var roleName = function (id) {
124
+ var k = names[id] || {}, n = nodeOf(id);
125
+ return k.name || (n && n.name) || id;
126
+ };
127
+ var roleLabel = function (id) {
128
+ var k = names[id] || {}, n = nodeOf(id);
129
+ return k.label || (n && n.label) || (kindOf(id) === 'human' ? '👤' : kindOf(id) === 'task' ? '⏰' : '🧙');
130
+ };
131
+ // `.name` 的点由 CSS 画 —— 人与定时任务没有地址, 不画点。
132
+ var nm = function (id, name) {
133
+ return '<span class="nm ' + kindOf(id) + '">' + esc(name || roleName(id)) + '</span>';
59
134
  };
135
+ var nameOf = function (id) { return (kindOf(id) === 'wizard' ? '.' : '') + roleName(id); };
136
+ var canSwitch = function (id) { return !!id && kindOf(id) !== 'task' && id !== 'human:'; };
60
137
 
61
138
  // ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
62
139
  var md = null;
@@ -82,7 +159,8 @@
82
159
  });
83
160
  };
84
161
 
85
- // ── 增量 reconcile (与 turn 页同款, 但会递归进 .bubbles) ──
162
+ // ── 增量 reconcile: sig 相同原节点留下; 同为 turn 卡片只换头 + 递归气泡层 ──
163
+ // 一次 tool_result 到达不该重建整张卡片 (滚动位置 / 展开态全部保住)。
86
164
  var snapOpen = function (scope) {
87
165
  var m = {};
88
166
  scope.querySelectorAll('[data-key]').forEach(function (b) {
@@ -103,8 +181,6 @@
103
181
  for (var i = 0; i < el.children.length; i++) if (el.children[i].classList.contains(cls)) return el.children[i];
104
182
  return null;
105
183
  };
106
- // sig 相同 → 原节点原样留下; 不同但两边都是 turn-group → 只换头 + 递归气泡层,
107
- // 这样一次 tool_result 到达不会重建整轮 DOM (滚动位置 / 展开态全部保住)。
108
184
  var reconcile = function (cur, next) {
109
185
  var open = snapOpen(cur), existing = {};
110
186
  Array.prototype.forEach.call(cur.children, function (c) {
@@ -133,263 +209,422 @@
133
209
  };
134
210
 
135
211
  // ── 滚动: 默认到底; 用户手动上翻后解除吸附, 回到底部再吸附 ──
136
- var atBottom = function () {
137
- return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
212
+ var atBottom = function () { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80; };
213
+ var toBottom = function (force) { if (force || S.pinned) thread.scrollTop = thread.scrollHeight; };
214
+ thread.addEventListener('scroll', function () { S.pinned = atBottom(); });
215
+
216
+ // ── 左栏: 当前 role 的名片 + 会话列表 ──
217
+ var convOf = function (key) { return R.convs.filter(function (c) { return c.key === key; })[0]; };
218
+
219
+ // 群聊头像 = 参与者头像的拼图 (最多 4 格, 自己排第一)。私聊就是对端自己的头像。
220
+ var membersOf = function (c) {
221
+ var seen = {}, out = [];
222
+ [{ role: ROLE, label: R.role ? R.role.label : '' }].concat(c.subs || []).forEach(function (m) {
223
+ if (m.role && !seen[m.role]) { seen[m.role] = 1; out.push(m); }
224
+ });
225
+ return out;
138
226
  };
139
- var toBottom = function (force) {
140
- if (force || S.pinned) thread.scrollTop = thread.scrollHeight;
227
+ var avatarOf = function (c) {
228
+ if (c.kind !== 'group') return '<span class="av">' + esc(c.label) + '</span>';
229
+ var ms = membersOf(c).slice(0, 4);
230
+ return '<span class="av mosaic n' + ms.length + '" aria-hidden="true">' +
231
+ ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
141
232
  };
142
- thread.addEventListener('scroll', function () { S.pinned = atBottom(); });
143
233
 
144
- // ── 侧栏 (聊天列表) ──
145
- var tagMeta = function (t) {
146
- var u = t.usage || {};
147
- var bits = [];
148
- if (t.turns) bits.push(t.turns + ' 轮');
149
- if (u.tools) bits.push(u.tools + ' 工具');
150
- var tok = (u.output || 0) + (u.input || 0);
151
- if (tok) bits.push(fmtTok(tok) + ' tok');
152
- // 目录只取最后一段: 兄弟会话常常一个在主仓、一个在 worktree, 差别就在这一段。
153
- if (t.cwd) bits.push('📁 ' + (t.cwd.replace(/\/+$/, '').split('/').filter(Boolean).pop() || t.cwd));
154
- // graph 驱动的会话单独标出来 —— 它的 userQuery 长得和真人消息一样, 不标就
155
- // 分不清是有人在跟它说话, 还是某个 run 在喂它。
156
- var g = t.origin
157
- ? '<span class="gtag" title="由 graph ' + esc(t.origin.runId) + ' 驱动">🕸 ' + esc(t.origin.runId) + '</span>'
234
+ var convItem = function (c) {
235
+ var on = c.key === CONV;
236
+ var title = c.kind === 'wizard' ? nm(c.peer, c.name) : '<span class="nm chat">' + esc(c.name) + '</span>';
237
+ var subs = on && c.subs.length
238
+ ? '<div class="subs">' + c.subs.map(function (s) {
239
+ return '<button class="si' + (s.role === WITH ? ' on' : '') + (s.count ? '' : ' quiet') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
240
+ 'title="' + esc(s.count ? '我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来' : '在这里, 但还没和我说过话') + '">' +
241
+ '<span>' + esc(s.label) + '</span><span class="n">' + nm(s.role, s.name) + '</span>' +
242
+ (s.count ? '<span class="c">' + s.count + '</span>' : '') + '</button>';
243
+ }).join('') + '</div>'
158
244
  : '';
159
- if (!bits.length && !g) return '';
160
- return '<div class="tag-meta">' +
161
- bits.map(esc).join('<span class="sep">·</span>') +
162
- (g && bits.length ? '<span class="sep">·</span>' : '') + g +
163
- '</div>';
245
+ return '<button class="ci' + (on && !WITH ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
246
+ avatarOf(c) +
247
+ '<span class="b"><span class="l1"><span class="t">' + title + '</span>' +
248
+ '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
249
+ '<span class="pv">' + esc(c.preview) + '</span></span>' +
250
+ '</button>' + subs;
164
251
  };
165
252
 
166
- // ── graph 运行条 ──
167
- // 只画最近活跃的那一个 run: 同一 chat 同时跑两张图是罕见情形, 而两条并排的
168
- // 流水线会把顶栏挤成一团 —— 宁可只讲清楚当前这一条。
169
- var labelOfTag = function (tag) {
170
- var hit = S.tags.filter(function (x) { return x.tag === tag; })[0];
171
- return hit ? hit.label : '•';
172
- };
173
- var renderGraph = function () {
174
- var g = (S.graphs || [])[0];
175
- if (!g) { gbarEl.hidden = true; gbarEl.innerHTML = ''; return; }
176
- // 与会话行同一判定: 服务端只给"到这个时刻自动算结束", 熄灯由本地定时判。
177
- var run = isRunning(g);
178
- var nodes = g.pipeline.map(function (p) {
179
- var on = p.step === g.step;
180
- return '<span class="nd' + (on ? ' on' : '') + (on && run ? ' live' : '') +
181
- '" data-tag="' + esc(p.tag) + '" title="步 ' + p.step + '/' + g.steps + ' · #' + esc(p.tag) + '">' +
182
- esc(labelOfTag(p.tag)) + ' ' + esc(p.tag ? '#' + p.tag : 'default') + '</span>';
183
- }).join('<span class="arw">→</span>');
184
- gbarEl.hidden = false;
185
- gbarEl.innerHTML =
186
- '<span class="gid" title="graph run">🕸 ' + esc(g.runId) + '</span>' +
187
- '<span class="pipe">' + nodes + '</span>' +
188
- '<span class="prog' + (run ? '' : ' done') + '">' +
189
- (run ? '⟳ ' : '✓ ') + '轮 ' + g.round + '/' + g.rounds + '</span>';
190
- gbarEl.querySelectorAll('.nd').forEach(function (n) {
191
- n.onclick = function () {
192
- var tag = n.getAttribute('data-tag');
193
- var hit = S.tags.filter(function (x) { return x.tag === tag; })[0];
194
- if (hit) select(hit.target);
195
- };
253
+ var renderConvs = function () {
254
+ var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
255
+ var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
256
+ var sec = function (title, list, none) {
257
+ return '<h2>' + title + '<span>' + list.length + '</span></h2>' +
258
+ (list.length ? list.map(convItem).join('') : '<div class="none">' + none + '</div>');
259
+ };
260
+ // 人没有私聊 —— wizard↔wizard 才有, 空着的那一栏也不画。
261
+ convsEl.innerHTML = sec('群聊', groups, '还没在任何群里说过话') +
262
+ (R.role && R.role.kind === 'human' ? '' : sec('私聊', dms, '没有和别的 wizard 私聊过'));
263
+ convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
264
+ b.onclick = function () { selectConv(b.getAttribute('data-conv'), b.getAttribute('data-with') || ''); };
196
265
  });
197
266
  };
198
- var renderTags = function () {
199
- if (!S.tags.length) {
200
- tagsEl.innerHTML = '<div class="empty-side">这个 chat 还没有会话记录</div>';
201
- return;
202
- }
203
- $('#side-n').textContent = S.tags.length + ' 个会话';
204
- tagsEl.replaceChildren.apply(tagsEl, S.tags.map(function (t) {
205
- var run = isRunning(t);
206
- var el = document.createElement('div');
207
- el.className = 'tag-row' + (t.target === S.target ? ' on' : '') + (run ? ' running' : '');
208
- el.innerHTML =
209
- '<div class="tag-av">' + esc(t.label) + (run ? '<span class="live"></span>' : '') + '</div>' +
210
- '<div class="tag-main">' +
211
- '<div class="tag-l1">' +
212
- '<span class="tag-name">' + esc(t.tag ? '#' + t.tag : 'default') + '</span>' +
213
- '<span class="tag-ts">' + esc(fmtAgo(t.lastTs)) + '</span>' +
214
- '</div>' +
215
- '<div class="tag-prev">' + esc(t.preview || '(暂无对话)') + '</div>' +
216
- tagMeta(t) +
217
- '</div>';
218
- el.onclick = function () { select(t.target); };
219
- return el;
220
- }));
221
- };
222
-
223
- // cwd 只显示尾部两段 —— 顶栏放不下全路径, 而"哪个项目/哪个 worktree"恰好就在
224
- // 尾部; 全路径留在 title 里。~ 前缀在服务端不可知, 客户端也无从展开, 原样保留。
267
+
225
268
  var shortCwd = function (p) {
226
269
  var seg = String(p).replace(/\/+$/, '').split('/').filter(Boolean);
227
270
  return seg.length <= 2 ? p : '…/' + seg.slice(-2).join('/');
228
271
  };
272
+ var sessLabel = function (s) {
273
+ return (s.sessionId ? s.sessionId.slice(0, 8) : '(无 id)') + ' · ' + fmtAgo(s.start) + ' · ' + s.turns + ' 轮';
274
+ };
275
+
276
+ // session 切换: 原生 <select> 的弹层画不了样式, 换成按钮 + 列表。
277
+ // renderRole 每次刷新都重画, 展开态记在 SESS_OPEN 里才不会被轮询收起。
278
+ var SESS_OPEN = false;
279
+ var sessRow = function (s, on) {
280
+ return '<button class="sp-it' + (on ? ' on' : '') + '" role="option" aria-selected="' + on + '" data-s="' + esc(s.sessionId || '') + '">' +
281
+ '<span class="id">' + esc(s.sessionId ? s.sessionId.slice(0, 8) : '(无 id)') + '</span>' +
282
+ '<span class="ago">' + esc(fmtAgo(s.start)) + '</span>' +
283
+ '<span class="n">' + s.turns + ' 轮</span></button>';
284
+ };
285
+ var sessPicker = function () {
286
+ var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
287
+ var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="">' +
288
+ '<span class="id">全部 session</span><span class="n">' + R.sessions.length + ' 个</span></button>';
289
+ return '<div class="sp' + (SESS_OPEN ? ' open' : '') + '">' +
290
+ '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + SESS_OPEN + '">' +
291
+ '<span class="lb">session</span>' +
292
+ '<span class="v">' + esc(cur ? sessLabel(cur) : '全部 ' + R.sessions.length + ' 个') + '</span>' +
293
+ '<span class="car" aria-hidden="true"></span></button>' +
294
+ '<div class="sp-list" role="listbox">' + all +
295
+ R.sessions.slice().reverse().map(function (s) { return sessRow(s, s.sessionId === SESSION); }).join('') +
296
+ '</div></div>';
297
+ };
298
+ var setSessOpen = function (open) {
299
+ SESS_OPEN = open;
300
+ var sp = $('#rb-sess .sp');
301
+ if (!sp) return;
302
+ sp.classList.toggle('open', open);
303
+ sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
304
+ };
305
+ var bindSessPicker = function () {
306
+ var sp = $('#rb-sess .sp');
307
+ if (!sp) return;
308
+ sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
309
+ sp.querySelectorAll('.sp-it').forEach(function (it) {
310
+ it.onclick = function () { setSessOpen(false); SESSION = it.getAttribute('data-s'); refresh(); };
311
+ });
312
+ };
313
+ document.addEventListener('click', function (e) {
314
+ if (SESS_OPEN && !e.target.closest('#rb-sess .sp')) setSessOpen(false);
315
+ });
316
+ document.addEventListener('keydown', function (e) {
317
+ if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
318
+ });
319
+
320
+ // 名片: 身份 · 出身 · cwd · 出生 · 关系 / 日程入口。
321
+ var renderRole = function () {
322
+ var r = R.role;
323
+ if (!r) return;
324
+ // 分身 = 从父亲某个 session 节点 fork 出来的 (带着那一刻的上下文);
325
+ // 子 wizard = 父亲 spawn 的白板, 只有出身、没有继承。
326
+ var kind = r.kind === 'human' ? '人' : !r.parent ? 'wizard' : r.forkedFrom ? '分身' : '子 wizard';
327
+ // 忙闲由右上角的状态点表达, 名片不再重复。
328
+ var facts = [];
329
+ if (r.cwd) facts.push('<span title="' + esc(r.cwd) + '">📁 ' + esc(shortCwd(r.cwd)) + '</span>');
330
+ if (r.bornAt) facts.push('<span title="创建时间">🐣 ' + esc(fmtDay(r.bornAt)) + '</span>');
331
+ if (r.parent) facts.push(r.forkedFrom
332
+ ? '<span class="go" data-r="' + esc(r.parent.id) + '" title="从它的 session ' + esc(r.forkedFrom) + ' fork, 开局带着那一刻的上下文">⧉ .' + esc(r.parent.name) + ' 的分身 · @' + esc(r.forkedFrom.slice(0, 8)) + '</span>'
333
+ : '<span class="go" data-r="' + esc(r.parent.id) + '" title="它 spawn 的白板, 没有继承上下文">↳ .' + esc(r.parent.name) + ' 的子 wizard</span>');
334
+ var kin = function (xs, what) {
335
+ return xs.length ? '<span title="' + esc(xs.map(function (x) { return '.' + x.name; }).join(' ')) + '">' + xs.length + ' 个' + what + '</span>' : '';
336
+ };
337
+ facts.push(kin(r.clones, '分身'), kin(r.spawns, '子 wizard'));
338
+ $('#rb-who').innerHTML =
339
+ '<div class="id"><span class="av">' + esc(r.label) + '</span>' +
340
+ '<span class="l">' + nm(r.id, r.name) +
341
+ '<span class="k">' + esc(kind + (r.chat ? ' · 住在 ' + r.chat : '')) + '</span></span></div>' +
342
+ (r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
343
+ '<div class="facts">' + facts.join('') + '</div>';
344
+ $('#rb-who').querySelectorAll('.go').forEach(function (g) {
345
+ g.onclick = function () { switchRole(g.getAttribute('data-r')); };
346
+ });
347
+ $('#rb-sess').innerHTML = R.sessions.length > 1 ? sessPicker() : '';
348
+ bindSessPicker();
349
+ syncFoot();
350
+ var acts = [];
351
+ if (R.relations) acts.push('<button class="vb' + (VIEW === 'world' ? ' on' : '') + '" data-view="world">关系图</button>');
352
+ if (r.kind === 'wizard') acts.push('<button class="vb' + (VIEW === 'plan' ? ' on' : '') + '" data-view="plan">日程' + (R.schedules ? '<b>' + R.schedules + '</b>' : '') + '</button>');
353
+ $('#rb-acts').innerHTML = acts.join('');
354
+ $('#rb-acts').querySelectorAll('.vb').forEach(function (b) {
355
+ b.onclick = function () { setView(VIEW === b.getAttribute('data-view') ? 'msgs' : b.getAttribute('data-view')); };
356
+ });
357
+ // 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题跟着换。
358
+ document.title = nameOf(r.id) + ' 的视角';
359
+ };
229
360
 
230
- var topbar = function () {
231
- var t = curTag();
232
- $('#tb-em').textContent = t ? t.label : '💬';
233
- $('#tb-h').textContent = t ? (t.tag ? '#' + t.tag : 'default') : '';
234
- var cwdEl = $('#tb-cwd');
235
- cwdEl.hidden = !(t && t.cwd);
236
- if (t && t.cwd) { cwdEl.textContent = '📁 ' + shortCwd(t.cwd); cwdEl.title = t.cwd; }
237
- $('#tb-sub').textContent = S.target;
361
+ // 左栏底: session 切换 + 用量总账, 都是「轮次」的事, 与上面的身份分开; 两样都空就整条收起。
362
+ var syncFoot = function () {
363
+ $('#rb-foot').hidden = !$('#rb-sess').innerHTML && !$('#rb-prof').innerHTML;
238
364
  };
239
365
 
240
- // ── 页脚总账 ──
366
+ // ── profile: 当前 role 自己跑过的轮次的总账 (选了 session 就只算那一段) ──
241
367
  var COLORS = { input: '#0a7d6b', cacheRead: '#8250df', cacheWrite: '#953800', output: '#1a7f37' };
242
- // 缩写键本身没有自解释性, 悬停给出中文口径 (尤其 ctx 是峰值、cache 是累计读写)。
243
368
  var TIP = {
244
369
  turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
245
370
  ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值',
246
371
  out: '累计输出 token', cache: '累计缓存 token (读 + 写)', time: '累计耗时',
247
372
  };
248
- // 进行中的会话, 耗时要跟着走: durationMs 只统计到快照时刻 at, 之后的补上;
249
- // runningUntil 一过就冻住, 不会像旧版那样把静默期一路累加成几十小时。
250
373
  var liveDur = function (t) {
251
374
  var d = (t.usage && t.usage.durationMs) || 0;
252
375
  if (!t.runningUntil) return d;
253
- return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - S.at);
376
+ return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - R.at);
254
377
  };
255
- var renderStatus = function (t) {
256
- if (!t) { sbEl.innerHTML = ''; return; }
257
- var u = t.usage || {}, run = isRunning(t);
378
+ var renderProfile = function () {
379
+ var el = $('#rb-prof');
380
+ var t = R.stats;
381
+ if (!t) { el.innerHTML = ''; syncFoot(); return; }
382
+ var u = t.usage || {};
258
383
  var segs = [['input', '输入'], ['cacheRead', '缓存读'], ['cacheWrite', '缓存写'], ['output', '输出']]
259
384
  .filter(function (s) { return u[s[0]] > 0; });
260
385
  var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
261
- // token 一条都没采到就别画那条空槽 —— 空进度条看着像"用了 0%", 是误导。
262
- // 光有色块看不出哪段是什么 (tooltip 要悬停才知道), 所以条后面always跟图例。
263
386
  var io = total > 0
264
- ? '<span class="sb-io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
265
- '<span class="k">token i/o</span>' +
266
- '<span class="sb-bar">' + segs.map(function (s) {
387
+ ? '<div class="io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
388
+ '<span class="bar">' + segs.map(function (s) {
267
389
  return '<span class="seg" style="width:' + (u[s[0]] / total * 100).toFixed(2) + '%;background:' +
268
390
  COLORS[s[0]] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
269
391
  }).join('') + '</span>' +
270
- '<span class="sb-leg">' + segs.map(function (s) {
392
+ '<span class="leg">' + segs.map(function (s) {
271
393
  return '<span class="lg"><i style="background:' + COLORS[s[0]] + '"></i>' + s[1] +
272
394
  '<b>' + fmtTok(u[s[0]]) + '</b></span>';
273
395
  }).join('') + '</span>' +
274
- '</span>'
396
+ '</div>'
275
397
  : '';
276
- // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 压暗成 "–" 与真实的 0 区分。
398
+ // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占格子。
277
399
  var st = function (k, n, text) {
278
- return '<span class="st' + (n ? '' : ' void') + '" title="' + esc(TIP[k] || k) + '">' +
279
- '<span class="k">' + k + '</span><span class="v">' + (n ? esc(text) : '–') + '</span></span>';
400
+ return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '">' +
401
+ '<span class="v">' + esc(text) + '</span><span class="k">' + k + '</span></span>' : '';
280
402
  };
281
403
  var cache = (u.cacheRead || 0) + (u.cacheWrite || 0);
282
- sbEl.innerHTML =
283
- '<span class="pill' + (run ? ' run' : '') + '"><span class="d"></span>' + (run ? '进行中' : '空闲') + '</span>' +
284
- (t.model ? '<span class="model" title="' + esc(t.model) + '">' + esc(t.model) + '</span>' : '') +
285
- st('turns', t.turns, t.turns) +
286
- st('tools', u.tools, u.tools) +
287
- st('api', u.calls, u.calls) +
288
- st('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) +
289
- st('out', u.output, fmtTok(u.output)) +
290
- st('cache', cache, fmtTok(cache)) +
291
- st('time', liveDur(t), fmtDur(liveDur(t))) +
292
- io;
293
- };
294
-
295
- // ── 线程 ──
296
- var applySummary = function (d) {
297
- S.base = d.base; S.tags = d.tags || []; S.graphs = d.graphs || [];
298
- S.at = d.at || Date.now(); S.recvAt = Date.now();
299
- $('#side-sub').textContent = d.base || '';
300
- renderTags(); topbar(); renderStatus(curTag()); renderGraph();
301
- };
302
-
303
- var loadThread = function (target, limit) {
304
- return api('api/thread', limit ? { target: target, limit: limit } : { target: target }).then(function (d) {
305
- if (!d.ok || target !== S.target) return;
306
- var inner = $('#thread-in');
307
- if (!d.turns.length) { inner.innerHTML = '<div class="empty">这个会话还没有记录</div>'; return; }
308
- // 默认只取最近若干轮 (贴底阅读), 更早的按需一次性拉全。
404
+ // 忙闲已在名片的状态里, 这里只留模型。
405
+ el.innerHTML =
406
+ '<div class="hd"><span class="lb">用量</span>' +
407
+ (t.model ? '<span class="model" title="' + esc(t.model) + '">' + esc(t.model.replace(/^claude-/, '')) + '</span>' : '') +
408
+ '</div>' +
409
+ '<div class="grid">' +
410
+ st('turns', t.turns, t.turns) + st('tools', u.tools, u.tools) + st('api', u.calls, u.calls) +
411
+ st('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + st('out', u.output, fmtTok(u.output)) +
412
+ st('cache', cache, fmtTok(cache)) + st('time', liveDur(t), fmtDur(liveDur(t))) +
413
+ '</div>' + io;
414
+ syncFoot();
415
+ };
416
+
417
+ // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
418
+ var renderHead = function () {
419
+ var who = $('#ch-who'), acts = $('#ch-acts');
420
+ if (VIEW !== 'msgs') {
421
+ who.innerHTML = '<span class="t">' + (VIEW === 'world' ? '关系图' : '日程') + '</span>' +
422
+ '<span class="sub">' + esc(nameOf(ROLE)) + (VIEW === 'world' ? ' 的家谱与协作网' : ' 名下的定时任务与工单') + '</span>';
423
+ acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
424
+ $('#ch-back').onclick = function () { setView('msgs'); };
425
+ return;
426
+ }
427
+ var c = convOf(CONV);
428
+ if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
429
+ if (c.kind === 'wizard') {
430
+ who.innerHTML = avatarOf(c) + '<span class="l"><span class="t">' + nm(c.peer, c.name) + '</span>' +
431
+ '<span class="sub">私聊 · ' + c.count + ' 条</span></span>';
432
+ } else {
433
+ var ms = membersOf(c);
434
+ who.innerHTML = avatarOf(c) + '<span class="l"><span class="t">' + esc(c.name) + '</span>' +
435
+ '<span class="sub">群聊 · ' + ms.length + ' 位成员 · ' +
436
+ ms.slice(0, 8).map(function (m) { return esc(m.label || roleLabel(m.role)); }).join('') +
437
+ (ms.length > 8 ? '…' : '') + '</span></span>';
438
+ }
439
+ acts.innerHTML = WITH
440
+ ? '<span class="with">只看我与 ' + nm(WITH) + '</span><button class="vb" id="ch-all">看全部</button>'
441
+ : '';
442
+ var x = $('#ch-all');
443
+ if (x) x.onclick = function () { selectConv(CONV, ''); };
444
+ };
445
+
446
+ // ── 消息行 ──
447
+ // 同一条消息从发话方看靠右、从收信方看靠左 —— 片段不带方向, 由这里按视角包装。
448
+ // 箭头朝外 = 这条气泡换视角后要去的那一侧; mine 行靠 CSS 翻转。
449
+ var CHEVRON = '<svg class="fc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
450
+ // 行的另一侧是 .flip: 点它 = 换成这条消息的对端 (我发的 → 收信方; 别人发的 → 发话方)。
451
+ var rowHTML = function (m) {
452
+ learn(m.from, m.fromName, m.fromLabel);
453
+ learn(m.to, m.toName, m.toLabel);
454
+ if (m.dir === 'mark') {
455
+ return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + m.html + '</div>';
456
+ }
457
+ var mine = m.from === ROLE;
458
+ var other = mine ? m.to : m.from;
459
+ // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
460
+ var group = CONV.indexOf('p:') !== 0;
461
+ // 群里我不是收信方的那条 (X → Y), 头上写清是说给谁的。
462
+ var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + nm(m.to, m.toName) + '</span>' : '';
463
+ var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
464
+ var who = mine
465
+ ? '<span class="to">' + (m.to && m.to !== 'human:' ? '→ ' + nm(m.to, m.toName) : '') + '</span>' + stamp(m.ts)
466
+ : nm(m.from, m.fromName) + to + priv + stamp(m.ts);
467
+ var sw = canSwitch(other);
468
+ var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
469
+ ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
470
+ (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
471
+ var av = mine ? '' : '<button class="av' + (canSwitch(m.from) ? ' go' : '') + '" data-r="' + esc(m.from) + '" title="' + esc(nameOf(m.from)) + '"' + (canSwitch(m.from) ? '' : ' disabled') + '>' + esc(m.fromLabel || roleLabel(m.from)) + '</button>';
472
+ return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
473
+ (m.ping ? ' data-ping="1"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
474
+ av +
475
+ '<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + m.html + '</div></div>' +
476
+ flip +
477
+ '</div>';
478
+ };
479
+ // ── 🏓 保温 ping 的折叠 ──
480
+ // ping 不是对话, 但它是真花销, 从时间轴上抹掉就等于说这段时间什么都没发生。
481
+ // 一次 ping 落成两行 (问 + pong); 相邻的整串收进一条虚线, 展开才见正文。
482
+ // 展开态是整页一个开关 —— 折行没有稳定 id, 而"想看 ping"是个一次性的念头。
483
+ var PING_OPEN = false;
484
+ var unfoldPings = function (root) {
485
+ root.querySelectorAll('.ping-fold').forEach(function (f) {
486
+ var body = f.querySelector('.ping-body');
487
+ while (body && body.firstChild) root.insertBefore(body.firstChild, f);
488
+ f.remove();
489
+ });
490
+ };
491
+ var foldPings = function (root) {
492
+ var run = [];
493
+ var flush = function (before) {
494
+ if (!run.length) { return; }
495
+ var seen = {}, n = 0;
496
+ run.forEach(function (r) {
497
+ var t = r.getAttribute('data-turn') || r.getAttribute('data-id');
498
+ if (!seen[t]) { seen[t] = 1; n++; }
499
+ });
500
+ var t0 = Number(run[0].getAttribute('data-ts') || 0);
501
+ var t1 = Number(run[run.length - 1].getAttribute('data-ts') || 0);
502
+ var span = fmtHM(t0) + (fmtHM(t1) !== fmtHM(t0) ? ' – ' + fmtHM(t1) : '');
503
+ var d = document.createElement('details');
504
+ d.className = 'ping-fold';
505
+ d.open = PING_OPEN;
506
+ d.innerHTML = '<summary class="ping-sum"><span class="pb">🏓 ×' + n + '</span>' +
507
+ '<span class="ps">保温 ping</span><span class="pt">' + esc(span) + '</span></summary>' +
508
+ '<div class="ping-body"></div>';
509
+ root.insertBefore(d, before);
510
+ var body = d.querySelector('.ping-body');
511
+ run.forEach(function (r) { body.appendChild(r); });
512
+ d.addEventListener('toggle', function () { PING_OPEN = d.open; });
513
+ run = [];
514
+ };
515
+ // 先取快照 —— flush 会把行搬进 details, 边遍历边改 children 会漏行。
516
+ [].slice.call(root.children).forEach(function (el) {
517
+ if (el.classList && el.classList.contains('mrow') && el.getAttribute('data-ping') === '1') { run.push(el); return; }
518
+ flush(el);
519
+ });
520
+ flush(null);
521
+ };
522
+
523
+ var bindRow = function (el) {
524
+ el.querySelectorAll('.flip[data-r]:not([disabled]), .av.go').forEach(function (b) {
525
+ b.onclick = function () { switchRole(b.getAttribute('data-r')); };
526
+ });
527
+ };
528
+ var rowNode = function (id) {
529
+ var list = inner.querySelectorAll('.mrow');
530
+ for (var i = 0; i < list.length; i++) if (list[i].getAttribute('data-id') === id) return list[i];
531
+ return null;
532
+ };
533
+
534
+ var loadMsgs = function (limit) {
535
+ var gen = S.gen;
536
+ if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
537
+ return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
538
+ if (!d.ok || gen !== S.gen) return;
539
+ S.frags = {};
540
+ d.msgs.forEach(function (m) { S.frags[m.id] = m; });
541
+ if (!d.msgs.length) { inner.innerHTML = '<div class="empty">这里还没有消息</div>'; return; }
309
542
  var more = d.truncated
310
- ? '<button class="more-btn" id="more">载入更早的 ' + (d.total - d.turns.length) + ' 轮</button>'
543
+ ? '<button class="more-btn" id="more">载入更早的 ' + (d.total - d.msgs.length) + ' 条</button>'
311
544
  : '';
312
- inner.innerHTML = more + d.turns.map(function (t) { return t.html; }).join('');
313
- d.turns.forEach(function (t) { markStale(t); });
545
+ inner.innerHTML = more + d.msgs.map(rowHTML).join('');
314
546
  var btn = $('#more');
315
- if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadThread(target, '0'); };
547
+ if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
548
+ bindRow(inner);
316
549
  render(inner);
317
- expireTurns();
550
+ foldPings(inner);
551
+ expireRows();
318
552
  S.pinned = true; toBottom(true);
319
553
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
320
554
  setTimeout(function () { toBottom(); }, 60);
321
555
  });
322
556
  };
323
557
 
324
- // staleAt 由 JSON 单独送来 (不在 html 里, 否则会打乱服务端的 sig 去重), 落到
325
- // DOM 上供 expireTurns 定时判定。
326
- // 't:' = 一轮对话, 'm:' = 上下文断点行。子 agent 的卡片嵌在父轮里面, 所以按
327
- // 整棵子树找 —— 只扫顶层会把它当成"还没渲染过"而重复插一份。
328
- var turnNode = function (id) {
329
- return $('#thread-in').querySelector('[data-key="t:' + id + '"],[data-key="m:' + id + '"]');
330
- };
331
- var markStale = function (t) {
332
- var n = turnNode(t.id);
333
- if (n) n.setAttribute('data-stale-at', t.staleAt || 0);
334
- };
335
-
336
- var upsertTurn = function (t) {
337
- var inner = $('#thread-in');
558
+ var upsertMsg = function (m) {
338
559
  var empty = inner.querySelector('.empty'); if (empty) empty.remove();
339
560
  var stick = S.pinned;
340
- var cur = turnNode(t.id);
561
+ var cur = rowNode(m.id);
562
+ S.frags[m.id] = m;
563
+ if (cur && cur.getAttribute('data-sig') === m.sig) return;
564
+ // 折行会把 .mrow 收进 details, 而下面的按时间插位假定它们都是 inner 的直接子节点。
565
+ unfoldPings(inner);
566
+ var next = frag(rowHTML(m)).firstElementChild;
341
567
  if (!cur) {
342
- inner.insertAdjacentHTML('beforeend', t.html);
343
- render(inner);
344
- } else if (cur.getAttribute('data-sig') !== t.sig) {
345
- var box = frag(t.html).firstElementChild;
346
- var eb = childBy(cur, 'bubbles'), nb = box && childBy(box, 'bubbles');
347
- if (eb && nb) {
348
- var eh = childBy(cur, 'tg-head'), nh = childBy(box, 'tg-head');
568
+ // 按时间插: SSE 推来的可能是一条更早的消息 (长轮次的入消息晚于别人的回复落盘)。
569
+ var after = null;
570
+ inner.querySelectorAll('.mrow').forEach(function (r) {
571
+ if (Number(r.getAttribute('data-ts')) > m.ts && !after) after = r;
572
+ });
573
+ inner.insertBefore(next, after);
574
+ bindRow(next); render(next);
575
+ } else {
576
+ var eb = cur.querySelector('.mb'), nb = next.querySelector('.mb');
577
+ var ec = eb && eb.firstElementChild, nc = nb && nb.firstElementChild;
578
+ var ebb = ec && childBy(ec, 'bubbles'), nbb = nc && childBy(nc, 'bubbles');
579
+ if (ebb && nbb) {
580
+ var eh = childBy(ec, 'tg-head'), nh = childBy(nc, 'tg-head');
349
581
  if (eh && nh) eh.innerHTML = nh.innerHTML;
350
- reconcile(eb, nb);
351
- cur.setAttribute('data-sig', t.sig);
352
- } else if (box) {
353
- cur.replaceWith(box); render(inner);
582
+ reconcile(ebb, nbb);
583
+ ['data-sig', 'data-stale-at'].forEach(function (a) { cur.setAttribute(a, next.getAttribute(a)); });
584
+ } else {
585
+ cur.replaceWith(next); bindRow(next); render(next);
354
586
  }
355
587
  }
356
- markStale(t);
588
+ foldPings(inner);
357
589
  if (stick) toBottom(true);
358
590
  };
359
591
 
360
592
  // 到点了还没有新写入 → 这一轮已经结束: 熄掉呼吸点、移除"正在思考"。
361
- var expireTurns = function () {
593
+ var expireRows = function () {
362
594
  var now = srvNow();
363
- $('#thread-in').querySelectorAll('.turn-group[data-stale-at]').forEach(function (g) {
595
+ inner.querySelectorAll('.mrow[data-stale-at]').forEach(function (g) {
364
596
  var until = Number(g.getAttribute('data-stale-at') || 0);
365
597
  if (!until || now <= until) return;
366
598
  g.setAttribute('data-stale-at', '0');
367
- // 连内嵌的子 agent 卡片一起熄灯: 父轮都到点了, 它派出去的必然也停了,
368
- // 而子卡片没有自己的 data-stale-at (staleAt 只随顶层片段下发)。
369
599
  g.querySelectorAll('.tg-dot').forEach(function (d) { d.classList.remove('live'); });
370
600
  g.querySelectorAll('.typing').forEach(function (t) { t.remove(); });
371
601
  });
372
602
  };
373
603
 
374
- var select = function (target) {
375
- if (!target) return;
376
- S.target = target;
377
- document.querySelector('.app').classList.add('reading');
378
- renderTags(); topbar(); renderStatus(curTag());
379
- $('#thread-in').innerHTML = '<div class="empty">加载中…</div>';
380
- loadThread(target).then(function () { connect(); });
604
+ // ── role 摘要 ──
605
+ var applyRole = function (d) {
606
+ R.at = d.at || Date.now(); R.recvAt = Date.now();
607
+ R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
608
+ R.relations = !!d.relations; R.schedules = d.schedules || 0; R.stats = d.stats || null;
609
+ ROLE = d.role.id;
610
+ learn(d.role.id, d.role.name, d.role.label);
611
+ R.convs.forEach(function (c) {
612
+ if (c.peer) learn(c.peer, c.name, c.label);
613
+ c.subs.forEach(function (s) { learn(s.role, s.name, s.label); });
614
+ });
615
+ if (!CONV || !convOf(CONV)) { CONV = d.conv || ''; WITH = ''; }
616
+ renderRole(); renderProfile(); renderConvs(); renderHead();
381
617
  };
382
- $('#tb-back').onclick = function () { document.querySelector('.app').classList.remove('reading'); };
383
618
 
384
- // ── SSE: chat 摘要 + 当前 tag 的 turn 增量。断线指数退避重连。 ──
619
+ // ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
385
620
  var backoff = 1000;
386
621
  var connect = function () {
387
622
  if (S.es) { S.es.close(); S.es = null; }
388
- var p = new URLSearchParams({ id: TOKEN, target: S.target });
389
- var es = new EventSource('api/events?' + p.toString());
623
+ var p = new URLSearchParams(viewParams({ id: TOKEN }));
624
+ var es = new EventSource('api/role-events?' + p.toString());
390
625
  S.es = es;
391
- es.addEventListener('chat', function (e) { try { applySummary(JSON.parse(e.data)); } catch (err) { } });
392
- es.addEventListener('turn', function (e) { try { upsertTurn(JSON.parse(e.data)); } catch (err) { } });
626
+ es.addEventListener('role', function (e) { try { applyRole(JSON.parse(e.data)); } catch (err) { } });
627
+ es.addEventListener('msg', function (e) { try { upsertMsg(JSON.parse(e.data)); } catch (err) { } });
393
628
  es.onopen = function () { backoff = 1000; connEl.className = 'conn'; connEl.title = '实时连接'; };
394
629
  es.onerror = function () {
395
630
  connEl.className = 'conn off'; connEl.title = '连接断开, 重连中';
@@ -399,23 +634,847 @@
399
634
  };
400
635
  };
401
636
 
637
+ /** 视角或窗口变了: 摘要、正文、SSE 全部按新参数重来。 */
638
+ var refresh = function () {
639
+ S.gen++;
640
+ syncUrl();
641
+ inner.innerHTML = '<div class="empty">加载中…</div>';
642
+ return api('api/role', viewParams()).then(function (d) {
643
+ if (!d.ok) {
644
+ document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
645
+ return;
646
+ }
647
+ applyRole(d);
648
+ syncUrl();
649
+ if (VIEW === 'world' || VIEW === 'plan') { W.sel = ROLE; loadWorld(); }
650
+ return loadMsgs().then(connect);
651
+ });
652
+ };
653
+
654
+ var selectConv = function (key, withRole) {
655
+ CONV = key; WITH = withRole || '';
656
+ if (VIEW !== 'msgs') setView('msgs');
657
+ app.classList.add('reading');
658
+ renderConvs(); renderHead();
659
+ S.gen++;
660
+ syncUrl();
661
+ inner.innerHTML = '<div class="empty">加载中…</div>';
662
+ loadMsgs().then(connect);
663
+ };
664
+
665
+ // 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
666
+ // 里的样子; 对方不在这个群 (私聊的另一头) 就交给服务端挑它最近的会话。
667
+ var switchRole = function (id) {
668
+ if (!canSwitch(id) || id === ROLE) return;
669
+ var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
670
+ var from = ROLE;
671
+ // 整个频道 / 两人私聊的消息集合与视角无关 —— 只有 with 与 session 按 role 过滤。
672
+ var same = VIEW === 'msgs' && !WITH && !SESSION && (keep || CONV.indexOf('p:') === 0);
673
+ // 群里「只看我与 X」时换过去, 对面看到的是「只看我与 from」—— 同一段往来, 选中只看而不是整个群。
674
+ var withBack = keep && WITH ? from : '';
675
+ ROLE = id; WITH = withBack; SESSION = '';
676
+ CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
677
+ inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping');
678
+ if (same) flipWindow(); else refresh();
679
+ };
680
+
681
+ /** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去; 侧栏交给 SSE 的首个 role 事件。 */
682
+ var flipWindow = function () {
683
+ S.gen++;
684
+ syncUrl();
685
+ unfoldPings(inner);
686
+ [].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
687
+ var m = S.frags[row.getAttribute('data-id')];
688
+ // 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
689
+ if (!m || m.dir === 'mark') { row.remove(); return; }
690
+ var next = frag(rowHTML(m)).firstElementChild;
691
+ var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
692
+ if (ob && nb) nb.replaceWith(ob);
693
+ row.replaceWith(next);
694
+ bindRow(next);
695
+ });
696
+ foldPings(inner);
697
+ expireRows();
698
+ connect();
699
+ var gen = S.gen;
700
+ api('api/msgs', viewParams()).then(function (d) {
701
+ if (!d.ok || gen !== S.gen) return;
702
+ d.msgs.filter(function (m) { return m.dir === 'mark'; }).forEach(upsertMsg);
703
+ });
704
+ };
705
+
706
+ $('#tb-back').onclick = function () { app.classList.remove('reading'); };
707
+
708
+ // ══ 关系视图 ═══════════════════════════════════════════════════════
709
+ // 三层叠在一起:
710
+ // 1. 聊天卡片 (HTML) —— 归属; 一张卡 = 一个群
711
+ // 2. 家谱缩进 (HTML) —— 层级; 分身 / 子 wizard 缩在父亲下面, 左侧一道折线
712
+ // 3. 连线 (SVG) —— 其余的关系; 布局表达不了的那些 (跨卡片的家谱、
713
+ // 同群与跨群的派活、流水线的一步)
714
+ // 前两层撑起版面 (所以文字永远可读), 第三层才是"图"。
715
+ var EDGE = {
716
+ // 分身: 从父亲某个 session 节点 fork, 带着上下文; 子 wizard: 父亲 spawn 的白板。
717
+ clone: { c: '#8250df', label: '分身', dash: '', tip: '从父亲的某个 session 节点 fork 出来, 开局带着那一刻的上下文' },
718
+ spawn: { c: '#bc4c00', label: '子 wizard', dash: '9 3', tip: '父亲 spawn 的白板 wizard, 只有出身、没有继承上下文' },
719
+ peer: { c: '#0969da', label: '派活', dash: '5 4' },
720
+ graph: { c: '#0a7d6b', label: '流水线', dash: '2 3' },
721
+ };
722
+
723
+ // 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器
724
+ // 直接抛异常, 整张图就白了。
725
+ var cssEsc = function (v) {
726
+ return window.CSS && CSS.escape
727
+ ? CSS.escape(v)
728
+ : String(v).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c; });
729
+ };
730
+
731
+ var shortPath = function (p, keep) {
732
+ var seg = String(p || '').replace(/\/+$/, '').split('/').filter(Boolean);
733
+ return seg.length <= keep ? p : '…/' + seg.slice(-keep).join('/');
734
+ };
735
+
736
+ var wRunning = function (n) { return n.busy || (!!n.runningUntil && srvNow() < n.runningUntil); };
737
+
738
+ // ── 走进一个 wizard ──
739
+ // 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁 —— 不再整页跳。
740
+ var chatOf = function (base) {
741
+ return (W.chats || []).filter(function (c) { return c.base === base; })[0];
742
+ };
743
+ var canOpen = function (n) { return !!n; };
744
+ var openNode = function (target) { if (target) switchRole(target); };
745
+
746
+ // 名字全局唯一, 卡片头写的是聊天名, 节点上只写它自己的 `.name`。
747
+ var shortName = function (n) { return '.' + (n.name || n.tag || n.target); };
748
+
749
+ // 与 sel 相连的一切 (含 sel 自己)。聚焦时其余的压暗而不是移除 —— 位置稳定,
750
+ // 反复点不同节点时版面不会跳。
751
+ var neighborhood = function (target) {
752
+ var set = {}; set[target] = 1;
753
+ W.edges.forEach(function (e) {
754
+ if (e.from === target) set[e.to] = 1;
755
+ if (e.to === target) set[e.from] = 1;
756
+ });
757
+ return set;
758
+ };
759
+
760
+ var edgeOn = function (e) { return !!W.kinds[e.kind]; };
761
+
762
+ // 一个节点身上挂了几条 (当前开着的) 边。0 = 它此刻不属于任何协作关系 ——
763
+ // 「只看有关系的」就是把这些收起来, 剩下的才是真正的那张网。
764
+ var degree = function (target) {
765
+ return W.edges.filter(edgeOn).filter(function (e) { return e.from === target || e.to === target; }).length;
766
+ };
767
+
768
+ var renderWTools = function () {
769
+ var counts = { clone: 0, spawn: 0, peer: 0, graph: 0 };
770
+ W.edges.forEach(function (e) { counts[e.kind] = (counts[e.kind] || 0) + 1; });
771
+ var cross = W.edges.filter(function (e) { return e.cross; }).length;
772
+ var chips = Object.keys(EDGE).map(function (k) {
773
+ return '<button class="chip' + (W.kinds[k] ? ' on' : '') + '" data-k="' + k + '" ' +
774
+ 'style="--c:' + EDGE[k].c + '"' + (EDGE[k].tip ? ' title="' + EDGE[k].tip + '"' : '') + '><i></i>' + EDGE[k].label +
775
+ '<b>' + (counts[k] || 0) + '</b></button>';
776
+ }).join('');
777
+ var force = W.layout === 'force';
778
+ wtoolsEl.innerHTML =
779
+ '<div class="wlegend">' + chips + '</div>' +
780
+ '<div class="wlay">' +
781
+ '<button class="lb' + (force ? '' : ' on') + '" data-lay="cards" ' +
782
+ 'title="按聊天分卡, 分身 / 子 wizard 缩在父亲下面 —— 归属与层级最准">▦ 卡片</button>' +
783
+ '<button class="lb' + (force ? ' on' : '') + '" data-lay="force" ' +
784
+ 'title="wizard 之间的力导向图, 聊天收进节点 —— 协作关系最直观">🕸 关系网</button>' +
785
+ '</div>' +
786
+ '<div class="wstat">' +
787
+ W.nodes.length + ' 个 wizard · ' + W.chats.length + ' 个聊天' +
788
+ (cross ? ' · <b>' + cross + '</b> 条跨聊天关系' : '') +
789
+ (W.degraded ? ' · <span class="warn" title="注册表不可达 (独立 svr 部署), 只画观测到的往来">名册缺席</span>' : '') +
790
+ '<span class="hint">单击聚焦 · 双击进入' + (force ? ' · 拖动钉住' : ' (外聊天整页跳过去)') + '</span>' +
791
+ '</div>' +
792
+ (force ? '<button class="chip" id="wre" title="松开所有钉住的节点, 重新排一遍">↻ 重排</button>' : '') +
793
+ '<button class="chip only' + (W.onlyRel ? ' on' : '') + '" id="wonly" ' +
794
+ 'title="把此刻不属于任何关系的 wizard 收起来 —— 剩下的就是这张协作网本身">' +
795
+ (W.onlyRel ? '☑' : '☐') + ' 只看有关系的</button>' +
796
+ (W.sel ? '<button class="chip clear" id="wclear">✕ 取消聚焦</button>' : '');
797
+ wtoolsEl.querySelectorAll('.lb').forEach(function (b) {
798
+ b.onclick = function () {
799
+ var v = b.getAttribute('data-lay');
800
+ if (v === W.layout) return;
801
+ setLayout(v);
802
+ renderWorld();
803
+ };
804
+ });
805
+ var re = $('#wre');
806
+ if (re) re.onclick = function () { FX.pos = {}; FX.sig = ''; renderForce(); };
807
+ wtoolsEl.querySelectorAll('.chip[data-k]').forEach(function (b) {
808
+ b.onclick = function () {
809
+ var k = b.getAttribute('data-k');
810
+ W.kinds[k] = W.kinds[k] ? 0 : 1;
811
+ renderWTools();
812
+ if (W.layout === 'force') renderForce(); else drawEdges();
813
+ };
814
+ });
815
+ var only = $('#wonly');
816
+ if (only) only.onclick = function () { W.onlyRel = !W.onlyRel; renderWorld(); };
817
+ var cl = $('#wclear');
818
+ if (cl) cl.onclick = function () { W.sel = ''; renderWorld(); };
819
+ };
820
+
821
+ var nodeHTML = function (n, depth) {
822
+ var run = wRunning(n), nm = shortName(n);
823
+ var bits = [];
824
+ if (n.model) bits.push(n.model.replace(/^claude-/, ''));
825
+ if (n.cwd) bits.push('📁 ' + shortPath(n.cwd, 1));
826
+ if (n.turns) bits.push(n.turns + ' 轮');
827
+ if (n.taskTurns) bits.push('⏰ ' + n.taskTurns);
828
+ if (n.peerTurns) bits.push('✉ ' + n.peerTurns);
829
+ return '<div class="wnode' + (n.self ? ' self' : '') + (run ? ' run' : '') +
830
+ (n.alive ? '' : ' cold') + (n.target === ROLE ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
831
+ '" data-t="' + esc(n.target) + '" style="margin-left:' + (depth * 16) + 'px">' +
832
+ (depth ? (n.inherited ? '<span class="lin" title="分身"></span>' : '<span class="lin sp" title="子 wizard"></span>') : '') +
833
+ '<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
834
+ '<span class="wbody">' +
835
+ '<span class="wl1">' +
836
+ '<b class="wname" title="' + esc(n.name || n.target) + '">' + esc(nm) + '</b>' +
837
+ (n.inherited ? '<span class="wih" title="分身: 从父亲的 session 节点 fork, 开局带着那一刻的上下文">⧉</span>' : '') +
838
+ '<span class="wts">' + esc(fmtAgo(n.lastTs)) + '</span>' +
839
+ '</span>' +
840
+ (n.description ? '<span class="wjob">' + esc(n.description) + '</span>' : '') +
841
+ (n.preview ? '<span class="wprev">' + esc(n.preview) + '</span>' : '') +
842
+ (bits.length ? '<span class="wmeta">' + bits.map(esc).join('<span class="sep">·</span>') + '</span>' : '') +
843
+ '</span>' +
844
+ '</div>';
845
+ };
846
+
847
+ var renderWMap = function () {
848
+ if (!W.chats.length) {
849
+ wmapEl.innerHTML = '<div class="empty">' + (W.loaded ? '还没有任何 wizard 记录' : '加载中…') + '</div>';
850
+ return;
851
+ }
852
+ var hood = W.sel ? neighborhood(W.sel) : null;
853
+ var cards = W.chats.map(function (c) {
854
+ var shown = c.members.filter(function (mm) { return !W.onlyRel || degree(mm.target); });
855
+ var live = shown.filter(function (mm) {
856
+ var n = nodeOf(mm.target); return n && wRunning(n);
857
+ }).length;
858
+ var rows = shown.map(function (mm) {
859
+ var n = nodeOf(mm.target);
860
+ // 「只看有关系的」会把父亲筛掉而留下孩子 —— 那时的缩进没有参照物, 拉平。
861
+ var d = W.onlyRel ? 0 : mm.depth;
862
+ return n ? nodeHTML(n, d) : '';
863
+ }).join('');
864
+ if (!shown.length) return '';
865
+ return '<section class="wchat' + (c.self ? ' self' : '') + '" data-base="' + esc(c.base) + '">' +
866
+ '<header class="wch">' +
867
+ '<span class="nm">' + esc(c.name || c.base) + '</span>' +
868
+ (c.self ? '<span class="here">当前</span>' : '') +
869
+ '<span class="ct">' + shown.length + (live ? ' · <em>' + live + ' 在跑</em>' : '') + '</span>' +
870
+ '</header>' +
871
+ '<div class="wrows">' + rows + '</div>' +
872
+ (c.hidden || shown.length < c.members.length
873
+ ? '<div class="wmore" title="不在图上的会话 —— 它们还在, 只是此刻既没在跑也没有关系">另有 ' +
874
+ (c.hidden + (c.members.length - shown.length)) + ' 个未显示</div>'
875
+ : '') +
876
+ '</section>';
877
+ }).filter(Boolean).join('');
878
+ wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg><div class="wgrid">' +
879
+ (cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身 / 生子 wizard 之后这里就有边了</div>') + '</div>';
880
+ wmapEl.querySelectorAll('.wnode').forEach(function (el) {
881
+ var t = el.getAttribute('data-t');
882
+ if (hood && !hood[t]) el.classList.add('dim');
883
+ if (t === W.sel) el.classList.add('sel');
884
+ if (canOpen(nodeOf(t))) el.classList.add('go');
885
+ el.onclick = function () { W.sel = (W.sel === t ? '' : t); renderWorld(); };
886
+ // 双击 = 走进它: 视角切成它 (见 openNode)。
887
+ el.ondblclick = function () { openNode(t); };
888
+ });
889
+ // 卡片头 = 那个群本身。点它进那个群里最近活跃的那一栏 —— 图上找不到哪个
890
+ // 节点该双击的时候, 这是最直觉的入口。
891
+ wmapEl.querySelectorAll('.wch').forEach(function (h) {
892
+ var c = chatOf(h.parentNode.getAttribute('data-base'));
893
+ var first = c && c.members.filter(function (mm) { return canOpen(nodeOf(mm.target)); })[0];
894
+ if (!first) return;
895
+ h.classList.add('go');
896
+ h.onclick = function () { openNode(first.target); };
897
+ });
898
+ };
899
+
900
+ // ── 连线 ──
901
+ // 端点在布局之后才知道 (卡片会换行、文字会折行), 所以连线是一个纯粹的
902
+ // "读版面 → 画路径" 的过程, 每次重排都重来一遍, 不维护任何位置状态。
903
+ var anchorsOf = function (a, b, base) {
904
+ var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
905
+ var ox = base.left, oy = base.top;
906
+ var A = { l: ra.left - ox, r: ra.right - ox, cy: ra.top - oy + ra.height / 2 };
907
+ var B = { l: rb.left - ox, r: rb.right - ox, cy: rb.top - oy + rb.height / 2 };
908
+ // 同一列 (同一张卡片里) → 两端都走左侧, 从左边的空白处绕出去, 像 git 图的
909
+ // 那条 gutter。左右分列 → 从近的一侧出、近的一侧进。
910
+ if (Math.abs(A.l - B.l) < 60) {
911
+ // 同卡片内的边走卡片自己的左内边距 (.wrows 的 padding-left) —— 那条车道
912
+ // 就是为它留的。拐到卡片外面既会被滚动容器裁掉, 也读不出"这两个是一个群
913
+ // 里的"。room 按最近的卡片左沿算, 没有卡片 (理论上不会) 才退回画布左沿。
914
+ var card = a.closest('.wchat');
915
+ var lane = card ? card.getBoundingClientRect().left - ox + 6 : 4;
916
+ var room = Math.max(6, Math.min(A.l, B.l) - lane);
917
+ var d = Math.min(room, 14 + Math.abs(A.cy - B.cy) * 0.22);
918
+ return {
919
+ d: 'M' + A.l + ',' + A.cy + ' C' + (A.l - d) + ',' + A.cy + ' ' + (B.l - d) + ',' + B.cy + ' ' + B.l + ',' + B.cy,
920
+ head: 'start',
921
+ };
922
+ }
923
+ var right = B.l > A.l;
924
+ var ax = right ? A.r : A.l, bx = right ? B.l : B.r;
925
+ var k = right ? 1 : -1, dd = Math.max(46, Math.abs(bx - ax) * 0.42);
926
+ return {
927
+ d: 'M' + ax + ',' + A.cy + ' C' + (ax + dd * k) + ',' + A.cy + ' ' + (bx - dd * k) + ',' + B.cy + ' ' + bx + ',' + B.cy,
928
+ head: right ? 'end' : 'start',
929
+ };
930
+ };
931
+
932
+ var drawEdges = function () {
933
+ var svg = $('#wedges');
934
+ if (!svg) return;
935
+ var base = wmapEl.getBoundingClientRect();
936
+ svg.setAttribute('width', wmapEl.scrollWidth);
937
+ svg.setAttribute('height', wmapEl.scrollHeight);
938
+ svg.setAttribute('viewBox', '0 0 ' + wmapEl.scrollWidth + ' ' + wmapEl.scrollHeight);
939
+ var defs = Object.keys(EDGE).map(function (k) {
940
+ return ['', '-d'].map(function (sfx) {
941
+ return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
942
+ 'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
943
+ 'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
944
+ }).join('');
945
+ }).join('');
946
+ var hood = W.sel ? neighborhood(W.sel) : null;
947
+ var paths = W.edges.filter(edgeOn).map(function (e) {
948
+ var a = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.from) + '"]');
949
+ var b = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.to) + '"]');
950
+ if (!a || !b) return '';
951
+ var p = anchorsOf(a, b, base);
952
+ // 聚焦时: 不碰 sel 的边压到近乎不可见 —— 删掉它们会让"这张图本来有多密"
953
+ // 这个信息消失, 而那恰恰是判断要不要收几个 wizard 的依据。
954
+ var off = hood && !(e.from === W.sel || e.to === W.sel);
955
+ var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
956
+ return '<path d="' + p.d + '" fill="none" stroke="' + EDGE[e.kind].c + '" ' +
957
+ 'stroke-width="' + (off ? 1 : w).toFixed(2) + '" ' +
958
+ 'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '" ' +
959
+ 'opacity="' + (off ? 0.14 : (e.cross ? 0.95 : 0.6)) + '" ' +
960
+ 'marker-' + p.head + '="url(#ah-' + e.kind + (off ? '-d' : '') + ')">' +
961
+ '<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
962
+ (e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
963
+ (e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
964
+ }).join('');
965
+ svg.innerHTML = '<defs>' + defs + '</defs>' + paths;
966
+ };
967
+
968
+ // ── 力导向布局 ────────────────────────────────────────────────────
969
+ // 卡片布局把「归属」和「层级」交给版面, 代价是聊天必须张张画出来: 一个只出了
970
+ // 一个分身的群也占一整张卡, 而跨聊天那条边得横穿半张图去找对面。
971
+ // 换一种折法 —— 只画 wizard, 聊天收进节点自己 (左侧色条 + 群名一行), 位置交给
972
+ // 力: 同群相吸自然成簇, 家谱 (分身 / 子 wizard) 的边比派活的边短, 家谱于是仍然读得出来。
973
+ // 两种读法各有盲区 (力导向读不出精确的父子层级), 所以是开关不是替换。
974
+ // 绿在这一页已经有主 (在跑), 所以群色不用绿 —— 一道绿色条会被读成「它活着」。
975
+ var CHAT_C = ['#0969da', '#8250df', '#bc4c00', '#bf3989', '#0a7d6b', '#cf222e', '#9a6700', '#4338ca'];
976
+ var hashOf = function (s) {
977
+ var v = String(s), h = 0;
978
+ for (var i = 0; i < v.length; i++) h = (h * 31 + v.charCodeAt(i)) >>> 0;
979
+ return h;
980
+ };
981
+ // 颜色按 base **排序**取位, 而不是按它在列表里的次序取位 —— 列表次序随最近活动
982
+ // 变, 颜色跟着变就没人记得住哪个色是哪个群; 排序是稳定的, 顺带保证 8 个群之内
983
+ // 一个不撞。超过 8 个才退回 hash。
984
+ var chatColor = function (base) {
985
+ return (FX.color || {})[base] || CHAT_C[hashOf(base) % CHAT_C.length];
986
+ };
987
+ // 取位用的是**全部**节点而不是画在图上的那些 —— 拿筛过的集合分色, 一按「只看
988
+ // 有关系的」每个群的颜色就换一轮。
989
+ var fxColors = function () {
990
+ var bases = [];
991
+ W.nodes.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
992
+ return bases.sort().reduce(function (m, b, i) { m[b] = CHAT_C[i % CHAT_C.length]; return m; }, {});
993
+ };
994
+
995
+ // pos 按 target 存活: 6 秒一次的轮询重画 DOM, 但坐标沿用, 图不会每轮跳一次。
996
+ var FX = { pos: {}, size: {}, els: {}, paths: [], color: {}, nodes: [], edges: [], w: 0, h: 0, alpha: 0, raf: 0, drag: null, sig: '' };
997
+
998
+ var fxNodes = function () {
999
+ return W.nodes.filter(function (n) { return !W.onlyRel || degree(n.target); });
1000
+ };
1001
+
1002
+ /** 种子位置: 聊天摆一圈, 成员散在自己那个群周围。确定性 (hash 而不是 random)
1003
+ * —— 同一批 wizard 每次打开这一页, 图的大致形状是同一个。 */
1004
+ var fxSeed = function (ns) {
1005
+ var bases = [];
1006
+ ns.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
1007
+ var cx = FX.w / 2, cy = FX.h / 2, R = Math.min(FX.w, FX.h) * 0.28;
1008
+ var on = {};
1009
+ ns.forEach(function (n) { on[n.target] = 1; });
1010
+ Object.keys(FX.pos).forEach(function (t) { if (!on[t]) delete FX.pos[t]; });
1011
+ ns.forEach(function (n) {
1012
+ if (FX.pos[n.target]) return;
1013
+ var i = bases.indexOf(n.base);
1014
+ var a = bases.length > 1 ? (i / bases.length) * Math.PI * 2 : 0;
1015
+ var bx = bases.length > 1 ? cx + Math.cos(a) * R : cx;
1016
+ var by = bases.length > 1 ? cy + Math.sin(a) * R : cy;
1017
+ var t = (hashOf(n.target) % 360) * Math.PI / 180;
1018
+ FX.pos[n.target] = { x: bx + Math.cos(t) * 46, y: by + Math.sin(t) * 46, vx: 0, vy: 0, pin: 0 };
1019
+ });
1020
+ };
1021
+
1022
+ var fxTick = function () {
1023
+ var ns = FX.nodes, P = FX.pos, n = ns.length;
1024
+ if (!n) return;
1025
+ var L = Math.max(86, Math.min(190, Math.sqrt(FX.w * FX.h / n) * 0.66));
1026
+ var rep = L * L * 1.15;
1027
+ var i, j, a, b, dx, dy, d, ux, uy, f;
1028
+ // 斥力 —— O(n²), 但这张图按设计就不超过几十个节点 (见 shared/world 的相关性)。
1029
+ for (i = 0; i < n; i++) {
1030
+ a = P[ns[i].target];
1031
+ for (j = i + 1; j < n; j++) {
1032
+ b = P[ns[j].target];
1033
+ dx = b.x - a.x; dy = b.y - a.y;
1034
+ d = Math.sqrt(dx * dx + dy * dy) || 0.01;
1035
+ f = rep / (d * d); ux = dx / d; uy = dy / d;
1036
+ a.vx -= ux * f; a.vy -= uy * f; b.vx += ux * f; b.vy += uy * f;
1037
+ }
1038
+ }
1039
+ // 弹簧 —— 家谱边的静息长度短于派活的, 于是家谱天然抱成一小团。
1040
+ // 静息长度有个下限: 节点是一张一百多像素宽的卡片, 不是一个点。两端贴到一起
1041
+ // 时连线被两张卡各自吃掉一半, 剩下的那截连箭头都放不下 —— 图上就成了一条
1042
+ // 看不见的边。下限按两张卡的半宽算, 横着摆也留得出一段看得见的线。
1043
+ FX.edges.forEach(function (e) {
1044
+ var pa = P[e.from], pb = P[e.to];
1045
+ if (!pa || !pb) return;
1046
+ var za = FX.size[e.from] || { w: 150, h: 36 }, zb = FX.size[e.to] || { w: 150, h: 36 };
1047
+ var ex = pb.x - pa.x, ey = pb.y - pa.y;
1048
+ var ed = Math.sqrt(ex * ex + ey * ey) || 0.01;
1049
+ var kin = e.kind === 'clone' || e.kind === 'spawn';
1050
+ var rest = Math.max((za.w + zb.w) / 2 + 30, kin ? L * 0.8 : L * 1.35);
1051
+ var k = (kin ? 0.06 : 0.032) * Math.min(2.2, 1 + Math.log(1 + e.count) * 0.45);
1052
+ var g = (ed - rest) * k, gx = ex / ed, gy = ey / ed;
1053
+ pa.vx += gx * g; pa.vy += gy * g; pb.vx -= gx * g; pb.vy -= gy * g;
1054
+ });
1055
+ // 同群相吸 + 朝心收 —— 归属在卡片布局里由版面表达, 这里由一股弱引力表达。
1056
+ var cen = {};
1057
+ ns.forEach(function (nd) {
1058
+ var p = P[nd.target], c = cen[nd.base] || (cen[nd.base] = { x: 0, y: 0, n: 0 });
1059
+ c.x += p.x; c.y += p.y; c.n++;
1060
+ });
1061
+ ns.forEach(function (nd) {
1062
+ var p = P[nd.target], c = cen[nd.base];
1063
+ p.vx += (c.x / c.n - p.x) * 0.022 + (FX.w / 2 - p.x) * 0.006;
1064
+ p.vy += (c.y / c.n - p.y) * 0.022 + (FX.h / 2 - p.y) * 0.006;
1065
+ });
1066
+ // 积分。alpha 同时当步长, 于是"冷却"就是自然停住。
1067
+ ns.forEach(function (nd) {
1068
+ var p = P[nd.target];
1069
+ if (p.pin) { p.vx = 0; p.vy = 0; return; }
1070
+ p.vx *= 0.8; p.vy *= 0.8;
1071
+ var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy), cap = 26;
1072
+ if (sp > cap) { p.vx *= cap / sp; p.vy *= cap / sp; }
1073
+ p.x += p.vx * FX.alpha; p.y += p.vy * FX.alpha;
1074
+ });
1075
+ // 矩形避让 —— 节点是一行字, 不是一个点。圆形斥力挡不住两张卡横向叠在一起,
1076
+ // 而这张图的全部价值就在于那行字读得出来。
1077
+ // 它排在积分之后, 所以收边必须排在它之后 —— 反过来的话, 被挤到边上的节点会
1078
+ // 被推出画布再也回不来 (弱引力拉不过避让)。
1079
+ var pass, pa, pb, sa, sb, mx, my, ox, oy, sh;
1080
+ for (pass = 0; pass < 2; pass++) {
1081
+ for (i = 0; i < n; i++) {
1082
+ for (j = i + 1; j < n; j++) {
1083
+ pa = P[ns[i].target]; pb = P[ns[j].target];
1084
+ sa = FX.size[ns[i].target]; sb = FX.size[ns[j].target];
1085
+ if (!sa || !sb) continue;
1086
+ mx = (sa.w + sb.w) / 2 + 18; my = (sa.h + sb.h) / 2 + 10;
1087
+ dx = pb.x - pa.x; dy = pb.y - pa.y;
1088
+ ox = mx - Math.abs(dx); oy = my - Math.abs(dy);
1089
+ if (ox <= 0 || oy <= 0) continue;
1090
+ if (ox / mx < oy / my) {
1091
+ sh = (dx < 0 ? -1 : 1) * ox;
1092
+ if (pa.pin) pb.x += sh; else if (pb.pin) pa.x -= sh;
1093
+ else { pa.x -= sh / 2; pb.x += sh / 2; }
1094
+ } else {
1095
+ sh = (dy < 0 ? -1 : 1) * oy;
1096
+ if (pa.pin) pb.y += sh; else if (pb.pin) pa.y -= sh;
1097
+ else { pa.y -= sh / 2; pb.y += sh / 2; }
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+ // 收进画布 —— 钉住的也收, 窗口缩小之后那些被人摆在外面的节点得跟着回来。
1103
+ ns.forEach(function (nd) {
1104
+ var p = P[nd.target], z = FX.size[nd.target] || { w: 150, h: 36 };
1105
+ p.x = Math.max(z.w / 2 + 6, Math.min(FX.w - z.w / 2 - 6, p.x));
1106
+ p.y = Math.max(z.h / 2 + 6, Math.min(FX.h - z.h / 2 - 6, p.y));
1107
+ });
1108
+ };
1109
+
1110
+ /** 中心沿 (ux,uy) 走到卡片矩形的边 —— 箭头要落在卡片外面才看得见。 */
1111
+ var fxRim = function (p, s, ux, uy) {
1112
+ var hw = (s ? s.w : 150) / 2 + 3, hh = (s ? s.h : 36) / 2 + 3;
1113
+ var t = Math.min(ux ? hw / Math.abs(ux) : 1e9, uy ? hh / Math.abs(uy) : 1e9);
1114
+ return { x: p.x + ux * t, y: p.y + uy * t };
1115
+ };
1116
+
1117
+ /** 连线只在重排时建一次 —— 逐帧重写 innerHTML 会把一张 50 条边的图拖成幻灯片。
1118
+ * 之后每帧只改 `d`, 聚焦只改描边 (见 fxClasses)。 */
1119
+ var fxEdgesDOM = function () {
1120
+ var svg = $('#wedges');
1121
+ if (!svg) return;
1122
+ var defs = Object.keys(EDGE).map(function (k) {
1123
+ return ['', '-d'].map(function (sfx) {
1124
+ return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
1125
+ 'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
1126
+ 'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
1127
+ }).join('');
1128
+ }).join('');
1129
+ svg.innerHTML = '<defs>' + defs + '</defs>' + FX.edges.map(function (e) {
1130
+ return '<path fill="none" stroke="' + EDGE[e.kind].c + '" ' +
1131
+ 'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '">' +
1132
+ '<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
1133
+ (e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
1134
+ (e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
1135
+ }).join('');
1136
+ FX.paths = Array.prototype.slice.call(svg.querySelectorAll('path[stroke]'));
1137
+ };
1138
+
1139
+ var fxPaint = function () {
1140
+ FX.nodes.forEach(function (n) {
1141
+ var p = FX.pos[n.target], el = FX.els[n.target];
1142
+ if (el && p) el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) translate(-50%,-50%)';
1143
+ });
1144
+ FX.edges.forEach(function (e, i) {
1145
+ var path = FX.paths[i], pa = FX.pos[e.from], pb = FX.pos[e.to];
1146
+ if (!path || !pa || !pb) return;
1147
+ var dx = pb.x - pa.x, dy = pb.y - pa.y;
1148
+ var d = Math.sqrt(dx * dx + dy * dy) || 0.01, ux = dx / d, uy = dy / d;
1149
+ var A = fxRim(pa, FX.size[e.from], ux, uy), B = fxRim(pb, FX.size[e.to], -ux, -uy);
1150
+ // 恒向左弓 —— 互相派活的两个 wizard 有两条方向相反的边, 直线会重合成一条。
1151
+ var bow = Math.min(30, d * 0.14);
1152
+ var qx = (A.x + B.x) / 2 - uy * bow, qy = (A.y + B.y) / 2 + ux * bow;
1153
+ path.setAttribute('d', 'M' + A.x.toFixed(1) + ',' + A.y.toFixed(1) +
1154
+ ' Q' + qx.toFixed(1) + ',' + qy.toFixed(1) + ' ' + B.x.toFixed(1) + ',' + B.y.toFixed(1));
1155
+ });
1156
+ };
1157
+
1158
+ var fxLoop = function () {
1159
+ FX.raf = 0;
1160
+ if (W.layout !== 'force' || VIEW !== 'world') return;
1161
+ fxTick();
1162
+ fxPaint();
1163
+ if (!FX.drag) FX.alpha *= 0.972;
1164
+ if (FX.alpha > 0.02 || FX.drag) FX.raf = requestAnimationFrame(fxLoop);
1165
+ };
1166
+
1167
+ var fxHeat = function (a) {
1168
+ FX.alpha = Math.max(FX.alpha, a === undefined ? 0.9 : a);
1169
+ if (!FX.raf) FX.raf = requestAnimationFrame(fxLoop);
1170
+ };
1171
+
1172
+ var fxStop = function () {
1173
+ if (FX.raf) cancelAnimationFrame(FX.raf);
1174
+ FX.raf = 0; FX.drag = null; FX.alpha = 0;
1175
+ wscrollEl.classList.remove('fx');
1176
+ wmapEl.classList.remove('force');
1177
+ };
1178
+
1179
+ var fxClasses = function () {
1180
+ var hood = W.sel ? neighborhood(W.sel) : null;
1181
+ FX.nodes.forEach(function (n) {
1182
+ var el = FX.els[n.target];
1183
+ if (!el) return;
1184
+ el.classList.toggle('dim', !!(hood && !hood[n.target]));
1185
+ el.classList.toggle('sel', n.target === W.sel);
1186
+ el.classList.toggle('pin', !!(FX.pos[n.target] || {}).pin);
1187
+ });
1188
+ // 聚焦时无关的边压到近乎不可见而不是删掉 —— "这张图本来有多密"是判断要不要
1189
+ // 收几个 wizard 的依据 (同 drawEdges)。
1190
+ FX.edges.forEach(function (e, i) {
1191
+ var path = FX.paths[i];
1192
+ if (!path) return;
1193
+ var off = hood && !(e.from === W.sel || e.to === W.sel);
1194
+ var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
1195
+ path.setAttribute('stroke-width', (off ? 1 : w).toFixed(2));
1196
+ path.setAttribute('opacity', off ? 0.12 : (e.cross ? 0.95 : 0.55));
1197
+ path.setAttribute('marker-end', 'url(#ah-' + e.kind + (off ? '-d' : '') + ')');
1198
+ });
1199
+ };
1200
+
1201
+ var fxNodeHTML = function (n) {
1202
+ var run = wRunning(n);
1203
+ var tip = [n.name, n.description, n.preview, n.cwd ? '📁 ' + n.cwd : '',
1204
+ [n.model && n.model.replace(/^claude-/, ''), n.turns ? n.turns + ' 轮' : ''].filter(Boolean).join(' · ')]
1205
+ .filter(Boolean).join('\n');
1206
+ return '<div class="fxn' + (n.self ? ' self' : '') + (run ? ' run' : '') + (n.alive ? '' : ' cold') +
1207
+ (canOpen(n) ? ' go' : '') + '" data-t="' + esc(n.target) + '" style="--c:' + chatColor(n.base) + '" ' +
1208
+ 'title="' + esc(tip) + '">' +
1209
+ '<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
1210
+ '<span class="fxb">' +
1211
+ '<b class="wname">' + esc(shortName(n)) + '</b>' +
1212
+ '<span class="fxc">' + esc(n.chat || n.base) + (n.self ? ' · 本会话' : '') + '</span>' +
1213
+ '</span>' +
1214
+ '</div>';
1215
+ };
1216
+
1217
+ // 拖动 = 钉住。点一下 (没挪动) 仍然是聚焦 —— 所以判据是位移而不是事件类型。
1218
+ var fxBind = function (el, t) {
1219
+ el.onpointerdown = function (ev) {
1220
+ if (ev.button) return;
1221
+ var p = FX.pos[t];
1222
+ if (!p) return;
1223
+ FX.drag = { t: t, dx: p.x - ev.clientX, dy: p.y - ev.clientY, moved: 0 };
1224
+ el.classList.add('drag');
1225
+ if (el.setPointerCapture) el.setPointerCapture(ev.pointerId);
1226
+ fxHeat(0.3);
1227
+ ev.preventDefault();
1228
+ };
1229
+ el.onpointermove = function (ev) {
1230
+ if (!FX.drag || FX.drag.t !== t) return;
1231
+ var p = FX.pos[t], nx = ev.clientX + FX.drag.dx, ny = ev.clientY + FX.drag.dy;
1232
+ FX.drag.moved += Math.abs(nx - p.x) + Math.abs(ny - p.y);
1233
+ p.x = nx; p.y = ny; p.vx = 0; p.vy = 0; p.pin = 1;
1234
+ };
1235
+ el.onpointerup = function () {
1236
+ if (!FX.drag || FX.drag.t !== t) return;
1237
+ var moved = FX.drag.moved;
1238
+ FX.drag = null;
1239
+ el.classList.remove('drag');
1240
+ if (moved < 5) {
1241
+ FX.pos[t].pin = 0;
1242
+ W.sel = (W.sel === t ? '' : t);
1243
+ renderWTools(); fxClasses(); fxHeat(0.12);
1244
+ } else { fxClasses(); fxHeat(0.2); }
1245
+ };
1246
+ el.ondblclick = function () { openNode(t); };
1247
+ };
1248
+
1249
+ var renderForce = function () {
1250
+ var ns = fxNodes();
1251
+ wscrollEl.classList.add('fx');
1252
+ wmapEl.classList.add('force');
1253
+ if (!ns.length) {
1254
+ wmapEl.innerHTML = '<div class="empty">' + (W.loaded ? '此刻没有任何 wizard 在图上' : '加载中…') + '</div>';
1255
+ return;
1256
+ }
1257
+ var box = wscrollEl.getBoundingClientRect();
1258
+ FX.w = Math.max(320, box.width); FX.h = Math.max(300, box.height);
1259
+ FX.nodes = ns;
1260
+ FX.color = fxColors();
1261
+ fxSeed(ns);
1262
+ // 端点被「只看有关系的」筛掉的边没有落脚处 —— 画一根悬空的线只会让人以为漏了谁。
1263
+ var on = {};
1264
+ ns.forEach(function (n) { on[n.target] = 1; });
1265
+ FX.edges = W.edges.filter(edgeOn).filter(function (e) { return on[e.from] && on[e.to]; });
1266
+ // 轮询回来的快照通常一模一样 —— 那就只重画文字, 不重新点火, 免得这张图每 6
1267
+ // 秒自己抖一下。
1268
+ var sig = ns.map(function (n) { return n.target; }).join(',') + '|' +
1269
+ FX.edges.map(function (e) { return e.kind + e.from + '>' + e.to; }).join(',');
1270
+ var fresh = sig !== FX.sig;
1271
+ FX.sig = sig;
1272
+ wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg>' + ns.map(fxNodeHTML).join('');
1273
+ FX.els = {};
1274
+ wmapEl.querySelectorAll('.fxn').forEach(function (el) {
1275
+ var t = el.getAttribute('data-t');
1276
+ FX.els[t] = el;
1277
+ FX.size[t] = { w: el.offsetWidth, h: el.offsetHeight };
1278
+ fxBind(el, t);
1279
+ });
1280
+ var svg = $('#wedges');
1281
+ svg.setAttribute('viewBox', '0 0 ' + FX.w + ' ' + FX.h);
1282
+ fxEdgesDOM();
1283
+ fxClasses();
1284
+ fxPaint();
1285
+ fxHeat(fresh ? 0.9 : 0.05);
1286
+ };
1287
+
1288
+ var renderWorld = function () {
1289
+ renderWTools();
1290
+ if (W.layout === 'force') { renderForce(); return; }
1291
+ fxStop();
1292
+ renderWMap();
1293
+ // 连线要等浏览器把卡片排好 —— 同一帧里量到的是上一次的版面。
1294
+ requestAnimationFrame(drawEdges);
1295
+ };
1296
+
1297
+ // ══ 日程视图 ═══════════════════════════════════════════════════════
1298
+ // 定时任务与工单摆在一起, 因为它们回答同一个问题: **什么被安排了**。
1299
+ // 区别只在时间的方向 —— 定时指向未来 (下次几点放枪), 工单指向现在 (还开着
1300
+ // 的这几路活干完没有)。
1301
+ var fmtClock = function (ts) {
1302
+ if (!ts) return '—';
1303
+ var d = new Date(ts), p = function (n) { return n < 10 ? '0' + n : '' + n; };
1304
+ var today = new Date(srvNow());
1305
+ var sameDay = d.toDateString() === today.toDateString();
1306
+ return (sameDay ? '今天 ' : (p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ')) + p(d.getHours()) + ':' + p(d.getMinutes());
1307
+ };
1308
+ var fmtIn = function (ts) {
1309
+ var d = ts - srvNow();
1310
+ if (d <= 0) return '即将';
1311
+ if (d < 3600000) return Math.max(1, Math.round(d / 60000)) + ' 分钟后';
1312
+ if (d < 86400000) return Math.round(d / 3600000) + ' 小时后';
1313
+ return Math.round(d / 86400000) + ' 天后';
1314
+ };
1315
+
1316
+ var wizChip = function (target) {
1317
+ var n = nodeOf(target);
1318
+ return '<span class="wchip' + (canOpen(n) ? ' go' : '') + '" data-t="' + esc(target) + '">' +
1319
+ esc((n && n.label) || '🧙') + ' ' + esc(nameOf(target)) + '</span>';
1320
+ };
1321
+
1322
+ // 未来 12 小时的刻度条: 哪几个钟点会有任务醒来, 一眼看完。超出 12h 的排进下面
1323
+ // 的列表但不占刻度 —— 把 24h 压进这么窄的一条, 刻度会密到读不出。
1324
+ var HORIZON_H = 12;
1325
+ var renderStrip = function (list) {
1326
+ var now = srvNow(), span = HORIZON_H * 3600000;
1327
+ var soon = list.filter(function (x) { return x.nextAt - now < span; });
1328
+ var ticks = [];
1329
+ for (var i = 0; i <= HORIZON_H; i += 3) {
1330
+ var t = new Date(now + i * 3600000);
1331
+ ticks.push('<span class="tk" style="left:' + (i / HORIZON_H * 100) + '%">' +
1332
+ (i ? (t.getHours() < 10 ? '0' : '') + t.getHours() + ':00' : '现在') + '</span>');
1333
+ }
1334
+ var pins = soon.map(function (x) {
1335
+ var pct = Math.max(0, Math.min(100, (x.nextAt - now) / span * 100));
1336
+ return '<span class="pin" style="left:' + pct.toFixed(2) + '%" data-t="' + esc(x.target) + '" ' +
1337
+ 'title="' + esc(fmtClock(x.nextAt) + ' · ' + nameOf(x.target) + ' · ' + x.when) + '"></span>';
1338
+ }).join('');
1339
+ return '<div class="strip"><div class="axis">' + ticks.join('') + '</div>' +
1340
+ '<div class="rail">' + pins + '</div>' +
1341
+ '<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
1342
+ };
1343
+
1344
+ // 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
1345
+ var renderPlan = function () {
1346
+ var ss = (W.schedules || []).filter(function (x) { return (x.owner || x.createdBy || x.target) === ROLE; });
1347
+ var js = (W.jobs || []).filter(function (j) {
1348
+ return j.owner === ROLE || j.members.some(function (mm) { return mm.target === ROLE; });
1349
+ });
1350
+ var open = js.filter(function (j) { return j.status === 'open'; });
1351
+ var closed = js.filter(function (j) { return j.status !== 'open'; });
1352
+ var schedRows = ss.length
1353
+ ? ss.map(function (x) {
1354
+ return '<div class="prow">' +
1355
+ '<div class="pl">' +
1356
+ '<div class="pwhen">⏰ ' + esc(x.when) + '</div>' +
1357
+ '<div class="pnext"><b>' + esc(fmtClock(x.nextAt)) + '</b><span>' + esc(fmtIn(x.nextAt)) + '</span></div>' +
1358
+ '</div>' +
1359
+ '<div class="pr">' +
1360
+ '<div class="ph">' + wizChip(x.target) + (x.note ? '<span class="note">' + esc(x.note) + '</span>' : '') +
1361
+ '<span class="pid">' + esc(x.id) + '</span></div>' +
1362
+ '<div class="ptext">' + esc(x.prompt.split('\n')[0].slice(0, 160)) + '</div>' +
1363
+ '<div class="pfoot">' + (x.lastFired ? '上次 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</div>' +
1364
+ '</div>' +
1365
+ '</div>';
1366
+ }).join('')
1367
+ : '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>';
1368
+
1369
+ var jobRow = function (j) {
1370
+ return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
1371
+ '<div class="jh"><span class="jid">' + esc(j.id) + '</span>' +
1372
+ '<span class="jt">' + esc(j.title) + '</span>' +
1373
+ '<span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
1374
+ '<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
1375
+ '<div class="jm">' + (j.members.length
1376
+ ? j.members.map(function (mm) {
1377
+ return '<span class="jmm">' + wizChip(mm.target) +
1378
+ (mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
1379
+ '<em>' + esc((mm.task || '').split('\n')[0].slice(0, 70)) + '</em></span>';
1380
+ }).join('')
1381
+ : '<span class="jmm none">还没有成员</span>') + '</div>' +
1382
+ (j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
1383
+ '</div>';
1384
+ };
1385
+
1386
+ planEl.innerHTML =
1387
+ '<section class="psec">' +
1388
+ '<h3>⏰ 定时任务<span>' + ss.length + '</span></h3>' +
1389
+ renderStrip(ss) + schedRows +
1390
+ '</section>' +
1391
+ '<section class="psec">' +
1392
+ '<h3>📋 工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
1393
+ (js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
1394
+ '</section>';
1395
+ planEl.querySelectorAll('.wchip.go').forEach(function (c) {
1396
+ c.onclick = function () { openNode(c.getAttribute('data-t')); };
1397
+ });
1398
+ };
1399
+
1400
+ // 关系图上随时间变化的只有两样: 呼吸灯该不该亮、"几分钟前"该写几。重建整张图
1401
+ // 会把滚动位置、聚焦态和连线一起抖掉, 所以这里只原地改这两处。
1402
+ var tickWorld = function () {
1403
+ wmapEl.querySelectorAll('.wnode').forEach(function (el) {
1404
+ var n = nodeOf(el.getAttribute('data-t'));
1405
+ if (!n) return;
1406
+ var run = wRunning(n);
1407
+ el.classList.toggle('run', run);
1408
+ var av = el.querySelector('.wav'), dot = el.querySelector('.wav .live');
1409
+ if (run && !dot && av) av.insertAdjacentHTML('beforeend', '<i class="live"></i>');
1410
+ if (!run && dot) dot.remove();
1411
+ var ts = el.querySelector('.wts');
1412
+ if (ts) ts.textContent = fmtAgo(n.lastTs);
1413
+ });
1414
+ };
1415
+
1416
+ // ══ 视图切换 ═══════════════════════════════════════════════════════
1417
+ // 世界快照不走 SSE: 它变动的源头 (spawn / 改职责 / 开收工单 / 排定时) 一条都
1418
+ // 不经过 detail store。改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
1419
+ var WORLD_MS = 6000;
1420
+ var worldTimer = null;
1421
+ var loadWorld = function () {
1422
+ return api('api/world', { role: ROLE }).then(function (d) {
1423
+ if (!d.ok) return;
1424
+ W.at = d.at; W.loaded = true;
1425
+ W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
1426
+ W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
1427
+ if (VIEW === 'world') renderWorld();
1428
+ if (VIEW === 'plan') renderPlan();
1429
+ }).catch(function () { });
1430
+ };
1431
+ var pollWorld = function () {
1432
+ if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
1433
+ if (VIEW === 'msgs') return;
1434
+ worldTimer = setInterval(function () {
1435
+ if (!document.hidden) loadWorld();
1436
+ }, WORLD_MS);
1437
+ };
1438
+
1439
+ var setView = function (v) {
1440
+ VIEW = v;
1441
+ thread.hidden = v !== 'msgs';
1442
+ $('#pane-world').hidden = v !== 'world';
1443
+ $('#pane-plan').hidden = v !== 'plan';
1444
+ app.classList.toggle('outer', v !== 'msgs');
1445
+ // 手机上关系/日程占满主区 —— 进入即阅读态。
1446
+ if (v !== 'msgs') app.classList.add('reading');
1447
+ renderRole(); renderHead();
1448
+ if (v === 'msgs') { fxStop(); toBottom(true); }
1449
+ else {
1450
+ // 关系图一打开就聚焦在当前 role 身上: 它的邻居亮着, 其余压暗。
1451
+ if (v === 'world') W.sel = ROLE;
1452
+ if (!W.loaded) { (v === 'world' ? wmapEl : planEl).innerHTML = '<div class="empty">加载中…</div>'; }
1453
+ else if (v === 'world') renderWorld();
1454
+ else renderPlan();
1455
+ loadWorld();
1456
+ }
1457
+ pollWorld();
1458
+ };
1459
+ // 卡片换行会改变每个节点的坐标 —— 连线必须跟着重画。
1460
+ window.addEventListener('resize', function () {
1461
+ if (VIEW !== 'world') return;
1462
+ if (W.layout === 'force') renderForce(); else requestAnimationFrame(drawEdges);
1463
+ });
1464
+
402
1465
  // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
403
1466
  setInterval(function () {
404
- if (!S.tags.length) return;
405
- renderTags();
406
- renderStatus(curTag());
407
- renderGraph();
408
- expireTurns();
1467
+ if (!R.role) return;
1468
+ renderProfile();
1469
+ expireRows();
1470
+ if (VIEW === 'world') tickWorld();
1471
+ else if (VIEW === 'plan') renderPlan();
409
1472
  }, TICK_MS);
1473
+ // 侧栏的「几分钟前」一分钟刷一次就够 —— 每 3s 重建会把列表的滚动与焦点蹭掉。
1474
+ setInterval(function () { if (R.role && VIEW === 'msgs') renderConvs(); }, 60000);
410
1475
 
411
1476
  // ── boot ──
412
- api('api/chat', {}).then(function (d) {
413
- if (!d.ok) {
414
- document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
415
- return;
416
- }
417
- applySummary(d);
418
- // 卡片链接带来的那条 turn 决定默认选中的 tag; 否则取最近活跃的。
419
- select(d.self && d.self.target ? d.self.target : (S.tags[0] && S.tags[0].target));
420
- });
1477
+ // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
1478
+ if (ROLE || CONV) app.classList.add('reading');
1479
+ refresh();
421
1480
  })();