wezard 1.4.2 → 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 (69) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +121 -67
  3. package/dist/daemon/approval.js +9 -3
  4. package/dist/daemon/approval.js.map +1 -1
  5. package/dist/daemon/cc-bridge.js.map +1 -1
  6. package/dist/daemon/chat-name.js +8 -18
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/detail.js +53 -12
  9. package/dist/daemon/detail.js.map +1 -1
  10. package/dist/daemon/graph.js +5 -7
  11. package/dist/daemon/graph.js.map +1 -1
  12. package/dist/daemon/inbound.js +135 -158
  13. package/dist/daemon/inbound.js.map +1 -1
  14. package/dist/daemon/index.js +381 -192
  15. package/dist/daemon/index.js.map +1 -1
  16. package/dist/daemon/jobs.js +2 -2
  17. package/dist/daemon/mirror-bridge.js +221 -134
  18. package/dist/daemon/mirror-bridge.js.map +1 -1
  19. package/dist/daemon/mirror-store.js.map +1 -1
  20. package/dist/daemon/model-select.js +74 -0
  21. package/dist/daemon/model-select.js.map +1 -0
  22. package/dist/daemon/peers.js +10 -16
  23. package/dist/daemon/peers.js.map +1 -1
  24. package/dist/daemon/spawn-tmux.js +27 -4
  25. package/dist/daemon/spawn-tmux.js.map +1 -1
  26. package/dist/daemon/task-gate.js +97 -0
  27. package/dist/daemon/task-gate.js.map +1 -0
  28. package/dist/daemon/task-registry.js +132 -0
  29. package/dist/daemon/task-registry.js.map +1 -0
  30. package/dist/daemon/tasks.js +126 -54
  31. package/dist/daemon/tasks.js.map +1 -1
  32. package/dist/daemon/wizard-memory.js +57 -0
  33. package/dist/daemon/wizard-memory.js.map +1 -0
  34. package/dist/daemon/wizard.js +84 -38
  35. package/dist/daemon/wizard.js.map +1 -1
  36. package/dist/mcp/server.js +138 -76
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/shared/chat-http.js +185 -108
  39. package/dist/shared/chat-http.js.map +1 -1
  40. package/dist/shared/chat-render.js +26 -34
  41. package/dist/shared/chat-render.js.map +1 -1
  42. package/dist/shared/chat-view.js +14 -2
  43. package/dist/shared/chat-view.js.map +1 -1
  44. package/dist/shared/config.js +23 -4
  45. package/dist/shared/config.js.map +1 -1
  46. package/dist/shared/detail-render.js +12 -6
  47. package/dist/shared/detail-render.js.map +1 -1
  48. package/dist/shared/detail-store.js.map +1 -1
  49. package/dist/shared/role-render.js +52 -0
  50. package/dist/shared/role-render.js.map +1 -0
  51. package/dist/shared/role-view.js +210 -0
  52. package/dist/shared/role-view.js.map +1 -0
  53. package/dist/shared/schedule-spec.js +92 -116
  54. package/dist/shared/schedule-spec.js.map +1 -1
  55. package/dist/shared/session-label.js +103 -72
  56. package/dist/shared/session-label.js.map +1 -1
  57. package/dist/shared/std.js +3 -0
  58. package/dist/shared/std.js.map +1 -1
  59. package/dist/shared/task-file.js +120 -0
  60. package/dist/shared/task-file.js.map +1 -0
  61. package/dist/shared/trigger.js +232 -0
  62. package/dist/shared/trigger.js.map +1 -0
  63. package/dist/shared/world.js +7 -5
  64. package/dist/shared/world.js.map +1 -1
  65. package/dist/svr/index.js +67 -2
  66. package/dist/svr/index.js.map +1 -1
  67. package/package.json +1 -1
  68. package/web/chat.css +407 -212
  69. package/web/chat.js +932 -321
package/web/chat.js CHANGED
@@ -1,41 +1,50 @@
1
- // Chat 详情 SPA。三块视图共用一个 `?id=` 凭据:
1
+ // Rolepage SPA —— 从**一个 role 的视角**看它的 IM: 它参与的群聊与单聊, 每一处
2
+ // 它说了什么 (靠右)、听到了什么 (靠左)。点气泡对面那片空白就换成对方的视角。
2
3
  //
3
- // 线程 /api/chat (侧栏 + 总账) + /api/thread (正文) + /api/events (SSE 增量)
4
- // 关系 /api/world —— 全部 wizard、家谱、跨聊天往来、工单、日程 (轮询)
5
- // 日程 同一份 /api/world, 换一种读法 (按时间而不是按关系)
4
+ // 对话 /api/role (身份 + 会话列表 + session) + /api/msgs (窗口正文) + /api/role-events (SSE)
5
+ // 关系 /api/world —— 聚焦当前 role 的家谱与协作网 (轮询)
6
+ // 日程 同一份 /api/world, 只列归当前 role 的定时任务与工单
6
7
  //
7
- // turn 正文是服务端渲染好的 HTML 片段 —— diff / ANSI / 语法高亮只在
8
- // shared/detail-render 实现一次, 这里只做 DOM 增量 reconcile。
9
- //
10
- // 关系图为什么不用力导向: 节点天然有归属 (哪个聊天) 和层级 (谁的分身), 力导向
11
- // 会把这两件确定的事揉成一团随机位置。这里改成 HTML 布局 + SVG 连线 —— 聊天是
12
- // 卡片、分身按家谱缩进 (结构由布局承担), SVG 只画那些布局表达不了的边 (跨卡片
13
- // 的派活、跨聊天的分身)。好处是文字永远可读、可省略、可响应式换行。
8
+ // 视角状态 (role / conv / with / session) 全在 URL 里 —— 刷新、转发、从别的聊天点
9
+ // 进来, 落点都一样。消息正文是服务端渲染好的 HTML 片段 (diff / ANSI / 高亮只在
10
+ // shared/detail-render 实现一次), 这里只包左右与头像, 再做 DOM 增量 reconcile。
14
11
  //
15
12
  // 无构建步骤: 保持 var / function 写法, 直接被浏览器加载。
16
13
  (function () {
17
14
  var qs = new URLSearchParams(location.search);
18
15
  var TOKEN = qs.get('id') || '';
19
- // 链接指定要开在哪个 wizard 那一栏。票据 (`id`) 可能是兄弟会话的 turn —— 它只
20
- // 决定授权范围 (整个聊天), 落点由这个参数说。
21
- var WANT = qs.get('target') || '';
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') || '';
22
21
  var TICK_MS = 3000;
23
22
 
24
23
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
25
24
  // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
26
- var S = { base: '', target: '', tags: [], graphs: [], at: 0, recvAt: 0, es: null, pinned: true };
27
- // 关系/日程两栏共用的世界快照。sel = 当前聚焦的节点 (空 = 不聚焦, 全图淡显),
28
- // kinds = 边类型开关。loaded 用来区分"还没拉过"与"拉过但是空的"。
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
29
  var W = {
30
30
  at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
31
- degraded: false, loaded: false, sel: '', onlyRel: false, kinds: { clone: 1, peer: 1, graph: 1 },
31
+ degraded: false, loaded: false, sel: '', onlyRel: false, kinds: { clone: 1, spawn: 1, peer: 1, graph: 1 },
32
+ layout: 'cards',
33
+ };
34
+ // 读法是个人偏好, 记在本地。存储不可用 (隐私窗口 / 老 webview) 就退回默认。
35
+ var LAY_KEY = 'wezard.world.layout';
36
+ try { if (localStorage.getItem(LAY_KEY) === 'force') W.layout = 'force'; } catch (e) { }
37
+ var setLayout = function (v) {
38
+ W.layout = v;
39
+ try { localStorage.setItem(LAY_KEY, v); } catch (e) { }
32
40
  };
33
- var VIEW = 'thread';
41
+ var VIEW = 'msgs';
34
42
  var $ = function (s) { return document.querySelector(s); };
35
- 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');
36
45
  var wmapEl = $('#wmap'), wscrollEl = $('#wscroll'), wtoolsEl = $('#wtools'), planEl = $('#plan-in');
37
46
 
38
- 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(); };
39
48
 
40
49
  var fmtTok = function (n) {
41
50
  if (!n) return '0';
@@ -51,15 +60,30 @@
51
60
  if (m < 60) return m + 'm' + (s - m * 60) + 's';
52
61
  return Math.floor(m / 60) + 'h' + (m % 60) + 'm';
53
62
  };
54
- // 聊天列表用的相对时间 — 一眼看出"刚刚 / 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 分钟前"。
55
69
  var fmtAgo = function (ts) {
56
70
  if (!ts) return '';
57
71
  var d = srvNow() - ts;
58
72
  if (d < 60000) return '刚刚';
59
73
  if (d < 3600000) return Math.floor(d / 60000) + '分钟前';
60
74
  if (d < 86400000) return Math.floor(d / 3600000) + '小时前';
61
- var x = new Date(ts), p = function (n) { return n < 10 ? '0' + n : '' + n; };
62
- 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>';
63
87
  };
64
88
  var esc = function (s) {
65
89
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@@ -70,14 +94,46 @@
70
94
  var p = new URLSearchParams(params || {}); p.set('id', TOKEN);
71
95
  return fetch(path + '?' + p.toString(), { cache: 'no-store' }).then(function (r) { return r.json(); });
72
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
+ };
73
112
 
74
- // ── 运行状态: 服务端只给"到这个时刻自动算结束", 由客户端定时判定 ──
75
- // SSE 只在有写入时推送, 一个真正停下来的 turn 不会再产生任何事件 —— 没有本地
76
- // 判定, 页面上的「运行中 / 正在思考」就永远熄不掉。
77
- var isRunning = function (t) { return !!t.runningUntil && srvNow() < t.runningUntil; };
78
- var curTag = function () {
79
- 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';
80
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>';
134
+ };
135
+ var nameOf = function (id) { return (kindOf(id) === 'wizard' ? '.' : '') + roleName(id); };
136
+ var canSwitch = function (id) { return !!id && kindOf(id) !== 'task' && id !== 'human:'; };
81
137
 
82
138
  // ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
83
139
  var md = null;
@@ -103,7 +159,8 @@
103
159
  });
104
160
  };
105
161
 
106
- // ── 增量 reconcile (与 turn 页同款, 但会递归进 .bubbles) ──
162
+ // ── 增量 reconcile: sig 相同原节点留下; 同为 turn 卡片只换头 + 递归气泡层 ──
163
+ // 一次 tool_result 到达不该重建整张卡片 (滚动位置 / 展开态全部保住)。
107
164
  var snapOpen = function (scope) {
108
165
  var m = {};
109
166
  scope.querySelectorAll('[data-key]').forEach(function (b) {
@@ -124,8 +181,6 @@
124
181
  for (var i = 0; i < el.children.length; i++) if (el.children[i].classList.contains(cls)) return el.children[i];
125
182
  return null;
126
183
  };
127
- // sig 相同 → 原节点原样留下; 不同但两边都是 turn-group → 只换头 + 递归气泡层,
128
- // 这样一次 tool_result 到达不会重建整轮 DOM (滚动位置 / 展开态全部保住)。
129
184
  var reconcile = function (cur, next) {
130
185
  var open = snapOpen(cur), existing = {};
131
186
  Array.prototype.forEach.call(cur.children, function (c) {
@@ -154,281 +209,422 @@
154
209
  };
155
210
 
156
211
  // ── 滚动: 默认到底; 用户手动上翻后解除吸附, 回到底部再吸附 ──
157
- var atBottom = function () {
158
- 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;
159
226
  };
160
- var toBottom = function (force) {
161
- 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>';
162
232
  };
163
- thread.addEventListener('scroll', function () { S.pinned = atBottom(); });
164
233
 
165
- // ── 侧栏 (聊天列表) ──
166
- var tagMeta = function (t) {
167
- var u = t.usage || {};
168
- var bits = [];
169
- if (t.turns) bits.push(t.turns + ' 轮');
170
- if (u.tools) bits.push(u.tools + ' 工具');
171
- var tok = (u.output || 0) + (u.input || 0);
172
- if (tok) bits.push(fmtTok(tok) + ' tok');
173
- // 目录只取最后一段: 兄弟会话常常一个在主仓、一个在 worktree, 差别就在这一段。
174
- if (t.cwd) bits.push('📁 ' + (t.cwd.replace(/\/+$/, '').split('/').filter(Boolean).pop() || t.cwd));
175
- // graph 驱动的会话单独标出来 —— 它的 userQuery 长得和真人消息一样, 不标就
176
- // 分不清是有人在跟它说话, 还是某个 run 在喂它。
177
- var g = t.origin
178
- ? '<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>'
179
244
  : '';
180
- if (!bits.length && !g) return '';
181
- return '<div class="tag-meta">' +
182
- bits.map(esc).join('<span class="sep">·</span>') +
183
- (g && bits.length ? '<span class="sep">·</span>' : '') + g +
184
- '</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;
185
251
  };
186
252
 
187
- // ── graph 运行条 ──
188
- // 只画最近活跃的那一个 run: 同一 chat 同时跑两张图是罕见情形, 而两条并排的
189
- // 流水线会把顶栏挤成一团 —— 宁可只讲清楚当前这一条。
190
- var labelOfTag = function (tag) {
191
- var hit = S.tags.filter(function (x) { return x.tag === tag; })[0];
192
- return hit ? hit.label : '•';
193
- };
194
- var renderGraph = function () {
195
- var g = (S.graphs || [])[0];
196
- if (!g) { gbarEl.hidden = true; gbarEl.innerHTML = ''; return; }
197
- // 与会话行同一判定: 服务端只给"到这个时刻自动算结束", 熄灯由本地定时判。
198
- var run = isRunning(g);
199
- var nodes = g.pipeline.map(function (p) {
200
- var on = p.step === g.step;
201
- return '<span class="nd' + (on ? ' on' : '') + (on && run ? ' live' : '') +
202
- '" data-tag="' + esc(p.tag) + '" title="步 ' + p.step + '/' + g.steps + ' · #' + esc(p.tag) + '">' +
203
- esc(labelOfTag(p.tag)) + ' ' + esc(p.tag ? '#' + p.tag : 'default') + '</span>';
204
- }).join('<span class="arw">→</span>');
205
- gbarEl.hidden = VIEW !== 'thread';
206
- gbarEl.innerHTML =
207
- '<span class="gid" title="graph run">🕸 ' + esc(g.runId) + '</span>' +
208
- '<span class="pipe">' + nodes + '</span>' +
209
- '<span class="prog' + (run ? '' : ' done') + '">' +
210
- (run ? '⟳ ' : '✓ ') + '轮 ' + g.round + '/' + g.rounds + '</span>';
211
- gbarEl.querySelectorAll('.nd').forEach(function (n) {
212
- n.onclick = function () {
213
- var tag = n.getAttribute('data-tag');
214
- var hit = S.tags.filter(function (x) { return x.tag === tag; })[0];
215
- if (hit) select(hit.target);
216
- };
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') || ''); };
217
265
  });
218
266
  };
219
- // 一个会话的 wizard 身份 (名字 / 职责 / 家谱) 只有 /api/world 知道 —— 侧栏
220
- // 因此是两份数据的合流: tag 那份讲"跑了多少", world 那份讲"它是谁"。世界还没
221
- // 拉回来时退化成原先的 `#tag` 视图, 不阻塞线程的首屏。
222
- var nodeOf = function (target) {
223
- return W.nodes.filter(function (n) { return n.target === target; })[0];
224
- };
225
- var nameOf = function (target) {
226
- var n = nodeOf(target);
227
- return (n && n.name) || (target.indexOf('#') >= 0 ? '#' + target.split('#').pop() : target) || target;
228
- };
229
267
 
230
- var renderTags = function () {
231
- if (!S.tags.length) {
232
- tagsEl.innerHTML = '<div class="empty-side">这个 chat 还没有会话记录</div>';
233
- return;
234
- }
235
- $('#side-n').textContent = S.tags.length + ' 个会话';
236
- tagsEl.replaceChildren.apply(tagsEl, S.tags.map(function (t) {
237
- var run = isRunning(t), n = nodeOf(t.target) || {};
238
- var el = document.createElement('div');
239
- el.className = 'tag-row' + (t.target === S.target ? ' on' : '') + (run ? ' running' : '');
240
- // 名字在前、地址在后: `#tag` 是地址, wizard 起过名之后人读的是名字。
241
- var kin = n.parent
242
- ? '<span class="kin" title="' + esc(nameOf(n.parent)) + ' 的分身' + (n.inherited ? ' (继承了它的上下文)' : '') + '">↳ ' +
243
- esc(nameOf(n.parent)) + (n.inherited ? ' ⧉' : '') + '</span>'
244
- : '';
245
- el.innerHTML =
246
- '<div class="tag-av">' + esc(t.label) + (run ? '<span class="live"></span>' : '') + '</div>' +
247
- '<div class="tag-main">' +
248
- '<div class="tag-l1">' +
249
- '<span class="tag-name">' + esc(t.tag ? '#' + t.tag : 'default') + '</span>' +
250
- '<span class="tag-ts">' + esc(fmtAgo(t.lastTs)) + '</span>' +
251
- '</div>' +
252
- (n.description ? '<div class="tag-job">' + esc(n.description) + '</div>' : '') +
253
- (kin ? '<div class="tag-kin">' + kin + '</div>' : '') +
254
- '<div class="tag-prev">' + esc(t.preview || '(暂无对话)') + '</div>' +
255
- tagMeta(t) +
256
- '</div>';
257
- el.onclick = function () { select(t.target); };
258
- return el;
259
- }));
260
- };
261
-
262
- // cwd 只显示尾部两段 —— 顶栏放不下全路径, 而"哪个项目/哪个 worktree"恰好就在
263
- // 尾部; 全路径留在 title 里。~ 前缀在服务端不可知, 客户端也无从展开, 原样保留。
264
268
  var shortCwd = function (p) {
265
269
  var seg = String(p).replace(/\/+$/, '').split('/').filter(Boolean);
266
270
  return seg.length <= 2 ? p : '…/' + seg.slice(-2).join('/');
267
271
  };
272
+ var sessLabel = function (s) {
273
+ return (s.sessionId ? s.sessionId.slice(0, 8) : '(无 id)') + ' · ' + fmtAgo(s.start) + ' · ' + s.turns + ' 轮';
274
+ };
268
275
 
269
- var topbar = function () {
270
- var t = curTag();
271
- $('#tb-em').textContent = t ? t.label : '💬';
272
- $('#tb-h').textContent = t ? (t.tag ? '#' + t.tag : 'default') : '';
273
- var cwdEl = $('#tb-cwd');
274
- cwdEl.hidden = !(t && t.cwd);
275
- if (t && t.cwd) { cwdEl.textContent = '📁 ' + shortCwd(t.cwd); cwdEl.title = t.cwd; }
276
- $('#tb-sub').textContent = S.target;
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);
277
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
+ });
278
319
 
279
- // ── 页脚总账 ──
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
+ };
360
+
361
+ // 左栏底: session 切换 + 用量总账, 都是「轮次」的事, 与上面的身份分开; 两样都空就整条收起。
362
+ var syncFoot = function () {
363
+ $('#rb-foot').hidden = !$('#rb-sess').innerHTML && !$('#rb-prof').innerHTML;
364
+ };
365
+
366
+ // ── profile: 当前 role 自己跑过的轮次的总账 (选了 session 就只算那一段) ──
280
367
  var COLORS = { input: '#0a7d6b', cacheRead: '#8250df', cacheWrite: '#953800', output: '#1a7f37' };
281
- // 缩写键本身没有自解释性, 悬停给出中文口径 (尤其 ctx 是峰值、cache 是累计读写)。
282
368
  var TIP = {
283
369
  turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
284
370
  ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值',
285
371
  out: '累计输出 token', cache: '累计缓存 token (读 + 写)', time: '累计耗时',
286
372
  };
287
- // 进行中的会话, 耗时要跟着走: durationMs 只统计到快照时刻 at, 之后的补上;
288
- // runningUntil 一过就冻住, 不会像旧版那样把静默期一路累加成几十小时。
289
373
  var liveDur = function (t) {
290
374
  var d = (t.usage && t.usage.durationMs) || 0;
291
375
  if (!t.runningUntil) return d;
292
- 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);
293
377
  };
294
- var renderStatus = function (t) {
295
- if (!t) { sbEl.innerHTML = ''; return; }
296
- 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 || {};
297
383
  var segs = [['input', '输入'], ['cacheRead', '缓存读'], ['cacheWrite', '缓存写'], ['output', '输出']]
298
384
  .filter(function (s) { return u[s[0]] > 0; });
299
385
  var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
300
- // token 一条都没采到就别画那条空槽 —— 空进度条看着像"用了 0%", 是误导。
301
- // 光有色块看不出哪段是什么 (tooltip 要悬停才知道), 所以条后面always跟图例。
302
386
  var io = total > 0
303
- ? '<span class="sb-io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
304
- '<span class="k">token i/o</span>' +
305
- '<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) {
306
389
  return '<span class="seg" style="width:' + (u[s[0]] / total * 100).toFixed(2) + '%;background:' +
307
390
  COLORS[s[0]] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
308
391
  }).join('') + '</span>' +
309
- '<span class="sb-leg">' + segs.map(function (s) {
392
+ '<span class="leg">' + segs.map(function (s) {
310
393
  return '<span class="lg"><i style="background:' + COLORS[s[0]] + '"></i>' + s[1] +
311
394
  '<b>' + fmtTok(u[s[0]]) + '</b></span>';
312
395
  }).join('') + '</span>' +
313
- '</span>'
396
+ '</div>'
314
397
  : '';
315
- // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 压暗成 "–" 与真实的 0 区分。
398
+ // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占格子。
316
399
  var st = function (k, n, text) {
317
- return '<span class="st' + (n ? '' : ' void') + '" title="' + esc(TIP[k] || k) + '">' +
318
- '<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>' : '';
319
402
  };
320
403
  var cache = (u.cacheRead || 0) + (u.cacheWrite || 0);
321
- sbEl.innerHTML =
322
- '<span class="pill' + (run ? ' run' : '') + '"><span class="d"></span>' + (run ? '进行中' : '空闲') + '</span>' +
323
- (t.model ? '<span class="model" title="' + esc(t.model) + '">' + esc(t.model) + '</span>' : '') +
324
- st('turns', t.turns, t.turns) +
325
- st('tools', u.tools, u.tools) +
326
- st('api', u.calls, u.calls) +
327
- st('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) +
328
- st('out', u.output, fmtTok(u.output)) +
329
- st('cache', cache, fmtTok(cache)) +
330
- st('time', liveDur(t), fmtDur(liveDur(t))) +
331
- io;
332
- };
333
-
334
- // ── 线程 ──
335
- var applySummary = function (d) {
336
- S.base = d.base; S.tags = d.tags || []; S.graphs = d.graphs || [];
337
- S.at = d.at || Date.now(); S.recvAt = Date.now();
338
- $('#side-sub').textContent = d.base || '';
339
- renderTags(); topbar(); renderStatus(curTag()); renderGraph();
340
- };
341
-
342
- var loadThread = function (target, limit) {
343
- return api('api/thread', limit ? { target: target, limit: limit } : { target: target }).then(function (d) {
344
- if (!d.ok || target !== S.target) return;
345
- var inner = $('#thread-in');
346
- if (!d.turns.length) { inner.innerHTML = '<div class="empty">这个会话还没有记录</div>'; return; }
347
- // 默认只取最近若干轮 (贴底阅读), 更早的按需一次性拉全。
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; }
348
542
  var more = d.truncated
349
- ? '<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>'
350
544
  : '';
351
- inner.innerHTML = more + d.turns.map(function (t) { return t.html; }).join('');
352
- d.turns.forEach(function (t) { markStale(t); });
545
+ inner.innerHTML = more + d.msgs.map(rowHTML).join('');
353
546
  var btn = $('#more');
354
- if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadThread(target, '0'); };
547
+ if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
548
+ bindRow(inner);
355
549
  render(inner);
356
- expireTurns();
550
+ foldPings(inner);
551
+ expireRows();
357
552
  S.pinned = true; toBottom(true);
358
553
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
359
554
  setTimeout(function () { toBottom(); }, 60);
360
555
  });
361
556
  };
362
557
 
363
- // staleAt 由 JSON 单独送来 (不在 html 里, 否则会打乱服务端的 sig 去重), 落到
364
- // DOM 上供 expireTurns 定时判定。
365
- // 't:' = 一轮对话, 'm:' = 上下文断点行。子 agent 的卡片嵌在父轮里面, 所以按
366
- // 整棵子树找 —— 只扫顶层会把它当成"还没渲染过"而重复插一份。
367
- var turnNode = function (id) {
368
- return $('#thread-in').querySelector('[data-key="t:' + id + '"],[data-key="m:' + id + '"]');
369
- };
370
- var markStale = function (t) {
371
- var n = turnNode(t.id);
372
- if (n) n.setAttribute('data-stale-at', t.staleAt || 0);
373
- };
374
-
375
- var upsertTurn = function (t) {
376
- var inner = $('#thread-in');
558
+ var upsertMsg = function (m) {
377
559
  var empty = inner.querySelector('.empty'); if (empty) empty.remove();
378
560
  var stick = S.pinned;
379
- 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;
380
567
  if (!cur) {
381
- inner.insertAdjacentHTML('beforeend', t.html);
382
- render(inner);
383
- } else if (cur.getAttribute('data-sig') !== t.sig) {
384
- var box = frag(t.html).firstElementChild;
385
- var eb = childBy(cur, 'bubbles'), nb = box && childBy(box, 'bubbles');
386
- if (eb && nb) {
387
- 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');
388
581
  if (eh && nh) eh.innerHTML = nh.innerHTML;
389
- reconcile(eb, nb);
390
- cur.setAttribute('data-sig', t.sig);
391
- } else if (box) {
392
- 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);
393
586
  }
394
587
  }
395
- markStale(t);
588
+ foldPings(inner);
396
589
  if (stick) toBottom(true);
397
590
  };
398
591
 
399
592
  // 到点了还没有新写入 → 这一轮已经结束: 熄掉呼吸点、移除"正在思考"。
400
- var expireTurns = function () {
593
+ var expireRows = function () {
401
594
  var now = srvNow();
402
- $('#thread-in').querySelectorAll('.turn-group[data-stale-at]').forEach(function (g) {
595
+ inner.querySelectorAll('.mrow[data-stale-at]').forEach(function (g) {
403
596
  var until = Number(g.getAttribute('data-stale-at') || 0);
404
597
  if (!until || now <= until) return;
405
598
  g.setAttribute('data-stale-at', '0');
406
- // 连内嵌的子 agent 卡片一起熄灯: 父轮都到点了, 它派出去的必然也停了,
407
- // 而子卡片没有自己的 data-stale-at (staleAt 只随顶层片段下发)。
408
599
  g.querySelectorAll('.tg-dot').forEach(function (d) { d.classList.remove('live'); });
409
600
  g.querySelectorAll('.typing').forEach(function (t) { t.remove(); });
410
601
  });
411
602
  };
412
603
 
413
- var select = function (target) {
414
- if (!target) return;
415
- S.target = target;
416
- document.querySelector('.app').classList.add('reading');
417
- renderTags(); topbar(); renderStatus(curTag());
418
- $('#thread-in').innerHTML = '<div class="empty">加载中…</div>';
419
- 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();
420
617
  };
421
- $('#tb-back').onclick = function () { document.querySelector('.app').classList.remove('reading'); };
422
618
 
423
- // ── SSE: chat 摘要 + 当前 tag 的 turn 增量。断线指数退避重连。 ──
619
+ // ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
424
620
  var backoff = 1000;
425
621
  var connect = function () {
426
622
  if (S.es) { S.es.close(); S.es = null; }
427
- var p = new URLSearchParams({ id: TOKEN, target: S.target });
428
- 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());
429
625
  S.es = es;
430
- es.addEventListener('chat', function (e) { try { applySummary(JSON.parse(e.data)); } catch (err) { } });
431
- 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) { } });
432
628
  es.onopen = function () { backoff = 1000; connEl.className = 'conn'; connEl.title = '实时连接'; };
433
629
  es.onerror = function () {
434
630
  connEl.className = 'conn off'; connEl.title = '连接断开, 重连中';
@@ -438,43 +634,88 @@
438
634
  };
439
635
  };
440
636
 
441
- // 关系图上随时间变化的只有两样: 呼吸灯该不该亮、"几分钟前"该写几。重建整张图
442
- // 会把滚动位置、聚焦态和连线一起抖掉, 所以这里只原地改这两处。
443
- var tickWorld = function () {
444
- wmapEl.querySelectorAll('.wnode').forEach(function (el) {
445
- var n = nodeOf(el.getAttribute('data-t'));
446
- if (!n) return;
447
- var run = wRunning(n);
448
- el.classList.toggle('run', run);
449
- var av = el.querySelector('.wav'), dot = el.querySelector('.wav .live');
450
- if (run && !dot && av) av.insertAdjacentHTML('beforeend', '<i class="live"></i>');
451
- if (!run && dot) dot.remove();
452
- var ts = el.querySelector('.wts');
453
- if (ts) ts.textContent = fmtAgo(n.lastTs);
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);
454
651
  });
455
652
  };
456
653
 
457
- // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
458
- setInterval(function () {
459
- if (!S.tags.length) return;
460
- renderTags();
461
- renderStatus(curTag());
462
- renderGraph();
463
- expireTurns();
464
- if (VIEW === 'world') tickWorld();
465
- else if (VIEW === 'plan') renderPlan();
466
- }, TICK_MS);
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
+ };
467
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'); };
468
707
 
469
708
  // ══ 关系视图 ═══════════════════════════════════════════════════════
470
709
  // 三层叠在一起:
471
710
  // 1. 聊天卡片 (HTML) —— 归属; 一张卡 = 一个群
472
- // 2. 家谱缩进 (HTML) —— 层级; 分身缩在父亲下面, 左侧一道折线
473
- // 3. 连线 (SVG) —— 其余的关系; 布局表达不了的那些 (跨卡片的分身、
711
+ // 2. 家谱缩进 (HTML) —— 层级; 分身 / 子 wizard 缩在父亲下面, 左侧一道折线
712
+ // 3. 连线 (SVG) —— 其余的关系; 布局表达不了的那些 (跨卡片的家谱、
474
713
  // 同群与跨群的派活、流水线的一步)
475
714
  // 前两层撑起版面 (所以文字永远可读), 第三层才是"图"。
476
715
  var EDGE = {
477
- clone: { c: '#8250df', label: '分身', dash: '' },
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, 只有出身、没有继承上下文' },
478
719
  peer: { c: '#0969da', label: '派活', dash: '5 4' },
479
720
  graph: { c: '#0a7d6b', label: '流水线', dash: '2 3' },
480
721
  };
@@ -494,13 +735,16 @@
494
735
 
495
736
  var wRunning = function (n) { return n.busy || (!!n.runningUntil && srvNow() < n.runningUntil); };
496
737
 
497
- // 卡片里的名字不重复卡片头已经说过的话。默认名是 `聊天名#tag`, 而卡片头写着
498
- // 聊天名、行尾还挂着 `#tag` —— 三处同一个词。所以: 起过名字就显示名字, 没起过
499
- // 就只显示 `#tag`; 名字本身已经以 `#tag` 收尾时也不再重复那枚 badge。
500
- var shortName = function (n) {
501
- var fallback = n.chat ? (n.tag ? n.chat + '#' + n.tag : n.chat) : n.tag;
502
- return n.name && n.name !== fallback ? n.name : (n.tag ? '#' + n.tag : '默认');
738
+ // ── 走进一个 wizard ──
739
+ // 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁 —— 不再整页跳。
740
+ var chatOf = function (base) {
741
+ return (W.chats || []).filter(function (c) { return c.base === base; })[0];
503
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); };
504
748
 
505
749
  // 与 sel 相连的一切 (含 sel 自己)。聚焦时其余的压暗而不是移除 —— 位置稳定,
506
750
  // 反复点不同节点时版面不会跳。
@@ -522,31 +766,50 @@
522
766
  };
523
767
 
524
768
  var renderWTools = function () {
525
- var counts = { clone: 0, peer: 0, graph: 0 };
769
+ var counts = { clone: 0, spawn: 0, peer: 0, graph: 0 };
526
770
  W.edges.forEach(function (e) { counts[e.kind] = (counts[e.kind] || 0) + 1; });
527
771
  var cross = W.edges.filter(function (e) { return e.cross; }).length;
528
772
  var chips = Object.keys(EDGE).map(function (k) {
529
773
  return '<button class="chip' + (W.kinds[k] ? ' on' : '') + '" data-k="' + k + '" ' +
530
- 'style="--c:' + EDGE[k].c + '"><i></i>' + EDGE[k].label +
774
+ 'style="--c:' + EDGE[k].c + '"' + (EDGE[k].tip ? ' title="' + EDGE[k].tip + '"' : '') + '><i></i>' + EDGE[k].label +
531
775
  '<b>' + (counts[k] || 0) + '</b></button>';
532
776
  }).join('');
777
+ var force = W.layout === 'force';
533
778
  wtoolsEl.innerHTML =
534
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>' +
535
786
  '<div class="wstat">' +
536
787
  W.nodes.length + ' 个 wizard · ' + W.chats.length + ' 个聊天' +
537
788
  (cross ? ' · <b>' + cross + '</b> 条跨聊天关系' : '') +
538
789
  (W.degraded ? ' · <span class="warn" title="注册表不可达 (独立 svr 部署), 只画观测到的往来">名册缺席</span>' : '') +
539
- '<span class="hint">单击聚焦 · 双击进入线程</span>' +
790
+ '<span class="hint">单击聚焦 · 双击进入' + (force ? ' · 拖动钉住' : ' (外聊天整页跳过去)') + '</span>' +
540
791
  '</div>' +
792
+ (force ? '<button class="chip" id="wre" title="松开所有钉住的节点, 重新排一遍">↻ 重排</button>' : '') +
541
793
  '<button class="chip only' + (W.onlyRel ? ' on' : '') + '" id="wonly" ' +
542
794
  'title="把此刻不属于任何关系的 wizard 收起来 —— 剩下的就是这张协作网本身">' +
543
795
  (W.onlyRel ? '☑' : '☐') + ' 只看有关系的</button>' +
544
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(); };
545
807
  wtoolsEl.querySelectorAll('.chip[data-k]').forEach(function (b) {
546
808
  b.onclick = function () {
547
809
  var k = b.getAttribute('data-k');
548
810
  W.kinds[k] = W.kinds[k] ? 0 : 1;
549
- renderWTools(); drawEdges();
811
+ renderWTools();
812
+ if (W.layout === 'force') renderForce(); else drawEdges();
550
813
  };
551
814
  });
552
815
  var only = $('#wonly');
@@ -557,7 +820,6 @@
557
820
 
558
821
  var nodeHTML = function (n, depth) {
559
822
  var run = wRunning(n), nm = shortName(n);
560
- var dupTag = !n.tag || nm === '#' + n.tag || nm.slice(-(n.tag.length + 1)) === '#' + n.tag;
561
823
  var bits = [];
562
824
  if (n.model) bits.push(n.model.replace(/^claude-/, ''));
563
825
  if (n.cwd) bits.push('📁 ' + shortPath(n.cwd, 1));
@@ -565,15 +827,14 @@
565
827
  if (n.taskTurns) bits.push('⏰ ' + n.taskTurns);
566
828
  if (n.peerTurns) bits.push('✉ ' + n.peerTurns);
567
829
  return '<div class="wnode' + (n.self ? ' self' : '') + (run ? ' run' : '') +
568
- (n.alive ? '' : ' cold') + (n.local ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
830
+ (n.alive ? '' : ' cold') + (n.target === ROLE ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
569
831
  '" data-t="' + esc(n.target) + '" style="margin-left:' + (depth * 16) + 'px">' +
570
- (depth ? '<span class="lin" title="分身"></span>' : '') +
832
+ (depth ? (n.inherited ? '<span class="lin" title="分身"></span>' : '<span class="lin sp" title="子 wizard"></span>') : '') +
571
833
  '<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
572
834
  '<span class="wbody">' +
573
835
  '<span class="wl1">' +
574
836
  '<b class="wname" title="' + esc(n.name || n.target) + '">' + esc(nm) + '</b>' +
575
- (dupTag ? '' : '<span class="wtag">#' + esc(n.tag) + '</span>') +
576
- (n.inherited ? '<span class="wih" title="开局继承了父亲的上下文">⧉</span>' : '') +
837
+ (n.inherited ? '<span class="wih" title="分身: 从父亲的 session 节点 fork, 开局带着那一刻的上下文">⧉</span>' : '') +
577
838
  '<span class="wts">' + esc(fmtAgo(n.lastTs)) + '</span>' +
578
839
  '</span>' +
579
840
  (n.description ? '<span class="wjob">' + esc(n.description) + '</span>' : '') +
@@ -596,7 +857,7 @@
596
857
  }).length;
597
858
  var rows = shown.map(function (mm) {
598
859
  var n = nodeOf(mm.target);
599
- // 「只看有关系的」会把父亲筛掉而留下分身 —— 那时的缩进没有参照物, 拉平。
860
+ // 「只看有关系的」会把父亲筛掉而留下孩子 —— 那时的缩进没有参照物, 拉平。
600
861
  var d = W.onlyRel ? 0 : mm.depth;
601
862
  return n ? nodeHTML(n, d) : '';
602
863
  }).join('');
@@ -615,18 +876,24 @@
615
876
  '</section>';
616
877
  }).filter(Boolean).join('');
617
878
  wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg><div class="wgrid">' +
618
- (cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身之后这里就有边了</div>') + '</div>';
879
+ (cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身 / 生子 wizard 之后这里就有边了</div>') + '</div>';
619
880
  wmapEl.querySelectorAll('.wnode').forEach(function (el) {
620
881
  var t = el.getAttribute('data-t');
621
882
  if (hood && !hood[t]) el.classList.add('dim');
622
883
  if (t === W.sel) el.classList.add('sel');
884
+ if (canOpen(nodeOf(t))) el.classList.add('go');
623
885
  el.onclick = function () { W.sel = (W.sel === t ? '' : t); renderWorld(); };
624
- el.ondblclick = function () {
625
- // 双击 = 进它的线程。只有同聊天的节点有线程可进 —— 凭据按聊天关
626
- // (见 chat-http 的 capability 说明), 外聊天的节点只有身份与关系。
627
- var n = nodeOf(t);
628
- if (n && n.local) { setView('thread'); select(t); }
629
- };
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); };
630
897
  });
631
898
  };
632
899
 
@@ -698,8 +965,330 @@
698
965
  svg.innerHTML = '<defs>' + defs + '</defs>' + paths;
699
966
  };
700
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
+
701
1288
  var renderWorld = function () {
702
1289
  renderWTools();
1290
+ if (W.layout === 'force') { renderForce(); return; }
1291
+ fxStop();
703
1292
  renderWMap();
704
1293
  // 连线要等浏览器把卡片排好 —— 同一帧里量到的是上一次的版面。
705
1294
  requestAnimationFrame(drawEdges);
@@ -726,7 +1315,7 @@
726
1315
 
727
1316
  var wizChip = function (target) {
728
1317
  var n = nodeOf(target);
729
- return '<span class="wchip' + (n && n.local ? ' go' : '') + '" data-t="' + esc(target) + '">' +
1318
+ return '<span class="wchip' + (canOpen(n) ? ' go' : '') + '" data-t="' + esc(target) + '">' +
730
1319
  esc((n && n.label) || '🧙') + ' ' + esc(nameOf(target)) + '</span>';
731
1320
  };
732
1321
 
@@ -752,8 +1341,12 @@
752
1341
  '<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
753
1342
  };
754
1343
 
1344
+ // 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
755
1345
  var renderPlan = function () {
756
- var ss = W.schedules || [], js = W.jobs || [];
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
+ });
757
1350
  var open = js.filter(function (j) { return j.status === 'open'; });
758
1351
  var closed = js.filter(function (j) { return j.status !== 'open'; });
759
1352
  var schedRows = ss.length
@@ -771,7 +1364,7 @@
771
1364
  '</div>' +
772
1365
  '</div>';
773
1366
  }).join('')
774
- : '<div class="pempty">没有定时任务 —— schedule_task 排一个</div>';
1367
+ : '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>';
775
1368
 
776
1369
  var jobRow = function (j) {
777
1370
  return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
@@ -782,7 +1375,7 @@
782
1375
  '<div class="jm">' + (j.members.length
783
1376
  ? j.members.map(function (mm) {
784
1377
  return '<span class="jmm">' + wizChip(mm.target) +
785
- (mm.spawned ? '<i class="tmp" title="为这个工单临时生的分身, 收工时回收">临时</i>' : '') +
1378
+ (mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
786
1379
  '<em>' + esc((mm.task || '').split('\n')[0].slice(0, 70)) + '</em></span>';
787
1380
  }).join('')
788
1381
  : '<span class="jmm none">还没有成员</span>') + '</div>' +
@@ -797,34 +1390,47 @@
797
1390
  '</section>' +
798
1391
  '<section class="psec">' +
799
1392
  '<h3>📋 工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
800
- (js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上分身时 open_job 开一个</div>') +
1393
+ (js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
801
1394
  '</section>';
802
1395
  planEl.querySelectorAll('.wchip.go').forEach(function (c) {
803
- c.onclick = function () { setView('thread'); select(c.getAttribute('data-t')); };
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);
804
1413
  });
805
1414
  };
806
1415
 
807
1416
  // ══ 视图切换 ═══════════════════════════════════════════════════════
808
1417
  // 世界快照不走 SSE: 它变动的源头 (spawn / 改职责 / 开收工单 / 排定时) 一条都
809
- // 不经过 detail store, 而给它们各自搭一条事件通路, 换来的只是几秒的新鲜度。
810
- // 改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
1418
+ // 不经过 detail store。改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
811
1419
  var WORLD_MS = 6000;
812
1420
  var worldTimer = null;
813
1421
  var loadWorld = function () {
814
- return api('api/world', {}).then(function (d) {
1422
+ return api('api/world', { role: ROLE }).then(function (d) {
815
1423
  if (!d.ok) return;
816
1424
  W.at = d.at; W.loaded = true;
817
1425
  W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
818
1426
  W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
819
- // 身份回来了, 侧栏那几行也跟着变 —— 名字/职责/家谱都在这份数据里。
820
- renderTags();
821
1427
  if (VIEW === 'world') renderWorld();
822
1428
  if (VIEW === 'plan') renderPlan();
823
1429
  }).catch(function () { });
824
1430
  };
825
1431
  var pollWorld = function () {
826
1432
  if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
827
- if (VIEW === 'thread') return;
1433
+ if (VIEW === 'msgs') return;
828
1434
  worldTimer = setInterval(function () {
829
1435
  if (!document.hidden) loadWorld();
830
1436
  }, WORLD_MS);
@@ -832,38 +1438,43 @@
832
1438
 
833
1439
  var setView = function (v) {
834
1440
  VIEW = v;
835
- $('#views').querySelectorAll('.vb').forEach(function (b) {
836
- b.classList.toggle('on', b.getAttribute('data-view') === v);
837
- });
838
- thread.hidden = v !== 'thread';
1441
+ thread.hidden = v !== 'msgs';
839
1442
  $('#pane-world').hidden = v !== 'world';
840
1443
  $('#pane-plan').hidden = v !== 'plan';
841
- // graph 条只对线程有意义 (它讲的是当前这一路被谁驱动)。
842
- gbarEl.hidden = v !== 'thread' || !(S.graphs || []).length;
843
- document.querySelector('.app').classList.toggle('wide', v !== 'thread');
844
- if (v === 'thread') { toBottom(true); }
845
- else if (!W.loaded) { (v === 'world' ? (wmapEl.innerHTML = '<div class="empty">加载中…</div>') : (planEl.innerHTML = '<div class="empty">加载中…</div>')); loadWorld(); }
846
- else if (v === 'world') renderWorld();
847
- else renderPlan();
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
+ }
848
1457
  pollWorld();
849
1458
  };
850
- $('#views').querySelectorAll('.vb').forEach(function (b) {
851
- b.onclick = function () { setView(b.getAttribute('data-view')); };
852
- });
853
1459
  // 卡片换行会改变每个节点的坐标 —— 连线必须跟着重画。
854
- window.addEventListener('resize', function () { if (VIEW === 'world') requestAnimationFrame(drawEdges); });
1460
+ window.addEventListener('resize', function () {
1461
+ if (VIEW !== 'world') return;
1462
+ if (W.layout === 'force') renderForce(); else requestAnimationFrame(drawEdges);
1463
+ });
1464
+
1465
+ // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
1466
+ setInterval(function () {
1467
+ if (!R.role) return;
1468
+ renderProfile();
1469
+ expireRows();
1470
+ if (VIEW === 'world') tickWorld();
1471
+ else if (VIEW === 'plan') renderPlan();
1472
+ }, TICK_MS);
1473
+ // 侧栏的「几分钟前」一分钟刷一次就够 —— 每 3s 重建会把列表的滚动与焦点蹭掉。
1474
+ setInterval(function () { if (R.role && VIEW === 'msgs') renderConvs(); }, 60000);
855
1475
 
856
1476
  // ── boot ──
857
- api('api/chat', WANT ? { target: WANT } : {}).then(function (d) {
858
- if (!d.ok) {
859
- document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
860
- return;
861
- }
862
- applySummary(d);
863
- // 卡片链接带来的那条 turn 决定默认选中的 tag; 否则取最近活跃的。
864
- select(d.self && d.self.target ? d.self.target : (S.tags[0] && S.tags[0].target));
865
- });
866
- // 与线程并行取: 侧栏那几行的名字 / 职责 / 家谱都在这份快照里, 不该等线程渲染完
867
- // 才补上。首屏之后就只在关系/日程栏轮询 (见 pollWorld)。
868
- loadWorld();
1477
+ // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
1478
+ if (ROLE || CONV) app.classList.add('reading');
1479
+ refresh();
869
1480
  })();