wezard 1.4.1 → 1.4.2
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 +133 -72
- package/cli/wezard.sh +6 -3
- package/dist/daemon/chat-name.js +6 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +53 -9
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/inbound.js +1 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +117 -10
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +74 -17
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/tasks.js +1 -1
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard.js +2 -0
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +25 -5
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +16 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/detail-render.js +2 -1
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +12 -5
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/std.js +17 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/world.js +191 -0
- package/dist/shared/world.js.map +1 -0
- package/dist/svr/index.js +1 -1
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +267 -0
- package/web/chat.js +454 -6
package/web/chat.js
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
1
|
-
// Chat 详情 SPA
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Chat 详情 SPA。三块视图共用一个 `?id=` 凭据:
|
|
2
|
+
//
|
|
3
|
+
// 线程 /api/chat (侧栏 + 总账) + /api/thread (正文) + /api/events (SSE 增量)
|
|
4
|
+
// 关系 /api/world —— 全部 wizard、家谱、跨聊天往来、工单、日程 (轮询)
|
|
5
|
+
// 日程 同一份 /api/world, 换一种读法 (按时间而不是按关系)
|
|
6
|
+
//
|
|
7
|
+
// turn 正文是服务端渲染好的 HTML 片段 —— diff / ANSI / 语法高亮只在
|
|
8
|
+
// shared/detail-render 实现一次, 这里只做 DOM 增量 reconcile。
|
|
9
|
+
//
|
|
10
|
+
// 关系图为什么不用力导向: 节点天然有归属 (哪个聊天) 和层级 (谁的分身), 力导向
|
|
11
|
+
// 会把这两件确定的事揉成一团随机位置。这里改成 HTML 布局 + SVG 连线 —— 聊天是
|
|
12
|
+
// 卡片、分身按家谱缩进 (结构由布局承担), SVG 只画那些布局表达不了的边 (跨卡片
|
|
13
|
+
// 的派活、跨聊天的分身)。好处是文字永远可读、可省略、可响应式换行。
|
|
4
14
|
//
|
|
5
15
|
// 无构建步骤: 保持 var / function 写法, 直接被浏览器加载。
|
|
6
16
|
(function () {
|
|
7
17
|
var qs = new URLSearchParams(location.search);
|
|
8
18
|
var TOKEN = qs.get('id') || '';
|
|
19
|
+
// 链接指定要开在哪个 wizard 那一栏。票据 (`id`) 可能是兄弟会话的 turn —— 它只
|
|
20
|
+
// 决定授权范围 (整个聊天), 落点由这个参数说。
|
|
21
|
+
var WANT = qs.get('target') || '';
|
|
9
22
|
var TICK_MS = 3000;
|
|
10
23
|
|
|
11
24
|
// at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
|
|
12
25
|
// 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
|
|
13
26
|
var S = { base: '', target: '', tags: [], graphs: [], at: 0, recvAt: 0, es: null, pinned: true };
|
|
27
|
+
// 关系/日程两栏共用的世界快照。sel = 当前聚焦的节点 (空 = 不聚焦, 全图淡显),
|
|
28
|
+
// kinds = 边类型开关。loaded 用来区分"还没拉过"与"拉过但是空的"。
|
|
29
|
+
var W = {
|
|
30
|
+
at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
|
|
31
|
+
degraded: false, loaded: false, sel: '', onlyRel: false, kinds: { clone: 1, peer: 1, graph: 1 },
|
|
32
|
+
};
|
|
33
|
+
var VIEW = 'thread';
|
|
14
34
|
var $ = function (s) { return document.querySelector(s); };
|
|
15
35
|
var thread = $('#thread'), tagsEl = $('#tags'), sbEl = $('#sb'), connEl = $('#conn'), gbarEl = $('#gbar');
|
|
36
|
+
var wmapEl = $('#wmap'), wscrollEl = $('#wscroll'), wtoolsEl = $('#wtools'), planEl = $('#plan-in');
|
|
16
37
|
|
|
17
38
|
var srvNow = function () { return S.at ? S.at + (Date.now() - S.recvAt) : Date.now(); };
|
|
18
39
|
|
|
@@ -181,7 +202,7 @@
|
|
|
181
202
|
'" data-tag="' + esc(p.tag) + '" title="步 ' + p.step + '/' + g.steps + ' · #' + esc(p.tag) + '">' +
|
|
182
203
|
esc(labelOfTag(p.tag)) + ' ' + esc(p.tag ? '#' + p.tag : 'default') + '</span>';
|
|
183
204
|
}).join('<span class="arw">→</span>');
|
|
184
|
-
gbarEl.hidden =
|
|
205
|
+
gbarEl.hidden = VIEW !== 'thread';
|
|
185
206
|
gbarEl.innerHTML =
|
|
186
207
|
'<span class="gid" title="graph run">🕸 ' + esc(g.runId) + '</span>' +
|
|
187
208
|
'<span class="pipe">' + nodes + '</span>' +
|
|
@@ -195,6 +216,17 @@
|
|
|
195
216
|
};
|
|
196
217
|
});
|
|
197
218
|
};
|
|
219
|
+
// 一个会话的 wizard 身份 (名字 / 职责 / 家谱) 只有 /api/world 知道 —— 侧栏
|
|
220
|
+
// 因此是两份数据的合流: tag 那份讲"跑了多少", world 那份讲"它是谁"。世界还没
|
|
221
|
+
// 拉回来时退化成原先的 `#tag` 视图, 不阻塞线程的首屏。
|
|
222
|
+
var nodeOf = function (target) {
|
|
223
|
+
return W.nodes.filter(function (n) { return n.target === target; })[0];
|
|
224
|
+
};
|
|
225
|
+
var nameOf = function (target) {
|
|
226
|
+
var n = nodeOf(target);
|
|
227
|
+
return (n && n.name) || (target.indexOf('#') >= 0 ? '#' + target.split('#').pop() : target) || target;
|
|
228
|
+
};
|
|
229
|
+
|
|
198
230
|
var renderTags = function () {
|
|
199
231
|
if (!S.tags.length) {
|
|
200
232
|
tagsEl.innerHTML = '<div class="empty-side">这个 chat 还没有会话记录</div>';
|
|
@@ -202,9 +234,14 @@
|
|
|
202
234
|
}
|
|
203
235
|
$('#side-n').textContent = S.tags.length + ' 个会话';
|
|
204
236
|
tagsEl.replaceChildren.apply(tagsEl, S.tags.map(function (t) {
|
|
205
|
-
var run = isRunning(t);
|
|
237
|
+
var run = isRunning(t), n = nodeOf(t.target) || {};
|
|
206
238
|
var el = document.createElement('div');
|
|
207
239
|
el.className = 'tag-row' + (t.target === S.target ? ' on' : '') + (run ? ' running' : '');
|
|
240
|
+
// 名字在前、地址在后: `#tag` 是地址, wizard 起过名之后人读的是名字。
|
|
241
|
+
var kin = n.parent
|
|
242
|
+
? '<span class="kin" title="' + esc(nameOf(n.parent)) + ' 的分身' + (n.inherited ? ' (继承了它的上下文)' : '') + '">↳ ' +
|
|
243
|
+
esc(nameOf(n.parent)) + (n.inherited ? ' ⧉' : '') + '</span>'
|
|
244
|
+
: '';
|
|
208
245
|
el.innerHTML =
|
|
209
246
|
'<div class="tag-av">' + esc(t.label) + (run ? '<span class="live"></span>' : '') + '</div>' +
|
|
210
247
|
'<div class="tag-main">' +
|
|
@@ -212,6 +249,8 @@
|
|
|
212
249
|
'<span class="tag-name">' + esc(t.tag ? '#' + t.tag : 'default') + '</span>' +
|
|
213
250
|
'<span class="tag-ts">' + esc(fmtAgo(t.lastTs)) + '</span>' +
|
|
214
251
|
'</div>' +
|
|
252
|
+
(n.description ? '<div class="tag-job">' + esc(n.description) + '</div>' : '') +
|
|
253
|
+
(kin ? '<div class="tag-kin">' + kin + '</div>' : '') +
|
|
215
254
|
'<div class="tag-prev">' + esc(t.preview || '(暂无对话)') + '</div>' +
|
|
216
255
|
tagMeta(t) +
|
|
217
256
|
'</div>';
|
|
@@ -399,6 +438,22 @@
|
|
|
399
438
|
};
|
|
400
439
|
};
|
|
401
440
|
|
|
441
|
+
// 关系图上随时间变化的只有两样: 呼吸灯该不该亮、"几分钟前"该写几。重建整张图
|
|
442
|
+
// 会把滚动位置、聚焦态和连线一起抖掉, 所以这里只原地改这两处。
|
|
443
|
+
var tickWorld = function () {
|
|
444
|
+
wmapEl.querySelectorAll('.wnode').forEach(function (el) {
|
|
445
|
+
var n = nodeOf(el.getAttribute('data-t'));
|
|
446
|
+
if (!n) return;
|
|
447
|
+
var run = wRunning(n);
|
|
448
|
+
el.classList.toggle('run', run);
|
|
449
|
+
var av = el.querySelector('.wav'), dot = el.querySelector('.wav .live');
|
|
450
|
+
if (run && !dot && av) av.insertAdjacentHTML('beforeend', '<i class="live"></i>');
|
|
451
|
+
if (!run && dot) dot.remove();
|
|
452
|
+
var ts = el.querySelector('.wts');
|
|
453
|
+
if (ts) ts.textContent = fmtAgo(n.lastTs);
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
|
|
402
457
|
// 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
|
|
403
458
|
setInterval(function () {
|
|
404
459
|
if (!S.tags.length) return;
|
|
@@ -406,10 +461,400 @@
|
|
|
406
461
|
renderStatus(curTag());
|
|
407
462
|
renderGraph();
|
|
408
463
|
expireTurns();
|
|
464
|
+
if (VIEW === 'world') tickWorld();
|
|
465
|
+
else if (VIEW === 'plan') renderPlan();
|
|
409
466
|
}, TICK_MS);
|
|
410
467
|
|
|
468
|
+
|
|
469
|
+
// ══ 关系视图 ═══════════════════════════════════════════════════════
|
|
470
|
+
// 三层叠在一起:
|
|
471
|
+
// 1. 聊天卡片 (HTML) —— 归属; 一张卡 = 一个群
|
|
472
|
+
// 2. 家谱缩进 (HTML) —— 层级; 分身缩在父亲下面, 左侧一道折线
|
|
473
|
+
// 3. 连线 (SVG) —— 其余的关系; 布局表达不了的那些 (跨卡片的分身、
|
|
474
|
+
// 同群与跨群的派活、流水线的一步)
|
|
475
|
+
// 前两层撑起版面 (所以文字永远可读), 第三层才是"图"。
|
|
476
|
+
var EDGE = {
|
|
477
|
+
clone: { c: '#8250df', label: '分身', dash: '' },
|
|
478
|
+
peer: { c: '#0969da', label: '派活', dash: '5 4' },
|
|
479
|
+
graph: { c: '#0a7d6b', label: '流水线', dash: '2 3' },
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器
|
|
483
|
+
// 直接抛异常, 整张图就白了。
|
|
484
|
+
var cssEsc = function (v) {
|
|
485
|
+
return window.CSS && CSS.escape
|
|
486
|
+
? CSS.escape(v)
|
|
487
|
+
: String(v).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c; });
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
var shortPath = function (p, keep) {
|
|
491
|
+
var seg = String(p || '').replace(/\/+$/, '').split('/').filter(Boolean);
|
|
492
|
+
return seg.length <= keep ? p : '…/' + seg.slice(-keep).join('/');
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
var wRunning = function (n) { return n.busy || (!!n.runningUntil && srvNow() < n.runningUntil); };
|
|
496
|
+
|
|
497
|
+
// 卡片里的名字不重复卡片头已经说过的话。默认名是 `聊天名#tag`, 而卡片头写着
|
|
498
|
+
// 聊天名、行尾还挂着 `#tag` —— 三处同一个词。所以: 起过名字就显示名字, 没起过
|
|
499
|
+
// 就只显示 `#tag`; 名字本身已经以 `#tag` 收尾时也不再重复那枚 badge。
|
|
500
|
+
var shortName = function (n) {
|
|
501
|
+
var fallback = n.chat ? (n.tag ? n.chat + '#' + n.tag : n.chat) : n.tag;
|
|
502
|
+
return n.name && n.name !== fallback ? n.name : (n.tag ? '#' + n.tag : '默认');
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// 与 sel 相连的一切 (含 sel 自己)。聚焦时其余的压暗而不是移除 —— 位置稳定,
|
|
506
|
+
// 反复点不同节点时版面不会跳。
|
|
507
|
+
var neighborhood = function (target) {
|
|
508
|
+
var set = {}; set[target] = 1;
|
|
509
|
+
W.edges.forEach(function (e) {
|
|
510
|
+
if (e.from === target) set[e.to] = 1;
|
|
511
|
+
if (e.to === target) set[e.from] = 1;
|
|
512
|
+
});
|
|
513
|
+
return set;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
var edgeOn = function (e) { return !!W.kinds[e.kind]; };
|
|
517
|
+
|
|
518
|
+
// 一个节点身上挂了几条 (当前开着的) 边。0 = 它此刻不属于任何协作关系 ——
|
|
519
|
+
// 「只看有关系的」就是把这些收起来, 剩下的才是真正的那张网。
|
|
520
|
+
var degree = function (target) {
|
|
521
|
+
return W.edges.filter(edgeOn).filter(function (e) { return e.from === target || e.to === target; }).length;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
var renderWTools = function () {
|
|
525
|
+
var counts = { clone: 0, peer: 0, graph: 0 };
|
|
526
|
+
W.edges.forEach(function (e) { counts[e.kind] = (counts[e.kind] || 0) + 1; });
|
|
527
|
+
var cross = W.edges.filter(function (e) { return e.cross; }).length;
|
|
528
|
+
var chips = Object.keys(EDGE).map(function (k) {
|
|
529
|
+
return '<button class="chip' + (W.kinds[k] ? ' on' : '') + '" data-k="' + k + '" ' +
|
|
530
|
+
'style="--c:' + EDGE[k].c + '"><i></i>' + EDGE[k].label +
|
|
531
|
+
'<b>' + (counts[k] || 0) + '</b></button>';
|
|
532
|
+
}).join('');
|
|
533
|
+
wtoolsEl.innerHTML =
|
|
534
|
+
'<div class="wlegend">' + chips + '</div>' +
|
|
535
|
+
'<div class="wstat">' +
|
|
536
|
+
W.nodes.length + ' 个 wizard · ' + W.chats.length + ' 个聊天' +
|
|
537
|
+
(cross ? ' · <b>' + cross + '</b> 条跨聊天关系' : '') +
|
|
538
|
+
(W.degraded ? ' · <span class="warn" title="注册表不可达 (独立 svr 部署), 只画观测到的往来">名册缺席</span>' : '') +
|
|
539
|
+
'<span class="hint">单击聚焦 · 双击进入线程</span>' +
|
|
540
|
+
'</div>' +
|
|
541
|
+
'<button class="chip only' + (W.onlyRel ? ' on' : '') + '" id="wonly" ' +
|
|
542
|
+
'title="把此刻不属于任何关系的 wizard 收起来 —— 剩下的就是这张协作网本身">' +
|
|
543
|
+
(W.onlyRel ? '☑' : '☐') + ' 只看有关系的</button>' +
|
|
544
|
+
(W.sel ? '<button class="chip clear" id="wclear">✕ 取消聚焦</button>' : '');
|
|
545
|
+
wtoolsEl.querySelectorAll('.chip[data-k]').forEach(function (b) {
|
|
546
|
+
b.onclick = function () {
|
|
547
|
+
var k = b.getAttribute('data-k');
|
|
548
|
+
W.kinds[k] = W.kinds[k] ? 0 : 1;
|
|
549
|
+
renderWTools(); drawEdges();
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
var only = $('#wonly');
|
|
553
|
+
if (only) only.onclick = function () { W.onlyRel = !W.onlyRel; renderWorld(); };
|
|
554
|
+
var cl = $('#wclear');
|
|
555
|
+
if (cl) cl.onclick = function () { W.sel = ''; renderWorld(); };
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
var nodeHTML = function (n, depth) {
|
|
559
|
+
var run = wRunning(n), nm = shortName(n);
|
|
560
|
+
var dupTag = !n.tag || nm === '#' + n.tag || nm.slice(-(n.tag.length + 1)) === '#' + n.tag;
|
|
561
|
+
var bits = [];
|
|
562
|
+
if (n.model) bits.push(n.model.replace(/^claude-/, ''));
|
|
563
|
+
if (n.cwd) bits.push('📁 ' + shortPath(n.cwd, 1));
|
|
564
|
+
if (n.turns) bits.push(n.turns + ' 轮');
|
|
565
|
+
if (n.taskTurns) bits.push('⏰ ' + n.taskTurns);
|
|
566
|
+
if (n.peerTurns) bits.push('✉ ' + n.peerTurns);
|
|
567
|
+
return '<div class="wnode' + (n.self ? ' self' : '') + (run ? ' run' : '') +
|
|
568
|
+
(n.alive ? '' : ' cold') + (n.local ? ' local' : '') + (degree(n.target) ? ' rel' : '') +
|
|
569
|
+
'" data-t="' + esc(n.target) + '" style="margin-left:' + (depth * 16) + 'px">' +
|
|
570
|
+
(depth ? '<span class="lin" title="分身"></span>' : '') +
|
|
571
|
+
'<span class="wav">' + esc(n.label) + (run ? '<i class="live"></i>' : '') + '</span>' +
|
|
572
|
+
'<span class="wbody">' +
|
|
573
|
+
'<span class="wl1">' +
|
|
574
|
+
'<b class="wname" title="' + esc(n.name || n.target) + '">' + esc(nm) + '</b>' +
|
|
575
|
+
(dupTag ? '' : '<span class="wtag">#' + esc(n.tag) + '</span>') +
|
|
576
|
+
(n.inherited ? '<span class="wih" title="开局继承了父亲的上下文">⧉</span>' : '') +
|
|
577
|
+
'<span class="wts">' + esc(fmtAgo(n.lastTs)) + '</span>' +
|
|
578
|
+
'</span>' +
|
|
579
|
+
(n.description ? '<span class="wjob">' + esc(n.description) + '</span>' : '') +
|
|
580
|
+
(n.preview ? '<span class="wprev">' + esc(n.preview) + '</span>' : '') +
|
|
581
|
+
(bits.length ? '<span class="wmeta">' + bits.map(esc).join('<span class="sep">·</span>') + '</span>' : '') +
|
|
582
|
+
'</span>' +
|
|
583
|
+
'</div>';
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
var renderWMap = function () {
|
|
587
|
+
if (!W.chats.length) {
|
|
588
|
+
wmapEl.innerHTML = '<div class="empty">' + (W.loaded ? '还没有任何 wizard 记录' : '加载中…') + '</div>';
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
var hood = W.sel ? neighborhood(W.sel) : null;
|
|
592
|
+
var cards = W.chats.map(function (c) {
|
|
593
|
+
var shown = c.members.filter(function (mm) { return !W.onlyRel || degree(mm.target); });
|
|
594
|
+
var live = shown.filter(function (mm) {
|
|
595
|
+
var n = nodeOf(mm.target); return n && wRunning(n);
|
|
596
|
+
}).length;
|
|
597
|
+
var rows = shown.map(function (mm) {
|
|
598
|
+
var n = nodeOf(mm.target);
|
|
599
|
+
// 「只看有关系的」会把父亲筛掉而留下分身 —— 那时的缩进没有参照物, 拉平。
|
|
600
|
+
var d = W.onlyRel ? 0 : mm.depth;
|
|
601
|
+
return n ? nodeHTML(n, d) : '';
|
|
602
|
+
}).join('');
|
|
603
|
+
if (!shown.length) return '';
|
|
604
|
+
return '<section class="wchat' + (c.self ? ' self' : '') + '" data-base="' + esc(c.base) + '">' +
|
|
605
|
+
'<header class="wch">' +
|
|
606
|
+
'<span class="nm">' + esc(c.name || c.base) + '</span>' +
|
|
607
|
+
(c.self ? '<span class="here">当前</span>' : '') +
|
|
608
|
+
'<span class="ct">' + shown.length + (live ? ' · <em>' + live + ' 在跑</em>' : '') + '</span>' +
|
|
609
|
+
'</header>' +
|
|
610
|
+
'<div class="wrows">' + rows + '</div>' +
|
|
611
|
+
(c.hidden || shown.length < c.members.length
|
|
612
|
+
? '<div class="wmore" title="不在图上的会话 —— 它们还在, 只是此刻既没在跑也没有关系">另有 ' +
|
|
613
|
+
(c.hidden + (c.members.length - shown.length)) + ' 个未显示</div>'
|
|
614
|
+
: '') +
|
|
615
|
+
'</section>';
|
|
616
|
+
}).filter(Boolean).join('');
|
|
617
|
+
wmapEl.innerHTML = '<svg class="wedges" id="wedges"></svg><div class="wgrid">' +
|
|
618
|
+
(cards || '<div class="empty">此刻没有任何协作关系 —— 派活 / 生分身之后这里就有边了</div>') + '</div>';
|
|
619
|
+
wmapEl.querySelectorAll('.wnode').forEach(function (el) {
|
|
620
|
+
var t = el.getAttribute('data-t');
|
|
621
|
+
if (hood && !hood[t]) el.classList.add('dim');
|
|
622
|
+
if (t === W.sel) el.classList.add('sel');
|
|
623
|
+
el.onclick = function () { W.sel = (W.sel === t ? '' : t); renderWorld(); };
|
|
624
|
+
el.ondblclick = function () {
|
|
625
|
+
// 双击 = 进它的线程。只有同聊天的节点有线程可进 —— 凭据按聊天关
|
|
626
|
+
// (见 chat-http 的 capability 说明), 外聊天的节点只有身份与关系。
|
|
627
|
+
var n = nodeOf(t);
|
|
628
|
+
if (n && n.local) { setView('thread'); select(t); }
|
|
629
|
+
};
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// ── 连线 ──
|
|
634
|
+
// 端点在布局之后才知道 (卡片会换行、文字会折行), 所以连线是一个纯粹的
|
|
635
|
+
// "读版面 → 画路径" 的过程, 每次重排都重来一遍, 不维护任何位置状态。
|
|
636
|
+
var anchorsOf = function (a, b, base) {
|
|
637
|
+
var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
|
638
|
+
var ox = base.left, oy = base.top;
|
|
639
|
+
var A = { l: ra.left - ox, r: ra.right - ox, cy: ra.top - oy + ra.height / 2 };
|
|
640
|
+
var B = { l: rb.left - ox, r: rb.right - ox, cy: rb.top - oy + rb.height / 2 };
|
|
641
|
+
// 同一列 (同一张卡片里) → 两端都走左侧, 从左边的空白处绕出去, 像 git 图的
|
|
642
|
+
// 那条 gutter。左右分列 → 从近的一侧出、近的一侧进。
|
|
643
|
+
if (Math.abs(A.l - B.l) < 60) {
|
|
644
|
+
// 同卡片内的边走卡片自己的左内边距 (.wrows 的 padding-left) —— 那条车道
|
|
645
|
+
// 就是为它留的。拐到卡片外面既会被滚动容器裁掉, 也读不出"这两个是一个群
|
|
646
|
+
// 里的"。room 按最近的卡片左沿算, 没有卡片 (理论上不会) 才退回画布左沿。
|
|
647
|
+
var card = a.closest('.wchat');
|
|
648
|
+
var lane = card ? card.getBoundingClientRect().left - ox + 6 : 4;
|
|
649
|
+
var room = Math.max(6, Math.min(A.l, B.l) - lane);
|
|
650
|
+
var d = Math.min(room, 14 + Math.abs(A.cy - B.cy) * 0.22);
|
|
651
|
+
return {
|
|
652
|
+
d: 'M' + A.l + ',' + A.cy + ' C' + (A.l - d) + ',' + A.cy + ' ' + (B.l - d) + ',' + B.cy + ' ' + B.l + ',' + B.cy,
|
|
653
|
+
head: 'start',
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
var right = B.l > A.l;
|
|
657
|
+
var ax = right ? A.r : A.l, bx = right ? B.l : B.r;
|
|
658
|
+
var k = right ? 1 : -1, dd = Math.max(46, Math.abs(bx - ax) * 0.42);
|
|
659
|
+
return {
|
|
660
|
+
d: 'M' + ax + ',' + A.cy + ' C' + (ax + dd * k) + ',' + A.cy + ' ' + (bx - dd * k) + ',' + B.cy + ' ' + bx + ',' + B.cy,
|
|
661
|
+
head: right ? 'end' : 'start',
|
|
662
|
+
};
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
var drawEdges = function () {
|
|
666
|
+
var svg = $('#wedges');
|
|
667
|
+
if (!svg) return;
|
|
668
|
+
var base = wmapEl.getBoundingClientRect();
|
|
669
|
+
svg.setAttribute('width', wmapEl.scrollWidth);
|
|
670
|
+
svg.setAttribute('height', wmapEl.scrollHeight);
|
|
671
|
+
svg.setAttribute('viewBox', '0 0 ' + wmapEl.scrollWidth + ' ' + wmapEl.scrollHeight);
|
|
672
|
+
var defs = Object.keys(EDGE).map(function (k) {
|
|
673
|
+
return ['', '-d'].map(function (sfx) {
|
|
674
|
+
return '<marker id="ah-' + k + sfx + '" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" ' +
|
|
675
|
+
'orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="' + EDGE[k].c + '" ' +
|
|
676
|
+
'opacity="' + (sfx ? '.16' : '.85') + '"/></marker>';
|
|
677
|
+
}).join('');
|
|
678
|
+
}).join('');
|
|
679
|
+
var hood = W.sel ? neighborhood(W.sel) : null;
|
|
680
|
+
var paths = W.edges.filter(edgeOn).map(function (e) {
|
|
681
|
+
var a = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.from) + '"]');
|
|
682
|
+
var b = wmapEl.querySelector('.wnode[data-t="' + cssEsc(e.to) + '"]');
|
|
683
|
+
if (!a || !b) return '';
|
|
684
|
+
var p = anchorsOf(a, b, base);
|
|
685
|
+
// 聚焦时: 不碰 sel 的边压到近乎不可见 —— 删掉它们会让"这张图本来有多密"
|
|
686
|
+
// 这个信息消失, 而那恰恰是判断要不要收几个 wizard 的依据。
|
|
687
|
+
var off = hood && !(e.from === W.sel || e.to === W.sel);
|
|
688
|
+
var w = Math.min(4, 1.1 + Math.log(1 + e.count) * 0.9);
|
|
689
|
+
return '<path d="' + p.d + '" fill="none" stroke="' + EDGE[e.kind].c + '" ' +
|
|
690
|
+
'stroke-width="' + (off ? 1 : w).toFixed(2) + '" ' +
|
|
691
|
+
'stroke-dasharray="' + (EDGE[e.kind].dash || '') + '" ' +
|
|
692
|
+
'opacity="' + (off ? 0.14 : (e.cross ? 0.95 : 0.6)) + '" ' +
|
|
693
|
+
'marker-' + p.head + '="url(#ah-' + e.kind + (off ? '-d' : '') + ')">' +
|
|
694
|
+
'<title>' + esc(nameOf(e.from) + ' → ' + nameOf(e.to)) + ' · ' + EDGE[e.kind].label +
|
|
695
|
+
(e.count > 1 ? ' ×' + e.count : '') + (e.cross ? ' (跨聊天)' : '') +
|
|
696
|
+
(e.jobs && e.jobs.length ? ' · 工单 ' + esc(e.jobs.join(' ')) : '') + '</title></path>';
|
|
697
|
+
}).join('');
|
|
698
|
+
svg.innerHTML = '<defs>' + defs + '</defs>' + paths;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
var renderWorld = function () {
|
|
702
|
+
renderWTools();
|
|
703
|
+
renderWMap();
|
|
704
|
+
// 连线要等浏览器把卡片排好 —— 同一帧里量到的是上一次的版面。
|
|
705
|
+
requestAnimationFrame(drawEdges);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// ══ 日程视图 ═══════════════════════════════════════════════════════
|
|
709
|
+
// 定时任务与工单摆在一起, 因为它们回答同一个问题: **什么被安排了**。
|
|
710
|
+
// 区别只在时间的方向 —— 定时指向未来 (下次几点放枪), 工单指向现在 (还开着
|
|
711
|
+
// 的这几路活干完没有)。
|
|
712
|
+
var fmtClock = function (ts) {
|
|
713
|
+
if (!ts) return '—';
|
|
714
|
+
var d = new Date(ts), p = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
715
|
+
var today = new Date(srvNow());
|
|
716
|
+
var sameDay = d.toDateString() === today.toDateString();
|
|
717
|
+
return (sameDay ? '今天 ' : (p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ')) + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
718
|
+
};
|
|
719
|
+
var fmtIn = function (ts) {
|
|
720
|
+
var d = ts - srvNow();
|
|
721
|
+
if (d <= 0) return '即将';
|
|
722
|
+
if (d < 3600000) return Math.max(1, Math.round(d / 60000)) + ' 分钟后';
|
|
723
|
+
if (d < 86400000) return Math.round(d / 3600000) + ' 小时后';
|
|
724
|
+
return Math.round(d / 86400000) + ' 天后';
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
var wizChip = function (target) {
|
|
728
|
+
var n = nodeOf(target);
|
|
729
|
+
return '<span class="wchip' + (n && n.local ? ' go' : '') + '" data-t="' + esc(target) + '">' +
|
|
730
|
+
esc((n && n.label) || '🧙') + ' ' + esc(nameOf(target)) + '</span>';
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// 未来 12 小时的刻度条: 哪几个钟点会有任务醒来, 一眼看完。超出 12h 的排进下面
|
|
734
|
+
// 的列表但不占刻度 —— 把 24h 压进这么窄的一条, 刻度会密到读不出。
|
|
735
|
+
var HORIZON_H = 12;
|
|
736
|
+
var renderStrip = function (list) {
|
|
737
|
+
var now = srvNow(), span = HORIZON_H * 3600000;
|
|
738
|
+
var soon = list.filter(function (x) { return x.nextAt - now < span; });
|
|
739
|
+
var ticks = [];
|
|
740
|
+
for (var i = 0; i <= HORIZON_H; i += 3) {
|
|
741
|
+
var t = new Date(now + i * 3600000);
|
|
742
|
+
ticks.push('<span class="tk" style="left:' + (i / HORIZON_H * 100) + '%">' +
|
|
743
|
+
(i ? (t.getHours() < 10 ? '0' : '') + t.getHours() + ':00' : '现在') + '</span>');
|
|
744
|
+
}
|
|
745
|
+
var pins = soon.map(function (x) {
|
|
746
|
+
var pct = Math.max(0, Math.min(100, (x.nextAt - now) / span * 100));
|
|
747
|
+
return '<span class="pin" style="left:' + pct.toFixed(2) + '%" data-t="' + esc(x.target) + '" ' +
|
|
748
|
+
'title="' + esc(fmtClock(x.nextAt) + ' · ' + nameOf(x.target) + ' · ' + x.when) + '"></span>';
|
|
749
|
+
}).join('');
|
|
750
|
+
return '<div class="strip"><div class="axis">' + ticks.join('') + '</div>' +
|
|
751
|
+
'<div class="rail">' + pins + '</div>' +
|
|
752
|
+
'<div class="cap">' + (soon.length ? '未来 ' + HORIZON_H + ' 小时内 ' + soon.length + ' 次触发' : '未来 ' + HORIZON_H + ' 小时内没有定时任务') + '</div></div>';
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
var renderPlan = function () {
|
|
756
|
+
var ss = W.schedules || [], js = W.jobs || [];
|
|
757
|
+
var open = js.filter(function (j) { return j.status === 'open'; });
|
|
758
|
+
var closed = js.filter(function (j) { return j.status !== 'open'; });
|
|
759
|
+
var schedRows = ss.length
|
|
760
|
+
? ss.map(function (x) {
|
|
761
|
+
return '<div class="prow">' +
|
|
762
|
+
'<div class="pl">' +
|
|
763
|
+
'<div class="pwhen">⏰ ' + esc(x.when) + '</div>' +
|
|
764
|
+
'<div class="pnext"><b>' + esc(fmtClock(x.nextAt)) + '</b><span>' + esc(fmtIn(x.nextAt)) + '</span></div>' +
|
|
765
|
+
'</div>' +
|
|
766
|
+
'<div class="pr">' +
|
|
767
|
+
'<div class="ph">' + wizChip(x.target) + (x.note ? '<span class="note">' + esc(x.note) + '</span>' : '') +
|
|
768
|
+
'<span class="pid">' + esc(x.id) + '</span></div>' +
|
|
769
|
+
'<div class="ptext">' + esc(x.prompt.split('\n')[0].slice(0, 160)) + '</div>' +
|
|
770
|
+
'<div class="pfoot">' + (x.lastFired ? '上次 ' + esc(fmtAgo(x.lastFired)) : '还没跑过') + '</div>' +
|
|
771
|
+
'</div>' +
|
|
772
|
+
'</div>';
|
|
773
|
+
}).join('')
|
|
774
|
+
: '<div class="pempty">没有定时任务 —— schedule_task 排一个</div>';
|
|
775
|
+
|
|
776
|
+
var jobRow = function (j) {
|
|
777
|
+
return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
|
|
778
|
+
'<div class="jh"><span class="jid">' + esc(j.id) + '</span>' +
|
|
779
|
+
'<span class="jt">' + esc(j.title) + '</span>' +
|
|
780
|
+
'<span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
|
|
781
|
+
'<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
|
|
782
|
+
'<div class="jm">' + (j.members.length
|
|
783
|
+
? j.members.map(function (mm) {
|
|
784
|
+
return '<span class="jmm">' + wizChip(mm.target) +
|
|
785
|
+
(mm.spawned ? '<i class="tmp" title="为这个工单临时生的分身, 收工时回收">临时</i>' : '') +
|
|
786
|
+
'<em>' + esc((mm.task || '').split('\n')[0].slice(0, 70)) + '</em></span>';
|
|
787
|
+
}).join('')
|
|
788
|
+
: '<span class="jmm none">还没有成员</span>') + '</div>' +
|
|
789
|
+
(j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
|
|
790
|
+
'</div>';
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
planEl.innerHTML =
|
|
794
|
+
'<section class="psec">' +
|
|
795
|
+
'<h3>⏰ 定时任务<span>' + ss.length + '</span></h3>' +
|
|
796
|
+
renderStrip(ss) + schedRows +
|
|
797
|
+
'</section>' +
|
|
798
|
+
'<section class="psec">' +
|
|
799
|
+
'<h3>📋 工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
|
|
800
|
+
(js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上分身时 open_job 开一个</div>') +
|
|
801
|
+
'</section>';
|
|
802
|
+
planEl.querySelectorAll('.wchip.go').forEach(function (c) {
|
|
803
|
+
c.onclick = function () { setView('thread'); select(c.getAttribute('data-t')); };
|
|
804
|
+
});
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// ══ 视图切换 ═══════════════════════════════════════════════════════
|
|
808
|
+
// 世界快照不走 SSE: 它变动的源头 (spawn / 改职责 / 开收工单 / 排定时) 一条都
|
|
809
|
+
// 不经过 detail store, 而给它们各自搭一条事件通路, 换来的只是几秒的新鲜度。
|
|
810
|
+
// 改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
|
|
811
|
+
var WORLD_MS = 6000;
|
|
812
|
+
var worldTimer = null;
|
|
813
|
+
var loadWorld = function () {
|
|
814
|
+
return api('api/world', {}).then(function (d) {
|
|
815
|
+
if (!d.ok) return;
|
|
816
|
+
W.at = d.at; W.loaded = true;
|
|
817
|
+
W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
|
|
818
|
+
W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
|
|
819
|
+
// 身份回来了, 侧栏那几行也跟着变 —— 名字/职责/家谱都在这份数据里。
|
|
820
|
+
renderTags();
|
|
821
|
+
if (VIEW === 'world') renderWorld();
|
|
822
|
+
if (VIEW === 'plan') renderPlan();
|
|
823
|
+
}).catch(function () { });
|
|
824
|
+
};
|
|
825
|
+
var pollWorld = function () {
|
|
826
|
+
if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
|
|
827
|
+
if (VIEW === 'thread') return;
|
|
828
|
+
worldTimer = setInterval(function () {
|
|
829
|
+
if (!document.hidden) loadWorld();
|
|
830
|
+
}, WORLD_MS);
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
var setView = function (v) {
|
|
834
|
+
VIEW = v;
|
|
835
|
+
$('#views').querySelectorAll('.vb').forEach(function (b) {
|
|
836
|
+
b.classList.toggle('on', b.getAttribute('data-view') === v);
|
|
837
|
+
});
|
|
838
|
+
thread.hidden = v !== 'thread';
|
|
839
|
+
$('#pane-world').hidden = v !== 'world';
|
|
840
|
+
$('#pane-plan').hidden = v !== 'plan';
|
|
841
|
+
// graph 条只对线程有意义 (它讲的是当前这一路被谁驱动)。
|
|
842
|
+
gbarEl.hidden = v !== 'thread' || !(S.graphs || []).length;
|
|
843
|
+
document.querySelector('.app').classList.toggle('wide', v !== 'thread');
|
|
844
|
+
if (v === 'thread') { toBottom(true); }
|
|
845
|
+
else if (!W.loaded) { (v === 'world' ? (wmapEl.innerHTML = '<div class="empty">加载中…</div>') : (planEl.innerHTML = '<div class="empty">加载中…</div>')); loadWorld(); }
|
|
846
|
+
else if (v === 'world') renderWorld();
|
|
847
|
+
else renderPlan();
|
|
848
|
+
pollWorld();
|
|
849
|
+
};
|
|
850
|
+
$('#views').querySelectorAll('.vb').forEach(function (b) {
|
|
851
|
+
b.onclick = function () { setView(b.getAttribute('data-view')); };
|
|
852
|
+
});
|
|
853
|
+
// 卡片换行会改变每个节点的坐标 —— 连线必须跟着重画。
|
|
854
|
+
window.addEventListener('resize', function () { if (VIEW === 'world') requestAnimationFrame(drawEdges); });
|
|
855
|
+
|
|
411
856
|
// ── boot ──
|
|
412
|
-
api('api/chat', {}).then(function (d) {
|
|
857
|
+
api('api/chat', WANT ? { target: WANT } : {}).then(function (d) {
|
|
413
858
|
if (!d.ok) {
|
|
414
859
|
document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
|
|
415
860
|
return;
|
|
@@ -418,4 +863,7 @@
|
|
|
418
863
|
// 卡片链接带来的那条 turn 决定默认选中的 tag; 否则取最近活跃的。
|
|
419
864
|
select(d.self && d.self.target ? d.self.target : (S.tags[0] && S.tags[0].target));
|
|
420
865
|
});
|
|
866
|
+
// 与线程并行取: 侧栏那几行的名字 / 职责 / 家谱都在这份快照里, 不该等线程渲染完
|
|
867
|
+
// 才补上。首屏之后就只在关系/日程栏轮询 (见 pollWorld)。
|
|
868
|
+
loadWorld();
|
|
421
869
|
})();
|