wezard 2.1.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/dist/daemon/cc-session.js +14 -1
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/detail.js +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 +407 -327
- package/web/chat.js +897 -726
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,12 +530,75 @@
|
|
|
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
|
|
|
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
|
+
|
|
489
602
|
// 名片: 身份 · 出身 · cwd · 出生; 名片下面一排是关系 / 日程入口。
|
|
490
603
|
var renderRole = function () {
|
|
491
604
|
var r = R.role;
|
|
@@ -502,32 +615,35 @@
|
|
|
502
615
|
var facts = [];
|
|
503
616
|
if (r.cwd) facts.push('<span title="' + esc(r.cwd) + '">📁 ' + esc(shortCwd(r.cwd)) + '</span>');
|
|
504
617
|
// 生在哪个群、什么时候 —— 一件事, 一行。
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
var
|
|
508
|
-
|
|
509
|
-
};
|
|
510
|
-
|
|
618
|
+
// 时刻跟着选中的 session 走: 选了一段就是那一段的开始与轮数, 全部就是最早那段起、共几轮。
|
|
619
|
+
var cur = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
620
|
+
var span = cur ? [cur] : R.sessions;
|
|
621
|
+
var t0 = span.reduce(function (m, x) { return x.start && (!m || x.start < m) ? x.start : m; }, 0);
|
|
622
|
+
var turns = span.reduce(function (n, x) { return n + (x.turns || 0); }, 0);
|
|
623
|
+
var when = [r.chat, t0 ? (cur ? '' : '自 ') + fmtClock(t0) : '', turns ? turns + ' 轮' : ''].filter(Boolean);
|
|
624
|
+
if (when.length) facts.push('<span title="' + esc((cur ? 'session ' + cur.sessionId : '全部 ' + R.sessions.length + ' 段 session') +
|
|
625
|
+
(t0 ? ' · 开始于 ' + fmtDay(t0) : '')) + '">🕘 ' + esc(when.join(' · ')) + '</span>');
|
|
511
626
|
$('#rb-who').innerHTML =
|
|
512
627
|
'<div class="id"><span class="av">' + esc(r.label) + '</span>' +
|
|
513
|
-
'<span class="l">' + nm(r.id, r.name) +
|
|
514
|
-
|
|
628
|
+
'<span class="l"><span class="nl"><span class="cp" title="' + esc('复制 ' + nameOf(r.id)) + '">' + nm(r.id, r.name) + '</span>' + (r.kind === 'wizard' ? '<span id="rb-st"></span>' : '') +
|
|
629
|
+
'<span id="rb-sp">' + (R.sessions.length > 1 ? sessPicker() : '') + '</span></span>' +
|
|
630
|
+
(kind ? '<span class="k">' + kind + '</span>' : '') + '</span></div>' +
|
|
515
631
|
(r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
|
|
516
632
|
'<div class="facts">' + facts.join('') + '</div>';
|
|
517
633
|
paintStatus();
|
|
634
|
+
$('#rb-who').querySelector('.cp').onclick = function () { copyText(nameOf(r.id)); };
|
|
518
635
|
$('#rb-who').querySelectorAll('.go').forEach(function (g) {
|
|
519
636
|
g.onclick = function () { switchRole(g.getAttribute('data-r')); };
|
|
520
637
|
});
|
|
521
|
-
|
|
522
|
-
$('#rb-foot').hidden =
|
|
638
|
+
// 老外壳的底栏选择框已并进 name 行。
|
|
639
|
+
if ($('#rb-foot')) $('#rb-foot').hidden = true;
|
|
523
640
|
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('');
|
|
641
|
+
// 入口只在有东西可看时出现 —— 挂在名片下、会话列表上, 不挤进名片: 名片的主角是身份,
|
|
642
|
+
// 家谱计数 (几个分身 / 子 wizard) 属于关系, 写在关系图入口上。
|
|
643
|
+
$('#rb-acts').innerHTML = [R.relations && relTile(r), R.schedules && planTile()].filter(Boolean).join('<span class="sep" aria-hidden="true">|</span>');
|
|
529
644
|
$('#rb-acts').querySelectorAll('.bd').forEach(function (b) {
|
|
530
|
-
|
|
645
|
+
var v = b.getAttribute('data-view');
|
|
646
|
+
b.onclick = function () { v === 'world' ? setWorld(!WORLD) : setView(VIEW === v ? 'msgs' : v); };
|
|
531
647
|
});
|
|
532
648
|
// 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题跟着换。
|
|
533
649
|
document.title = nameOf(r.id) + ' 的视角';
|
|
@@ -535,14 +651,17 @@
|
|
|
535
651
|
|
|
536
652
|
// ── 用量条: 一组轮次的总账 ──
|
|
537
653
|
// usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
|
|
654
|
+
// 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 退成浅灰蓝不抢眼 →
|
|
655
|
+
// 输入 (1×) 沉稳蓝 → 缓存写 (1.25×) 琥珀提醒 → 输出 (约 5×, 最贵) 珊瑚红警示。注意力留给后三段。
|
|
538
656
|
var SEGS = [
|
|
539
|
-
['
|
|
540
|
-
['cacheWrite', '缓存写', '#
|
|
657
|
+
['cacheRead', '缓存读', '#aab7c8', 'cr'], ['input', '输入', '#4c6fd6', 'in'],
|
|
658
|
+
['cacheWrite', '缓存写', '#e0a030', 'cw'], ['output', '输出', '#e4572e', 'out'],
|
|
541
659
|
];
|
|
542
660
|
var TIP = {
|
|
543
661
|
turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
|
|
544
662
|
ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值', time: '累计耗时',
|
|
545
663
|
};
|
|
664
|
+
var ICON = { turns: '💬', tools: '🛠️', api: '📡', ctx: '🧠', time: '⏱️' };
|
|
546
665
|
var liveDur = function (t) {
|
|
547
666
|
var d = (t.usage && t.usage.durationMs) || 0;
|
|
548
667
|
if (!t.runningUntil) return d;
|
|
@@ -553,59 +672,106 @@
|
|
|
553
672
|
var segs = SEGS.filter(function (s) { return u[s[0]] > 0; });
|
|
554
673
|
var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
|
|
555
674
|
// 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 不占位置。
|
|
675
|
+
// 指标一律 emoji 在前、数字在后, 不挂英文名 (含义在 title 里)。
|
|
676
|
+
// data-tip: 被 fitUsage 藏起来时, 它在整条 bar 的 title 里怎么读。
|
|
556
677
|
var kv = function (k, n, text) {
|
|
557
|
-
return n ? '<span class="kv" title="' + esc(TIP[k] || k) + '"
|
|
678
|
+
return n ? '<span class="kv kv-' + k + '" title="' + esc(TIP[k] || k) + '" data-tip="' + esc(ICON[k] + ' ' + text) +
|
|
679
|
+
'"><i class="u-ic">' + ICON[k] + '</i><b>' + esc(text) + '</b></span>' : '';
|
|
680
|
+
};
|
|
681
|
+
// 输出 / 缓存不再单列成指标 —— 它们就是底边色带的那几段。
|
|
682
|
+
// I/O 分布: 贴着整条 bar 的底边画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
|
|
683
|
+
// 两头留一位小数: 99.9% 不该被四舍五入成 100%, 一丁点也不该成 0%。
|
|
684
|
+
var pct = function (n) {
|
|
685
|
+
var p = n / total * 100;
|
|
686
|
+
return p < .1 ? '<0.1%' : (p < 1 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%';
|
|
558
687
|
};
|
|
559
|
-
// 输出 / 缓存不再单列成指标 —— 它们就是 I/O 图例里的那几格。
|
|
560
688
|
var io = total > 0
|
|
561
|
-
? '<span class="
|
|
562
|
-
'
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
689
|
+
? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total)].concat(segs.map(function (s) {
|
|
690
|
+
return s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]);
|
|
691
|
+
})).join('\n')) + '">' + segs.map(function (s) {
|
|
692
|
+
return '<span class="seg" style="flex-grow:' + u[s[0]] + ';background:' + s[2] + '" title="' +
|
|
693
|
+
s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"></span>';
|
|
694
|
+
}).join('') + '</span>' +
|
|
695
|
+
'<span class="leg" data-tip="' + esc(segs.map(function (s) { return s[3] + ' ' + fmtTok(u[s[0]]); }).join(' · ')) + '">' +
|
|
696
|
+
segs.map(function (s) {
|
|
697
|
+
// 常显的是缩写, 全称与占比在 title 里。
|
|
698
|
+
return '<span class="lg" title="' + s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]) + '"><i style="background:' +
|
|
699
|
+
s[2] + '"></i>' + s[3] + '<b>' + fmtTok(u[s[0]]) + '</b></span>';
|
|
700
|
+
}).join('') + '</span>'
|
|
571
701
|
: '';
|
|
572
|
-
//
|
|
573
|
-
|
|
702
|
+
// 条首先是账的主人 (rolepage 里它的头像 + .名字), 再是模型名: 这本账是谁、拿什么跑出来的。
|
|
703
|
+
// 没有模型记录 (老轮次) 才退回「用量」。
|
|
704
|
+
return '<span class="u-who" title="' + esc(nameOf(t.target)) + '">' + esc(roleLabel(t.target) || t.label) +
|
|
705
|
+
'<span class="u-nm" data-tip="' + esc(nameOf(t.target)) + '">' + esc(nameOf(t.target)) + '</span></span>' +
|
|
706
|
+
'<span class="u-lb" title="' + esc(t.model || '') + '">' +
|
|
574
707
|
esc(t.model ? t.model.replace(/^claude-/, '') : '用量') + '</span>' +
|
|
575
708
|
'<span class="u-kvs">' +
|
|
576
709
|
kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
|
|
577
710
|
kv('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) + kv('time', liveDur(t), fmtDur(liveDur(t))) +
|
|
578
711
|
'</span>' + io;
|
|
579
712
|
};
|
|
713
|
+
// 永远单行: 排不下就按 FOLD 的顺序一级级藏 (分布文字 → 名字 → 耗时), 宽度回来再按反序放出来。
|
|
714
|
+
// 判定看的是容器自己溢没溢出, 不看视口 —— 同一个组件挂在整页页脚和右栏底, 宽度各不相同。
|
|
715
|
+
var FOLD = ['f-leg', 'f-nm', 'f-time'];
|
|
716
|
+
var FOLDED = { 'f-leg': '.leg', 'f-nm': '.u-nm', 'f-time': '.kv-time' };
|
|
717
|
+
var fitUsage = function (el) {
|
|
718
|
+
if (el.hidden) return;
|
|
719
|
+
var over = function () { return el.scrollWidth > el.clientWidth; };
|
|
720
|
+
FOLD.forEach(function (c) { el.classList.remove(c); });
|
|
721
|
+
var n = 0;
|
|
722
|
+
while (n < FOLD.length && over()) el.classList.add(FOLD[n++]);
|
|
723
|
+
// 藏起来的那几样进整条 bar 的 title, hover 照样读得到。
|
|
724
|
+
el.title = FOLD.slice(0, n).map(function (c) {
|
|
725
|
+
var x = el.querySelector(FOLDED[c]);
|
|
726
|
+
return x ? x.getAttribute('data-tip') : '';
|
|
727
|
+
}).filter(Boolean).join('\n');
|
|
728
|
+
};
|
|
729
|
+
var watchUsage = function (el) {
|
|
730
|
+
if (el._ro || typeof ResizeObserver === 'undefined') return;
|
|
731
|
+
// 只在宽度变了时重算: 自己增删 class 不改宽度, 不会自激。
|
|
732
|
+
var w = -1;
|
|
733
|
+
el._ro = new ResizeObserver(function (es) {
|
|
734
|
+
var cw = Math.round(es[0].contentRect.width);
|
|
735
|
+
if (cw !== w) { w = cw; fitUsage(el); }
|
|
736
|
+
});
|
|
737
|
+
el._ro.observe(el);
|
|
738
|
+
};
|
|
580
739
|
// 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把悬停中的 tooltip 蹭掉。
|
|
581
740
|
var putUsage = function (el, t) {
|
|
582
741
|
var html = t ? usageHTML(t) : '';
|
|
583
742
|
// !el: web/ 是热更的, 外壳 HTML 要等 daemon 重启 —— 新脚本可能先遇上没有挂载点的旧外壳。
|
|
584
743
|
if (!el || el._html === html) return;
|
|
585
744
|
el._html = html; el.innerHTML = html; el.hidden = !html;
|
|
745
|
+
watchUsage(el); fitUsage(el);
|
|
586
746
|
// 条的高度挤的是消息区 —— 原本贴底的继续贴底。
|
|
587
747
|
toBottom();
|
|
588
748
|
};
|
|
589
749
|
// wizard: 整页页脚, 它自己跑过的全部轮次 (选了 session 就只算那一段)。
|
|
590
|
-
//
|
|
750
|
+
// 窗口: 对端恰好是一个 wizard 时 (服务端按这个窗口的 talkOf 入参判定, 给不给 winStats 就是答案),
|
|
751
|
+
// 窗口底下是那个 wizard 在这段往来里的账 —— 视角是人还是 wizard 都一样。
|
|
591
752
|
var renderUsage = function () {
|
|
592
753
|
var wiz = !!R.role && R.role.kind === 'wizard';
|
|
593
754
|
putUsage($('#pg-usage'), wiz ? R.stats : null);
|
|
594
|
-
putUsage($('#ch-usage'),
|
|
755
|
+
putUsage($('#ch-usage'), VIEW === 'msgs' ? R.winStats : null);
|
|
595
756
|
};
|
|
596
757
|
|
|
597
758
|
// ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
|
|
598
759
|
var renderHead = function () {
|
|
599
760
|
var who = $('#ch-who'), acts = $('#ch-acts');
|
|
600
761
|
if (VIEW !== 'msgs') {
|
|
601
|
-
who.innerHTML = '<span class="t">' + (
|
|
602
|
-
'<span class="sub">' + esc(nameOf(ROLE)) + (VIEW === 'world' ? ' 的家谱与协作网' : ' 名下的定时任务与工单') + '</span>';
|
|
762
|
+
who.innerHTML = '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务与工单</span>';
|
|
603
763
|
acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
|
|
604
764
|
$('#ch-back').onclick = function () { setView('msgs'); };
|
|
605
765
|
return;
|
|
606
766
|
}
|
|
607
767
|
var c = convOf(CONV);
|
|
608
768
|
if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
|
|
769
|
+
if (c.kind === 'all') {
|
|
770
|
+
who.innerHTML = pairOf([[c.who]]) + '<span class="t">' + esc(c.name) + '</span>';
|
|
771
|
+
acts.innerHTML = '';
|
|
772
|
+
bindGo(who);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
609
775
|
// 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
|
|
610
776
|
var dm = dmOf(c);
|
|
611
777
|
var peer = pairPeer(c);
|
|
@@ -630,32 +796,49 @@
|
|
|
630
796
|
var sw = canSwitch(id) && id !== ROLE;
|
|
631
797
|
return '<button class="av' + (sw ? ' go' : '') + '" data-r="' + esc(id) + '" title="' + esc(nameOf(id)) + '"' + (sw ? '' : ' disabled') + '>' + esc(label || roleLabel(id)) + '</button>';
|
|
632
798
|
};
|
|
799
|
+
// 断点 (/clear /new 轮换) 是某一个 role 的 —— 群窗里几个 wizard 交错时, 不署名就认不出是谁断的。
|
|
800
|
+
// 片段由服务端渲染、不知视角, 名字 (及能否点) 在这里按视角补进断点条的标签前。
|
|
801
|
+
var CUT_L = /(<div class="tg-(?:cut|mark)\b[^>]*>)(<span class="l">)/;
|
|
802
|
+
var signCut = function (html, id, name) {
|
|
803
|
+
return html.replace(CUT_L, function (_, open, l) { return open + nm(id, name, true) + l; });
|
|
804
|
+
};
|
|
633
805
|
var rowHTML = function (m) {
|
|
634
806
|
learn(m.from, m.fromName, m.fromLabel);
|
|
635
807
|
learn(m.to, m.toName, m.toLabel);
|
|
636
808
|
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>';
|
|
809
|
+
return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + signCut(m.html, m.from, m.fromName) + '</div>';
|
|
638
810
|
}
|
|
639
811
|
var mine = m.from === ROLE;
|
|
640
812
|
var other = mine ? m.to : m.from;
|
|
641
813
|
// 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
|
|
642
814
|
var group = CONV.indexOf('p:') !== 0;
|
|
643
|
-
// 群里我不是收信方的那条 (X → Y)
|
|
644
|
-
|
|
815
|
+
// 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
|
|
816
|
+
// (一对一里收信方就是顶栏那个对端, 不再重复写)。
|
|
817
|
+
var dst = mine
|
|
818
|
+
? (m.to && m.to !== 'human:' && m.to !== (group ? pairPeer(convOf(CONV)) : CONV.slice(2)) ? m.to : '')
|
|
819
|
+
: (m.to !== ROLE && group ? m.to : '');
|
|
820
|
+
// 宽屏写在气泡对面的空白里 (.dest, 在换视角的门里但不吃点击); 窄屏空白太窄, 退回消息头 (.to)。
|
|
821
|
+
var to = dst ? '<span class="to">→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) + '</span>' : '';
|
|
822
|
+
// 箭头顺着「发话人 → 收件人」: 气泡是发话人, 描述在哪一侧箭头就背着气泡指向哪一侧 ——
|
|
823
|
+
// 我的消息描述在左 (🦉 .x ←), 别人的在右 (→ 🦉 .x)。
|
|
824
|
+
var dest = dst
|
|
825
|
+
? '<span class="dest" aria-hidden="true">' + (mine ? '' : '<i class="arr">→</i>') +
|
|
826
|
+
'<span class="dav">' + esc(m.toLabel || roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
|
|
827
|
+
(mine ? '<i class="arr">←</i>' : '') + '</span>'
|
|
828
|
+
: '';
|
|
645
829
|
var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
|
|
646
830
|
// 本轮的账 (呼吸点 + 模型 / token / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
|
|
647
831
|
var stat = m.meta ? '<span class="mstat">' + m.meta + '</span>' : '';
|
|
648
|
-
// 一对一里收信方就是顶栏那个对端, 我的消息头不再重复写。
|
|
649
832
|
var who = mine
|
|
650
|
-
?
|
|
833
|
+
? to + stamp(m.ts) + stat + avBtn(m.from, m.fromLabel)
|
|
651
834
|
: avBtn(m.from, m.fromLabel) + nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
|
|
652
835
|
var sw = canSwitch(other);
|
|
653
836
|
var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
|
|
654
837
|
' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
|
|
655
|
-
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
838
|
+
dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
656
839
|
return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
657
840
|
(m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
658
|
-
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + m.html + '</div></div>' +
|
|
841
|
+
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + (m.dir === 'out' ? signCut(m.html, m.from, m.fromName) : m.html) + '</div></div>' +
|
|
659
842
|
flip +
|
|
660
843
|
'</div>';
|
|
661
844
|
};
|
|
@@ -710,7 +893,7 @@
|
|
|
710
893
|
};
|
|
711
894
|
|
|
712
895
|
var bindRow = function (el) {
|
|
713
|
-
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r]');
|
|
896
|
+
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r]');
|
|
714
897
|
};
|
|
715
898
|
var rowNode = function (id) {
|
|
716
899
|
var list = inner.querySelectorAll('.mrow');
|
|
@@ -718,6 +901,21 @@
|
|
|
718
901
|
return null;
|
|
719
902
|
};
|
|
720
903
|
|
|
904
|
+
// 载入更早的消息: 锚住此刻视口顶上的那一行, 重绘后原位不动 —— 不吸底。
|
|
905
|
+
var keepView = function () {
|
|
906
|
+
var top = thread.getBoundingClientRect().top;
|
|
907
|
+
var ref = [].filter.call(inner.querySelectorAll('.mrow'), function (r) { return r.getBoundingClientRect().bottom > top; })[0];
|
|
908
|
+
if (!ref) return null;
|
|
909
|
+
var id = ref.getAttribute('data-id'), y = ref.getBoundingClientRect().top;
|
|
910
|
+
return function () {
|
|
911
|
+
var el = rowNode(id);
|
|
912
|
+
if (!el) return false;
|
|
913
|
+
S.pinned = false;
|
|
914
|
+
thread.scrollTop += el.getBoundingClientRect().top - y;
|
|
915
|
+
return true;
|
|
916
|
+
};
|
|
917
|
+
};
|
|
918
|
+
|
|
721
919
|
// land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
|
|
722
920
|
var loadMsgs = function (limit, land) {
|
|
723
921
|
var gen = S.gen;
|
|
@@ -732,7 +930,7 @@
|
|
|
732
930
|
: '';
|
|
733
931
|
inner.innerHTML = more + d.msgs.map(rowHTML).join('');
|
|
734
932
|
var btn = $('#more');
|
|
735
|
-
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0'); };
|
|
933
|
+
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0', keepView()); };
|
|
736
934
|
bindRow(inner);
|
|
737
935
|
render(inner);
|
|
738
936
|
foldPings(inner);
|
|
@@ -796,7 +994,8 @@
|
|
|
796
994
|
var takeRole = function (d) {
|
|
797
995
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
798
996
|
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
|
|
799
|
-
|
|
997
|
+
SESSION = d.session || '';
|
|
998
|
+
R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null;
|
|
800
999
|
R.stats = d.stats || null; R.winStats = d.winStats || null;
|
|
801
1000
|
ROLE = d.role.id;
|
|
802
1001
|
learn(d.role.id, d.role.name, d.role.label);
|
|
@@ -847,7 +1046,7 @@
|
|
|
847
1046
|
// 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
|
|
848
1047
|
if (land) takeRole(d); else applyRole(d);
|
|
849
1048
|
syncUrl();
|
|
850
|
-
if (
|
|
1049
|
+
if (WORLD || VIEW === 'plan') loadWorld();
|
|
851
1050
|
return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
|
|
852
1051
|
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
853
1052
|
connect();
|
|
@@ -855,7 +1054,8 @@
|
|
|
855
1054
|
});
|
|
856
1055
|
};
|
|
857
1056
|
|
|
858
|
-
|
|
1057
|
+
// land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
|
|
1058
|
+
var selectConv = function (key, withRole, land) {
|
|
859
1059
|
CONV = key; WITH = withRole || '';
|
|
860
1060
|
reveal();
|
|
861
1061
|
if (VIEW !== 'msgs') setView('msgs');
|
|
@@ -866,7 +1066,7 @@
|
|
|
866
1066
|
S.gen++;
|
|
867
1067
|
syncUrl();
|
|
868
1068
|
inner.innerHTML = '<div class="empty">加载中…</div>';
|
|
869
|
-
loadMsgs().then(connect);
|
|
1069
|
+
loadMsgs(undefined, land).then(connect);
|
|
870
1070
|
};
|
|
871
1071
|
|
|
872
1072
|
// 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
|
|
@@ -874,6 +1074,7 @@
|
|
|
874
1074
|
// at: 被点的那条消息 —— 换视角后它留在指针底下, 其余的相对它滑动。
|
|
875
1075
|
var switchRole = function (id, at) {
|
|
876
1076
|
if (!canSwitch(id) || id === ROLE) return;
|
|
1077
|
+
W.treeFor = '';
|
|
877
1078
|
var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
|
|
878
1079
|
var from = ROLE;
|
|
879
1080
|
// 整个频道 / 两人私聊的消息集合与视角无关 —— 只有 with 与 session 按 role 过滤。
|
|
@@ -882,8 +1083,11 @@
|
|
|
882
1083
|
var withBack = keep && WITH ? from : '';
|
|
883
1084
|
ROLE = id; WITH = withBack; SESSION = '';
|
|
884
1085
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
1086
|
+
// 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
|
|
1087
|
+
// 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
|
|
1088
|
+
if (WORLD) { CONV = 'a:' + id; WITH = ''; same = false; W.pickSelf = id; }
|
|
885
1089
|
var snap = VIEW === 'msgs' && !calm() ? snapRows(at) : null;
|
|
886
|
-
if (same) { flipWindow(snap); return; }
|
|
1090
|
+
if (same) { if (WORLD) loadWorld(); flipWindow(snap); return; }
|
|
887
1091
|
var kept = VIEW === 'msgs' ? keepBodies() : {};
|
|
888
1092
|
refresh(function () {
|
|
889
1093
|
adoptBodies(kept);
|
|
@@ -1041,592 +1245,307 @@
|
|
|
1041
1245
|
$('#tb-back').onclick = function () { app.classList.remove('reading'); };
|
|
1042
1246
|
|
|
1043
1247
|
// ══ 关系视图 ═══════════════════════════════════════════════════════
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
var
|
|
1051
|
-
|
|
1052
|
-
clone: {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
graph: {
|
|
1056
|
-
};
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
//
|
|
1248
|
+
// 家谱 (clone / spawn) 与派活 (send_peer · 工单 · 流水线) 是同一种东西: 一条「谁把谁拉进这件事」的
|
|
1249
|
+
// 有向关系。一对 wizard 之间的全部关系合成一条边, 沿方向长成一棵树:
|
|
1250
|
+
// · 每个节点只认一个主父亲 —— 家谱父亲优先, 否则派活次数最多的那位; 其余入边记成节点上的引用
|
|
1251
|
+
// · 环 (互相派活) 在建树时截断: 哪个根都走不到的环, 挑最近活跃的那个当根
|
|
1252
|
+
// · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
|
|
1253
|
+
// 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
|
|
1254
|
+
var KIND = {
|
|
1255
|
+
spawn: { mark: '子', tip: 'spawn 的白板 wizard, 只有出身、没有继承上下文' },
|
|
1256
|
+
clone: { mark: '分身', tip: 'fork 自父亲的 session, 开局带着那一刻的上下文' },
|
|
1257
|
+
peer: { mark: '对话', tip: 'send_peer 发起的对话' },
|
|
1258
|
+
job: { mark: '工单', tip: '它开的工单里有这位成员' },
|
|
1259
|
+
graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
|
|
1260
|
+
};
|
|
1261
|
+
var LINEAGE = { spawn: 1, clone: 1 };
|
|
1262
|
+
|
|
1263
|
+
// 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
|
|
1060
1264
|
var cssEsc = function (v) {
|
|
1061
1265
|
return window.CSS && CSS.escape
|
|
1062
1266
|
? CSS.escape(v)
|
|
1063
1267
|
: String(v).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c; });
|
|
1064
1268
|
};
|
|
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
1269
|
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
|
-
};
|
|
1270
|
+
// 任一票据都能看任一 role (见 chat-http), 所以走进谁就是把视角切成谁。
|
|
1078
1271
|
var canOpen = function (n) { return !!n; };
|
|
1079
1272
|
var openNode = function (target) { if (target) switchRole(target); };
|
|
1080
1273
|
|
|
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>';
|
|
1274
|
+
// ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
|
|
1275
|
+
var rangeOf = function () {
|
|
1276
|
+
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1277
|
+
return s ? { from: s.start, to: s.end || Infinity, s: s } : null;
|
|
1180
1278
|
};
|
|
1279
|
+
var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
|
|
1181
1280
|
|
|
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
|
-
});
|
|
1281
|
+
// 工单归属也是一条关系: 开单的人 → 每个成员。
|
|
1282
|
+
var jobEdges = function () {
|
|
1283
|
+
return (W.jobs || []).reduce(function (acc, j) {
|
|
1284
|
+
var o = nodeOf(j.owner);
|
|
1285
|
+
return !o ? acc : acc.concat(j.members.filter(function (mm) { return mm.target !== j.owner && nodeOf(mm.target); }).map(function (mm) {
|
|
1286
|
+
return { kind: 'job', from: j.owner, to: mm.target, cross: o.base !== nodeOf(mm.target).base, jobs: [j.id], ts: [j.openedAt] };
|
|
1287
|
+
}));
|
|
1288
|
+
}, []);
|
|
1233
1289
|
};
|
|
1234
1290
|
|
|
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
|
-
|
|
1291
|
+
/** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
|
|
1292
|
+
var relations = function (rg) {
|
|
1293
|
+
var pairs = W.edges.map(function (e) {
|
|
1294
|
+
return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs] };
|
|
1295
|
+
}).concat(jobEdges()).reduce(function (m, e) {
|
|
1296
|
+
// 家谱是身份, 不是发生在某段 session 里的事 —— 不受时间窗裁剪, 否则切到子 wizard 时
|
|
1297
|
+
// 它的出生早于自己的 session, 父亲那条边就被裁掉了。
|
|
1298
|
+
var ts = LINEAGE[e.kind] ? e.ts : e.ts.filter(function (t) { return inRange(rg, t); });
|
|
1299
|
+
if (!ts.length) return m;
|
|
1300
|
+
var k = e.from + '\u0000' + e.to;
|
|
1301
|
+
var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
|
|
1302
|
+
p.kinds[e.kind] = (p.kinds[e.kind] || 0) + ts.length;
|
|
1303
|
+
if (!LINEAGE[e.kind]) p.n += ts.length;
|
|
1304
|
+
p.first = Math.min(p.first, Math.min.apply(null, ts));
|
|
1305
|
+
p.last = Math.max(p.last, Math.max.apply(null, ts));
|
|
1306
|
+
e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
|
|
1307
|
+
return m;
|
|
1308
|
+
}, {});
|
|
1309
|
+
// 派活本身带着工单号时, 「工单」标记就是重复的。
|
|
1310
|
+
Object.keys(pairs).forEach(function (k) { if (pairs[k].kinds.peer) delete pairs[k].kinds.job; });
|
|
1311
|
+
return pairs;
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
/** 关系 → 森林: pp = 主父亲那条边, kids = 主孩子, roots = 森林的根。 */
|
|
1315
|
+
var forestOf = function (pairs) {
|
|
1316
|
+
var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
|
|
1317
|
+
var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
|
|
1318
|
+
var weight = function (p) { return (p.kinds.spawn ? 3e6 : p.kinds.clone ? 2e6 : 0) + p.n; };
|
|
1319
|
+
var pp = Object.keys(inc).reduce(function (m, t) {
|
|
1320
|
+
m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
|
|
1321
|
+
return m;
|
|
1322
|
+
}, {});
|
|
1323
|
+
var ends = list.reduce(function (m, p) { m[p.from] = 1; m[p.to] = 1; return m; }, {});
|
|
1324
|
+
var kidsIn = function () {
|
|
1325
|
+
return Object.keys(pp).reduce(function (m, t) { (m[pp[t].from] = m[pp[t].from] || []).push(t); return m; }, {});
|
|
1264
1326
|
};
|
|
1327
|
+
var reach = function (kids, roots) {
|
|
1328
|
+
var seen = {};
|
|
1329
|
+
var walk = function (t) { if (seen[t]) return; seen[t] = 1; (kids[t] || []).forEach(walk); };
|
|
1330
|
+
roots.forEach(walk);
|
|
1331
|
+
return seen;
|
|
1332
|
+
};
|
|
1333
|
+
var roots = Object.keys(ends).filter(function (t) { return !pp[t]; });
|
|
1334
|
+
var recent = function (a, b) { return ((nodeOf(b) || {}).lastTs || 0) - ((nodeOf(a) || {}).lastTs || 0); };
|
|
1335
|
+
// 从 t 沿主父亲往上走, 直到撞回走过的点 —— 撞上的那一圈就是环。
|
|
1336
|
+
var loopFrom = function (t, path) {
|
|
1337
|
+
var at = path.indexOf(t);
|
|
1338
|
+
return at >= 0 ? path.slice(at) : loopFrom(pp[t].from, path.concat([t]));
|
|
1339
|
+
};
|
|
1340
|
+
// 环: 只断环上的派活边 (家谱无环, 断它会让子 wizard 丢了父亲), 挑最近活跃的那个当根 —— 直到都走得到。
|
|
1341
|
+
var close = function () {
|
|
1342
|
+
var seen = reach(kidsIn(), roots);
|
|
1343
|
+
var left = Object.keys(ends).filter(function (t) { return !seen[t]; });
|
|
1344
|
+
if (!left.length) return;
|
|
1345
|
+
var ring = loopFrom(left[0], []);
|
|
1346
|
+
var cut = ring.filter(function (t) { return !LINEAGE[domKind(pp[t])]; });
|
|
1347
|
+
var pick = (cut.length ? cut : ring).sort(recent)[0];
|
|
1348
|
+
delete pp[pick]; roots.push(pick);
|
|
1349
|
+
close();
|
|
1350
|
+
};
|
|
1351
|
+
close();
|
|
1352
|
+
// 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
|
|
1353
|
+
var kids = kidsIn();
|
|
1354
|
+
return { pairs: pairs, inc: inc, pp: pp, kids: kids, roots: roots, ends: ends };
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
// F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
|
|
1358
|
+
var shows = function (F, t) { return !F.vis || !!F.vis[t]; };
|
|
1359
|
+
var countSub = function (F, t) {
|
|
1360
|
+
return (F.kids[t] || []).filter(function (k) { return shows(F, k); })
|
|
1361
|
+
.reduce(function (n, k) { return n + 1 + countSub(F, k); }, 0);
|
|
1362
|
+
};
|
|
1363
|
+
var chainUp = function (F, t, seen) {
|
|
1364
|
+
seen = seen || {};
|
|
1365
|
+
var p = F.pp[t];
|
|
1366
|
+
if (!p || seen[p.from]) return [t];
|
|
1367
|
+
seen[t] = 1;
|
|
1368
|
+
return chainUp(F, p.from, seen).concat([t]);
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
// 线接入卡片处的 label: 种类 (家谱不计次, 对话等一律写次数, ×1 也写) + 跨群; 经手的工单号放进悬停提示。
|
|
1372
|
+
var labelHTML = function (p) {
|
|
1373
|
+
if (!p) return '';
|
|
1374
|
+
return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
|
|
1375
|
+
Object.keys(KIND).filter(function (k) { return p.kinds[k]; }).map(function (k) {
|
|
1376
|
+
return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
|
|
1377
|
+
(!LINEAGE[k] ? ' ×' + p.kinds[k] : '') + '</span>';
|
|
1378
|
+
}).join('') +
|
|
1379
|
+
(p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') + '</span>';
|
|
1380
|
+
};
|
|
1381
|
+
// 一条边的主色: 家谱优先, 决定树上那道线的颜色。
|
|
1382
|
+
var domKind = function (p) {
|
|
1383
|
+
return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
|
|
1387
|
+
var edgeEnds = function (F, t) { var p = F.pp[t]; return p ? [p.from, t] : [t]; };
|
|
1388
|
+
// 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
|
|
1389
|
+
var pairConv = function (other) {
|
|
1390
|
+
if (convOf('p:' + other)) return ['p:' + other, ''];
|
|
1391
|
+
var hit = R.convs.filter(function (c) { return c.kind === 'group'; }).map(function (c) {
|
|
1392
|
+
return [c, (c.subs || []).filter(function (x) { return x.role === other && x.count; })[0]];
|
|
1393
|
+
}).filter(function (x) { return x[1]; }).sort(function (a, b) { return b[1].lastTs - a[1].lastTs; })[0];
|
|
1394
|
+
return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
|
|
1395
|
+
};
|
|
1396
|
+
// 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
|
|
1397
|
+
var edgeConv = function (F, t) {
|
|
1398
|
+
var ends = edgeEnds(F, t);
|
|
1399
|
+
var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
|
|
1400
|
+
return other ? pairConv(other) : null;
|
|
1401
|
+
};
|
|
1402
|
+
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话, 不换视角。
|
|
1403
|
+
// links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
|
|
1404
|
+
var talkKey = function (links, t) {
|
|
1405
|
+
var ls = links[t] || [];
|
|
1406
|
+
return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
|
|
1407
|
+
};
|
|
1408
|
+
// 图上相连 = 画出来的主父亲边: 它的父亲 + 它的孩子, 两端都画出来了才算。
|
|
1409
|
+
var linksOf = function (F, shown) {
|
|
1410
|
+
var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
|
|
1411
|
+
return shown.reduce(function (m, n) {
|
|
1412
|
+
var t = n.target, p = F.pp[t];
|
|
1413
|
+
m[t] = (p && on[p.from] ? [p.from] : []).concat((F.kids[t] || []).filter(function (k) { return on[k]; }));
|
|
1414
|
+
return m;
|
|
1415
|
+
}, {});
|
|
1265
1416
|
};
|
|
1266
1417
|
|
|
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);
|
|
1418
|
+
// 卡片与侧栏会话项同一套 (glance / roleRow / recentFirst)。
|
|
1419
|
+
// 视角与对端有会话项 → 一行的数据就是那一项的 glance; 没有 → 取节点, 时刻取这条边最近一次发生,
|
|
1420
|
+
// 而不是节点自己最后的动静 —— 卡片承载的是边。
|
|
1421
|
+
// 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
|
|
1422
|
+
// (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
|
|
1423
|
+
// 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
|
|
1424
|
+
// 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
|
|
1425
|
+
var cardGlance = function (F, t) {
|
|
1426
|
+
var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
|
|
1427
|
+
var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
|
|
1428
|
+
var n = nodeOf(t) || {}, p = F.pp[t];
|
|
1429
|
+
var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
|
|
1430
|
+
var w = F.links && W.glance[talkKey(F.links, t)];
|
|
1431
|
+
return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
|
|
1432
|
+
};
|
|
1433
|
+
// 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
|
|
1434
|
+
var loadGlance = function () {
|
|
1435
|
+
var keys = W.gKeys, role = ROLE;
|
|
1436
|
+
if (!keys) return;
|
|
1437
|
+
api('api/glance', { role: role, keys: keys }).then(function (d) {
|
|
1438
|
+
if (!d.ok || role !== ROLE || keys !== W.gKeys) return;
|
|
1439
|
+
W.glance = d.glances || {};
|
|
1440
|
+
renderWorld();
|
|
1441
|
+
}).catch(function () { });
|
|
1505
1442
|
};
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1443
|
+
var byCard = function (F) {
|
|
1444
|
+
return function (a, b) { return recentFirst(cardGlance(F, a), cardGlance(F, b)); };
|
|
1445
|
+
};
|
|
1446
|
+
var tnodeHTML = function (F, n, folded) {
|
|
1447
|
+
var me = n.target === ROLE;
|
|
1448
|
+
var tail = folded ? '<span class="tfold" title="它下面还有 ' + folded + ' 个, 切到它的视角可见">+' + folded + '</span>' : '';
|
|
1449
|
+
var p = F.pp[n.target];
|
|
1450
|
+
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, tail);
|
|
1451
|
+
var via = F.vis && F.vis[n.target] === 'via';
|
|
1452
|
+
return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
|
|
1453
|
+
(via ? ' title="' + esc(nameOf(n.target) + ' 没和 ' + nameOf(ROLE) + ' 对过话, 留着是为了连到它下面对过话的') + '"' : '') + '>' +
|
|
1454
|
+
labelHTML(p) + row + '</button>';
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
// keep(父, 孩子) → 'full' 整枝 / 'leaf' 只画它自己 / '' 不画; 不给 keep = 全画。shown 收集画出来的节点。
|
|
1458
|
+
var treeHTML = function (F, t, depth, keep, leaf, shown, seen, z) {
|
|
1459
|
+
var n = nodeOf(t);
|
|
1460
|
+
if (!n || seen[t] || depth > 32) return '';
|
|
1461
|
+
seen[t] = 1;
|
|
1462
|
+
shown.push(n);
|
|
1463
|
+
var kids = leaf ? [] : (F.kids[t] || []).filter(function (k) { return shows(F, k) && (!keep || keep(t, k)); }).sort(byCard(F));
|
|
1464
|
+
return '<li class="' + domKind(F.pp[t]) + '"' + (z ? ' style="z-index:' + z + '"' : '') + '>' + tnodeHTML(F, n, leaf ? countSub(F, t) : 0) +
|
|
1465
|
+
// 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
|
|
1466
|
+
(kids.length ? '<ul>' + kids.map(function (k, i) {
|
|
1467
|
+
return treeHTML(F, k, depth + 1, keep, keep && keep(t, k) === 'leaf', shown, seen, kids.length - i);
|
|
1468
|
+
}).join('') + '</ul>' : '') +
|
|
1469
|
+
'</li>';
|
|
1470
|
+
};
|
|
1471
|
+
// 只看相关: 主父亲链一条直线下来, 到它自己再整枝展开; 同一个主父亲下的兄弟只画本人。
|
|
1472
|
+
var focusKeep = function (path) {
|
|
1473
|
+
var on = path.reduce(function (m, t) { m[t] = 1; return m; }, {});
|
|
1474
|
+
var parent = path[path.length - 2];
|
|
1475
|
+
return function (t, k) {
|
|
1476
|
+
if (!on[t] || t === ROLE) return 'full';
|
|
1477
|
+
return on[k] ? 'full' : t === parent ? 'leaf' : '';
|
|
1478
|
+
};
|
|
1512
1479
|
};
|
|
1513
1480
|
|
|
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
|
-
});
|
|
1481
|
+
// 关系图替换的是侧栏的会话列表 —— 右边照旧是选中的那段对话。
|
|
1482
|
+
// 只看相关时画谁: 视角自己 + 和视角对过话的 ('talk'); 它们通向根的主父亲链上没对过话的祖先
|
|
1483
|
+
// 留作连接 ('via', 画淡) —— 抹掉它, 有对话的后代就从家谱上脱开, 成了一棵来历不明的孤树。
|
|
1484
|
+
var talks = function (F, a, b) {
|
|
1485
|
+
var p = F.pairs[a + '\u0000' + b];
|
|
1486
|
+
return !!p && !!(p.kinds.peer || p.kinds.graph);
|
|
1534
1487
|
};
|
|
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>';
|
|
1488
|
+
var visibleOf = function (F) {
|
|
1489
|
+
var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || talks(F, ROLE, t) || talks(F, t, ROLE); });
|
|
1490
|
+
var vis = keep.reduce(function (m, t) { m[t] = 'talk'; return m; }, {});
|
|
1491
|
+
if (!vis[ROLE]) vis[ROLE] = 'talk';
|
|
1492
|
+
keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
|
|
1493
|
+
return vis;
|
|
1550
1494
|
};
|
|
1551
1495
|
|
|
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); }
|
|
1496
|
+
var renderWorld = function () {
|
|
1497
|
+
if (!WORLD) return;
|
|
1498
|
+
if (!W.loaded) { convsEl._tree = ''; convsEl.innerHTML = '<div class="empty">加载中…</div>'; return; }
|
|
1499
|
+
var rg = rangeOf();
|
|
1500
|
+
var F = forestOf(relations(rg));
|
|
1501
|
+
var me = nodeOf(ROLE);
|
|
1502
|
+
var all = W.treeAll || !me;
|
|
1503
|
+
var shown = [];
|
|
1504
|
+
var path = me ? chainUp(F, ROLE) : [];
|
|
1505
|
+
// 和视角之间没有对话的关系只在「看全部」里画。
|
|
1506
|
+
F.vis = all ? null : visibleOf(F);
|
|
1507
|
+
var draw = function (into) {
|
|
1508
|
+
return all
|
|
1509
|
+
? F.roots.slice().sort(function (a, b) {
|
|
1510
|
+
var mine = path[0];
|
|
1511
|
+
return (b === mine) - (a === mine) || byCard(F)(a, b);
|
|
1512
|
+
}).map(function (r) { return treeHTML(F, r, 0, null, false, into, {}); }).join('')
|
|
1513
|
+
: treeHTML(F, path[0], 0, focusKeep(path), false, into, {});
|
|
1580
1514
|
};
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
return;
|
|
1515
|
+
// 两遍: 先量出画了谁 (卡片的窗口要知道它在图上连着谁), 再带着连线画 —— 选中态也就进了 html。
|
|
1516
|
+
draw(shown);
|
|
1517
|
+
F.links = linksOf(F, shown);
|
|
1518
|
+
var keys = shown.map(function (n) { return talkKey(F.links, n.target); }).join('\n');
|
|
1519
|
+
if (keys !== W.gKeys) { W.gKeys = keys; loadGlance(); }
|
|
1520
|
+
// 换视角后的第一张新树: 世界快照与摘要都已是新视角的, 才知道它自己那张卡片连着谁。
|
|
1521
|
+
if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
|
|
1522
|
+
W.pickSelf = '';
|
|
1523
|
+
var self = talkKey(F.links, ROLE);
|
|
1524
|
+
if (CONV !== self) return selectConv(self, '');
|
|
1591
1525
|
}
|
|
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);
|
|
1526
|
+
var trees = draw([]);
|
|
1527
|
+
var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
|
|
1528
|
+
var alone = !all && shown.length < 2;
|
|
1529
|
+
var html = '<div class="tview">' +
|
|
1530
|
+
'<h2>关系图<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
|
|
1531
|
+
(W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
|
|
1532
|
+
(me ? '<button class="tall" title="' + (all ? '只留它的上游链、它自己、它的下游与同源兄弟' : '画出范围内所有有关系的 wizard') + '">' +
|
|
1533
|
+
(all ? '只看相关' : '看全部') + '</button>' : '') + '</h2>' +
|
|
1534
|
+
(alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁对过话</div>' : '') +
|
|
1535
|
+
(shown.length ? '<ul class="tree' + (all ? ' all' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
|
|
1536
|
+
'</div>';
|
|
1537
|
+
// 心跳每 3s 重算一次 (状态灯 / 几分钟前) —— 没变就不碰 DOM, 免得蹭掉悬停与滚动。
|
|
1538
|
+
if (convsEl._tree === html && convsEl.querySelector('.tview')) return;
|
|
1539
|
+
convsEl._tree = html; convsEl._html = '';
|
|
1540
|
+
convsEl.innerHTML = html;
|
|
1541
|
+
convsEl.querySelectorAll('.tci').forEach(function (el) {
|
|
1542
|
+
el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
|
|
1614
1543
|
});
|
|
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);
|
|
1544
|
+
bindGo(convsEl);
|
|
1545
|
+
var tall = convsEl.querySelector('.tall');
|
|
1546
|
+
if (tall) tall.onclick = function () { W.treeAll = !W.treeAll; W.treeFor = ''; renderWorld(); };
|
|
1547
|
+
var cur = convsEl.querySelector('.tci.me');
|
|
1548
|
+
if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
|
|
1630
1549
|
};
|
|
1631
1550
|
|
|
1632
1551
|
// ══ 日程视图 ═══════════════════════════════════════════════════════
|
|
@@ -1657,6 +1576,7 @@
|
|
|
1657
1576
|
// 未来 12 小时的刻度条: 哪几个钟点会有任务醒来, 一眼看完。超出 12h 的排进下面
|
|
1658
1577
|
// 的列表但不占刻度 —— 把 24h 压进这么窄的一条, 刻度会密到读不出。
|
|
1659
1578
|
var HORIZON_H = 12;
|
|
1579
|
+
var broken = function (x) { return x.lastGate === 'error' || !!x.loadError; };
|
|
1660
1580
|
var renderStrip = function (list) {
|
|
1661
1581
|
var now = srvNow(), span = HORIZON_H * 3600000;
|
|
1662
1582
|
var soon = list.filter(function (x) { return x.nextAt - now < span; });
|
|
@@ -1664,16 +1584,66 @@
|
|
|
1664
1584
|
for (var i = 0; i <= HORIZON_H; i += 3) {
|
|
1665
1585
|
var t = new Date(now + i * 3600000);
|
|
1666
1586
|
ticks.push('<span class="tk" style="left:' + (i / HORIZON_H * 100) + '%">' +
|
|
1667
|
-
(i ? (t.getHours()
|
|
1587
|
+
(i ? pad(t.getHours()) + ':00' : '现在') + '</span>');
|
|
1668
1588
|
}
|
|
1669
1589
|
var pins = soon.map(function (x) {
|
|
1670
1590
|
var pct = Math.max(0, Math.min(100, (x.nextAt - now) / span * 100));
|
|
1671
|
-
return '<
|
|
1672
|
-
'title="' + esc(fmtClock(x.nextAt) + ' · ' +
|
|
1591
|
+
return '<button class="pin' + (broken(x) ? ' bad' : '') + '" style="left:' + pct.toFixed(2) + '%" data-id="' + esc(x.id) + '" ' +
|
|
1592
|
+
'title="' + esc(fmtClock(x.nextAt) + ' · ' + (x.note || x.id) + ' · ' + x.when) + '"><span>' + esc(fmtHM(x.nextAt)) + '</span></button>';
|
|
1673
1593
|
}).join('');
|
|
1674
1594
|
return '<div class="strip"><div class="axis">' + ticks.join('') + '</div>' +
|
|
1675
1595
|
'<div class="rail">' + pins + '</div>' +
|
|
1676
|
-
'<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
|
|
1596
|
+
'<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发 · 点刻度跳到那条' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// 触发规则 = describeTrigger 的人话, 各段以 ` · ` 相接: 第一段是时钟 (什么时候响), 其余是闸 (什么时候不响)。
|
|
1600
|
+
var ruleHTML = function (when) {
|
|
1601
|
+
return String(when || '').split(' · ').filter(Boolean).map(function (part, i) {
|
|
1602
|
+
return '<span class="rl' + (i ? ' guard' : ' clock') + '">' + (i ? '' : '⏱ ') + esc(part) + '</span>';
|
|
1603
|
+
}).join('');
|
|
1604
|
+
};
|
|
1605
|
+
var gateHTML = function (x) {
|
|
1606
|
+
if (!x.hasGate) return '<span class="gt none" title="没有 gate: 到点就放">无 gate</span>';
|
|
1607
|
+
var at = x.lastGateAt ? ' · ' + fmtAgo(x.lastGateAt) : '';
|
|
1608
|
+
return x.lastGate === 'go' ? '<span class="gt go" title="上一轮 gate 放行">gate 放行' + esc(at) + '</span>'
|
|
1609
|
+
: x.lastGate === 'skip' ? '<span class="gt skip" title="上一轮 gate 判定没活, 没放枪 (不出声)">gate 挡下' + esc(at) + '</span>'
|
|
1610
|
+
: x.lastGate === 'error' ? '<span class="gt err">gate 出错' + esc(at) + '</span>'
|
|
1611
|
+
: '<span class="gt none">gate 未跑过</span>';
|
|
1612
|
+
};
|
|
1613
|
+
var flowHTML = function (x) {
|
|
1614
|
+
var own = x.owner || x.createdBy;
|
|
1615
|
+
return '<div class="pflow">' +
|
|
1616
|
+
(own && own !== x.target ? '<span class="fl">排班</span>' + wizChip(own) + '<span class="ar">→</span>' : '') +
|
|
1617
|
+
(x.fresh
|
|
1618
|
+
? '<span class="fl">执行</span><span class="fresh" title="每次到点新起一个白板 wizard, 跑完自动回收">✦ 新起白板</span><span class="fl">在</span>' + wizChip(x.target) + '<span class="fl">名下</span>'
|
|
1619
|
+
: '<span class="fl">执行</span>' + wizChip(x.target) + '<span class="fl">注入已有会话</span>') +
|
|
1620
|
+
'</div>';
|
|
1621
|
+
};
|
|
1622
|
+
var PLAN_OPEN = {};
|
|
1623
|
+
var taskHTML = function (x) {
|
|
1624
|
+
var lines = String(x.prompt || '').split('\n');
|
|
1625
|
+
var head = x.note || lines[0];
|
|
1626
|
+
var errs = [
|
|
1627
|
+
x.loadError ? '<div class="perr"><b>任务文件加载失败</b> · 仍在跑上一版<code>' + esc(x.loadError.split('\n')[0].slice(0, 240)) + '</code></div>' : '',
|
|
1628
|
+
x.lastGate === 'error' ? '<div class="perr"><b>gate 出错</b>' + (x.lastGateAt ? ' · ' + esc(fmtAgo(x.lastGateAt)) : '') +
|
|
1629
|
+
' · 本轮未放枪, 下次照常重试' + (x.lastError ? '<code>' + esc(x.lastError.split('\n')[0].slice(0, 240)) + '</code>' : '') + '</div>' : '',
|
|
1630
|
+
].join('');
|
|
1631
|
+
return '<article class="tcard' + (broken(x) ? ' bad' : '') + '" data-id="' + esc(x.id) + '">' +
|
|
1632
|
+
'<div class="tc-time"><b>' + esc(fmtHM(x.nextAt)) + '</b><span>' + esc(fmtClock(x.nextAt).replace(/ \d\d:\d\d$/, '')) + '</span>' +
|
|
1633
|
+
'<em>' + esc(fmtIn(x.nextAt)) + '</em></div>' +
|
|
1634
|
+
'<div class="tc-main">' +
|
|
1635
|
+
errs +
|
|
1636
|
+
'<div class="prule">' + ruleHTML(x.when) + '</div>' +
|
|
1637
|
+
'<div class="ptitle">' + esc(head.slice(0, 120)) + '</div>' +
|
|
1638
|
+
flowHTML(x) +
|
|
1639
|
+
'<details class="pprompt" data-id="' + esc(x.id) + '"' + (PLAN_OPEN[x.id] ? ' open' : '') + '>' +
|
|
1640
|
+
'<summary>prompt<span>' + esc((x.note ? lines[0] : lines.slice(1).join(' ')).slice(0, 90)) + '</span></summary>' +
|
|
1641
|
+
'<pre>' + esc(x.prompt) + '</pre></details>' +
|
|
1642
|
+
'<div class="pfoot">' + gateHTML(x) +
|
|
1643
|
+
'<span>' + (x.lastFired ? '上次触发 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</span>' +
|
|
1644
|
+
'<span class="pid" title="' + esc(x.file || '') + '">' + esc(x.id) + '</span></div>' +
|
|
1645
|
+
'</div>' +
|
|
1646
|
+
'</article>';
|
|
1677
1647
|
};
|
|
1678
1648
|
|
|
1679
1649
|
// 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
|
|
@@ -1684,67 +1654,63 @@
|
|
|
1684
1654
|
});
|
|
1685
1655
|
var open = js.filter(function (j) { return j.status === 'open'; });
|
|
1686
1656
|
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>';
|
|
1657
|
+
var bad = ss.filter(broken);
|
|
1658
|
+
var next = ss[0];
|
|
1659
|
+
var stat = function (k, v, sub, tone) {
|
|
1660
|
+
return '<div class="pst' + (tone ? ' ' + tone : '') + '"><span class="k">' + k + '</span><b>' + v + '</b><span class="s">' + sub + '</span></div>';
|
|
1661
|
+
};
|
|
1662
|
+
var stats = '<div class="pstats">' +
|
|
1663
|
+
stat('下一次', next ? esc(fmtHM(next.nextAt)) : '—', next ? esc(fmtIn(next.nextAt) + ' · ' + (next.note || next.id)) : '没有排期') +
|
|
1664
|
+
stat('定时任务', ss.length, ss.length ? esc(ss.filter(function (x) { return x.hasGate; }).length + ' 条带 gate') : '—') +
|
|
1665
|
+
stat('工单', open.length + '<small> / ' + js.length + '</small>', '进行中 / 全部') +
|
|
1666
|
+
(bad.length ? stat('出错', bad.length, '见下方红色卡片', 'bad') : '') +
|
|
1667
|
+
'</div>';
|
|
1703
1668
|
|
|
1704
1669
|
var jobRow = function (j) {
|
|
1705
1670
|
return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
|
|
1706
|
-
'<div class="jh"><span class="
|
|
1671
|
+
'<div class="jh"><span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
|
|
1707
1672
|
'<span class="jt">' + esc(j.title) + '</span>' +
|
|
1708
|
-
'<span class="
|
|
1673
|
+
'<span class="jid">' + esc(j.id) + '</span>' +
|
|
1709
1674
|
'<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
|
|
1710
1675
|
'<div class="jm">' + (j.members.length
|
|
1711
1676
|
? j.members.map(function (mm) {
|
|
1712
|
-
|
|
1677
|
+
var n = nodeOf(mm.target);
|
|
1678
|
+
return '<span class="jmm">' + wizChip(mm.target) + stTag(n) +
|
|
1713
1679
|
(mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
|
|
1714
|
-
'<em>' + esc((mm.task || '').split('\n')[0].slice(0,
|
|
1680
|
+
'<em>' + esc((mm.task || '').split('\n')[0].slice(0, 90)) + '</em></span>';
|
|
1715
1681
|
}).join('')
|
|
1716
1682
|
: '<span class="jmm none">还没有成员</span>') + '</div>' +
|
|
1717
1683
|
(j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
|
|
1718
1684
|
'</div>';
|
|
1719
1685
|
};
|
|
1720
1686
|
|
|
1721
|
-
|
|
1687
|
+
var html = stats +
|
|
1722
1688
|
'<section class="psec">' +
|
|
1723
|
-
'<h3
|
|
1724
|
-
renderStrip(ss) +
|
|
1689
|
+
'<h3>定时任务<span>' + ss.length + '</span></h3>' +
|
|
1690
|
+
(ss.length ? renderStrip(ss) + bad.concat(ss.filter(function (x) { return !broken(x); })).map(taskHTML).join('')
|
|
1691
|
+
: '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>') +
|
|
1725
1692
|
'</section>' +
|
|
1726
1693
|
'<section class="psec">' +
|
|
1727
|
-
'<h3
|
|
1694
|
+
'<h3>工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
|
|
1728
1695
|
(js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
|
|
1729
1696
|
'</section>';
|
|
1697
|
+
// 心跳每 3s 重画一次 —— 没变就不碰 DOM (展开着的 prompt、悬停的提示都留着)。
|
|
1698
|
+
if (planEl._html === html) return;
|
|
1699
|
+
planEl._html = html;
|
|
1700
|
+
planEl.innerHTML = html;
|
|
1730
1701
|
planEl.querySelectorAll('.wchip.go').forEach(function (c) {
|
|
1731
1702
|
c.onclick = function () { openNode(c.getAttribute('data-t')); };
|
|
1732
1703
|
});
|
|
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);
|
|
1704
|
+
planEl.querySelectorAll('.pprompt').forEach(function (d) {
|
|
1705
|
+
d.ontoggle = function () { PLAN_OPEN[d.getAttribute('data-id')] = d.open; planEl._html = ''; };
|
|
1706
|
+
});
|
|
1707
|
+
planEl.querySelectorAll('.strip .pin').forEach(function (p) {
|
|
1708
|
+
p.onclick = function () {
|
|
1709
|
+
var card = planEl.querySelector('.tcard[data-id="' + cssEsc(p.getAttribute('data-id')) + '"]');
|
|
1710
|
+
if (!card) return;
|
|
1711
|
+
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1712
|
+
card.classList.remove('flash'); void card.offsetWidth; card.classList.add('flash');
|
|
1713
|
+
};
|
|
1748
1714
|
});
|
|
1749
1715
|
};
|
|
1750
1716
|
|
|
@@ -1754,18 +1720,20 @@
|
|
|
1754
1720
|
var WORLD_MS = 6000;
|
|
1755
1721
|
var worldTimer = null;
|
|
1756
1722
|
var loadWorld = function () {
|
|
1757
|
-
|
|
1723
|
+
var asked = ROLE;
|
|
1724
|
+
return api('api/world', { role: asked }).then(function (d) {
|
|
1758
1725
|
if (!d.ok) return;
|
|
1759
|
-
W.at = d.at; W.loaded = true;
|
|
1726
|
+
W.at = d.at; W.loaded = true; W.role = asked;
|
|
1760
1727
|
W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
|
|
1761
1728
|
W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
|
|
1762
|
-
|
|
1729
|
+
renderWorld();
|
|
1730
|
+
loadGlance();
|
|
1763
1731
|
if (VIEW === 'plan') renderPlan();
|
|
1764
1732
|
}).catch(function () { });
|
|
1765
1733
|
};
|
|
1766
1734
|
var pollWorld = function () {
|
|
1767
1735
|
if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
|
|
1768
|
-
if (VIEW === 'msgs') return;
|
|
1736
|
+
if (VIEW === 'msgs' && !WORLD) return;
|
|
1769
1737
|
worldTimer = setInterval(function () {
|
|
1770
1738
|
if (!document.hidden) loadWorld();
|
|
1771
1739
|
}, WORLD_MS);
|
|
@@ -1774,29 +1742,42 @@
|
|
|
1774
1742
|
var setView = function (v) {
|
|
1775
1743
|
VIEW = v;
|
|
1776
1744
|
thread.hidden = v !== 'msgs';
|
|
1777
|
-
$('#pane-world').hidden = v !== 'world';
|
|
1778
1745
|
$('#pane-plan').hidden = v !== 'plan';
|
|
1779
1746
|
app.classList.toggle('outer', v !== 'msgs');
|
|
1780
1747
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
1781
1748
|
if (v !== 'msgs') app.classList.add('reading');
|
|
1782
1749
|
renderRole(); renderHead(); renderUsage();
|
|
1783
|
-
if (v === 'msgs') {
|
|
1750
|
+
if (v === 'msgs') { toBottom(true); renderConvs(); }
|
|
1784
1751
|
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();
|
|
1752
|
+
if (!W.loaded) { planEl._html = ''; planEl.innerHTML = '<div class="empty">加载中…</div>'; }
|
|
1789
1753
|
else renderPlan();
|
|
1790
1754
|
loadWorld();
|
|
1791
1755
|
}
|
|
1792
1756
|
pollWorld();
|
|
1793
1757
|
};
|
|
1794
|
-
//
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1758
|
+
// 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
|
|
1759
|
+
var setWorld = function (on) {
|
|
1760
|
+
WORLD = on; W.treeFor = ''; W.pickSelf = '';
|
|
1761
|
+
convsEl._html = convsEl._tree = '';
|
|
1762
|
+
syncUrl();
|
|
1763
|
+
// 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
|
|
1764
|
+
if (on) app.classList.remove('reading');
|
|
1765
|
+
renderRole();
|
|
1766
|
+
if (on) { renderWorld(); loadWorld(); }
|
|
1767
|
+
else {
|
|
1768
|
+
// 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
|
|
1769
|
+
var c = convOf(CONV);
|
|
1770
|
+
if (c) {
|
|
1771
|
+
OPEN[CONV] = true; OPEN_AT = CONV;
|
|
1772
|
+
var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
|
|
1773
|
+
if (rank >= SUB_FOLD) MORE[CONV] = true;
|
|
1774
|
+
}
|
|
1775
|
+
renderConvs();
|
|
1776
|
+
var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
|
|
1777
|
+
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
1778
|
+
}
|
|
1779
|
+
pollWorld();
|
|
1780
|
+
};
|
|
1800
1781
|
// 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
|
|
1801
1782
|
setInterval(function () {
|
|
1802
1783
|
if (!R.role) return;
|
|
@@ -1804,12 +1785,202 @@
|
|
|
1804
1785
|
expireRows();
|
|
1805
1786
|
// 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
|
|
1806
1787
|
paintStatus(); renderConvs();
|
|
1807
|
-
if (VIEW === '
|
|
1808
|
-
else if (VIEW === 'plan') renderPlan();
|
|
1788
|
+
if (VIEW === 'plan') renderPlan();
|
|
1809
1789
|
}, TICK_MS);
|
|
1810
1790
|
|
|
1791
|
+
// ══ 搜索 (⌘K / Ctrl+K) ═══════════════════════════════════════════════
|
|
1792
|
+
// 从当前视角搜三样: role 名字 (→ 切视角)、会话名 (→ 打开)、消息正文 (→ 打开会话并定位到那一句)。
|
|
1793
|
+
// 服务端搜全部时间; 那一句不在当前 session 段里就放宽成「全部」再跳。空查询时列出最近的会话,
|
|
1794
|
+
// 面板本身就是一个键盘快速切换器。面板由脚本挂载 —— 外壳 HTML 要等 daemon 重启才换。
|
|
1795
|
+
var Q = { open: false, q: '', done: null, enter: false, items: [], sel: 0, gen: 0, timer: 0 };
|
|
1796
|
+
var sk = document.body.appendChild(document.createElement('div'));
|
|
1797
|
+
sk.className = 'sk'; sk.hidden = true;
|
|
1798
|
+
sk.innerHTML = '<div class="sk-panel" role="dialog" aria-label="搜索">' +
|
|
1799
|
+
'<div class="sk-in"><span class="ic">' + SEARCH_SVG + '</span>' +
|
|
1800
|
+
'<input id="sk-q" type="search" autocomplete="off" spellcheck="false" placeholder="搜索 role、会话、消息…" aria-controls="sk-list">' +
|
|
1801
|
+
'<kbd>esc</kbd></div>' +
|
|
1802
|
+
'<div class="sk-list" id="sk-list" role="listbox"></div>' +
|
|
1803
|
+
'<div class="sk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> 选择</span><span><kbd>↵</kbd> 打开</span><span><kbd>esc</kbd> 关闭</span></div>' +
|
|
1804
|
+
'</div>';
|
|
1805
|
+
var skQ = sk.querySelector('#sk-q'), skList = sk.querySelector('#sk-list');
|
|
1806
|
+
|
|
1807
|
+
var reEsc = function (s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); };
|
|
1808
|
+
// 先切再转义: 命中段包 <mark>, 其余照常 esc。
|
|
1809
|
+
var hl = function (text, q) {
|
|
1810
|
+
var ts = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1811
|
+
if (!ts.length) return esc(text);
|
|
1812
|
+
var re = new RegExp('(' + ts.map(reEsc).join('|') + ')', 'ig');
|
|
1813
|
+
return String(text).split(re).map(function (p, i) { return i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p); }).join('');
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
// 一项 = { kind, html, go }: 画什么、回车做什么。
|
|
1817
|
+
var roleItem = function (r, q) {
|
|
1818
|
+
return {
|
|
1819
|
+
kind: 'role',
|
|
1820
|
+
html: '<span class="av">' + esc(r.label) + '</span><span class="b"><span class="l1">' +
|
|
1821
|
+
'<span class="nm ' + (r.wizard ? 'wizard' : 'human') + '">' + hl(r.name, q) + '</span><span class="k">切到视角</span></span>' +
|
|
1822
|
+
(r.description ? '<span class="pv">' + hl(r.description, q) + '</span>' : '') + '</span>',
|
|
1823
|
+
go: function () { switchRole(r.id); },
|
|
1824
|
+
};
|
|
1825
|
+
};
|
|
1826
|
+
// 时刻: 列表里写相对的, 悬停给完整的。
|
|
1827
|
+
var skTs = function (ts) { return '<time class="ts" title="' + esc(fmtFull(ts)) + '">' + esc(fmtAgo(ts)) + '</time>'; };
|
|
1828
|
+
// 会话归属: 群聊带群名, 私聊写明是私聊 —— 结果项第三层, 比发话人与正文都淡。
|
|
1829
|
+
var whereOf = function (kind, label, name, q) {
|
|
1830
|
+
return '<span class="where' + (kind === 'wizard' ? ' priv' : '') + '"><i>' + esc(label || '💬') + '</i>' +
|
|
1831
|
+
(kind === 'wizard' ? '私聊 · ' : '') + hl(name, q || '') + '</span>';
|
|
1832
|
+
};
|
|
1833
|
+
var convItem_ = function (c, q) {
|
|
1834
|
+
var title = c.kind === 'wizard' ? '<span class="nm wizard">' + hl(c.name, q) + '</span>' : '<span class="nm chat">' + hl(c.name, q) + '</span>';
|
|
1835
|
+
return {
|
|
1836
|
+
kind: 'conv',
|
|
1837
|
+
html: '<span class="av">' + esc(c.label) + '</span><span class="b"><span class="l1">' + title +
|
|
1838
|
+
'<span class="tag">' + (c.kind === 'wizard' ? '私聊' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
|
|
1839
|
+
(c.preview ? '<span class="pv">' + esc(c.preview) + '</span>' : '') + '</span>',
|
|
1840
|
+
go: function () { selectConv(c.key, ''); },
|
|
1841
|
+
};
|
|
1842
|
+
};
|
|
1843
|
+
// 三层: 谁 → 对谁 · 何时 / 命中的那一截 / 在哪个会话。
|
|
1844
|
+
var msgItem = function (h, q) {
|
|
1845
|
+
var to = h.to && h.to !== h.from && h.to !== 'human:'
|
|
1846
|
+
? '<span class="arr">→</span>' + nm(h.to, h.toName) : '';
|
|
1847
|
+
return {
|
|
1848
|
+
kind: 'msg',
|
|
1849
|
+
html: '<span class="av">' + esc(h.fromLabel) + '</span><span class="b"><span class="l1">' +
|
|
1850
|
+
nm(h.from, h.fromName) + to + skTs(h.ts) + '</span>' +
|
|
1851
|
+
'<span class="sn">' + hl(h.snippet, q) + '</span>' +
|
|
1852
|
+
'<span class="l3">' + whereOf(h.convKind, h.convLabel, h.convName) +
|
|
1853
|
+
(h.hits > 1 ? '<span class="cnt">' + h.hits + ' 处命中</span>' : '') + '</span></span>',
|
|
1854
|
+
go: function () { jumpMsg(h); },
|
|
1855
|
+
};
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
// 一句消息落在当前 session 段里没有 —— 不在就得放宽成全部才看得见。
|
|
1859
|
+
var inSession = function (ts) {
|
|
1860
|
+
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1861
|
+
return !s || (ts >= s.start && (!s.end || ts < s.end));
|
|
1862
|
+
};
|
|
1863
|
+
// 定位一行: 滚到视口中间, 闪一下。窗口只上屏最近一截, 不在就整窗重取一次再找。
|
|
1864
|
+
var focusRow = function (id) {
|
|
1865
|
+
var row = rowNode(id);
|
|
1866
|
+
if (!row) return false;
|
|
1867
|
+
row.scrollIntoView({ block: 'center' });
|
|
1868
|
+
S.pinned = atBottom();
|
|
1869
|
+
row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
|
|
1870
|
+
return true;
|
|
1871
|
+
};
|
|
1872
|
+
var landOn = function (id) {
|
|
1873
|
+
return function () {
|
|
1874
|
+
if (focusRow(id)) return true;
|
|
1875
|
+
loadMsgs('0', function () { return focusRow(id); });
|
|
1876
|
+
return true;
|
|
1877
|
+
};
|
|
1878
|
+
};
|
|
1879
|
+
var jumpMsg = function (h) {
|
|
1880
|
+
if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, '', landOn(h.id));
|
|
1881
|
+
SESSION = 'all'; CONV = h.conv; WITH = '';
|
|
1882
|
+
if (VIEW !== 'msgs') setView('msgs');
|
|
1883
|
+
app.classList.add('reading');
|
|
1884
|
+
reveal();
|
|
1885
|
+
refresh(landOn(h.id));
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
var paintSearch = function (sections) {
|
|
1889
|
+
Q.items = sections.reduce(function (a, s) { return a.concat(s.items); }, []);
|
|
1890
|
+
Q.sel = Math.min(Q.sel, Math.max(0, Q.items.length - 1));
|
|
1891
|
+
var n = 0;
|
|
1892
|
+
skList.innerHTML = sections.filter(function (s) { return s.items.length || s.empty; }).map(function (s) {
|
|
1893
|
+
return '<h3>' + esc(s.title) + (s.count ? '<span>' + s.count + '</span>' : '') + '</h3>' +
|
|
1894
|
+
(s.items.length
|
|
1895
|
+
? s.items.map(function (it) {
|
|
1896
|
+
var i = n++;
|
|
1897
|
+
return '<button class="sk-it ' + it.kind + (i === Q.sel ? ' on' : '') + '" role="option" data-i="' + i + '" aria-selected="' + (i === Q.sel) + '">' + it.html + '</button>';
|
|
1898
|
+
}).join('')
|
|
1899
|
+
: '<div class="sk-empty">' + esc(s.empty) + '</div>');
|
|
1900
|
+
}).join('');
|
|
1901
|
+
};
|
|
1902
|
+
var moveSel = function (d) {
|
|
1903
|
+
if (!Q.items.length) return;
|
|
1904
|
+
Q.sel = (Q.sel + d + Q.items.length) % Q.items.length;
|
|
1905
|
+
skList.querySelectorAll('.sk-it').forEach(function (b) {
|
|
1906
|
+
var on = Number(b.getAttribute('data-i')) === Q.sel;
|
|
1907
|
+
b.classList.toggle('on', on); b.setAttribute('aria-selected', on);
|
|
1908
|
+
if (on) b.scrollIntoView({ block: 'nearest' });
|
|
1909
|
+
});
|
|
1910
|
+
};
|
|
1911
|
+
var pick = function (i) {
|
|
1912
|
+
var it = Q.items[i];
|
|
1913
|
+
if (!it) return;
|
|
1914
|
+
closeSearch();
|
|
1915
|
+
it.go();
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
var recent = function () {
|
|
1919
|
+
Q.done = '';
|
|
1920
|
+
paintSearch([{ title: '最近的会话', items: R.convs.slice(0, 8).map(function (c) { return convItem_(c, ''); }), empty: '还没有会话' }]);
|
|
1921
|
+
};
|
|
1922
|
+
var runSearch = function () {
|
|
1923
|
+
var q = skQ.value.trim(), gen = ++Q.gen;
|
|
1924
|
+
Q.q = q; Q.sel = 0; Q.done = null;
|
|
1925
|
+
if (!q) return recent();
|
|
1926
|
+
api('api/search', { role: ROLE, q: q }).then(function (d) {
|
|
1927
|
+
if (!d.ok || gen !== Q.gen) return;
|
|
1928
|
+
var sections = [
|
|
1929
|
+
{ title: 'Role', items: d.roles.map(function (r) { return roleItem(r, q); }) },
|
|
1930
|
+
{ title: '会话', items: d.convs.map(function (c) { return convItem_(c, q); }) },
|
|
1931
|
+
{ title: '消息', count: d.msgTotal > d.msgs.length ? d.msgs.length + ' / ' + d.msgTotal : d.msgs.length,
|
|
1932
|
+
items: d.msgs.map(function (h) { return msgItem(h, q); }) },
|
|
1933
|
+
];
|
|
1934
|
+
if (!d.roles.length && !d.convs.length && !d.msgs.length) sections = [{ title: '结果', items: [], empty: '没有找到「' + q + '」' }];
|
|
1935
|
+
paintSearch(sections);
|
|
1936
|
+
Q.done = q;
|
|
1937
|
+
// 结果还没回来时按下的回车, 落在回来的第一项上。
|
|
1938
|
+
if (Q.enter) { Q.enter = false; pick(Q.sel); }
|
|
1939
|
+
}).catch(function () { });
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
var openSearch = function () {
|
|
1943
|
+
if (Q.open) { skQ.select(); return; }
|
|
1944
|
+
Q.open = true; sk.hidden = false;
|
|
1945
|
+
skQ.value = Q.q; skQ.focus(); skQ.select();
|
|
1946
|
+
runSearch();
|
|
1947
|
+
};
|
|
1948
|
+
var closeSearch = function () {
|
|
1949
|
+
if (!Q.open) return;
|
|
1950
|
+
Q.open = false; sk.hidden = true; Q.enter = false;
|
|
1951
|
+
clearTimeout(Q.timer);
|
|
1952
|
+
};
|
|
1953
|
+
skQ.addEventListener('input', function () { clearTimeout(Q.timer); Q.timer = setTimeout(runSearch, 140); });
|
|
1954
|
+
skQ.addEventListener('keydown', function (e) {
|
|
1955
|
+
if (e.isComposing) return; // 输入法选词时的回车 / 方向键归输入法
|
|
1956
|
+
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { e.preventDefault(); moveSel(1); }
|
|
1957
|
+
else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) { e.preventDefault(); moveSel(-1); }
|
|
1958
|
+
else if (e.key === 'Enter') {
|
|
1959
|
+
e.preventDefault();
|
|
1960
|
+
var v = skQ.value.trim();
|
|
1961
|
+
if (Q.done === v) return pick(Q.sel);
|
|
1962
|
+
Q.enter = true;
|
|
1963
|
+
if (Q.q !== v) { clearTimeout(Q.timer); runSearch(); }
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
skList.addEventListener('click', function (e) {
|
|
1967
|
+
var b = e.target.closest('.sk-it');
|
|
1968
|
+
if (b) pick(Number(b.getAttribute('data-i')));
|
|
1969
|
+
});
|
|
1970
|
+
skList.addEventListener('mousemove', function (e) {
|
|
1971
|
+
var b = e.target.closest('.sk-it');
|
|
1972
|
+
if (b && Number(b.getAttribute('data-i')) !== Q.sel) moveSel(Number(b.getAttribute('data-i')) - Q.sel);
|
|
1973
|
+
});
|
|
1974
|
+
sk.addEventListener('mousedown', function (e) { if (e.target === sk) closeSearch(); });
|
|
1975
|
+
document.addEventListener('keydown', function (e) {
|
|
1976
|
+
if ((MAC ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
|
|
1977
|
+
e.preventDefault();
|
|
1978
|
+
Q.open ? closeSearch() : openSearch();
|
|
1979
|
+
} else if (Q.open && e.key === 'Escape') { e.preventDefault(); closeSearch(); }
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1811
1982
|
// ── boot ──
|
|
1812
1983
|
// 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
|
|
1813
|
-
if (ROLE || CONV) app.classList.add('reading');
|
|
1984
|
+
if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
|
|
1814
1985
|
refresh();
|
|
1815
1986
|
})();
|