wezard 2.1.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/dist/daemon/cc-session.js +14 -1
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/detail.js +40 -7
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +132 -0
- package/dist/daemon/handoff.js.map +1 -0
- package/dist/daemon/inbound.js +13 -4
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +191 -99
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +161 -12
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +114 -34
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/receipts.js +190 -0
- package/dist/daemon/receipts.js.map +1 -0
- package/dist/daemon/route.js +92 -0
- package/dist/daemon/route.js.map +1 -0
- package/dist/daemon/wizard.js +38 -19
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/ws.js +3 -0
- package/dist/daemon/ws.js.map +1 -1
- package/dist/mcp/server.js +60 -25
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +101 -21
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +0 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +2 -9
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/config.js +6 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +3 -2
- 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/keepalive.js +46 -0
- package/dist/shared/keepalive.js.map +1 -0
- package/dist/shared/reminder.js +13 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +2 -1
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-search.js +100 -0
- package/dist/shared/role-search.js.map +1 -0
- package/dist/shared/role-view.js +85 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +38 -8
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +1 -1
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +420 -327
- package/web/chat.js +924 -728
package/web/chat.js
CHANGED
|
@@ -17,31 +17,26 @@
|
|
|
17
17
|
var ROLE = qs.get('role') || qs.get('target') || '';
|
|
18
18
|
var CONV = qs.get('conv') || '';
|
|
19
19
|
var WITH = qs.get('with') || '';
|
|
20
|
+
// '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
|
|
20
21
|
var SESSION = qs.get('session') || '';
|
|
21
22
|
var TICK_MS = 3000;
|
|
22
23
|
|
|
23
24
|
// at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
|
|
24
25
|
// 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
|
|
25
|
-
var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, stats: null, winStats: null };
|
|
26
|
+
var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, stats: null, winStats: null };
|
|
26
27
|
// frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
|
|
27
28
|
var S = { es: null, pinned: true, gen: 0, frags: {} };
|
|
28
|
-
// 关系/日程两栏共用的世界快照。
|
|
29
|
+
// 关系/日程两栏共用的世界快照。treeAll = 关系树展开全部; treeFor = 已把谁滚进过视野。
|
|
29
30
|
var W = {
|
|
30
31
|
at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
|
|
31
|
-
degraded: false, loaded: false,
|
|
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
|
+
degraded: false, loaded: false, treeAll: false, treeFor: '', glance: {}, gKeys: '',
|
|
40
33
|
};
|
|
41
34
|
var VIEW = 'msgs';
|
|
35
|
+
// 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
|
|
36
|
+
var WORLD = qs.get('side') === 'world';
|
|
42
37
|
var $ = function (s) { return document.querySelector(s); };
|
|
43
38
|
var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
|
|
44
|
-
var
|
|
39
|
+
var planEl = $('#plan-in');
|
|
45
40
|
|
|
46
41
|
var srvNow = function () { return R.at ? R.at + (Date.now() - R.recvAt) : Date.now(); };
|
|
47
42
|
|
|
@@ -98,6 +93,7 @@
|
|
|
98
93
|
if (CONV) p.conv = CONV;
|
|
99
94
|
if (WITH) p['with'] = WITH;
|
|
100
95
|
if (SESSION) p.session = SESSION;
|
|
96
|
+
if (WORLD) p.side = 'world';
|
|
101
97
|
Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
|
|
102
98
|
return p;
|
|
103
99
|
};
|
|
@@ -278,7 +274,27 @@
|
|
|
278
274
|
});
|
|
279
275
|
|
|
280
276
|
// ── 左栏: 当前 role 的名片 + 会话列表 ──
|
|
281
|
-
|
|
277
|
+
// `a:<x>` / `a:<x>|<p1>,<p2>` 不在列表里: x 参与的全部对话 / x 与这几个对端之间的, 按时间排开
|
|
278
|
+
// (关系图里点卡片看的就是它, 服务端同一个 talkOf) —— 现造一项, 落地时才不会被当成失效的会话退回默认。
|
|
279
|
+
var convOf = function (key) {
|
|
280
|
+
if (key && key.indexOf('a:') === 0) {
|
|
281
|
+
var part = key.slice(2).split('|'), who = part[0], peers = (part[1] || '').split(',').filter(Boolean);
|
|
282
|
+
var name = nameOf(who) + (peers.length ? ' 与 ' + peers.map(nameOf).join('、') + ' 的对话' : ' 的全部对话');
|
|
283
|
+
return { key: key, kind: 'all', who: who, peers: peers, name: name, subs: [] };
|
|
284
|
+
}
|
|
285
|
+
return R.convs.filter(function (c) { return c.key === key; })[0];
|
|
286
|
+
};
|
|
287
|
+
// 人的单聊 (user:…) 在数据上是频道, 但对端只有那一个人时同样是一对一。
|
|
288
|
+
var dmOf = function (c) {
|
|
289
|
+
return c && c.kind === 'group' && c.base.indexOf('user:') === 0 && (c.subs || []).length === 1 ? c.subs[0] : null;
|
|
290
|
+
};
|
|
291
|
+
// 一对一会话的对端 (wizard 私聊 / 人的单聊 / 「只看我与 X」); 不是一对一则空。
|
|
292
|
+
var pairPeer = function (c) {
|
|
293
|
+
if (!c) return WITH || '';
|
|
294
|
+
if (c.kind === 'all') return '';
|
|
295
|
+
var dm = dmOf(c);
|
|
296
|
+
return c.kind !== 'group' ? c.peer : WITH || (dm ? dm.role : '');
|
|
297
|
+
};
|
|
282
298
|
|
|
283
299
|
// 群聊头像 = 参与者头像的拼图 (最多 4 格, 自己排第一)。私聊就是对端自己的头像。
|
|
284
300
|
var membersOf = function (c) {
|
|
@@ -293,15 +309,17 @@
|
|
|
293
309
|
// 到点没有新写入就自己熄 (同 expireRows), 不必等下一次推送。
|
|
294
310
|
var ST = { run: '执行中', idle: '空闲', off: '已关闭' };
|
|
295
311
|
var stateOf = function (s) { return wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
312
|
+
// 忙闲只有这一个渲染点, 永远挂在名字右边 —— 名片 / 关系树 / 侧栏共用。
|
|
313
|
+
// quiet: 只在执行中出现、只要灯不要字 (侧栏: 列表里安静是常态, 亮着的才值得看一眼)。
|
|
314
|
+
var stTag = function (s, quiet) {
|
|
315
|
+
if (!s) return '';
|
|
316
|
+
var k = stateOf(s);
|
|
317
|
+
if (quiet && k !== 'run') return '';
|
|
318
|
+
return '<span class="wst ' + k + '" title="' + ST[k] + '">' + (quiet ? '' : ST[k]) + '</span>';
|
|
299
319
|
};
|
|
300
320
|
var paintStatus = function () {
|
|
301
|
-
var el = $('#rb-st')
|
|
302
|
-
if (
|
|
303
|
-
var k = stateOf(r);
|
|
304
|
-
el.className = 'st ' + k; el.textContent = ST[k];
|
|
321
|
+
var el = $('#rb-st');
|
|
322
|
+
if (el && R.role) el.innerHTML = stTag(R.role);
|
|
305
323
|
};
|
|
306
324
|
|
|
307
325
|
var avatarOf = function (c, extra) {
|
|
@@ -311,6 +329,11 @@
|
|
|
311
329
|
ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
|
|
312
330
|
};
|
|
313
331
|
|
|
332
|
+
// ends = [[id, 已知头像]]; 没带头像的按 role 查。
|
|
333
|
+
var pairOf = function (ends) {
|
|
334
|
+
return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
|
|
335
|
+
};
|
|
336
|
+
|
|
314
337
|
var line = function (title, ts, pv, lamp, unread) {
|
|
315
338
|
return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
|
|
316
339
|
'<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
|
|
@@ -342,12 +365,28 @@
|
|
|
342
365
|
}).length;
|
|
343
366
|
};
|
|
344
367
|
var reading = function () { return VIEW === 'msgs' && !document.hidden; };
|
|
368
|
+
// 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
|
|
369
|
+
// 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
|
|
370
|
+
var talkCovers = function (p) {
|
|
371
|
+
var t = convOf(CONV);
|
|
372
|
+
if (!t || t.kind !== 'all') return false;
|
|
373
|
+
var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
|
|
374
|
+
return hit(ROLE, p) || hit(p, ROLE);
|
|
375
|
+
};
|
|
376
|
+
// 一处会话里这次读到了哪几对 (存水位用的 withRole): 侧栏选中的就是那一项; 关系图卡片打开的
|
|
377
|
+
// 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
|
|
378
|
+
var readPairs = function (c) {
|
|
379
|
+
if (c.key === CONV) return [WITH];
|
|
380
|
+
if (c.kind !== 'group') return c.peer && talkCovers(c.peer) ? [''] : [];
|
|
381
|
+
return (c.subs || []).filter(function (s) { return talkCovers(s.role); }).map(function (s) { return s.role; });
|
|
382
|
+
};
|
|
345
383
|
// 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
|
|
346
384
|
var markRead = function (c) {
|
|
347
|
-
if (!reading()
|
|
348
|
-
var h = c.heard[c.heard.length - 1];
|
|
349
|
-
|
|
350
|
-
|
|
385
|
+
if (!reading()) return;
|
|
386
|
+
var h = c.heard && c.heard[c.heard.length - 1];
|
|
387
|
+
var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
|
|
388
|
+
hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
|
|
389
|
+
if (hit.length) saveRead();
|
|
351
390
|
};
|
|
352
391
|
|
|
353
392
|
// 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
|
|
@@ -369,11 +408,29 @@
|
|
|
369
408
|
renderConvs();
|
|
370
409
|
};
|
|
371
410
|
|
|
411
|
+
// ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
|
|
412
|
+
// glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
|
|
413
|
+
var glance = function (c, s) {
|
|
414
|
+
var x = s || c;
|
|
415
|
+
return { lastTs: x.lastTs, preview: x.preview, unread: unreadOf(c, s ? s.role : undefined) };
|
|
416
|
+
};
|
|
417
|
+
// 同层按最近活动排, 有新话就上浮。
|
|
418
|
+
var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
|
|
419
|
+
// 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
|
|
420
|
+
var roleRow = function (id, name, label, g, status, tail) {
|
|
421
|
+
return goSpan('av', id, esc(label)) +
|
|
422
|
+
line(nm(id, name, true) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
|
|
423
|
+
};
|
|
424
|
+
var convRow = function (c) {
|
|
425
|
+
if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status);
|
|
426
|
+
return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>', c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
|
|
427
|
+
};
|
|
428
|
+
var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status); };
|
|
429
|
+
|
|
372
430
|
var convItem = function (c) {
|
|
373
431
|
var on = c.key === CONV;
|
|
374
|
-
var title = c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>';
|
|
375
432
|
// 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
|
|
376
|
-
var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }) : [];
|
|
433
|
+
var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }).sort(recentFirst) : [];
|
|
377
434
|
var hidden = talked.length - SUB_FOLD;
|
|
378
435
|
var bar = hidden > 0
|
|
379
436
|
? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
|
|
@@ -383,8 +440,7 @@
|
|
|
383
440
|
var sel = on && s.role === WITH;
|
|
384
441
|
return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
|
|
385
442
|
'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
|
|
386
|
-
|
|
387
|
-
line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status), unreadOf(c, s.role)) + '</button>';
|
|
443
|
+
subRow(c, s) + '</button>';
|
|
388
444
|
};
|
|
389
445
|
var rest = MORE[c.key] ? talked.slice(SUB_FOLD).map(sub).join('') : '';
|
|
390
446
|
var subs = talked.length
|
|
@@ -392,14 +448,23 @@
|
|
|
392
448
|
: '';
|
|
393
449
|
var sel = on && !WITH;
|
|
394
450
|
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
451
|
+
convRow(c) + '</button>' + subs;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// 搜索入口挂在名片与关系/日程入口之间 —— 侧栏的公共区, 会话列表与关系图下都在。⌘K 见下方「搜索」。
|
|
455
|
+
var MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
456
|
+
var KBD = MAC ? '⌘K' : 'Ctrl K';
|
|
457
|
+
var SEARCH_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.6"/><path d="m10.4 10.4 3.6 3.6"/></svg>';
|
|
458
|
+
var sbox = document.createElement('button');
|
|
459
|
+
sbox.className = 'sbox'; sbox.type = 'button';
|
|
460
|
+
sbox.innerHTML = '<span class="ic">' + SEARCH_SVG + '</span><span class="lb">搜索 role、会话、消息</span><kbd>' + KBD + '</kbd>';
|
|
461
|
+
sbox.onclick = function () { openSearch(); };
|
|
462
|
+
$('#rb-acts').parentNode.insertBefore(sbox, $('#rb-acts'));
|
|
399
463
|
var renderConvs = function () {
|
|
400
|
-
var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
|
|
401
|
-
var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
|
|
464
|
+
var groups = R.convs.filter(function (c) { return c.kind === 'group'; }).sort(recentFirst);
|
|
465
|
+
var dms = R.convs.filter(function (c) { return c.kind !== 'group'; }).sort(recentFirst);
|
|
402
466
|
R.convs.forEach(markRead);
|
|
467
|
+
if (WORLD) return renderWorld();
|
|
403
468
|
// 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
|
|
404
469
|
var sec = function (title, list) {
|
|
405
470
|
return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
|
|
@@ -423,41 +488,41 @@
|
|
|
423
488
|
var seg = String(p).replace(/\/+$/, '').split('/').filter(Boolean);
|
|
424
489
|
return seg.length <= 2 ? p : '…/' + seg.slice(-2).join('/');
|
|
425
490
|
};
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
};
|
|
491
|
+
// session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
|
|
492
|
+
var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
|
|
493
|
+
var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
|
|
429
494
|
|
|
430
|
-
// session 切换:
|
|
495
|
+
// session 切换: name 右边一个小号文字触发器 + 浮层列表。
|
|
431
496
|
// renderRole 每次刷新都重画, 展开态记在 SESS_OPEN 里才不会被轮询收起。
|
|
432
497
|
var SESS_OPEN = false;
|
|
433
498
|
var sessRow = function (s, on) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
'
|
|
499
|
+
var last = s === latestSess();
|
|
500
|
+
return '<button class="sp-it' + (on ? ' on' : '') + '" role="option" aria-selected="' + on + '" data-s="' + esc(s.sessionId || '') + '" ' +
|
|
501
|
+
'title="' + esc(s.sessionId || '') + '">' +
|
|
502
|
+
'<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span>' +
|
|
437
503
|
'<span class="n">' + s.turns + ' 轮</span></button>';
|
|
438
504
|
};
|
|
439
505
|
var sessPicker = function () {
|
|
440
506
|
var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
|
|
441
|
-
var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="">' +
|
|
442
|
-
'<span class="id"
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
'<span class="
|
|
447
|
-
|
|
448
|
-
'<div class="sp-list" role="listbox">' + all +
|
|
507
|
+
var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="all">' +
|
|
508
|
+
'<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span></button>';
|
|
509
|
+
var v = !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur);
|
|
510
|
+
return '<span class="sp' + (SESS_OPEN ? ' open' : '') + '">' +
|
|
511
|
+
'<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + SESS_OPEN + '" title="切换 session">' +
|
|
512
|
+
esc(v) + '<span class="car" aria-hidden="true"></span></button>' +
|
|
513
|
+
'<span class="sp-list" role="listbox">' + all +
|
|
449
514
|
R.sessions.slice().reverse().map(function (s) { return sessRow(s, s.sessionId === SESSION); }).join('') +
|
|
450
|
-
'</
|
|
515
|
+
'</span></span>';
|
|
451
516
|
};
|
|
452
517
|
var setSessOpen = function (open) {
|
|
453
518
|
SESS_OPEN = open;
|
|
454
|
-
var sp = $('#rb-
|
|
519
|
+
var sp = $('#rb-sp .sp');
|
|
455
520
|
if (!sp) return;
|
|
456
521
|
sp.classList.toggle('open', open);
|
|
457
522
|
sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
|
|
458
523
|
};
|
|
459
524
|
var bindSessPicker = function () {
|
|
460
|
-
var sp = $('#rb-
|
|
525
|
+
var sp = $('#rb-sp .sp');
|
|
461
526
|
if (!sp) return;
|
|
462
527
|
sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
|
|
463
528
|
sp.querySelectorAll('.sp-it').forEach(function (it) {
|
|
@@ -465,12 +530,75 @@
|
|
|
465
530
|
});
|
|
466
531
|
};
|
|
467
532
|
document.addEventListener('click', function (e) {
|
|
468
|
-
if (SESS_OPEN && !e.target.closest('#rb-
|
|
533
|
+
if (SESS_OPEN && !e.target.closest('#rb-sp .sp')) setSessOpen(false);
|
|
469
534
|
});
|
|
470
535
|
document.addEventListener('keydown', function (e) {
|
|
471
536
|
if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
|
|
472
537
|
});
|
|
473
538
|
|
|
539
|
+
// ── 关系 / 日程入口: 名片下一行居中的文字链接, 「|」分隔 ──
|
|
540
|
+
// 副标题 (家谱计数 / 下一枪) 收进悬停提示; 打开着的染视角色, 定时出错的染红。
|
|
541
|
+
var tile = function (view, label, sub, tone) {
|
|
542
|
+
var on = view === 'world' ? WORLD : VIEW === view;
|
|
543
|
+
return '<button class="bd' + (on ? ' on' : '') + (tone ? ' ' + tone : '') + '" data-view="' + view + '"' +
|
|
544
|
+
(sub ? ' title="' + esc(sub) + '"' : '') + '>' + label + '</button>';
|
|
545
|
+
};
|
|
546
|
+
var relTile = function (r) {
|
|
547
|
+
var sub = [r.clones.length ? r.clones.length + ' 分身' : '', r.spawns.length ? r.spawns.length + ' 子' : ''].filter(Boolean);
|
|
548
|
+
return tile('world', '关系图', sub.join(' · '));
|
|
549
|
+
};
|
|
550
|
+
var planTile = function () {
|
|
551
|
+
var p = R.plan || {};
|
|
552
|
+
var sub = p.broken ? '⚠ ' + p.broken + ' 出错'
|
|
553
|
+
: p.nextAt ? fmtClock(p.nextAt).replace(/^今天 /, '')
|
|
554
|
+
: String(R.schedules);
|
|
555
|
+
return tile('plan', '日程', sub, p.broken ? 'bad' : '');
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// 轻提示: 底部居中一枚, 新的顶掉旧的。
|
|
559
|
+
var toast = (function () {
|
|
560
|
+
var t, el;
|
|
561
|
+
return function (msg) {
|
|
562
|
+
el = el || document.body.appendChild(document.createElement('div'));
|
|
563
|
+
el.className = 'toast on'; el.textContent = msg;
|
|
564
|
+
clearTimeout(t); t = setTimeout(function () { el.className = 'toast'; }, 1600);
|
|
565
|
+
};
|
|
566
|
+
})();
|
|
567
|
+
// navigator.clipboard 只在安全上下文里有 —— 走 http 的远端页退回 execCommand。
|
|
568
|
+
var copyText = function (text) {
|
|
569
|
+
var legacy = function () {
|
|
570
|
+
var ta = document.body.appendChild(document.createElement('textarea'));
|
|
571
|
+
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; ta.select();
|
|
572
|
+
var ok = document.execCommand('copy'); ta.remove();
|
|
573
|
+
return ok ? Promise.resolve() : Promise.reject();
|
|
574
|
+
};
|
|
575
|
+
(navigator.clipboard ? navigator.clipboard.writeText(text).catch(legacy) : legacy())
|
|
576
|
+
.then(function () { toast('已复制 ' + text); }, function () { toast('复制失败'); });
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// 截断提示: 被省略号 (或 line-clamp) 截掉的文本, 悬停时给 title 看全文 —— 一处委托管全页,
|
|
580
|
+
// 不必每个渲染点各挂一份。只在真被截断时挂; 元素自带的 title (如「切到 X 的视角」) 不碰。
|
|
581
|
+
var isClipped = function (el, cs) {
|
|
582
|
+
return (cs.textOverflow === 'ellipsis' && el.scrollWidth > el.clientWidth + 1) ||
|
|
583
|
+
(cs.webkitLineClamp !== 'none' && cs.webkitLineClamp !== '' && el.scrollHeight > el.clientHeight + 1);
|
|
584
|
+
};
|
|
585
|
+
var clipTip = function (el) {
|
|
586
|
+
var cs = getComputedStyle(el);
|
|
587
|
+
if (cs.textOverflow !== 'ellipsis' && (cs.webkitLineClamp === 'none' || !cs.webkitLineClamp)) return false;
|
|
588
|
+
var ours = el.hasAttribute('data-cliptip');
|
|
589
|
+
if (el.hasAttribute('title') && !ours) return true;
|
|
590
|
+
if (isClipped(el, cs)) {
|
|
591
|
+
el.title = el.textContent.replace(/\s+/g, ' ').trim();
|
|
592
|
+
el.setAttribute('data-cliptip', '');
|
|
593
|
+
} else if (ours) {
|
|
594
|
+
el.removeAttribute('title'); el.removeAttribute('data-cliptip');
|
|
595
|
+
}
|
|
596
|
+
return true;
|
|
597
|
+
};
|
|
598
|
+
document.addEventListener('mouseover', function (e) {
|
|
599
|
+
for (var el = e.target; el && el.nodeType === 1 && el !== document.body && !clipTip(el); el = el.parentElement);
|
|
600
|
+
});
|
|
601
|
+
|
|
474
602
|
// 名片: 身份 · 出身 · cwd · 出生; 名片下面一排是关系 / 日程入口。
|
|
475
603
|
var renderRole = function () {
|
|
476
604
|
var r = R.role;
|
|
@@ -487,32 +615,35 @@
|
|
|
487
615
|
var facts = [];
|
|
488
616
|
if (r.cwd) facts.push('<span title="' + esc(r.cwd) + '">📁 ' + esc(shortCwd(r.cwd)) + '</span>');
|
|
489
617
|
// 生在哪个群、什么时候 —— 一件事, 一行。
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
var
|
|
493
|
-
|
|
494
|
-
};
|
|
495
|
-
|
|
618
|
+
// 时刻跟着选中的 session 走: 选了一段就是那一段的开始与轮数, 全部就是最早那段起、共几轮。
|
|
619
|
+
var cur = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
620
|
+
var span = cur ? [cur] : R.sessions;
|
|
621
|
+
var t0 = span.reduce(function (m, x) { return x.start && (!m || x.start < m) ? x.start : m; }, 0);
|
|
622
|
+
var turns = span.reduce(function (n, x) { return n + (x.turns || 0); }, 0);
|
|
623
|
+
var when = [r.chat, t0 ? (cur ? '' : '自 ') + fmtClock(t0) : '', turns ? turns + ' 轮' : ''].filter(Boolean);
|
|
624
|
+
if (when.length) facts.push('<span title="' + esc((cur ? 'session ' + cur.sessionId : '全部 ' + R.sessions.length + ' 段 session') +
|
|
625
|
+
(t0 ? ' · 开始于 ' + fmtDay(t0) : '')) + '">🕘 ' + esc(when.join(' · ')) + '</span>');
|
|
496
626
|
$('#rb-who').innerHTML =
|
|
497
627
|
'<div class="id"><span class="av">' + esc(r.label) + '</span>' +
|
|
498
|
-
'<span class="l">' + nm(r.id, r.name) +
|
|
499
|
-
|
|
628
|
+
'<span class="l"><span class="nl"><span class="cp" title="' + esc('复制 ' + nameOf(r.id)) + '">' + nm(r.id, r.name) + '</span>' + (r.kind === 'wizard' ? '<span id="rb-st"></span>' : '') +
|
|
629
|
+
'<span id="rb-sp">' + (R.sessions.length > 1 ? sessPicker() : '') + '</span></span>' +
|
|
630
|
+
(kind ? '<span class="k">' + kind + '</span>' : '') + '</span></div>' +
|
|
500
631
|
(r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
|
|
501
632
|
'<div class="facts">' + facts.join('') + '</div>';
|
|
502
633
|
paintStatus();
|
|
634
|
+
$('#rb-who').querySelector('.cp').onclick = function () { copyText(nameOf(r.id)); };
|
|
503
635
|
$('#rb-who').querySelectorAll('.go').forEach(function (g) {
|
|
504
636
|
g.onclick = function () { switchRole(g.getAttribute('data-r')); };
|
|
505
637
|
});
|
|
506
|
-
|
|
507
|
-
$('#rb-foot').hidden =
|
|
638
|
+
// 老外壳的底栏选择框已并进 name 行。
|
|
639
|
+
if ($('#rb-foot')) $('#rb-foot').hidden = true;
|
|
508
640
|
bindSessPicker();
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
if (R.schedules) acts.push('<button class="bd' + (VIEW === 'plan' ? ' on' : '') + '" data-view="plan">日程<b>' + R.schedules + '</b></button>');
|
|
513
|
-
$('#rb-acts').innerHTML = acts.join('');
|
|
641
|
+
// 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份,
|
|
642
|
+
// 家谱计数 (几个分身 / 子 wizard) 属于关系, 写在关系图入口上。
|
|
643
|
+
$('#rb-acts').innerHTML = [R.relations && relTile(r), R.schedules && planTile()].filter(Boolean).join('<span class="sep" aria-hidden="true">|</span>');
|
|
514
644
|
$('#rb-acts').querySelectorAll('.bd').forEach(function (b) {
|
|
515
|
-
|
|
645
|
+
var v = b.getAttribute('data-view');
|
|
646
|
+
b.onclick = function () { v === 'world' ? setWorld(!WORLD) : setView(VIEW === v ? 'msgs' : v); };
|
|
516
647
|
});
|
|
517
648
|
// 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题跟着换。
|
|
518
649
|
document.title = nameOf(r.id) + ' 的视角';
|
|
@@ -520,14 +651,17 @@
|
|
|
520
651
|
|
|
521
652
|
// ── 用量条: 一组轮次的总账 ──
|
|
522
653
|
// usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
|
|
654
|
+
// 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 退成浅灰蓝不抢眼 →
|
|
655
|
+
// 输入 (1×) 沉稳蓝 → 缓存写 (1.25×) 琥珀提醒 → 输出 (约 5×, 最贵) 珊瑚红警示。注意力留给后三段。
|
|
523
656
|
var SEGS = [
|
|
524
|
-
['
|
|
525
|
-
['cacheWrite', '缓存写', '#
|
|
657
|
+
['cacheRead', '缓存读', '#aab7c8', 'cr'], ['input', '输入', '#4c6fd6', 'in'],
|
|
658
|
+
['cacheWrite', '缓存写', '#e0a030', 'cw'], ['output', '输出', '#e4572e', 'out'],
|
|
526
659
|
];
|
|
527
660
|
var TIP = {
|
|
528
661
|
turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
|
|
529
662
|
ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值', time: '累计耗时',
|
|
530
663
|
};
|
|
664
|
+
var ICON = { turns: '💬', tools: '🛠️', api: '📡', ctx: '🧠', time: '⏱️' };
|
|
531
665
|
var liveDur = function (t) {
|
|
532
666
|
var d = (t.usage && t.usage.durationMs) || 0;
|
|
533
667
|
if (!t.runningUntil) return d;
|
|
@@ -538,60 +672,112 @@
|
|
|
538
672
|
var segs = SEGS.filter(function (s) { return u[s[0]] > 0; });
|
|
539
673
|
var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
|
|
540
674
|
// 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占位置。
|
|
675
|
+
// 指标一律 emoji 在前、数字在后, 不挂英文名 (含义在 title 里)。
|
|
676
|
+
// data-tip: 被 fitUsage 藏起来时, 它在整条 bar 的 title 里怎么读。
|
|
541
677
|
var kv = function (k, n, text) {
|
|
542
|
-
return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '"
|
|
678
|
+
return n ? '<span class="kv kv-' + k + '" title="' + esc(TIP[k] || k) + '" data-tip="' + esc(ICON[k] + ' ' + text) +
|
|
679
|
+
'"><i class="u-ic">' + ICON[k] + '</i><b>' + esc(text) + '</b></span>' : '';
|
|
680
|
+
};
|
|
681
|
+
// 输出 / 缓存不再单列成指标 —— 它们就是底边色带的那几段。
|
|
682
|
+
// I/O 分布: 贴着整条 bar 的底边画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
|
|
683
|
+
// 两头留一位小数: 99.9% 不该被四舍五入成 100%, 一丁点也不该成 0%。
|
|
684
|
+
var pct = function (n) {
|
|
685
|
+
var p = n / total * 100;
|
|
686
|
+
return p < .1 ? '<0.1%' : (p < 1 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%';
|
|
543
687
|
};
|
|
544
|
-
// 输出 / 缓存不再单列成指标 —— 它们就是 I/O 图例里的那几格。
|
|
545
688
|
var io = total > 0
|
|
546
|
-
? '<span class="
|
|
547
|
-
'
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
689
|
+
? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total)].concat(segs.map(function (s) {
|
|
690
|
+
return s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]);
|
|
691
|
+
})).join('\n')) + '">' + segs.map(function (s) {
|
|
692
|
+
return '<span class="seg" style="flex-grow:' + u[s[0]] + ';background:' + s[2] + '" title="' +
|
|
693
|
+
s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"></span>';
|
|
694
|
+
}).join('') + '</span>' +
|
|
695
|
+
'<span class="leg" data-tip="' + esc(segs.map(function (s) { return s[3] + ' ' + fmtTok(u[s[0]]); }).join(' · ')) + '">' +
|
|
696
|
+
segs.map(function (s) {
|
|
697
|
+
// 常显的是缩写, 全称与占比在 title 里。
|
|
698
|
+
return '<span class="lg" title="' + s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"><i style="background:' +
|
|
699
|
+
s[2] + '"></i>' + s[3] + '<b>' + fmtTok(u[s[0]]) + '</b></span>';
|
|
700
|
+
}).join('') + '</span>'
|
|
556
701
|
: '';
|
|
557
|
-
//
|
|
558
|
-
|
|
702
|
+
// 条首先是账的主人 (rolepage 里它的头像 + .名字), 再是模型名: 这本账是谁、拿什么跑出来的。
|
|
703
|
+
// 没有模型记录 (老轮次) 才退回「用量」。
|
|
704
|
+
return '<span class="u-who" title="' + esc(nameOf(t.target)) + '">' + esc(roleLabel(t.target) || t.label) +
|
|
705
|
+
'<span class="u-nm" data-tip="' + esc(nameOf(t.target)) + '">' + esc(nameOf(t.target)) + '</span></span>' +
|
|
706
|
+
'<span class="u-lb" title="' + esc(t.model || '') + '">' +
|
|
559
707
|
esc(t.model ? t.model.replace(/^claude-/, '') : '用量') + '</span>' +
|
|
560
708
|
'<span class="u-kvs">' +
|
|
561
709
|
kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
|
|
562
710
|
kv('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + kv('time', liveDur(t), fmtDur(liveDur(t))) +
|
|
563
711
|
'</span>' + io;
|
|
564
712
|
};
|
|
713
|
+
// 永远单行: 排不下就按 FOLD 的顺序一级级藏 (分布文字 → 名字 → 耗时), 宽度回来再按反序放出来。
|
|
714
|
+
// 判定看的是容器自己溢没溢出, 不看视口 —— 同一个组件挂在整页页脚和右栏底, 宽度各不相同。
|
|
715
|
+
var FOLD = ['f-leg', 'f-nm', 'f-time'];
|
|
716
|
+
var FOLDED = { 'f-leg': '.leg', 'f-nm': '.u-nm', 'f-time': '.kv-time' };
|
|
717
|
+
var fitUsage = function (el) {
|
|
718
|
+
if (el.hidden) return;
|
|
719
|
+
var over = function () { return el.scrollWidth > el.clientWidth; };
|
|
720
|
+
FOLD.forEach(function (c) { el.classList.remove(c); });
|
|
721
|
+
var n = 0;
|
|
722
|
+
while (n < FOLD.length && over()) el.classList.add(FOLD[n++]);
|
|
723
|
+
// 藏起来的那几样进整条 bar 的 title, hover 照样读得到。
|
|
724
|
+
el.title = FOLD.slice(0, n).map(function (c) {
|
|
725
|
+
var x = el.querySelector(FOLDED[c]);
|
|
726
|
+
return x ? x.getAttribute('data-tip') : '';
|
|
727
|
+
}).filter(Boolean).join('\n');
|
|
728
|
+
};
|
|
729
|
+
var watchUsage = function (el) {
|
|
730
|
+
if (el._ro || typeof ResizeObserver === 'undefined') return;
|
|
731
|
+
// 只在宽度变了时重算: 自己增删 class 不改宽度, 不会自激。
|
|
732
|
+
var w = -1;
|
|
733
|
+
el._ro = new ResizeObserver(function (es) {
|
|
734
|
+
var cw = Math.round(es[0].contentRect.width);
|
|
735
|
+
if (cw !== w) { w = cw; fitUsage(el); }
|
|
736
|
+
});
|
|
737
|
+
el._ro.observe(el);
|
|
738
|
+
};
|
|
565
739
|
// 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把悬停中的 tooltip 蹭掉。
|
|
566
740
|
var putUsage = function (el, t) {
|
|
567
741
|
var html = t ? usageHTML(t) : '';
|
|
568
742
|
// !el: web/ 是热更的, 外壳 HTML 要等 daemon 重启 —— 新脚本可能先遇上没有挂载点的旧外壳。
|
|
569
743
|
if (!el || el._html === html) return;
|
|
570
744
|
el._html = html; el.innerHTML = html; el.hidden = !html;
|
|
745
|
+
watchUsage(el); fitUsage(el);
|
|
571
746
|
// 条的高度挤的是消息区 —— 原本贴底的继续贴底。
|
|
572
747
|
toBottom();
|
|
573
748
|
};
|
|
574
749
|
// wizard: 整页页脚, 它自己跑过的全部轮次 (选了 session 就只算那一段)。
|
|
575
|
-
//
|
|
750
|
+
// 窗口: 对端恰好是一个 wizard 时 (服务端按这个窗口的 talkOf 入参判定, 给不给 winStats 就是答案),
|
|
751
|
+
// 窗口底下是那个 wizard 在这段往来里的账 —— 视角是人还是 wizard 都一样。
|
|
576
752
|
var renderUsage = function () {
|
|
577
753
|
var wiz = !!R.role && R.role.kind === 'wizard';
|
|
578
754
|
putUsage($('#pg-usage'), wiz ? R.stats : null);
|
|
579
|
-
putUsage($('#ch-usage'),
|
|
755
|
+
putUsage($('#ch-usage'), VIEW === 'msgs' ? R.winStats : null);
|
|
580
756
|
};
|
|
581
757
|
|
|
582
758
|
// ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
|
|
583
759
|
var renderHead = function () {
|
|
584
760
|
var who = $('#ch-who'), acts = $('#ch-acts');
|
|
585
761
|
if (VIEW !== 'msgs') {
|
|
586
|
-
who.innerHTML = '<span class="t">' + (
|
|
587
|
-
'<span class="sub">' + esc(nameOf(ROLE)) + (VIEW === 'world' ? ' 的家谱与协作网' : ' 名下的定时任务与工单') + '</span>';
|
|
762
|
+
who.innerHTML = '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务与工单</span>';
|
|
588
763
|
acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
|
|
589
764
|
$('#ch-back').onclick = function () { setView('msgs'); };
|
|
590
765
|
return;
|
|
591
766
|
}
|
|
592
767
|
var c = convOf(CONV);
|
|
593
768
|
if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
|
|
594
|
-
|
|
769
|
+
if (c.kind === 'all') {
|
|
770
|
+
who.innerHTML = pairOf([[c.who]]) + '<span class="t">' + esc(c.name) + '</span>';
|
|
771
|
+
acts.innerHTML = '';
|
|
772
|
+
bindGo(who);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
|
|
776
|
+
var dm = dmOf(c);
|
|
777
|
+
var peer = pairPeer(c);
|
|
778
|
+
var peerLabel = c.kind !== 'group' ? c.label : dm && peer === dm.role ? dm.label : '';
|
|
779
|
+
who.innerHTML = (peer ? pairOf([[ROLE, R.role && R.role.label], [peer, peerLabel]]) : '') +
|
|
780
|
+
'<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
|
|
595
781
|
acts.innerHTML = WITH
|
|
596
782
|
? '<span class="with">只看我与 ' + nm(WITH, '', true) + '</span><button class="vb" id="ch-all">看全部</button>'
|
|
597
783
|
: '';
|
|
@@ -605,32 +791,54 @@
|
|
|
605
791
|
// 箭头朝外 = 这条气泡换视角后要去的那一侧; mine 行靠 CSS 翻转。
|
|
606
792
|
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>';
|
|
607
793
|
// 行的另一侧是 .flip: 点它 = 换成这条消息的对端 (我发的 → 收信方; 别人发的 → 发话方)。
|
|
794
|
+
// 消息头上的角色头像 (发话方 / 收信方同一种画法), 点它切到那个角色的视角。
|
|
795
|
+
var avBtn = function (id, label) {
|
|
796
|
+
var sw = canSwitch(id) && id !== ROLE;
|
|
797
|
+
return '<button class="av' + (sw ? ' go' : '') + '" data-r="' + esc(id) + '" title="' + esc(nameOf(id)) + '"' + (sw ? '' : ' disabled') + '>' + esc(label || roleLabel(id)) + '</button>';
|
|
798
|
+
};
|
|
799
|
+
// 断点 (/clear /new 轮换) 是某一个 role 的 —— 群窗里几个 wizard 交错时, 不署名就认不出是谁断的。
|
|
800
|
+
// 片段由服务端渲染、不知视角, 名字 (及能否点) 在这里按视角补进断点条的标签前。
|
|
801
|
+
var CUT_L = /(<div class="tg-(?:cut|mark)\b[^>]*>)(<span class="l">)/;
|
|
802
|
+
var signCut = function (html, id, name) {
|
|
803
|
+
return html.replace(CUT_L, function (_, open, l) { return open + nm(id, name, true) + l; });
|
|
804
|
+
};
|
|
608
805
|
var rowHTML = function (m) {
|
|
609
806
|
learn(m.from, m.fromName, m.fromLabel);
|
|
610
807
|
learn(m.to, m.toName, m.toLabel);
|
|
611
808
|
if (m.dir === 'mark') {
|
|
612
|
-
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>';
|
|
809
|
+
return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + signCut(m.html, m.from, m.fromName) + '</div>';
|
|
613
810
|
}
|
|
614
811
|
var mine = m.from === ROLE;
|
|
615
812
|
var other = mine ? m.to : m.from;
|
|
616
813
|
// 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
|
|
617
814
|
var group = CONV.indexOf('p:') !== 0;
|
|
618
|
-
// 群里我不是收信方的那条 (X → Y)
|
|
619
|
-
|
|
815
|
+
// 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
|
|
816
|
+
// (一对一里收信方就是顶栏那个对端, 不再重复写)。
|
|
817
|
+
var dst = mine
|
|
818
|
+
? (m.to && m.to !== 'human:' && m.to !== (group ? pairPeer(convOf(CONV)) : CONV.slice(2)) ? m.to : '')
|
|
819
|
+
: (m.to !== ROLE && group ? m.to : '');
|
|
820
|
+
// 宽屏写在气泡对面的空白里 (.dest, 在换视角的门里但不吃点击); 窄屏空白太窄, 退回消息头 (.to)。
|
|
821
|
+
var to = dst ? '<span class="to">→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) + '</span>' : '';
|
|
822
|
+
// 箭头顺着「发话人 → 收件人」: 气泡是发话人, 描述在哪一侧箭头就背着气泡指向哪一侧 ——
|
|
823
|
+
// 我的消息描述在左 (🦉 .x ←), 别人的在右 (→ 🦉 .x)。
|
|
824
|
+
var dest = dst
|
|
825
|
+
? '<span class="dest" aria-hidden="true">' + (mine ? '' : '<i class="arr">→</i>') +
|
|
826
|
+
'<span class="dav">' + esc(m.toLabel || roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
|
|
827
|
+
(mine ? '<i class="arr">←</i>' : '') + '</span>'
|
|
828
|
+
: '';
|
|
620
829
|
var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
|
|
621
830
|
// 本轮的账 (呼吸点 + 模型 / token / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
|
|
622
831
|
var stat = m.meta ? '<span class="mstat">' + m.meta + '</span>' : '';
|
|
623
832
|
var who = mine
|
|
624
|
-
?
|
|
625
|
-
: nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
|
|
833
|
+
? to + stamp(m.ts) + stat + avBtn(m.from, m.fromLabel)
|
|
834
|
+
: avBtn(m.from, m.fromLabel) + nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
|
|
626
835
|
var sw = canSwitch(other);
|
|
627
836
|
var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
|
|
628
837
|
' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
|
|
629
|
-
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
630
|
-
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>';
|
|
838
|
+
dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
631
839
|
return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
632
840
|
(m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
633
|
-
'<div class="mcol"><div class="mwho">' +
|
|
841
|
+
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + (m.dir === 'out' ? signCut(m.html, m.from, m.fromName) : m.html) + '</div></div>' +
|
|
634
842
|
flip +
|
|
635
843
|
'</div>';
|
|
636
844
|
};
|
|
@@ -685,7 +893,7 @@
|
|
|
685
893
|
};
|
|
686
894
|
|
|
687
895
|
var bindRow = function (el) {
|
|
688
|
-
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r]');
|
|
896
|
+
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r]');
|
|
689
897
|
};
|
|
690
898
|
var rowNode = function (id) {
|
|
691
899
|
var list = inner.querySelectorAll('.mrow');
|
|
@@ -693,6 +901,21 @@
|
|
|
693
901
|
return null;
|
|
694
902
|
};
|
|
695
903
|
|
|
904
|
+
// 载入更早的消息: 锚住此刻视口顶上的那一行, 重绘后原位不动 —— 不吸底。
|
|
905
|
+
var keepView = function () {
|
|
906
|
+
var top = thread.getBoundingClientRect().top;
|
|
907
|
+
var ref = [].filter.call(inner.querySelectorAll('.mrow'), function (r) { return r.getBoundingClientRect().bottom > top; })[0];
|
|
908
|
+
if (!ref) return null;
|
|
909
|
+
var id = ref.getAttribute('data-id'), y = ref.getBoundingClientRect().top;
|
|
910
|
+
return function () {
|
|
911
|
+
var el = rowNode(id);
|
|
912
|
+
if (!el) return false;
|
|
913
|
+
S.pinned = false;
|
|
914
|
+
thread.scrollTop += el.getBoundingClientRect().top - y;
|
|
915
|
+
return true;
|
|
916
|
+
};
|
|
917
|
+
};
|
|
918
|
+
|
|
696
919
|
// land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
|
|
697
920
|
var loadMsgs = function (limit, land) {
|
|
698
921
|
var gen = S.gen;
|
|
@@ -707,7 +930,7 @@
|
|
|
707
930
|
: '';
|
|
708
931
|
inner.innerHTML = more + d.msgs.map(rowHTML).join('');
|
|
709
932
|
var btn = $('#more');
|
|
710
|
-
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
|
|
933
|
+
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0', keepView()); };
|
|
711
934
|
bindRow(inner);
|
|
712
935
|
render(inner);
|
|
713
936
|
foldPings(inner);
|
|
@@ -771,7 +994,8 @@
|
|
|
771
994
|
var takeRole = function (d) {
|
|
772
995
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
773
996
|
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
|
|
774
|
-
|
|
997
|
+
SESSION = d.session || '';
|
|
998
|
+
R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null;
|
|
775
999
|
R.stats = d.stats || null; R.winStats = d.winStats || null;
|
|
776
1000
|
ROLE = d.role.id;
|
|
777
1001
|
learn(d.role.id, d.role.name, d.role.label);
|
|
@@ -822,7 +1046,7 @@
|
|
|
822
1046
|
// 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
|
|
823
1047
|
if (land) takeRole(d); else applyRole(d);
|
|
824
1048
|
syncUrl();
|
|
825
|
-
if (
|
|
1049
|
+
if (WORLD || VIEW === 'plan') loadWorld();
|
|
826
1050
|
return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
|
|
827
1051
|
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
828
1052
|
connect();
|
|
@@ -830,7 +1054,8 @@
|
|
|
830
1054
|
});
|
|
831
1055
|
};
|
|
832
1056
|
|
|
833
|
-
|
|
1057
|
+
// land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
|
|
1058
|
+
var selectConv = function (key, withRole, land) {
|
|
834
1059
|
CONV = key; WITH = withRole || '';
|
|
835
1060
|
reveal();
|
|
836
1061
|
if (VIEW !== 'msgs') setView('msgs');
|
|
@@ -841,7 +1066,7 @@
|
|
|
841
1066
|
S.gen++;
|
|
842
1067
|
syncUrl();
|
|
843
1068
|
inner.innerHTML = '<div class="empty">加载中…</div>';
|
|
844
|
-
loadMsgs().then(connect);
|
|
1069
|
+
loadMsgs(undefined, land).then(connect);
|
|
845
1070
|
};
|
|
846
1071
|
|
|
847
1072
|
// 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
|
|
@@ -849,6 +1074,7 @@
|
|
|
849
1074
|
// at: 被点的那条消息 —— 换视角后它留在指针底下, 其余的相对它滑动。
|
|
850
1075
|
var switchRole = function (id, at) {
|
|
851
1076
|
if (!canSwitch(id) || id === ROLE) return;
|
|
1077
|
+
W.treeFor = '';
|
|
852
1078
|
var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
|
|
853
1079
|
var from = ROLE;
|
|
854
1080
|
// 整个频道 / 两人私聊的消息集合与视角无关 —— 只有 with 与 session 按 role 过滤。
|
|
@@ -857,8 +1083,11 @@
|
|
|
857
1083
|
var withBack = keep && WITH ? from : '';
|
|
858
1084
|
ROLE = id; WITH = withBack; SESSION = '';
|
|
859
1085
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
1086
|
+
// 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
|
|
1087
|
+
// 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
|
|
1088
|
+
if (WORLD) { CONV = 'a:' + id; WITH = ''; same = false; W.pickSelf = id; }
|
|
860
1089
|
var snap = VIEW === 'msgs' && !calm() ? snapRows(at) : null;
|
|
861
|
-
if (same) { flipWindow(snap); return; }
|
|
1090
|
+
if (same) { if (WORLD) loadWorld(); flipWindow(snap); return; }
|
|
862
1091
|
var kept = VIEW === 'msgs' ? keepBodies() : {};
|
|
863
1092
|
refresh(function () {
|
|
864
1093
|
adoptBodies(kept);
|
|
@@ -1016,592 +1245,307 @@
|
|
|
1016
1245
|
$('#tb-back').onclick = function () { app.classList.remove('reading'); };
|
|
1017
1246
|
|
|
1018
1247
|
// ══ 关系视图 ═══════════════════════════════════════════════════════
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
var
|
|
1026
|
-
|
|
1027
|
-
clone: {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
graph: {
|
|
1031
|
-
};
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
//
|
|
1248
|
+
// 家谱 (clone / spawn) 与派活 (send_peer · 工单 · 流水线) 是同一种东西: 一条「谁把谁拉进这件事」的
|
|
1249
|
+
// 有向关系。一对 wizard 之间的全部关系合成一条边, 沿方向长成一棵树:
|
|
1250
|
+
// · 每个节点只认一个主父亲 —— 家谱父亲优先, 否则派活次数最多的那位; 其余入边记成节点上的引用
|
|
1251
|
+
// · 环 (互相派活) 在建树时截断: 哪个根都走不到的环, 挑最近活跃的那个当根
|
|
1252
|
+
// · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
|
|
1253
|
+
// 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
|
|
1254
|
+
var KIND = {
|
|
1255
|
+
spawn: { mark: '子', tip: 'spawn 的白板 wizard, 只有出身、没有继承上下文' },
|
|
1256
|
+
clone: { mark: '分身', tip: 'fork 自父亲的 session, 开局带着那一刻的上下文' },
|
|
1257
|
+
peer: { mark: '对话', tip: 'send_peer 发起的对话' },
|
|
1258
|
+
job: { mark: '工单', tip: '它开的工单里有这位成员' },
|
|
1259
|
+
graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
|
|
1260
|
+
};
|
|
1261
|
+
var LINEAGE = { spawn: 1, clone: 1 };
|
|
1262
|
+
|
|
1263
|
+
// 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
|
|
1035
1264
|
var cssEsc = function (v) {
|
|
1036
1265
|
return window.CSS && CSS.escape
|
|
1037
1266
|
? CSS.escape(v)
|
|
1038
1267
|
: String(v).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c; });
|
|
1039
1268
|
};
|
|
1040
|
-
|
|
1041
|
-
var shortPath = function (p, keep) {
|
|
1042
|
-
var seg = String(p || '').replace(/\/+$/, '').split('/').filter(Boolean);
|
|
1043
|
-
return seg.length <= keep ? p : '…/' + seg.slice(-keep).join('/');
|
|
1044
|
-
};
|
|
1045
|
-
|
|
1046
1269
|
var wRunning = function (n) { return n.busy || (!!n.runningUntil && srvNow() < n.runningUntil); };
|
|
1047
|
-
|
|
1048
|
-
// ── 走进一个 wizard ──
|
|
1049
|
-
// 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁 —— 不再整页跳。
|
|
1050
|
-
var chatOf = function (base) {
|
|
1051
|
-
return (W.chats || []).filter(function (c) { return c.base === base; })[0];
|
|
1052
|
-
};
|
|
1270
|
+
// 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁。
|
|
1053
1271
|
var canOpen = function (n) { return !!n; };
|
|
1054
1272
|
var openNode = function (target) { if (target) switchRole(target); };
|
|
1055
1273
|
|
|
1056
|
-
//
|
|
1057
|
-
var
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
// 反复点不同节点时版面不会跳。
|
|
1061
|
-
var neighborhood = function (target) {
|
|
1062
|
-
var set = {}; set[target] = 1;
|
|
1063
|
-
W.edges.forEach(function (e) {
|
|
1064
|
-
if (e.from === target) set[e.to] = 1;
|
|
1065
|
-
if (e.to === target) set[e.from] = 1;
|
|
1066
|
-
});
|
|
1067
|
-
return set;
|
|
1068
|
-
};
|
|
1069
|
-
|
|
1070
|
-
var edgeOn = function (e) { return !!W.kinds[e.kind]; };
|
|
1071
|
-
|
|
1072
|
-
// 一个节点身上挂了几条 (当前开着的) 边。0 = 它此刻不属于任何协作关系 ——
|
|
1073
|
-
// 「只看有关系的」就是把这些收起来, 剩下的才是真正的那张网。
|
|
1074
|
-
var degree = function (target) {
|
|
1075
|
-
return W.edges.filter(edgeOn).filter(function (e) { return e.from === target || e.to === target; }).length;
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
var renderWTools = function () {
|
|
1079
|
-
var counts = { clone: 0, spawn: 0, peer: 0, graph: 0 };
|
|
1080
|
-
W.edges.forEach(function (e) { counts[e.kind] = (counts[e.kind] || 0) + 1; });
|
|
1081
|
-
var cross = W.edges.filter(function (e) { return e.cross; }).length;
|
|
1082
|
-
var chips = Object.keys(EDGE).map(function (k) {
|
|
1083
|
-
return '<button class="chip' + (W.kinds[k] ? ' on' : '') + '" data-k="' + k + '" ' +
|
|
1084
|
-
'style="--c:' + EDGE[k].c + '"' + (EDGE[k].tip ? ' title="' + EDGE[k].tip + '"' : '') + '><i></i>' + EDGE[k].label +
|
|
1085
|
-
'<b>' + (counts[k] || 0) + '</b></button>';
|
|
1086
|
-
}).join('');
|
|
1087
|
-
var force = W.layout === 'force';
|
|
1088
|
-
wtoolsEl.innerHTML =
|
|
1089
|
-
'<div class="wlegend">' + chips + '</div>' +
|
|
1090
|
-
'<div class="wlay">' +
|
|
1091
|
-
'<button class="lb' + (force ? '' : ' on') + '" data-lay="cards" ' +
|
|
1092
|
-
'title="按聊天分卡, 分身 / 子 wizard 缩在父亲下面 —— 归属与层级最准">▦ 卡片</button>' +
|
|
1093
|
-
'<button class="lb' + (force ? ' on' : '') + '" data-lay="force" ' +
|
|
1094
|
-
'title="wizard 之间的力导向图, 聊天收进节点 —— 协作关系最直观">🕸 关系网</button>' +
|
|
1095
|
-
'</div>' +
|
|
1096
|
-
'<div class="wstat">' +
|
|
1097
|
-
W.nodes.length + ' 个 wizard · ' + W.chats.length + ' 个聊天' +
|
|
1098
|
-
(cross ? ' · <b>' + cross + '</b> 条跨聊天关系' : '') +
|
|
1099
|
-
(W.degraded ? ' · <span class="warn" title="注册表不可达 (独立 svr 部署), 只画观测到的往来">名册缺席</span>' : '') +
|
|
1100
|
-
'<span class="hint">单击聚焦 · 双击进入' + (force ? ' · 拖动钉住' : ' (外聊天整页跳过去)') + '</span>' +
|
|
1101
|
-
'</div>' +
|
|
1102
|
-
(force ? '<button class="chip" id="wre" title="松开所有钉住的节点, 重新排一遍">↻ 重排</button>' : '') +
|
|
1103
|
-
'<button class="chip only' + (W.onlyRel ? ' on' : '') + '" id="wonly" ' +
|
|
1104
|
-
'title="把此刻不属于任何关系的 wizard 收起来 —— 剩下的就是这张协作网本身">' +
|
|
1105
|
-
(W.onlyRel ? '☑' : '☐') + ' 只看有关系的</button>' +
|
|
1106
|
-
(W.sel ? '<button class="chip clear" id="wclear">✕ 取消聚焦</button>' : '');
|
|
1107
|
-
wtoolsEl.querySelectorAll('.lb').forEach(function (b) {
|
|
1108
|
-
b.onclick = function () {
|
|
1109
|
-
var v = b.getAttribute('data-lay');
|
|
1110
|
-
if (v === W.layout) return;
|
|
1111
|
-
setLayout(v);
|
|
1112
|
-
renderWorld();
|
|
1113
|
-
};
|
|
1114
|
-
});
|
|
1115
|
-
var re = $('#wre');
|
|
1116
|
-
if (re) re.onclick = function () { FX.pos = {}; FX.sig = ''; renderForce(); };
|
|
1117
|
-
wtoolsEl.querySelectorAll('.chip[data-k]').forEach(function (b) {
|
|
1118
|
-
b.onclick = function () {
|
|
1119
|
-
var k = b.getAttribute('data-k');
|
|
1120
|
-
W.kinds[k] = W.kinds[k] ? 0 : 1;
|
|
1121
|
-
renderWTools();
|
|
1122
|
-
if (W.layout === 'force') renderForce(); else drawEdges();
|
|
1123
|
-
};
|
|
1124
|
-
});
|
|
1125
|
-
var only = $('#wonly');
|
|
1126
|
-
if (only) only.onclick = function () { W.onlyRel = !W.onlyRel; renderWorld(); };
|
|
1127
|
-
var cl = $('#wclear');
|
|
1128
|
-
if (cl) cl.onclick = function () { W.sel = ''; renderWorld(); };
|
|
1129
|
-
};
|
|
1130
|
-
|
|
1131
|
-
var nodeHTML = function (n, depth) {
|
|
1132
|
-
var run = wRunning(n), nm = shortName(n);
|
|
1133
|
-
var bits = [];
|
|
1134
|
-
if (n.model) bits.push(n.model.replace(/^claude-/, ''));
|
|
1135
|
-
if (n.cwd) bits.push('📁 ' + shortPath(n.cwd, 1));
|
|
1136
|
-
if (n.turns) bits.push(n.turns + ' 轮');
|
|
1137
|
-
if (n.taskTurns) bits.push('⏰ ' + n.taskTurns);
|
|
1138
|
-
if (n.peerTurns) bits.push('✉ ' + n.peerTurns);
|
|
1139
|
-
return '<div class="wnode' + (n.self ? ' self' : '') + (run ? ' run' : '') +
|
|
1140
|
-
(n.alive ? '' : ' cold') + (n.target === ROLE ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
|
|
1141
|
-
'" data-t="' + esc(n.target) + '" style="margin-left:' + (depth * 16) + 'px">' +
|
|
1142
|
-
(depth ? (n.inherited ? '<span class="lin" title="分身"></span>' : '<span class="lin sp" title="子 wizard"></span>') : '') +
|
|
1143
|
-
'<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
|
|
1144
|
-
'<span class="wbody">' +
|
|
1145
|
-
'<span class="wl1">' +
|
|
1146
|
-
'<b class="wname" title="' + esc(n.name || n.target) + '">' + esc(nm) + '</b>' +
|
|
1147
|
-
(n.inherited ? '<span class="wih" title="分身: 从父亲的 session 节点 fork, 开局带着那一刻的上下文">⧉</span>' : '') +
|
|
1148
|
-
'<span class="wts">' + esc(fmtAgo(n.lastTs)) + '</span>' +
|
|
1149
|
-
'</span>' +
|
|
1150
|
-
(n.description ? '<span class="wjob">' + esc(n.description) + '</span>' : '') +
|
|
1151
|
-
(n.preview ? '<span class="wprev">' + esc(n.preview) + '</span>' : '') +
|
|
1152
|
-
(bits.length ? '<span class="wmeta">' + bits.map(esc).join('<span class="sep">·</span>') + '</span>' : '') +
|
|
1153
|
-
'</span>' +
|
|
1154
|
-
'</div>';
|
|
1274
|
+
// ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
|
|
1275
|
+
var rangeOf = function () {
|
|
1276
|
+
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1277
|
+
return s ? { from: s.start, to: s.end || Infinity, s: s } : null;
|
|
1155
1278
|
};
|
|
1279
|
+
var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
|
|
1156
1280
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
var live = shown.filter(function (mm) {
|
|
1166
|
-
var n = nodeOf(mm.target); return n && wRunning(n);
|
|
1167
|
-
}).length;
|
|
1168
|
-
var rows = shown.map(function (mm) {
|
|
1169
|
-
var n = nodeOf(mm.target);
|
|
1170
|
-
// 「只看有关系的」会把父亲筛掉而留下孩子 —— 那时的缩进没有参照物, 拉平。
|
|
1171
|
-
var d = W.onlyRel ? 0 : mm.depth;
|
|
1172
|
-
return n ? nodeHTML(n, d) : '';
|
|
1173
|
-
}).join('');
|
|
1174
|
-
if (!shown.length) return '';
|
|
1175
|
-
return '<section class="wchat' + (c.self ? ' self' : '') + '" data-base="' + esc(c.base) + '">' +
|
|
1176
|
-
'<header class="wch">' +
|
|
1177
|
-
'<span class="nm">' + esc(c.name || c.base) + '</span>' +
|
|
1178
|
-
(c.self ? '<span class="here">当前</span>' : '') +
|
|
1179
|
-
'<span class="ct">' + shown.length + (live ? ' · <em>' + live + ' 在跑</em>' : '') + '</span>' +
|
|
1180
|
-
'</header>' +
|
|
1181
|
-
'<div class="wrows">' + rows + '</div>' +
|
|
1182
|
-
(c.hidden || shown.length < c.members.length
|
|
1183
|
-
? '<div class="wmore" title="不在图上的会话 —— 它们还在, 只是此刻既没在跑也没有关系">另有 ' +
|
|
1184
|
-
(c.hidden + (c.members.length - shown.length)) + ' 个未显示</div>'
|
|
1185
|
-
: '') +
|
|
1186
|
-
'</section>';
|
|
1187
|
-
}).filter(Boolean).join('');
|
|
1188
|
-
wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg><div class="wgrid">' +
|
|
1189
|
-
(cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身 / 生子 wizard 之后这里就有边了</div>') + '</div>';
|
|
1190
|
-
wmapEl.querySelectorAll('.wnode').forEach(function (el) {
|
|
1191
|
-
var t = el.getAttribute('data-t');
|
|
1192
|
-
if (hood && !hood[t]) el.classList.add('dim');
|
|
1193
|
-
if (t === W.sel) el.classList.add('sel');
|
|
1194
|
-
if (canOpen(nodeOf(t))) el.classList.add('go');
|
|
1195
|
-
el.onclick = function () { W.sel = (W.sel === t ? '' : t); renderWorld(); };
|
|
1196
|
-
// 双击 = 走进它: 视角切成它 (见 openNode)。
|
|
1197
|
-
el.ondblclick = function () { openNode(t); };
|
|
1198
|
-
});
|
|
1199
|
-
// 卡片头 = 那个群本身。点它进那个群里最近活跃的那一栏 —— 图上找不到哪个
|
|
1200
|
-
// 节点该双击的时候, 这是最直觉的入口。
|
|
1201
|
-
wmapEl.querySelectorAll('.wch').forEach(function (h) {
|
|
1202
|
-
var c = chatOf(h.parentNode.getAttribute('data-base'));
|
|
1203
|
-
var first = c && c.members.filter(function (mm) { return canOpen(nodeOf(mm.target)); })[0];
|
|
1204
|
-
if (!first) return;
|
|
1205
|
-
h.classList.add('go');
|
|
1206
|
-
h.onclick = function () { openNode(first.target); };
|
|
1207
|
-
});
|
|
1281
|
+
// 工单归属也是一条关系: 开单的人 → 每个成员。
|
|
1282
|
+
var jobEdges = function () {
|
|
1283
|
+
return (W.jobs || []).reduce(function (acc, j) {
|
|
1284
|
+
var o = nodeOf(j.owner);
|
|
1285
|
+
return !o ? acc : acc.concat(j.members.filter(function (mm) { return mm.target !== j.owner && nodeOf(mm.target); }).map(function (mm) {
|
|
1286
|
+
return { kind: 'job', from: j.owner, to: mm.target, cross: o.base !== nodeOf(mm.target).base, jobs: [j.id], ts: [j.openedAt] };
|
|
1287
|
+
}));
|
|
1288
|
+
}, []);
|
|
1208
1289
|
};
|
|
1209
1290
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
var
|
|
1236
|
-
return {
|
|
1237
|
-
|
|
1238
|
-
|
|
1291
|
+
/** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
|
|
1292
|
+
var relations = function (rg) {
|
|
1293
|
+
var pairs = W.edges.map(function (e) {
|
|
1294
|
+
return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs] };
|
|
1295
|
+
}).concat(jobEdges()).reduce(function (m, e) {
|
|
1296
|
+
// 家谱是身份, 不是发生在某段 session 里的事 —— 不受时间窗裁剪, 否则切到子 wizard 时
|
|
1297
|
+
// 它的出生早于自己的 session, 父亲那条边就被裁掉了。
|
|
1298
|
+
var ts = LINEAGE[e.kind] ? e.ts : e.ts.filter(function (t) { return inRange(rg, t); });
|
|
1299
|
+
if (!ts.length) return m;
|
|
1300
|
+
var k = e.from + '\u0000' + e.to;
|
|
1301
|
+
var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
|
|
1302
|
+
p.kinds[e.kind] = (p.kinds[e.kind] || 0) + ts.length;
|
|
1303
|
+
if (!LINEAGE[e.kind]) p.n += ts.length;
|
|
1304
|
+
p.first = Math.min(p.first, Math.min.apply(null, ts));
|
|
1305
|
+
p.last = Math.max(p.last, Math.max.apply(null, ts));
|
|
1306
|
+
e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
|
|
1307
|
+
return m;
|
|
1308
|
+
}, {});
|
|
1309
|
+
// 派活本身带着工单号时, 「工单」标记就是重复的。
|
|
1310
|
+
Object.keys(pairs).forEach(function (k) { if (pairs[k].kinds.peer) delete pairs[k].kinds.job; });
|
|
1311
|
+
return pairs;
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
/** 关系 → 森林: pp = 主父亲那条边, kids = 主孩子, roots = 森林的根。 */
|
|
1315
|
+
var forestOf = function (pairs) {
|
|
1316
|
+
var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
|
|
1317
|
+
var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
|
|
1318
|
+
var weight = function (p) { return (p.kinds.spawn ? 3e6 : p.kinds.clone ? 2e6 : 0) + p.n; };
|
|
1319
|
+
var pp = Object.keys(inc).reduce(function (m, t) {
|
|
1320
|
+
m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
|
|
1321
|
+
return m;
|
|
1322
|
+
}, {});
|
|
1323
|
+
var ends = list.reduce(function (m, p) { m[p.from] = 1; m[p.to] = 1; return m; }, {});
|
|
1324
|
+
var kidsIn = function () {
|
|
1325
|
+
return Object.keys(pp).reduce(function (m, t) { (m[pp[t].from] = m[pp[t].from] || []).push(t); return m; }, {});
|
|
1239
1326
|
};
|
|
1327
|
+
var reach = function (kids, roots) {
|
|
1328
|
+
var seen = {};
|
|
1329
|
+
var walk = function (t) { if (seen[t]) return; seen[t] = 1; (kids[t] || []).forEach(walk); };
|
|
1330
|
+
roots.forEach(walk);
|
|
1331
|
+
return seen;
|
|
1332
|
+
};
|
|
1333
|
+
var roots = Object.keys(ends).filter(function (t) { return !pp[t]; });
|
|
1334
|
+
var recent = function (a, b) { return ((nodeOf(b) || {}).lastTs || 0) - ((nodeOf(a) || {}).lastTs || 0); };
|
|
1335
|
+
// 从 t 沿主父亲往上走, 直到撞回走过的点 —— 撞上的那一圈就是环。
|
|
1336
|
+
var loopFrom = function (t, path) {
|
|
1337
|
+
var at = path.indexOf(t);
|
|
1338
|
+
return at >= 0 ? path.slice(at) : loopFrom(pp[t].from, path.concat([t]));
|
|
1339
|
+
};
|
|
1340
|
+
// 环: 只断环上的派活边 (家谱无环, 断它会让子 wizard 丢了父亲), 挑最近活跃的那个当根 —— 直到都走得到。
|
|
1341
|
+
var close = function () {
|
|
1342
|
+
var seen = reach(kidsIn(), roots);
|
|
1343
|
+
var left = Object.keys(ends).filter(function (t) { return !seen[t]; });
|
|
1344
|
+
if (!left.length) return;
|
|
1345
|
+
var ring = loopFrom(left[0], []);
|
|
1346
|
+
var cut = ring.filter(function (t) { return !LINEAGE[domKind(pp[t])]; });
|
|
1347
|
+
var pick = (cut.length ? cut : ring).sort(recent)[0];
|
|
1348
|
+
delete pp[pick]; roots.push(pick);
|
|
1349
|
+
close();
|
|
1350
|
+
};
|
|
1351
|
+
close();
|
|
1352
|
+
// 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
|
|
1353
|
+
var kids = kidsIn();
|
|
1354
|
+
return { pairs: pairs, inc: inc, pp: pp, kids: kids, roots: roots, ends: ends };
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
// F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
|
|
1358
|
+
var shows = function (F, t) { return !F.vis || !!F.vis[t]; };
|
|
1359
|
+
var countSub = function (F, t) {
|
|
1360
|
+
return (F.kids[t] || []).filter(function (k) { return shows(F, k); })
|
|
1361
|
+
.reduce(function (n, k) { return n + 1 + countSub(F, k); }, 0);
|
|
1362
|
+
};
|
|
1363
|
+
var chainUp = function (F, t, seen) {
|
|
1364
|
+
seen = seen || {};
|
|
1365
|
+
var p = F.pp[t];
|
|
1366
|
+
if (!p || seen[p.from]) return [t];
|
|
1367
|
+
seen[t] = 1;
|
|
1368
|
+
return chainUp(F, p.from, seen).concat([t]);
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
// 线接入卡片处的 label: 种类 (家谱不计次, 对话等一律写次数, ×1 也写) + 跨群; 经手的工单号放进悬停提示。
|
|
1372
|
+
var labelHTML = function (p) {
|
|
1373
|
+
if (!p) return '';
|
|
1374
|
+
return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
|
|
1375
|
+
Object.keys(KIND).filter(function (k) { return p.kinds[k]; }).map(function (k) {
|
|
1376
|
+
return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
|
|
1377
|
+
(!LINEAGE[k] ? ' ×' + p.kinds[k] : '') + '</span>';
|
|
1378
|
+
}).join('') +
|
|
1379
|
+
(p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') + '</span>';
|
|
1380
|
+
};
|
|
1381
|
+
// 一条边的主色: 家谱优先, 决定树上那道线的颜色。
|
|
1382
|
+
var domKind = function (p) {
|
|
1383
|
+
return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
|
|
1387
|
+
var edgeEnds = function (F, t) { var p = F.pp[t]; return p ? [p.from, t] : [t]; };
|
|
1388
|
+
// 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
|
|
1389
|
+
var pairConv = function (other) {
|
|
1390
|
+
if (convOf('p:' + other)) return ['p:' + other, ''];
|
|
1391
|
+
var hit = R.convs.filter(function (c) { return c.kind === 'group'; }).map(function (c) {
|
|
1392
|
+
return [c, (c.subs || []).filter(function (x) { return x.role === other && x.count; })[0]];
|
|
1393
|
+
}).filter(function (x) { return x[1]; }).sort(function (a, b) { return b[1].lastTs - a[1].lastTs; })[0];
|
|
1394
|
+
return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
|
|
1395
|
+
};
|
|
1396
|
+
// 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
|
|
1397
|
+
var edgeConv = function (F, t) {
|
|
1398
|
+
var ends = edgeEnds(F, t);
|
|
1399
|
+
var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
|
|
1400
|
+
return other ? pairConv(other) : null;
|
|
1401
|
+
};
|
|
1402
|
+
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话, 不换视角。
|
|
1403
|
+
// links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
|
|
1404
|
+
var talkKey = function (links, t) {
|
|
1405
|
+
var ls = links[t] || [];
|
|
1406
|
+
return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
|
|
1407
|
+
};
|
|
1408
|
+
// 图上相连 = 画出来的主父亲边: 它的父亲 + 它的孩子, 两端都画出来了才算。
|
|
1409
|
+
var linksOf = function (F, shown) {
|
|
1410
|
+
var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
|
|
1411
|
+
return shown.reduce(function (m, n) {
|
|
1412
|
+
var t = n.target, p = F.pp[t];
|
|
1413
|
+
m[t] = (p && on[p.from] ? [p.from] : []).concat((F.kids[t] || []).filter(function (k) { return on[k]; }));
|
|
1414
|
+
return m;
|
|
1415
|
+
}, {});
|
|
1240
1416
|
};
|
|
1241
1417
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
return '<path d="' + p.d + '" fill="none" stroke="' + EDGE[e.kind].c + '" ' +
|
|
1267
|
-
'stroke-width="' + (off ? 1 : w).toFixed(2) + '" ' +
|
|
1268
|
-
'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '" ' +
|
|
1269
|
-
'opacity="' + (off ? 0.14 : (e.cross ? 0.95 : 0.6)) + '" ' +
|
|
1270
|
-
'marker-' + p.head + '="url(#ah-' + e.kind + (off ? '-d' : '') + ')">' +
|
|
1271
|
-
'<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
|
|
1272
|
-
(e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
|
|
1273
|
-
(e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
|
|
1274
|
-
}).join('');
|
|
1275
|
-
svg.innerHTML = '<defs>' + defs + '</defs>' + paths;
|
|
1276
|
-
};
|
|
1277
|
-
|
|
1278
|
-
// ── 力导向布局 ────────────────────────────────────────────────────
|
|
1279
|
-
// 卡片布局把「归属」和「层级」交给版面, 代价是聊天必须张张画出来: 一个只出了
|
|
1280
|
-
// 一个分身的群也占一整张卡, 而跨聊天那条边得横穿半张图去找对面。
|
|
1281
|
-
// 换一种折法 —— 只画 wizard, 聊天收进节点自己 (左侧色条 + 群名一行), 位置交给
|
|
1282
|
-
// 力: 同群相吸自然成簇, 家谱 (分身 / 子 wizard) 的边比派活的边短, 家谱于是仍然读得出来。
|
|
1283
|
-
// 两种读法各有盲区 (力导向读不出精确的父子层级), 所以是开关不是替换。
|
|
1284
|
-
// 绿在这一页已经有主 (在跑), 所以群色不用绿 —— 一道绿色条会被读成「它活着」。
|
|
1285
|
-
var CHAT_C = ['#0969da', '#8250df', '#bc4c00', '#bf3989', '#0a7d6b', '#cf222e', '#9a6700', '#4338ca'];
|
|
1286
|
-
var hashOf = function (s) {
|
|
1287
|
-
var v = String(s), h = 0;
|
|
1288
|
-
for (var i = 0; i < v.length; i++) h = (h * 31 + v.charCodeAt(i)) >>> 0;
|
|
1289
|
-
return h;
|
|
1290
|
-
};
|
|
1291
|
-
// 颜色按 base **排序**取位, 而不是按它在列表里的次序取位 —— 列表次序随最近活动
|
|
1292
|
-
// 变, 颜色跟着变就没人记得住哪个色是哪个群; 排序是稳定的, 顺带保证 8 个群之内
|
|
1293
|
-
// 一个不撞。超过 8 个才退回 hash。
|
|
1294
|
-
var chatColor = function (base) {
|
|
1295
|
-
return (FX.color || {})[base] || CHAT_C[hashOf(base) % CHAT_C.length];
|
|
1296
|
-
};
|
|
1297
|
-
// 取位用的是**全部**节点而不是画在图上的那些 —— 拿筛过的集合分色, 一按「只看
|
|
1298
|
-
// 有关系的」每个群的颜色就换一轮。
|
|
1299
|
-
var fxColors = function () {
|
|
1300
|
-
var bases = [];
|
|
1301
|
-
W.nodes.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
|
|
1302
|
-
return bases.sort().reduce(function (m, b, i) { m[b] = CHAT_C[i % CHAT_C.length]; return m; }, {});
|
|
1303
|
-
};
|
|
1304
|
-
|
|
1305
|
-
// pos 按 target 存活: 6 秒一次的轮询重画 DOM, 但坐标沿用, 图不会每轮跳一次。
|
|
1306
|
-
var FX = { pos: {}, size: {}, els: {}, paths: [], color: {}, nodes: [], edges: [], w: 0, h: 0, alpha: 0, raf: 0, drag: null, sig: '' };
|
|
1307
|
-
|
|
1308
|
-
var fxNodes = function () {
|
|
1309
|
-
return W.nodes.filter(function (n) { return !W.onlyRel || degree(n.target); });
|
|
1310
|
-
};
|
|
1311
|
-
|
|
1312
|
-
/** 种子位置: 聊天摆一圈, 成员散在自己那个群周围。确定性 (hash 而不是 random)
|
|
1313
|
-
* —— 同一批 wizard 每次打开这一页, 图的大致形状是同一个。 */
|
|
1314
|
-
var fxSeed = function (ns) {
|
|
1315
|
-
var bases = [];
|
|
1316
|
-
ns.forEach(function (n) { if (bases.indexOf(n.base) < 0) bases.push(n.base); });
|
|
1317
|
-
var cx = FX.w / 2, cy = FX.h / 2, R = Math.min(FX.w, FX.h) * 0.28;
|
|
1318
|
-
var on = {};
|
|
1319
|
-
ns.forEach(function (n) { on[n.target] = 1; });
|
|
1320
|
-
Object.keys(FX.pos).forEach(function (t) { if (!on[t]) delete FX.pos[t]; });
|
|
1321
|
-
ns.forEach(function (n) {
|
|
1322
|
-
if (FX.pos[n.target]) return;
|
|
1323
|
-
var i = bases.indexOf(n.base);
|
|
1324
|
-
var a = bases.length > 1 ? (i / bases.length) * Math.PI * 2 : 0;
|
|
1325
|
-
var bx = bases.length > 1 ? cx + Math.cos(a) * R : cx;
|
|
1326
|
-
var by = bases.length > 1 ? cy + Math.sin(a) * R : cy;
|
|
1327
|
-
var t = (hashOf(n.target) % 360) * Math.PI / 180;
|
|
1328
|
-
FX.pos[n.target] = { x: bx + Math.cos(t) * 46, y: by + Math.sin(t) * 46, vx: 0, vy: 0, pin: 0 };
|
|
1329
|
-
});
|
|
1330
|
-
};
|
|
1331
|
-
|
|
1332
|
-
var fxTick = function () {
|
|
1333
|
-
var ns = FX.nodes, P = FX.pos, n = ns.length;
|
|
1334
|
-
if (!n) return;
|
|
1335
|
-
var L = Math.max(86, Math.min(190, Math.sqrt(FX.w * FX.h / n) * 0.66));
|
|
1336
|
-
var rep = L * L * 1.15;
|
|
1337
|
-
var i, j, a, b, dx, dy, d, ux, uy, f;
|
|
1338
|
-
// 斥力 —— O(n²), 但这张图按设计就不超过几十个节点 (见 shared/world 的相关性)。
|
|
1339
|
-
for (i = 0; i < n; i++) {
|
|
1340
|
-
a = P[ns[i].target];
|
|
1341
|
-
for (j = i + 1; j < n; j++) {
|
|
1342
|
-
b = P[ns[j].target];
|
|
1343
|
-
dx = b.x - a.x; dy = b.y - a.y;
|
|
1344
|
-
d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
1345
|
-
f = rep / (d * d); ux = dx / d; uy = dy / d;
|
|
1346
|
-
a.vx -= ux * f; a.vy -= uy * f; b.vx += ux * f; b.vy += uy * f;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
// 弹簧 —— 家谱边的静息长度短于派活的, 于是家谱天然抱成一小团。
|
|
1350
|
-
// 静息长度有个下限: 节点是一张一百多像素宽的卡片, 不是一个点。两端贴到一起
|
|
1351
|
-
// 时连线被两张卡各自吃掉一半, 剩下的那截连箭头都放不下 —— 图上就成了一条
|
|
1352
|
-
// 看不见的边。下限按两张卡的半宽算, 横着摆也留得出一段看得见的线。
|
|
1353
|
-
FX.edges.forEach(function (e) {
|
|
1354
|
-
var pa = P[e.from], pb = P[e.to];
|
|
1355
|
-
if (!pa || !pb) return;
|
|
1356
|
-
var za = FX.size[e.from] || { w: 150, h: 36 }, zb = FX.size[e.to] || { w: 150, h: 36 };
|
|
1357
|
-
var ex = pb.x - pa.x, ey = pb.y - pa.y;
|
|
1358
|
-
var ed = Math.sqrt(ex * ex + ey * ey) || 0.01;
|
|
1359
|
-
var kin = e.kind === 'clone' || e.kind === 'spawn';
|
|
1360
|
-
var rest = Math.max((za.w + zb.w) / 2 + 30, kin ? L * 0.8 : L * 1.35);
|
|
1361
|
-
var k = (kin ? 0.06 : 0.032) * Math.min(2.2, 1 + Math.log(1 + e.count) * 0.45);
|
|
1362
|
-
var g = (ed - rest) * k, gx = ex / ed, gy = ey / ed;
|
|
1363
|
-
pa.vx += gx * g; pa.vy += gy * g; pb.vx -= gx * g; pb.vy -= gy * g;
|
|
1364
|
-
});
|
|
1365
|
-
// 同群相吸 + 朝心收 —— 归属在卡片布局里由版面表达, 这里由一股弱引力表达。
|
|
1366
|
-
var cen = {};
|
|
1367
|
-
ns.forEach(function (nd) {
|
|
1368
|
-
var p = P[nd.target], c = cen[nd.base] || (cen[nd.base] = { x: 0, y: 0, n: 0 });
|
|
1369
|
-
c.x += p.x; c.y += p.y; c.n++;
|
|
1370
|
-
});
|
|
1371
|
-
ns.forEach(function (nd) {
|
|
1372
|
-
var p = P[nd.target], c = cen[nd.base];
|
|
1373
|
-
p.vx += (c.x / c.n - p.x) * 0.022 + (FX.w / 2 - p.x) * 0.006;
|
|
1374
|
-
p.vy += (c.y / c.n - p.y) * 0.022 + (FX.h / 2 - p.y) * 0.006;
|
|
1375
|
-
});
|
|
1376
|
-
// 积分。alpha 同时当步长, 于是"冷却"就是自然停住。
|
|
1377
|
-
ns.forEach(function (nd) {
|
|
1378
|
-
var p = P[nd.target];
|
|
1379
|
-
if (p.pin) { p.vx = 0; p.vy = 0; return; }
|
|
1380
|
-
p.vx *= 0.8; p.vy *= 0.8;
|
|
1381
|
-
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy), cap = 26;
|
|
1382
|
-
if (sp > cap) { p.vx *= cap / sp; p.vy *= cap / sp; }
|
|
1383
|
-
p.x += p.vx * FX.alpha; p.y += p.vy * FX.alpha;
|
|
1384
|
-
});
|
|
1385
|
-
// 矩形避让 —— 节点是一行字, 不是一个点。圆形斥力挡不住两张卡横向叠在一起,
|
|
1386
|
-
// 而这张图的全部价值就在于那行字读得出来。
|
|
1387
|
-
// 它排在积分之后, 所以收边必须排在它之后 —— 反过来的话, 被挤到边上的节点会
|
|
1388
|
-
// 被推出画布再也回不来 (弱引力拉不过避让)。
|
|
1389
|
-
var pass, pa, pb, sa, sb, mx, my, ox, oy, sh;
|
|
1390
|
-
for (pass = 0; pass < 2; pass++) {
|
|
1391
|
-
for (i = 0; i < n; i++) {
|
|
1392
|
-
for (j = i + 1; j < n; j++) {
|
|
1393
|
-
pa = P[ns[i].target]; pb = P[ns[j].target];
|
|
1394
|
-
sa = FX.size[ns[i].target]; sb = FX.size[ns[j].target];
|
|
1395
|
-
if (!sa || !sb) continue;
|
|
1396
|
-
mx = (sa.w + sb.w) / 2 + 18; my = (sa.h + sb.h) / 2 + 10;
|
|
1397
|
-
dx = pb.x - pa.x; dy = pb.y - pa.y;
|
|
1398
|
-
ox = mx - Math.abs(dx); oy = my - Math.abs(dy);
|
|
1399
|
-
if (ox <= 0 || oy <= 0) continue;
|
|
1400
|
-
if (ox / mx < oy / my) {
|
|
1401
|
-
sh = (dx < 0 ? -1 : 1) * ox;
|
|
1402
|
-
if (pa.pin) pb.x += sh; else if (pb.pin) pa.x -= sh;
|
|
1403
|
-
else { pa.x -= sh / 2; pb.x += sh / 2; }
|
|
1404
|
-
} else {
|
|
1405
|
-
sh = (dy < 0 ? -1 : 1) * oy;
|
|
1406
|
-
if (pa.pin) pb.y += sh; else if (pb.pin) pa.y -= sh;
|
|
1407
|
-
else { pa.y -= sh / 2; pb.y += sh / 2; }
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
// 收进画布 —— 钉住的也收, 窗口缩小之后那些被人摆在外面的节点得跟着回来。
|
|
1413
|
-
ns.forEach(function (nd) {
|
|
1414
|
-
var p = P[nd.target], z = FX.size[nd.target] || { w: 150, h: 36 };
|
|
1415
|
-
p.x = Math.max(z.w / 2 + 6, Math.min(FX.w - z.w / 2 - 6, p.x));
|
|
1416
|
-
p.y = Math.max(z.h / 2 + 6, Math.min(FX.h - z.h / 2 - 6, p.y));
|
|
1417
|
-
});
|
|
1418
|
-
};
|
|
1419
|
-
|
|
1420
|
-
/** 中心沿 (ux,uy) 走到卡片矩形的边 —— 箭头要落在卡片外面才看得见。 */
|
|
1421
|
-
var fxRim = function (p, s, ux, uy) {
|
|
1422
|
-
var hw = (s ? s.w : 150) / 2 + 3, hh = (s ? s.h : 36) / 2 + 3;
|
|
1423
|
-
var t = Math.min(ux ? hw / Math.abs(ux) : 1e9, uy ? hh / Math.abs(uy) : 1e9);
|
|
1424
|
-
return { x: p.x + ux * t, y: p.y + uy * t };
|
|
1425
|
-
};
|
|
1426
|
-
|
|
1427
|
-
/** 连线只在重排时建一次 —— 逐帧重写 innerHTML 会把一张 50 条边的图拖成幻灯片。
|
|
1428
|
-
* 之后每帧只改 `d`, 聚焦只改描边 (见 fxClasses)。 */
|
|
1429
|
-
var fxEdgesDOM = function () {
|
|
1430
|
-
var svg = $('#wedges');
|
|
1431
|
-
if (!svg) return;
|
|
1432
|
-
var defs = Object.keys(EDGE).map(function (k) {
|
|
1433
|
-
return ['', '-d'].map(function (sfx) {
|
|
1434
|
-
return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
|
|
1435
|
-
'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
|
|
1436
|
-
'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
|
|
1437
|
-
}).join('');
|
|
1438
|
-
}).join('');
|
|
1439
|
-
svg.innerHTML = '<defs>' + defs + '</defs>' + FX.edges.map(function (e) {
|
|
1440
|
-
return '<path fill="none" stroke="' + EDGE[e.kind].c + '" ' +
|
|
1441
|
-
'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '">' +
|
|
1442
|
-
'<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
|
|
1443
|
-
(e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
|
|
1444
|
-
(e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
|
|
1445
|
-
}).join('');
|
|
1446
|
-
FX.paths = Array.prototype.slice.call(svg.querySelectorAll('path[stroke]'));
|
|
1447
|
-
};
|
|
1448
|
-
|
|
1449
|
-
var fxPaint = function () {
|
|
1450
|
-
FX.nodes.forEach(function (n) {
|
|
1451
|
-
var p = FX.pos[n.target], el = FX.els[n.target];
|
|
1452
|
-
if (el && p) el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) translate(-50%,-50%)';
|
|
1453
|
-
});
|
|
1454
|
-
FX.edges.forEach(function (e, i) {
|
|
1455
|
-
var path = FX.paths[i], pa = FX.pos[e.from], pb = FX.pos[e.to];
|
|
1456
|
-
if (!path || !pa || !pb) return;
|
|
1457
|
-
var dx = pb.x - pa.x, dy = pb.y - pa.y;
|
|
1458
|
-
var d = Math.sqrt(dx * dx + dy * dy) || 0.01, ux = dx / d, uy = dy / d;
|
|
1459
|
-
var A = fxRim(pa, FX.size[e.from], ux, uy), B = fxRim(pb, FX.size[e.to], -ux, -uy);
|
|
1460
|
-
// 恒向左弓 —— 互相派活的两个 wizard 有两条方向相反的边, 直线会重合成一条。
|
|
1461
|
-
var bow = Math.min(30, d * 0.14);
|
|
1462
|
-
var qx = (A.x + B.x) / 2 - uy * bow, qy = (A.y + B.y) / 2 + ux * bow;
|
|
1463
|
-
path.setAttribute('d', 'M' + A.x.toFixed(1) + ',' + A.y.toFixed(1) +
|
|
1464
|
-
' Q' + qx.toFixed(1) + ',' + qy.toFixed(1) + ' ' + B.x.toFixed(1) + ',' + B.y.toFixed(1));
|
|
1465
|
-
});
|
|
1466
|
-
};
|
|
1467
|
-
|
|
1468
|
-
var fxLoop = function () {
|
|
1469
|
-
FX.raf = 0;
|
|
1470
|
-
if (W.layout !== 'force' || VIEW !== 'world') return;
|
|
1471
|
-
fxTick();
|
|
1472
|
-
fxPaint();
|
|
1473
|
-
if (!FX.drag) FX.alpha *= 0.972;
|
|
1474
|
-
if (FX.alpha > 0.02 || FX.drag) FX.raf = requestAnimationFrame(fxLoop);
|
|
1475
|
-
};
|
|
1476
|
-
|
|
1477
|
-
var fxHeat = function (a) {
|
|
1478
|
-
FX.alpha = Math.max(FX.alpha, a === undefined ? 0.9 : a);
|
|
1479
|
-
if (!FX.raf) FX.raf = requestAnimationFrame(fxLoop);
|
|
1418
|
+
// 卡片与侧栏会话项同一套 (glance / roleRow / recentFirst)。
|
|
1419
|
+
// 视角与对端有会话项 → 一行的数据就是那一项的 glance; 没有 → 取节点, 时刻取这条边最近一次发生,
|
|
1420
|
+
// 而不是节点自己最后的动静 —— 卡片承载的是边。
|
|
1421
|
+
// 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
|
|
1422
|
+
// (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
|
|
1423
|
+
// 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
|
|
1424
|
+
// 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
|
|
1425
|
+
var cardGlance = function (F, t) {
|
|
1426
|
+
var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
|
|
1427
|
+
var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
|
|
1428
|
+
var n = nodeOf(t) || {}, p = F.pp[t];
|
|
1429
|
+
var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
|
|
1430
|
+
var w = F.links && W.glance[talkKey(F.links, t)];
|
|
1431
|
+
return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
|
|
1432
|
+
};
|
|
1433
|
+
// 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
|
|
1434
|
+
var loadGlance = function () {
|
|
1435
|
+
var keys = W.gKeys, role = ROLE;
|
|
1436
|
+
if (!keys) return;
|
|
1437
|
+
api('api/glance', { role: role, keys: keys }).then(function (d) {
|
|
1438
|
+
if (!d.ok || role !== ROLE || keys !== W.gKeys) return;
|
|
1439
|
+
W.glance = d.glances || {};
|
|
1440
|
+
renderWorld();
|
|
1441
|
+
}).catch(function () { });
|
|
1480
1442
|
};
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1443
|
+
var byCard = function (F) {
|
|
1444
|
+
return function (a, b) { return recentFirst(cardGlance(F, a), cardGlance(F, b)); };
|
|
1445
|
+
};
|
|
1446
|
+
var tnodeHTML = function (F, n, folded) {
|
|
1447
|
+
var me = n.target === ROLE;
|
|
1448
|
+
var tail = folded ? '<span class="tfold" title="它下面还有 ' + folded + ' 个, 切到它的视角可见">+' + folded + '</span>' : '';
|
|
1449
|
+
var p = F.pp[n.target];
|
|
1450
|
+
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, tail);
|
|
1451
|
+
var via = F.vis && F.vis[n.target] === 'via';
|
|
1452
|
+
return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
|
|
1453
|
+
(via ? ' title="' + esc(nameOf(n.target) + ' 没和 ' + nameOf(ROLE) + ' 对过话, 留着是为了连到它下面对过话的') + '"' : '') + '>' +
|
|
1454
|
+
labelHTML(p) + row + '</button>';
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
// keep(父, 孩子) → 'full' 整枝 / 'leaf' 只画它自己 / '' 不画; 不给 keep = 全画。shown 收集画出来的节点。
|
|
1458
|
+
var treeHTML = function (F, t, depth, keep, leaf, shown, seen, z) {
|
|
1459
|
+
var n = nodeOf(t);
|
|
1460
|
+
if (!n || seen[t] || depth > 32) return '';
|
|
1461
|
+
seen[t] = 1;
|
|
1462
|
+
shown.push(n);
|
|
1463
|
+
var kids = leaf ? [] : (F.kids[t] || []).filter(function (k) { return shows(F, k) && (!keep || keep(t, k)); }).sort(byCard(F));
|
|
1464
|
+
return '<li class="' + domKind(F.pp[t]) + '"' + (z ? ' style="z-index:' + z + '"' : '') + '>' + tnodeHTML(F, n, leaf ? countSub(F, t) : 0) +
|
|
1465
|
+
// 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
|
|
1466
|
+
(kids.length ? '<ul>' + kids.map(function (k, i) {
|
|
1467
|
+
return treeHTML(F, k, depth + 1, keep, keep && keep(t, k) === 'leaf', shown, seen, kids.length - i);
|
|
1468
|
+
}).join('') + '</ul>' : '') +
|
|
1469
|
+
'</li>';
|
|
1470
|
+
};
|
|
1471
|
+
// 只看相关: 主父亲链一条直线下来, 到它自己再整枝展开; 同一个主父亲下的兄弟只画本人。
|
|
1472
|
+
var focusKeep = function (path) {
|
|
1473
|
+
var on = path.reduce(function (m, t) { m[t] = 1; return m; }, {});
|
|
1474
|
+
var parent = path[path.length - 2];
|
|
1475
|
+
return function (t, k) {
|
|
1476
|
+
if (!on[t] || t === ROLE) return 'full';
|
|
1477
|
+
return on[k] ? 'full' : t === parent ? 'leaf' : '';
|
|
1478
|
+
};
|
|
1487
1479
|
};
|
|
1488
1480
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
el.classList.toggle('sel', n.target === W.sel);
|
|
1496
|
-
el.classList.toggle('pin', !!(FX.pos[n.target] || {}).pin);
|
|
1497
|
-
});
|
|
1498
|
-
// 聚焦时无关的边压到近乎不可见而不是删掉 —— "这张图本来有多密"是判断要不要
|
|
1499
|
-
// 收几个 wizard 的依据 (同 drawEdges)。
|
|
1500
|
-
FX.edges.forEach(function (e, i) {
|
|
1501
|
-
var path = FX.paths[i];
|
|
1502
|
-
if (!path) return;
|
|
1503
|
-
var off = hood && !(e.from === W.sel || e.to === W.sel);
|
|
1504
|
-
var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
|
|
1505
|
-
path.setAttribute('stroke-width', (off ? 1 : w).toFixed(2));
|
|
1506
|
-
path.setAttribute('opacity', off ? 0.12 : (e.cross ? 0.95 : 0.55));
|
|
1507
|
-
path.setAttribute('marker-end', 'url(#ah-' + e.kind + (off ? '-d' : '') + ')');
|
|
1508
|
-
});
|
|
1481
|
+
// 关系图替换的是侧栏的会话列表 —— 右边照旧是选中的那段对话。
|
|
1482
|
+
// 只看相关时画谁: 视角自己 + 和视角对过话的 ('talk'); 它们通向根的主父亲链上没对过话的祖先
|
|
1483
|
+
// 留作连接 ('via', 画淡) —— 抹掉它, 有对话的后代就从家谱上脱开, 成了一棵来历不明的孤树。
|
|
1484
|
+
var talks = function (F, a, b) {
|
|
1485
|
+
var p = F.pairs[a + '\u0000' + b];
|
|
1486
|
+
return !!p && !!(p.kinds.peer || p.kinds.graph);
|
|
1509
1487
|
};
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
var
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
return '<div class="fxn' + (n.self ? ' self' : '') + (run ? ' run' : '') + (n.alive ? '' : ' cold') +
|
|
1517
|
-
(canOpen(n) ? ' go' : '') + '" data-t="' + esc(n.target) + '" style="--c:' + chatColor(n.base) + '" ' +
|
|
1518
|
-
'title="' + esc(tip) + '">' +
|
|
1519
|
-
'<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
|
|
1520
|
-
'<span class="fxb">' +
|
|
1521
|
-
'<b class="wname">' + esc(shortName(n)) + '</b>' +
|
|
1522
|
-
'<span class="fxc">' + esc(n.chat || n.base) + (n.self ? ' · 本会话' : '') + '</span>' +
|
|
1523
|
-
'</span>' +
|
|
1524
|
-
'</div>';
|
|
1488
|
+
var visibleOf = function (F) {
|
|
1489
|
+
var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || talks(F, ROLE, t) || talks(F, t, ROLE); });
|
|
1490
|
+
var vis = keep.reduce(function (m, t) { m[t] = 'talk'; return m; }, {});
|
|
1491
|
+
if (!vis[ROLE]) vis[ROLE] = 'talk';
|
|
1492
|
+
keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
|
|
1493
|
+
return vis;
|
|
1525
1494
|
};
|
|
1526
1495
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
el.onpointerup = function () {
|
|
1546
|
-
if (!FX.drag || FX.drag.t !== t) return;
|
|
1547
|
-
var moved = FX.drag.moved;
|
|
1548
|
-
FX.drag = null;
|
|
1549
|
-
el.classList.remove('drag');
|
|
1550
|
-
if (moved < 5) {
|
|
1551
|
-
FX.pos[t].pin = 0;
|
|
1552
|
-
W.sel = (W.sel === t ? '' : t);
|
|
1553
|
-
renderWTools(); fxClasses(); fxHeat(0.12);
|
|
1554
|
-
} else { fxClasses(); fxHeat(0.2); }
|
|
1496
|
+
var renderWorld = function () {
|
|
1497
|
+
if (!WORLD) return;
|
|
1498
|
+
if (!W.loaded) { convsEl._tree = ''; convsEl.innerHTML = '<div class="empty">加载中…</div>'; return; }
|
|
1499
|
+
var rg = rangeOf();
|
|
1500
|
+
var F = forestOf(relations(rg));
|
|
1501
|
+
var me = nodeOf(ROLE);
|
|
1502
|
+
var all = W.treeAll || !me;
|
|
1503
|
+
var shown = [];
|
|
1504
|
+
var path = me ? chainUp(F, ROLE) : [];
|
|
1505
|
+
// 和视角之间没有对话的关系只在「看全部」里画。
|
|
1506
|
+
F.vis = all ? null : visibleOf(F);
|
|
1507
|
+
var draw = function (into) {
|
|
1508
|
+
return all
|
|
1509
|
+
? F.roots.slice().sort(function (a, b) {
|
|
1510
|
+
var mine = path[0];
|
|
1511
|
+
return (b === mine) - (a === mine) || byCard(F)(a, b);
|
|
1512
|
+
}).map(function (r) { return treeHTML(F, r, 0, null, false, into, {}); }).join('')
|
|
1513
|
+
: treeHTML(F, path[0], 0, focusKeep(path), false, into, {});
|
|
1555
1514
|
};
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
return;
|
|
1515
|
+
// 两遍: 先量出画了谁 (卡片的窗口要知道它在图上连着谁), 再带着连线画 —— 选中态也就进了 html。
|
|
1516
|
+
draw(shown);
|
|
1517
|
+
F.links = linksOf(F, shown);
|
|
1518
|
+
var keys = shown.map(function (n) { return talkKey(F.links, n.target); }).join('\n');
|
|
1519
|
+
if (keys !== W.gKeys) { W.gKeys = keys; loadGlance(); }
|
|
1520
|
+
// 换视角后的第一张新树: 世界快照与摘要都已是新视角的, 才知道它自己那张卡片连着谁。
|
|
1521
|
+
if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
|
|
1522
|
+
W.pickSelf = '';
|
|
1523
|
+
var self = talkKey(F.links, ROLE);
|
|
1524
|
+
if (CONV !== self) return selectConv(self, '');
|
|
1566
1525
|
}
|
|
1567
|
-
var
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
wmapEl.querySelectorAll('.fxn').forEach(function (el) {
|
|
1585
|
-
var t = el.getAttribute('data-t');
|
|
1586
|
-
FX.els[t] = el;
|
|
1587
|
-
FX.size[t] = { w: el.offsetWidth, h: el.offsetHeight };
|
|
1588
|
-
fxBind(el, t);
|
|
1526
|
+
var trees = draw([]);
|
|
1527
|
+
var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
|
|
1528
|
+
var alone = !all && shown.length < 2;
|
|
1529
|
+
var html = '<div class="tview">' +
|
|
1530
|
+
'<h2>关系图<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
|
|
1531
|
+
(W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
|
|
1532
|
+
(me ? '<button class="tall" title="' + (all ? '只留它的上游链、它自己、它的下游与同源兄弟' : '画出范围内所有有关系的 wizard') + '">' +
|
|
1533
|
+
(all ? '只看相关' : '看全部') + '</button>' : '') + '</h2>' +
|
|
1534
|
+
(alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁对过话</div>' : '') +
|
|
1535
|
+
(shown.length ? '<ul class="tree' + (all ? ' all' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
|
|
1536
|
+
'</div>';
|
|
1537
|
+
// 心跳每 3s 重算一次 (状态灯 / 几分钟前) —— 没变就不碰 DOM, 免得蹭掉悬停与滚动。
|
|
1538
|
+
if (convsEl._tree === html && convsEl.querySelector('.tview')) return;
|
|
1539
|
+
convsEl._tree = html; convsEl._html = '';
|
|
1540
|
+
convsEl.innerHTML = html;
|
|
1541
|
+
convsEl.querySelectorAll('.tci').forEach(function (el) {
|
|
1542
|
+
el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
|
|
1589
1543
|
});
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
fxHeat(fresh ? 0.9 : 0.05);
|
|
1596
|
-
};
|
|
1597
|
-
|
|
1598
|
-
var renderWorld = function () {
|
|
1599
|
-
renderWTools();
|
|
1600
|
-
if (W.layout === 'force') { renderForce(); return; }
|
|
1601
|
-
fxStop();
|
|
1602
|
-
renderWMap();
|
|
1603
|
-
// 连线要等浏览器把卡片排好 —— 同一帧里量到的是上一次的版面。
|
|
1604
|
-
requestAnimationFrame(drawEdges);
|
|
1544
|
+
bindGo(convsEl);
|
|
1545
|
+
var tall = convsEl.querySelector('.tall');
|
|
1546
|
+
if (tall) tall.onclick = function () { W.treeAll = !W.treeAll; W.treeFor = ''; renderWorld(); };
|
|
1547
|
+
var cur = convsEl.querySelector('.tci.me');
|
|
1548
|
+
if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
|
|
1605
1549
|
};
|
|
1606
1550
|
|
|
1607
1551
|
// ══ 日程视图 ═══════════════════════════════════════════════════════
|
|
@@ -1632,6 +1576,7 @@
|
|
|
1632
1576
|
// 未来 12 小时的刻度条: 哪几个钟点会有任务醒来, 一眼看完。超出 12h 的排进下面
|
|
1633
1577
|
// 的列表但不占刻度 —— 把 24h 压进这么窄的一条, 刻度会密到读不出。
|
|
1634
1578
|
var HORIZON_H = 12;
|
|
1579
|
+
var broken = function (x) { return x.lastGate === 'error' || !!x.loadError; };
|
|
1635
1580
|
var renderStrip = function (list) {
|
|
1636
1581
|
var now = srvNow(), span = HORIZON_H * 3600000;
|
|
1637
1582
|
var soon = list.filter(function (x) { return x.nextAt - now < span; });
|
|
@@ -1639,16 +1584,66 @@
|
|
|
1639
1584
|
for (var i = 0; i <= HORIZON_H; i += 3) {
|
|
1640
1585
|
var t = new Date(now + i * 3600000);
|
|
1641
1586
|
ticks.push('<span class="tk" style="left:' + (i / HORIZON_H * 100) + '%">' +
|
|
1642
|
-
(i ? (t.getHours()
|
|
1587
|
+
(i ? pad(t.getHours()) + ':00' : '现在') + '</span>');
|
|
1643
1588
|
}
|
|
1644
1589
|
var pins = soon.map(function (x) {
|
|
1645
1590
|
var pct = Math.max(0, Math.min(100, (x.nextAt - now) / span * 100));
|
|
1646
|
-
return '<
|
|
1647
|
-
'title="' + esc(fmtClock(x.nextAt) + ' · ' +
|
|
1591
|
+
return '<button class="pin' + (broken(x) ? ' bad' : '') + '" style="left:' + pct.toFixed(2) + '%" data-id="' + esc(x.id) + '" ' +
|
|
1592
|
+
'title="' + esc(fmtClock(x.nextAt) + ' · ' + (x.note || x.id) + ' · ' + x.when) + '"><span>' + esc(fmtHM(x.nextAt)) + '</span></button>';
|
|
1648
1593
|
}).join('');
|
|
1649
1594
|
return '<div class="strip"><div class="axis">' + ticks.join('') + '</div>' +
|
|
1650
1595
|
'<div class="rail">' + pins + '</div>' +
|
|
1651
|
-
'<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
|
|
1596
|
+
'<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发 · 点刻度跳到那条' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// 触发规则 = describeTrigger 的人话, 各段以 ` · ` 相接: 第一段是时钟 (什么时候响), 其余是闸 (什么时候不响)。
|
|
1600
|
+
var ruleHTML = function (when) {
|
|
1601
|
+
return String(when || '').split(' · ').filter(Boolean).map(function (part, i) {
|
|
1602
|
+
return '<span class="rl' + (i ? ' guard' : ' clock') + '">' + (i ? '' : '⏱ ') + esc(part) + '</span>';
|
|
1603
|
+
}).join('');
|
|
1604
|
+
};
|
|
1605
|
+
var gateHTML = function (x) {
|
|
1606
|
+
if (!x.hasGate) return '<span class="gt none" title="没有 gate: 到点就放">无 gate</span>';
|
|
1607
|
+
var at = x.lastGateAt ? ' · ' + fmtAgo(x.lastGateAt) : '';
|
|
1608
|
+
return x.lastGate === 'go' ? '<span class="gt go" title="上一轮 gate 放行">gate 放行' + esc(at) + '</span>'
|
|
1609
|
+
: x.lastGate === 'skip' ? '<span class="gt skip" title="上一轮 gate 判定没活, 没放枪 (不出声)">gate 挡下' + esc(at) + '</span>'
|
|
1610
|
+
: x.lastGate === 'error' ? '<span class="gt err">gate 出错' + esc(at) + '</span>'
|
|
1611
|
+
: '<span class="gt none">gate 未跑过</span>';
|
|
1612
|
+
};
|
|
1613
|
+
var flowHTML = function (x) {
|
|
1614
|
+
var own = x.owner || x.createdBy;
|
|
1615
|
+
return '<div class="pflow">' +
|
|
1616
|
+
(own && own !== x.target ? '<span class="fl">排班</span>' + wizChip(own) + '<span class="ar">→</span>' : '') +
|
|
1617
|
+
(x.fresh
|
|
1618
|
+
? '<span class="fl">执行</span><span class="fresh" title="每次到点新起一个白板 wizard, 跑完自动回收">✦ 新起白板</span><span class="fl">在</span>' + wizChip(x.target) + '<span class="fl">名下</span>'
|
|
1619
|
+
: '<span class="fl">执行</span>' + wizChip(x.target) + '<span class="fl">注入已有会话</span>') +
|
|
1620
|
+
'</div>';
|
|
1621
|
+
};
|
|
1622
|
+
var PLAN_OPEN = {};
|
|
1623
|
+
var taskHTML = function (x) {
|
|
1624
|
+
var lines = String(x.prompt || '').split('\n');
|
|
1625
|
+
var head = x.note || lines[0];
|
|
1626
|
+
var errs = [
|
|
1627
|
+
x.loadError ? '<div class="perr"><b>任务文件加载失败</b> · 仍在跑上一版<code>' + esc(x.loadError.split('\n')[0].slice(0, 240)) + '</code></div>' : '',
|
|
1628
|
+
x.lastGate === 'error' ? '<div class="perr"><b>gate 出错</b>' + (x.lastGateAt ? ' · ' + esc(fmtAgo(x.lastGateAt)) : '') +
|
|
1629
|
+
' · 本轮未放枪, 下次照常重试' + (x.lastError ? '<code>' + esc(x.lastError.split('\n')[0].slice(0, 240)) + '</code>' : '') + '</div>' : '',
|
|
1630
|
+
].join('');
|
|
1631
|
+
return '<article class="tcard' + (broken(x) ? ' bad' : '') + '" data-id="' + esc(x.id) + '">' +
|
|
1632
|
+
'<div class="tc-time"><b>' + esc(fmtHM(x.nextAt)) + '</b><span>' + esc(fmtClock(x.nextAt).replace(/ \d\d:\d\d$/, '')) + '</span>' +
|
|
1633
|
+
'<em>' + esc(fmtIn(x.nextAt)) + '</em></div>' +
|
|
1634
|
+
'<div class="tc-main">' +
|
|
1635
|
+
errs +
|
|
1636
|
+
'<div class="prule">' + ruleHTML(x.when) + '</div>' +
|
|
1637
|
+
'<div class="ptitle">' + esc(head.slice(0, 120)) + '</div>' +
|
|
1638
|
+
flowHTML(x) +
|
|
1639
|
+
'<details class="pprompt" data-id="' + esc(x.id) + '"' + (PLAN_OPEN[x.id] ? ' open' : '') + '>' +
|
|
1640
|
+
'<summary>prompt<span>' + esc((x.note ? lines[0] : lines.slice(1).join(' ')).slice(0, 90)) + '</span></summary>' +
|
|
1641
|
+
'<pre>' + esc(x.prompt) + '</pre></details>' +
|
|
1642
|
+
'<div class="pfoot">' + gateHTML(x) +
|
|
1643
|
+
'<span>' + (x.lastFired ? '上次触发 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</span>' +
|
|
1644
|
+
'<span class="pid" title="' + esc(x.file || '') + '">' + esc(x.id) + '</span></div>' +
|
|
1645
|
+
'</div>' +
|
|
1646
|
+
'</article>';
|
|
1652
1647
|
};
|
|
1653
1648
|
|
|
1654
1649
|
// 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
|
|
@@ -1659,67 +1654,63 @@
|
|
|
1659
1654
|
});
|
|
1660
1655
|
var open = js.filter(function (j) { return j.status === 'open'; });
|
|
1661
1656
|
var closed = js.filter(function (j) { return j.status !== 'open'; });
|
|
1662
|
-
var
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
'<div class="pfoot">' + (x.lastFired ? '上次 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</div>' +
|
|
1674
|
-
'</div>' +
|
|
1675
|
-
'</div>';
|
|
1676
|
-
}).join('')
|
|
1677
|
-
: '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>';
|
|
1657
|
+
var bad = ss.filter(broken);
|
|
1658
|
+
var next = ss[0];
|
|
1659
|
+
var stat = function (k, v, sub, tone) {
|
|
1660
|
+
return '<div class="pst' + (tone ? ' ' + tone : '') + '"><span class="k">' + k + '</span><b>' + v + '</b><span class="s">' + sub + '</span></div>';
|
|
1661
|
+
};
|
|
1662
|
+
var stats = '<div class="pstats">' +
|
|
1663
|
+
stat('下一次', next ? esc(fmtHM(next.nextAt)) : '—', next ? esc(fmtIn(next.nextAt) + ' · ' + (next.note || next.id)) : '没有排期') +
|
|
1664
|
+
stat('定时任务', ss.length, ss.length ? esc(ss.filter(function (x) { return x.hasGate; }).length + ' 条带 gate') : '—') +
|
|
1665
|
+
stat('工单', open.length + '<small> / ' + js.length + '</small>', '进行中 / 全部') +
|
|
1666
|
+
(bad.length ? stat('出错', bad.length, '见下方红色卡片', 'bad') : '') +
|
|
1667
|
+
'</div>';
|
|
1678
1668
|
|
|
1679
1669
|
var jobRow = function (j) {
|
|
1680
1670
|
return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
|
|
1681
|
-
'<div class="jh"><span class="
|
|
1671
|
+
'<div class="jh"><span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
|
|
1682
1672
|
'<span class="jt">' + esc(j.title) + '</span>' +
|
|
1683
|
-
'<span class="
|
|
1673
|
+
'<span class="jid">' + esc(j.id) + '</span>' +
|
|
1684
1674
|
'<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
|
|
1685
1675
|
'<div class="jm">' + (j.members.length
|
|
1686
1676
|
? j.members.map(function (mm) {
|
|
1687
|
-
|
|
1677
|
+
var n = nodeOf(mm.target);
|
|
1678
|
+
return '<span class="jmm">' + wizChip(mm.target) + stTag(n) +
|
|
1688
1679
|
(mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
|
|
1689
|
-
'<em>' + esc((mm.task || '').split('\n')[0].slice(0,
|
|
1680
|
+
'<em>' + esc((mm.task || '').split('\n')[0].slice(0, 90)) + '</em></span>';
|
|
1690
1681
|
}).join('')
|
|
1691
1682
|
: '<span class="jmm none">还没有成员</span>') + '</div>' +
|
|
1692
1683
|
(j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
|
|
1693
1684
|
'</div>';
|
|
1694
1685
|
};
|
|
1695
1686
|
|
|
1696
|
-
|
|
1687
|
+
var html = stats +
|
|
1697
1688
|
'<section class="psec">' +
|
|
1698
|
-
'<h3
|
|
1699
|
-
renderStrip(ss) +
|
|
1689
|
+
'<h3>定时任务<span>' + ss.length + '</span></h3>' +
|
|
1690
|
+
(ss.length ? renderStrip(ss) + bad.concat(ss.filter(function (x) { return !broken(x); })).map(taskHTML).join('')
|
|
1691
|
+
: '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>') +
|
|
1700
1692
|
'</section>' +
|
|
1701
1693
|
'<section class="psec">' +
|
|
1702
|
-
'<h3
|
|
1694
|
+
'<h3>工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
|
|
1703
1695
|
(js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
|
|
1704
1696
|
'</section>';
|
|
1697
|
+
// 心跳每 3s 重画一次 —— 没变就不碰 DOM (展开着的 prompt、悬停的提示都留着)。
|
|
1698
|
+
if (planEl._html === html) return;
|
|
1699
|
+
planEl._html = html;
|
|
1700
|
+
planEl.innerHTML = html;
|
|
1705
1701
|
planEl.querySelectorAll('.wchip.go').forEach(function (c) {
|
|
1706
1702
|
c.onclick = function () { openNode(c.getAttribute('data-t')); };
|
|
1707
1703
|
});
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
var av = el.querySelector('.wav'), dot = el.querySelector('.wav .live');
|
|
1719
|
-
if (run && !dot && av) av.insertAdjacentHTML('beforeend', '<i class="live"></i>');
|
|
1720
|
-
if (!run && dot) dot.remove();
|
|
1721
|
-
var ts = el.querySelector('.wts');
|
|
1722
|
-
if (ts) ts.textContent = fmtAgo(n.lastTs);
|
|
1704
|
+
planEl.querySelectorAll('.pprompt').forEach(function (d) {
|
|
1705
|
+
d.ontoggle = function () { PLAN_OPEN[d.getAttribute('data-id')] = d.open; planEl._html = ''; };
|
|
1706
|
+
});
|
|
1707
|
+
planEl.querySelectorAll('.strip .pin').forEach(function (p) {
|
|
1708
|
+
p.onclick = function () {
|
|
1709
|
+
var card = planEl.querySelector('.tcard[data-id="' + cssEsc(p.getAttribute('data-id')) + '"]');
|
|
1710
|
+
if (!card) return;
|
|
1711
|
+
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1712
|
+
card.classList.remove('flash'); void card.offsetWidth; card.classList.add('flash');
|
|
1713
|
+
};
|
|
1723
1714
|
});
|
|
1724
1715
|
};
|
|
1725
1716
|
|
|
@@ -1729,18 +1720,20 @@
|
|
|
1729
1720
|
var WORLD_MS = 6000;
|
|
1730
1721
|
var worldTimer = null;
|
|
1731
1722
|
var loadWorld = function () {
|
|
1732
|
-
|
|
1723
|
+
var asked = ROLE;
|
|
1724
|
+
return api('api/world', { role: asked }).then(function (d) {
|
|
1733
1725
|
if (!d.ok) return;
|
|
1734
|
-
W.at = d.at; W.loaded = true;
|
|
1726
|
+
W.at = d.at; W.loaded = true; W.role = asked;
|
|
1735
1727
|
W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
|
|
1736
1728
|
W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
|
|
1737
|
-
|
|
1729
|
+
renderWorld();
|
|
1730
|
+
loadGlance();
|
|
1738
1731
|
if (VIEW === 'plan') renderPlan();
|
|
1739
1732
|
}).catch(function () { });
|
|
1740
1733
|
};
|
|
1741
1734
|
var pollWorld = function () {
|
|
1742
1735
|
if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
|
|
1743
|
-
if (VIEW === 'msgs') return;
|
|
1736
|
+
if (VIEW === 'msgs' && !WORLD) return;
|
|
1744
1737
|
worldTimer = setInterval(function () {
|
|
1745
1738
|
if (!document.hidden) loadWorld();
|
|
1746
1739
|
}, WORLD_MS);
|
|
@@ -1749,29 +1742,42 @@
|
|
|
1749
1742
|
var setView = function (v) {
|
|
1750
1743
|
VIEW = v;
|
|
1751
1744
|
thread.hidden = v !== 'msgs';
|
|
1752
|
-
$('#pane-world').hidden = v !== 'world';
|
|
1753
1745
|
$('#pane-plan').hidden = v !== 'plan';
|
|
1754
1746
|
app.classList.toggle('outer', v !== 'msgs');
|
|
1755
1747
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
1756
1748
|
if (v !== 'msgs') app.classList.add('reading');
|
|
1757
1749
|
renderRole(); renderHead(); renderUsage();
|
|
1758
|
-
if (v === 'msgs') {
|
|
1750
|
+
if (v === 'msgs') { toBottom(true); renderConvs(); }
|
|
1759
1751
|
else {
|
|
1760
|
-
|
|
1761
|
-
if (v === 'world') W.sel = ROLE;
|
|
1762
|
-
if (!W.loaded) { (v === 'world' ? wmapEl : planEl).innerHTML = '<div class="empty">加载中…</div>'; }
|
|
1763
|
-
else if (v === 'world') renderWorld();
|
|
1752
|
+
if (!W.loaded) { planEl._html = ''; planEl.innerHTML = '<div class="empty">加载中…</div>'; }
|
|
1764
1753
|
else renderPlan();
|
|
1765
1754
|
loadWorld();
|
|
1766
1755
|
}
|
|
1767
1756
|
pollWorld();
|
|
1768
1757
|
};
|
|
1769
|
-
//
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1758
|
+
// 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
|
|
1759
|
+
var setWorld = function (on) {
|
|
1760
|
+
WORLD = on; W.treeFor = ''; W.pickSelf = '';
|
|
1761
|
+
convsEl._html = convsEl._tree = '';
|
|
1762
|
+
syncUrl();
|
|
1763
|
+
// 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
|
|
1764
|
+
if (on) app.classList.remove('reading');
|
|
1765
|
+
renderRole();
|
|
1766
|
+
if (on) { renderWorld(); loadWorld(); }
|
|
1767
|
+
else {
|
|
1768
|
+
// 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
|
|
1769
|
+
var c = convOf(CONV);
|
|
1770
|
+
if (c) {
|
|
1771
|
+
OPEN[CONV] = true; OPEN_AT = CONV;
|
|
1772
|
+
var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
|
|
1773
|
+
if (rank >= SUB_FOLD) MORE[CONV] = true;
|
|
1774
|
+
}
|
|
1775
|
+
renderConvs();
|
|
1776
|
+
var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
|
|
1777
|
+
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
1778
|
+
}
|
|
1779
|
+
pollWorld();
|
|
1780
|
+
};
|
|
1775
1781
|
// 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
|
|
1776
1782
|
setInterval(function () {
|
|
1777
1783
|
if (!R.role) return;
|
|
@@ -1779,12 +1785,202 @@
|
|
|
1779
1785
|
expireRows();
|
|
1780
1786
|
// 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
|
|
1781
1787
|
paintStatus(); renderConvs();
|
|
1782
|
-
if (VIEW === '
|
|
1783
|
-
else if (VIEW === 'plan') renderPlan();
|
|
1788
|
+
if (VIEW === 'plan') renderPlan();
|
|
1784
1789
|
}, TICK_MS);
|
|
1785
1790
|
|
|
1791
|
+
// ══ 搜索 (⌘K / Ctrl+K) ═══════════════════════════════════════════════
|
|
1792
|
+
// 从当前视角搜三样: role 名字 (→ 切视角)、会话名 (→ 打开)、消息正文 (→ 打开会话并定位到那一句)。
|
|
1793
|
+
// 服务端搜全部时间; 那一句不在当前 session 段里就放宽成「全部」再跳。空查询时列出最近的会话,
|
|
1794
|
+
// 面板本身就是一个键盘快速切换器。面板由脚本挂载 —— 外壳 HTML 要等 daemon 重启才换。
|
|
1795
|
+
var Q = { open: false, q: '', done: null, enter: false, items: [], sel: 0, gen: 0, timer: 0 };
|
|
1796
|
+
var sk = document.body.appendChild(document.createElement('div'));
|
|
1797
|
+
sk.className = 'sk'; sk.hidden = true;
|
|
1798
|
+
sk.innerHTML = '<div class="sk-panel" role="dialog" aria-label="搜索">' +
|
|
1799
|
+
'<div class="sk-in"><span class="ic">' + SEARCH_SVG + '</span>' +
|
|
1800
|
+
'<input id="sk-q" type="search" autocomplete="off" spellcheck="false" placeholder="搜索 role、会话、消息…" aria-controls="sk-list">' +
|
|
1801
|
+
'<kbd>esc</kbd></div>' +
|
|
1802
|
+
'<div class="sk-list" id="sk-list" role="listbox"></div>' +
|
|
1803
|
+
'<div class="sk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> 选择</span><span><kbd>↵</kbd> 打开</span><span><kbd>esc</kbd> 关闭</span></div>' +
|
|
1804
|
+
'</div>';
|
|
1805
|
+
var skQ = sk.querySelector('#sk-q'), skList = sk.querySelector('#sk-list');
|
|
1806
|
+
|
|
1807
|
+
var reEsc = function (s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); };
|
|
1808
|
+
// 先切再转义: 命中段包 <mark>, 其余照常 esc。
|
|
1809
|
+
var hl = function (text, q) {
|
|
1810
|
+
var ts = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1811
|
+
if (!ts.length) return esc(text);
|
|
1812
|
+
var re = new RegExp('(' + ts.map(reEsc).join('|') + ')', 'ig');
|
|
1813
|
+
return String(text).split(re).map(function (p, i) { return i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p); }).join('');
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
// 一项 = { kind, html, go }: 画什么、回车做什么。
|
|
1817
|
+
var roleItem = function (r, q) {
|
|
1818
|
+
return {
|
|
1819
|
+
kind: 'role',
|
|
1820
|
+
html: '<span class="av">' + esc(r.label) + '</span><span class="b"><span class="l1">' +
|
|
1821
|
+
'<span class="nm ' + (r.wizard ? 'wizard' : 'human') + '">' + hl(r.name, q) + '</span><span class="k">切到视角</span></span>' +
|
|
1822
|
+
(r.description ? '<span class="pv">' + hl(r.description, q) + '</span>' : '') + '</span>',
|
|
1823
|
+
go: function () { switchRole(r.id); },
|
|
1824
|
+
};
|
|
1825
|
+
};
|
|
1826
|
+
// 时刻: 列表里写相对的, 悬停给完整的。
|
|
1827
|
+
var skTs = function (ts) { return '<time class="ts" title="' + esc(fmtFull(ts)) + '">' + esc(fmtAgo(ts)) + '</time>'; };
|
|
1828
|
+
// 会话归属: 群聊带群名, 私聊写明是私聊 —— 结果项第三层, 比发话人与正文都淡。
|
|
1829
|
+
var whereOf = function (kind, label, name, q) {
|
|
1830
|
+
return '<span class="where' + (kind === 'wizard' ? ' priv' : '') + '"><i>' + esc(label || '💬') + '</i>' +
|
|
1831
|
+
(kind === 'wizard' ? '私聊 · ' : '') + hl(name, q || '') + '</span>';
|
|
1832
|
+
};
|
|
1833
|
+
var convItem_ = function (c, q) {
|
|
1834
|
+
var title = c.kind === 'wizard' ? '<span class="nm wizard">' + hl(c.name, q) + '</span>' : '<span class="nm chat">' + hl(c.name, q) + '</span>';
|
|
1835
|
+
return {
|
|
1836
|
+
kind: 'conv',
|
|
1837
|
+
html: '<span class="av">' + esc(c.label) + '</span><span class="b"><span class="l1">' + title +
|
|
1838
|
+
'<span class="tag">' + (c.kind === 'wizard' ? '私聊' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
|
|
1839
|
+
(c.preview ? '<span class="pv">' + esc(c.preview) + '</span>' : '') + '</span>',
|
|
1840
|
+
go: function () { selectConv(c.key, ''); },
|
|
1841
|
+
};
|
|
1842
|
+
};
|
|
1843
|
+
// 三层: 谁 → 对谁 · 何时 / 命中的那一截 / 在哪个会话。
|
|
1844
|
+
var msgItem = function (h, q) {
|
|
1845
|
+
var to = h.to && h.to !== h.from && h.to !== 'human:'
|
|
1846
|
+
? '<span class="arr">→</span>' + nm(h.to, h.toName) : '';
|
|
1847
|
+
return {
|
|
1848
|
+
kind: 'msg',
|
|
1849
|
+
html: '<span class="av">' + esc(h.fromLabel) + '</span><span class="b"><span class="l1">' +
|
|
1850
|
+
nm(h.from, h.fromName) + to + skTs(h.ts) + '</span>' +
|
|
1851
|
+
'<span class="sn">' + hl(h.snippet, q) + '</span>' +
|
|
1852
|
+
'<span class="l3">' + whereOf(h.convKind, h.convLabel, h.convName) +
|
|
1853
|
+
(h.hits > 1 ? '<span class="cnt">' + h.hits + ' 处命中</span>' : '') + '</span></span>',
|
|
1854
|
+
go: function () { jumpMsg(h); },
|
|
1855
|
+
};
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
// 一句消息落在当前 session 段里没有 —— 不在就得放宽成全部才看得见。
|
|
1859
|
+
var inSession = function (ts) {
|
|
1860
|
+
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1861
|
+
return !s || (ts >= s.start && (!s.end || ts < s.end));
|
|
1862
|
+
};
|
|
1863
|
+
// 定位一行: 滚到视口中间, 闪一下。窗口只上屏最近一截, 不在就整窗重取一次再找。
|
|
1864
|
+
var focusRow = function (id) {
|
|
1865
|
+
var row = rowNode(id);
|
|
1866
|
+
if (!row) return false;
|
|
1867
|
+
row.scrollIntoView({ block: 'center' });
|
|
1868
|
+
S.pinned = atBottom();
|
|
1869
|
+
row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
|
|
1870
|
+
return true;
|
|
1871
|
+
};
|
|
1872
|
+
var landOn = function (id) {
|
|
1873
|
+
return function () {
|
|
1874
|
+
if (focusRow(id)) return true;
|
|
1875
|
+
loadMsgs('0', function () { return focusRow(id); });
|
|
1876
|
+
return true;
|
|
1877
|
+
};
|
|
1878
|
+
};
|
|
1879
|
+
var jumpMsg = function (h) {
|
|
1880
|
+
if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, '', landOn(h.id));
|
|
1881
|
+
SESSION = 'all'; CONV = h.conv; WITH = '';
|
|
1882
|
+
if (VIEW !== 'msgs') setView('msgs');
|
|
1883
|
+
app.classList.add('reading');
|
|
1884
|
+
reveal();
|
|
1885
|
+
refresh(landOn(h.id));
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
var paintSearch = function (sections) {
|
|
1889
|
+
Q.items = sections.reduce(function (a, s) { return a.concat(s.items); }, []);
|
|
1890
|
+
Q.sel = Math.min(Q.sel, Math.max(0, Q.items.length - 1));
|
|
1891
|
+
var n = 0;
|
|
1892
|
+
skList.innerHTML = sections.filter(function (s) { return s.items.length || s.empty; }).map(function (s) {
|
|
1893
|
+
return '<h3>' + esc(s.title) + (s.count ? '<span>' + s.count + '</span>' : '') + '</h3>' +
|
|
1894
|
+
(s.items.length
|
|
1895
|
+
? s.items.map(function (it) {
|
|
1896
|
+
var i = n++;
|
|
1897
|
+
return '<button class="sk-it ' + it.kind + (i === Q.sel ? ' on' : '') + '" role="option" data-i="' + i + '" aria-selected="' + (i === Q.sel) + '">' + it.html + '</button>';
|
|
1898
|
+
}).join('')
|
|
1899
|
+
: '<div class="sk-empty">' + esc(s.empty) + '</div>');
|
|
1900
|
+
}).join('');
|
|
1901
|
+
};
|
|
1902
|
+
var moveSel = function (d) {
|
|
1903
|
+
if (!Q.items.length) return;
|
|
1904
|
+
Q.sel = (Q.sel + d + Q.items.length) % Q.items.length;
|
|
1905
|
+
skList.querySelectorAll('.sk-it').forEach(function (b) {
|
|
1906
|
+
var on = Number(b.getAttribute('data-i')) === Q.sel;
|
|
1907
|
+
b.classList.toggle('on', on); b.setAttribute('aria-selected', on);
|
|
1908
|
+
if (on) b.scrollIntoView({ block: 'nearest' });
|
|
1909
|
+
});
|
|
1910
|
+
};
|
|
1911
|
+
var pick = function (i) {
|
|
1912
|
+
var it = Q.items[i];
|
|
1913
|
+
if (!it) return;
|
|
1914
|
+
closeSearch();
|
|
1915
|
+
it.go();
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
var recent = function () {
|
|
1919
|
+
Q.done = '';
|
|
1920
|
+
paintSearch([{ title: '最近的会话', items: R.convs.slice(0, 8).map(function (c) { return convItem_(c, ''); }), empty: '还没有会话' }]);
|
|
1921
|
+
};
|
|
1922
|
+
var runSearch = function () {
|
|
1923
|
+
var q = skQ.value.trim(), gen = ++Q.gen;
|
|
1924
|
+
Q.q = q; Q.sel = 0; Q.done = null;
|
|
1925
|
+
if (!q) return recent();
|
|
1926
|
+
api('api/search', { role: ROLE, q: q }).then(function (d) {
|
|
1927
|
+
if (!d.ok || gen !== Q.gen) return;
|
|
1928
|
+
var sections = [
|
|
1929
|
+
{ title: 'Role', items: d.roles.map(function (r) { return roleItem(r, q); }) },
|
|
1930
|
+
{ title: '会话', items: d.convs.map(function (c) { return convItem_(c, q); }) },
|
|
1931
|
+
{ title: '消息', count: d.msgTotal > d.msgs.length ? d.msgs.length + ' / ' + d.msgTotal : d.msgs.length,
|
|
1932
|
+
items: d.msgs.map(function (h) { return msgItem(h, q); }) },
|
|
1933
|
+
];
|
|
1934
|
+
if (!d.roles.length && !d.convs.length && !d.msgs.length) sections = [{ title: '结果', items: [], empty: '没有找到「' + q + '」' }];
|
|
1935
|
+
paintSearch(sections);
|
|
1936
|
+
Q.done = q;
|
|
1937
|
+
// 结果还没回来时按下的回车, 落在回来的第一项上。
|
|
1938
|
+
if (Q.enter) { Q.enter = false; pick(Q.sel); }
|
|
1939
|
+
}).catch(function () { });
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
var openSearch = function () {
|
|
1943
|
+
if (Q.open) { skQ.select(); return; }
|
|
1944
|
+
Q.open = true; sk.hidden = false;
|
|
1945
|
+
skQ.value = Q.q; skQ.focus(); skQ.select();
|
|
1946
|
+
runSearch();
|
|
1947
|
+
};
|
|
1948
|
+
var closeSearch = function () {
|
|
1949
|
+
if (!Q.open) return;
|
|
1950
|
+
Q.open = false; sk.hidden = true; Q.enter = false;
|
|
1951
|
+
clearTimeout(Q.timer);
|
|
1952
|
+
};
|
|
1953
|
+
skQ.addEventListener('input', function () { clearTimeout(Q.timer); Q.timer = setTimeout(runSearch, 140); });
|
|
1954
|
+
skQ.addEventListener('keydown', function (e) {
|
|
1955
|
+
if (e.isComposing) return; // 输入法选词时的回车 / 方向键归输入法
|
|
1956
|
+
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { e.preventDefault(); moveSel(1); }
|
|
1957
|
+
else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) { e.preventDefault(); moveSel(-1); }
|
|
1958
|
+
else if (e.key === 'Enter') {
|
|
1959
|
+
e.preventDefault();
|
|
1960
|
+
var v = skQ.value.trim();
|
|
1961
|
+
if (Q.done === v) return pick(Q.sel);
|
|
1962
|
+
Q.enter = true;
|
|
1963
|
+
if (Q.q !== v) { clearTimeout(Q.timer); runSearch(); }
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
skList.addEventListener('click', function (e) {
|
|
1967
|
+
var b = e.target.closest('.sk-it');
|
|
1968
|
+
if (b) pick(Number(b.getAttribute('data-i')));
|
|
1969
|
+
});
|
|
1970
|
+
skList.addEventListener('mousemove', function (e) {
|
|
1971
|
+
var b = e.target.closest('.sk-it');
|
|
1972
|
+
if (b && Number(b.getAttribute('data-i')) !== Q.sel) moveSel(Number(b.getAttribute('data-i')) - Q.sel);
|
|
1973
|
+
});
|
|
1974
|
+
sk.addEventListener('mousedown', function (e) { if (e.target === sk) closeSearch(); });
|
|
1975
|
+
document.addEventListener('keydown', function (e) {
|
|
1976
|
+
if ((MAC ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
|
|
1977
|
+
e.preventDefault();
|
|
1978
|
+
Q.open ? closeSearch() : openSearch();
|
|
1979
|
+
} else if (Q.open && e.key === 'Escape') { e.preventDefault(); closeSearch(); }
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1786
1982
|
// ── boot ──
|
|
1787
1983
|
// 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
|
|
1788
|
-
if (ROLE || CONV) app.classList.add('reading');
|
|
1984
|
+
if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
|
|
1789
1985
|
refresh();
|
|
1790
1986
|
})();
|