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