wezard 1.4.2 → 2.0.1

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