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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +17 -13
- package/README.md +130 -77
- package/cli/wezard.sh +0 -62
- package/config.example.jsonc +3 -9
- package/dist/cli/init.js +12 -21
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +11 -7
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-session.js +136 -0
- package/dist/daemon/cc-session.js.map +1 -0
- package/dist/daemon/chat-name.js +8 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +54 -13
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +5 -7
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +188 -254
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +459 -258
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +370 -170
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +74 -0
- package/dist/daemon/model-select.js.map +1 -0
- package/dist/daemon/peers.js +10 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/session-name.js +4 -27
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +61 -16
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-gate.js +97 -0
- package/dist/daemon/task-gate.js.map +1 -0
- package/dist/daemon/task-registry.js +132 -0
- package/dist/daemon/task-registry.js.map +1 -0
- package/dist/daemon/tasks.js +126 -54
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard-memory.js +52 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +107 -41
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +181 -209
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +187 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +25 -34
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +14 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -7
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +26 -9
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +12 -6
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +55 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +228 -0
- package/dist/shared/role-view.js.map +1 -0
- package/dist/shared/schedule-spec.js +92 -116
- package/dist/shared/schedule-spec.js.map +1 -1
- package/dist/shared/session-label.js +103 -72
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +3 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/task-file.js +120 -0
- package/dist/shared/task-file.js.map +1 -0
- package/dist/shared/trigger.js +232 -0
- package/dist/shared/trigger.js.map +1 -0
- package/dist/shared/world.js +17 -5
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +67 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +424 -212
- package/web/chat.js +953 -323
- package/commands/wrc.md +0 -7
- package/dist/daemon/cc-bridge.js +0 -183
- package/dist/daemon/cc-bridge.js.map +0 -1
- package/dist/daemon/sessions.js +0 -4
- package/dist/daemon/sessions.js.map +0 -1
package/web/chat.js
CHANGED
|
@@ -1,41 +1,49 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Rolepage SPA —— 从**一个 role 的视角**看它的 IM: 它参与的群聊与单聊, 每一处
|
|
2
|
+
// 它说了什么 (靠右)、听到了什么 (靠左)。点气泡对面那片空白就换成对方的视角。
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
// 关系 /api/world ——
|
|
5
|
-
// 日程 同一份 /api/world,
|
|
4
|
+
// 对话 /api/role (身份 + 会话列表 + session) + /api/msgs (窗口正文) + /api/role-events (SSE)
|
|
5
|
+
// 关系 /api/world —— 聚焦当前 role 的家谱与协作网 (轮询)
|
|
6
|
+
// 日程 同一份 /api/world, 只列归当前 role 的定时任务与工单
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
20
|
-
|
|
21
|
-
var
|
|
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
|
|
27
|
-
//
|
|
28
|
-
|
|
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 = '
|
|
41
|
+
var VIEW = 'msgs';
|
|
34
42
|
var $ = function (s) { return document.querySelector(s); };
|
|
35
|
-
var
|
|
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
|
|
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
|
-
|
|
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)
|
|
62
|
-
return
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
var
|
|
78
|
-
var
|
|
79
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
161
|
-
if (
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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) -
|
|
397
|
+
return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - R.at);
|
|
293
398
|
};
|
|
294
|
-
var
|
|
295
|
-
|
|
296
|
-
var
|
|
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
|
-
? '<
|
|
304
|
-
'<span class="
|
|
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="
|
|
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
|
-
'</
|
|
417
|
+
'</div>'
|
|
314
418
|
: '';
|
|
315
|
-
// 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage),
|
|
419
|
+
// 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占格子。
|
|
316
420
|
var st = function (k, n, text) {
|
|
317
|
-
return '<span class="
|
|
318
|
-
'<span class="
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
// ──
|
|
335
|
-
var
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
563
|
+
? '<button class="more-btn" id="more">载入更早的 ' + (d.total - d.msgs.length) + ' 条</button>'
|
|
350
564
|
: '';
|
|
351
|
-
inner.innerHTML = more + d.
|
|
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 = '载入中…';
|
|
567
|
+
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
|
|
568
|
+
bindRow(inner);
|
|
355
569
|
render(inner);
|
|
356
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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(
|
|
390
|
-
|
|
391
|
-
} else
|
|
392
|
-
cur.replaceWith(
|
|
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
|
-
|
|
608
|
+
foldPings(inner);
|
|
396
609
|
if (stick) toBottom(true);
|
|
397
610
|
};
|
|
398
611
|
|
|
399
612
|
// 到点了还没有新写入 → 这一轮已经结束: 熄掉呼吸点、移除"正在思考"。
|
|
400
|
-
var
|
|
613
|
+
var expireRows = function () {
|
|
401
614
|
var now = srvNow();
|
|
402
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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:
|
|
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
|
|
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('
|
|
431
|
-
es.addEventListener('
|
|
432
|
-
es.onopen = function () { backoff = 1000;
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (
|
|
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
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
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">单击聚焦 ·
|
|
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();
|
|
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.
|
|
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
|
-
(
|
|
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">此刻没有任何协作关系 —— 派活 /
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
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 || []
|
|
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"
|
|
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="
|
|
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">没有工单 ——
|
|
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 () {
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
else
|
|
847
|
-
|
|
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 () {
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
})();
|