wezard 2.2.2 → 2.4.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/CLAUDE.md +9 -6
- package/README.md +5 -5
- package/config.example.jsonc +12 -0
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +90 -6
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-log.js +6 -1
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +15 -0
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +265 -70
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/danger.js +15 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +49 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +12 -5
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/idle-nudge.js +55 -0
- package/dist/daemon/idle-nudge.js.map +1 -0
- package/dist/daemon/inbound.js +19 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1509 -259
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +180 -32
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +15 -0
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/memory-steward.js +124 -68
- package/dist/daemon/memory-steward.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +506 -124
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +102 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +147 -33
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +261 -124
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +171 -0
- package/dist/daemon/pending-items.js.map +1 -0
- package/dist/daemon/receipts.js +428 -80
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +133 -19
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +24 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +32 -0
- package/dist/daemon/steward-guard.js.map +1 -0
- package/dist/daemon/task-registry.js +1 -1
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +12 -4
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/usage.js +4 -11
- package/dist/daemon/usage.js.map +1 -1
- package/dist/daemon/wizard-memory.js +36 -4
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +174 -87
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +209 -132
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/charter-view.js +35 -0
- package/dist/shared/charter-view.js.map +1 -0
- package/dist/shared/chat-http.js +134 -32
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +6 -3
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +17 -6
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config-meta.js +91 -0
- package/dist/shared/config-meta.js.map +1 -0
- package/dist/shared/config-writer.js +5 -6
- package/dist/shared/config-writer.js.map +1 -1
- package/dist/shared/config.js +273 -294
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/daemon-token.js +18 -0
- package/dist/shared/daemon-token.js.map +1 -0
- package/dist/shared/detail-render.js +233 -20
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +33 -12
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/effort.js +11 -0
- package/dist/shared/effort.js.map +1 -0
- package/dist/shared/keepalive.js +5 -5
- package/dist/shared/keepalive.js.map +1 -1
- package/dist/shared/model-prices.js +258 -0
- package/dist/shared/model-prices.js.map +1 -0
- package/dist/shared/pricing.js +55 -0
- package/dist/shared/pricing.js.map +1 -0
- package/dist/shared/reminder.js +18 -4
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +210 -8
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +104 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +4 -0
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/task-file.js +16 -1
- package/dist/shared/task-file.js.map +1 -1
- package/dist/shared/turn-state.js +24 -0
- package/dist/shared/turn-state.js.map +1 -0
- package/dist/shared/world.js +116 -13
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +2 -1
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +15 -3
- package/package.json +1 -1
- package/web/chat.css +226 -74
- package/web/chat.js +1075 -370
package/web/chat.js
CHANGED
|
@@ -19,20 +19,27 @@
|
|
|
19
19
|
var WITH = qs.get('with') || '';
|
|
20
20
|
// '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
|
|
21
21
|
var SESSION = qs.get('session') || '';
|
|
22
|
+
// 页面上两个独立的视角: ROLE 是页面视角 (名片、侧栏、标题、关系图中心), 只有点头像 / 名字及其装饰 (⇄ 等) 换它;
|
|
23
|
+
// VIEWPOINT 是 chat detail 的视角 (谁算「自己」靠右、描述行以谁为准), 点卡片 / 会话项的其余地方与详情区里的行设它 ——
|
|
24
|
+
// 关系图卡片 = 被点的那个 role, .far 子项 = 那个 role, 移交行 = 移交人 (派活那一方),
|
|
25
|
+
// 新生 / 被收的提示行 = 那个 wizard; 会话与成对的子项 = 页面视角自己。'' = 与页面视角相同。老链接的 `eye=` 照读。
|
|
26
|
+
var VIEWPOINT = qs.get('viewpoint') || qs.get('eye') || '';
|
|
27
|
+
var viewpointOf = function () { return VIEWPOINT || ROLE; };
|
|
22
28
|
var TICK_MS = 3000;
|
|
23
29
|
|
|
24
30
|
// at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
|
|
25
31
|
// 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
|
|
26
|
-
var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, stats: null, winStats: null };
|
|
32
|
+
var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, charter: null, stats: null, winStats: null };
|
|
27
33
|
// frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
|
|
28
34
|
var S = { es: null, pinned: true, gen: 0, frags: {} };
|
|
29
|
-
// 关系/日程两栏共用的世界快照。
|
|
35
|
+
// 关系/日程两栏共用的世界快照。treeFor = 已把谁滚进过视野。
|
|
30
36
|
var W = {
|
|
31
37
|
at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
|
|
32
|
-
degraded: false, loaded: false,
|
|
38
|
+
degraded: false, loaded: false, treeFor: '', glance: {}, gKeys: '',
|
|
33
39
|
};
|
|
34
40
|
var VIEW = 'msgs';
|
|
35
41
|
// 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
|
|
42
|
+
// URL 带了 side 以它为准; 没带 (新链接) 用本地记下的, 见 WORLD_KEY。
|
|
36
43
|
var WORLD = qs.get('side') === 'world';
|
|
37
44
|
var $ = function (s) { return document.querySelector(s); };
|
|
38
45
|
var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
|
|
@@ -46,13 +53,13 @@
|
|
|
46
53
|
if (n < 1e6) { var v = n / 1000; return (v >= 10 ? v.toFixed(0) : v.toFixed(1).replace(/\.0$/, '')) + 'k'; }
|
|
47
54
|
return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
|
|
48
55
|
};
|
|
56
|
+
// 与服务端 detail-render 的 fmtDuration 同一写法: 走表的那一轮收口后换成服务端写死的值, 不该跳格式。
|
|
49
57
|
var fmtDur = function (ms) {
|
|
50
58
|
if (ms < 1000) return ms + 'ms';
|
|
51
|
-
var s = Math.round(ms /
|
|
59
|
+
var s = Math.round(ms / 100) / 10;
|
|
52
60
|
if (s < 60) return s + 's';
|
|
53
61
|
var m = Math.floor(s / 60);
|
|
54
|
-
|
|
55
|
-
return Math.floor(m / 60) + 'h' + (m % 60) + 'm';
|
|
62
|
+
return m + 'm' + Math.round(s - m * 60) + 's';
|
|
56
63
|
};
|
|
57
64
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
58
65
|
var fmtDay = function (ts) {
|
|
@@ -93,6 +100,7 @@
|
|
|
93
100
|
if (CONV) p.conv = CONV;
|
|
94
101
|
if (WITH) p['with'] = WITH;
|
|
95
102
|
if (SESSION) p.session = SESSION;
|
|
103
|
+
if (VIEWPOINT) p.viewpoint = VIEWPOINT;
|
|
96
104
|
if (WORLD) p.side = 'world';
|
|
97
105
|
Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
|
|
98
106
|
return p;
|
|
@@ -106,9 +114,18 @@
|
|
|
106
114
|
};
|
|
107
115
|
|
|
108
116
|
// ── 名字 ──
|
|
109
|
-
// 一个 role
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
// 一个 role 的名字与头像, 两路来: 摘要 (名册, 每拍刷新, 为准) 与消息片段 (渲染那一刻的名册, 只在摘要没给时兜底)。
|
|
118
|
+
// svr 刚重启、还没收到 daemon 推来的名册那几秒, 两路回的「名字」都只是 id 本身 (头像也是按 id 算的) ——
|
|
119
|
+
// id 不是名字, 不记; 否则它会钉在已上屏的行里, 名册到了也不改。返回: 这一次记的有没有变。
|
|
120
|
+
var names = {}, fromDir = {};
|
|
121
|
+
var learn = function (id, name, label, dir) {
|
|
122
|
+
if (!id || !name || name === id || (!dir && fromDir[id])) return false;
|
|
123
|
+
if (dir) fromDir[id] = 1;
|
|
124
|
+
var k = names[id];
|
|
125
|
+
if (k && k.name === name && k.label === (label || '')) return false;
|
|
126
|
+
names[id] = { name: name, label: label || '' };
|
|
127
|
+
return true;
|
|
128
|
+
};
|
|
112
129
|
var kindOf = function (id) {
|
|
113
130
|
return /^task:/.test(id) ? 'task' : id === 'system:' ? 'system' : /^human:/.test(id) ? 'human' : 'wizard';
|
|
114
131
|
};
|
|
@@ -132,8 +149,9 @@
|
|
|
132
149
|
var on = canSwitch(id) && id !== ROLE;
|
|
133
150
|
return '<span class="' + cls + (on ? ' go" data-r="' + esc(id) + '" title="' + esc('切到 ' + nameOf(id) + ' 的视角') : '') + '">' + inner + '</span>';
|
|
134
151
|
};
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
// extra = 跟在名字后、同属一个点击范围的徽标 (原样 html)。
|
|
153
|
+
var nm = function (id, name, go, extra) {
|
|
154
|
+
var cls = 'nm ' + kindOf(id), txt = esc(name || roleName(id)) + (extra || '');
|
|
137
155
|
return go ? goSpan(cls, id, txt) : '<span class="' + cls + '">' + txt + '</span>';
|
|
138
156
|
};
|
|
139
157
|
var bindGo = function (root, sel) {
|
|
@@ -148,8 +166,8 @@
|
|
|
148
166
|
|
|
149
167
|
// ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
|
|
150
168
|
var md = null;
|
|
151
|
-
var
|
|
152
|
-
if (!window.markdownit) return;
|
|
169
|
+
var mdReady = function () {
|
|
170
|
+
if (!window.markdownit) return false;
|
|
153
171
|
if (!md) {
|
|
154
172
|
md = window.markdownit({
|
|
155
173
|
html: false, linkify: true, breaks: true, highlight: function (str, lang) {
|
|
@@ -161,10 +179,18 @@
|
|
|
161
179
|
}
|
|
162
180
|
});
|
|
163
181
|
}
|
|
164
|
-
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
var render = function (scope) {
|
|
185
|
+
if (!mdReady()) return;
|
|
186
|
+
// 工单页的开工 / 收工两行不是气泡, 计划与结论同样是 markdown。
|
|
187
|
+
scope.querySelectorAll('.bubble, .tg-job').forEach(function (b) {
|
|
165
188
|
var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
|
|
166
189
|
if (src && body && body.dataset.rendered !== '1') {
|
|
190
|
+
// 「发给谁」先挂在空 .md-body 里; 填进 markdown 后把它塞进第一段, 才能和正文第一行同行。
|
|
191
|
+
var to = body.querySelector(':scope > .to-in');
|
|
167
192
|
body.innerHTML = md.render(src.textContent || '');
|
|
193
|
+
if (to) { var p = body.firstElementChild; (p && p.tagName === 'P' ? p : body).prepend(to, ' '); }
|
|
168
194
|
body.dataset.rendered = '1';
|
|
169
195
|
}
|
|
170
196
|
});
|
|
@@ -176,15 +202,18 @@
|
|
|
176
202
|
var m = {};
|
|
177
203
|
scope.querySelectorAll('[data-key]').forEach(function (b) {
|
|
178
204
|
var k = b.getAttribute('data-key');
|
|
179
|
-
b.querySelectorAll('details').forEach(function (d, i) { m[k
|
|
205
|
+
b.querySelectorAll('details').forEach(function (d, i) { m[openKey(k, d, i)] = d.open; });
|
|
180
206
|
});
|
|
181
207
|
return m;
|
|
182
208
|
};
|
|
209
|
+
// 展开态按位置记; 移交行单独记一格 —— 它是后来才挂上去的 (公开移交从气泡变成可开合),
|
|
210
|
+
// 跟着位置走会把旁边那个框的开合套到它身上, 默认展开的公开移交被收起、箭头朝右。
|
|
211
|
+
var openKey = function (k, d, i) { return k + '#' + (d.classList.contains('handoff') ? 'ho' : i); };
|
|
183
212
|
var restoreOpen = function (scope, m) {
|
|
184
213
|
scope.querySelectorAll('[data-key]').forEach(function (b) {
|
|
185
214
|
var k = b.getAttribute('data-key');
|
|
186
215
|
b.querySelectorAll('details').forEach(function (d, i) {
|
|
187
|
-
var v = m[k
|
|
216
|
+
var v = m[openKey(k, d, i)]; if (v !== undefined) d.open = v;
|
|
188
217
|
});
|
|
189
218
|
});
|
|
190
219
|
};
|
|
@@ -210,11 +239,8 @@
|
|
|
210
239
|
var eb = childBy(ex, 'bubbles'), nb = childBy(nc, 'bubbles');
|
|
211
240
|
if (eb && nb) {
|
|
212
241
|
swapHead(ex, nc);
|
|
213
|
-
//
|
|
214
|
-
if (nc.hasAttribute('data-fold')
|
|
215
|
-
ex.setAttribute('data-fold', '');
|
|
216
|
-
ex.classList.add('folded');
|
|
217
|
-
}
|
|
242
|
+
// 下面有了新东西 / 这一轮结束: 过程框收起一次。标记留在节点上, 用户之后再点开不会被下一帧收回去。
|
|
243
|
+
if (nc.hasAttribute('data-fold')) settle(ex);
|
|
218
244
|
reconcile(eb, nb);
|
|
219
245
|
ex.setAttribute('data-sig', nc.getAttribute('data-sig') || '');
|
|
220
246
|
return ex;
|
|
@@ -225,6 +251,28 @@
|
|
|
225
251
|
restoreOpen(cur, open);
|
|
226
252
|
render(cur);
|
|
227
253
|
};
|
|
254
|
+
// 人手点过的过程框: data-key → 是否收起。只由点击写入, 每次换节点 (增量、翻页、换视角
|
|
255
|
+
// 整窗重拉) 之后按它回填 —— 默认态归服务端, 人的选择压过默认态。
|
|
256
|
+
var FOLD = {};
|
|
257
|
+
var settle = function (s) {
|
|
258
|
+
if (s.hasAttribute('data-fold')) return;
|
|
259
|
+
s.setAttribute('data-fold', '');
|
|
260
|
+
s.classList.add('folded');
|
|
261
|
+
};
|
|
262
|
+
// 一轮还在跑, 但线程里它下面已经有了别的消息 —— 它也不再是「最末尾那个」。
|
|
263
|
+
// 这件事只有整条线程知道 (片段按轮缓存, 服务端不看邻居), 所以在客户端补。
|
|
264
|
+
var settleAbove = function () {
|
|
265
|
+
var rows = inner.querySelectorAll('.mrow');
|
|
266
|
+
Array.prototype.slice.call(rows, 0, -1).forEach(function (r) {
|
|
267
|
+
r.querySelectorAll('.steps').forEach(settle);
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
var applyFold = function (root) {
|
|
271
|
+
root.querySelectorAll('.steps[data-key]').forEach(function (s) {
|
|
272
|
+
var v = FOLD[s.getAttribute('data-key')];
|
|
273
|
+
if (v !== undefined) s.classList.toggle('folded', v);
|
|
274
|
+
});
|
|
275
|
+
};
|
|
228
276
|
var frag = function (html) {
|
|
229
277
|
var d = document.createElement('div'); d.innerHTML = html; return d;
|
|
230
278
|
};
|
|
@@ -264,7 +312,9 @@
|
|
|
264
312
|
inner.addEventListener('click', function (e) {
|
|
265
313
|
var s = e.target.closest && e.target.closest('.steps');
|
|
266
314
|
if (!s) return;
|
|
267
|
-
if (e.target.closest('.steps-head') || e.target === s || e.target === childBy(s, 'bubbles'))
|
|
315
|
+
if (e.target.closest('.steps-head') || e.target === s || e.target === childBy(s, 'bubbles')) {
|
|
316
|
+
FOLD[s.getAttribute('data-key')] = s.classList.toggle('folded');
|
|
317
|
+
}
|
|
268
318
|
});
|
|
269
319
|
// reminder 块: 摘要 ⇄ 原文。刚划选了一段原文的那次松手不算点击。
|
|
270
320
|
inner.addEventListener('click', function (e) {
|
|
@@ -272,17 +322,135 @@
|
|
|
272
322
|
if (!r || String(window.getSelection() || '')) return;
|
|
273
323
|
r.classList.toggle('raw');
|
|
274
324
|
});
|
|
325
|
+
inner.addEventListener('click', function (e) {
|
|
326
|
+
var c = e.target.closest && e.target.closest('.mchip.job[data-job], .tg-job .jc[data-job], .jcrumb [data-job]');
|
|
327
|
+
if (c) openJob(c.getAttribute('data-job'));
|
|
328
|
+
});
|
|
329
|
+
// 回执 chip / 移交行的活号: 跳到派活那句 (同一个活号的入消息)。先在眼前这个窗口里找;
|
|
330
|
+
// 不在就按它带来的坐标切到它所在的会话再落点, 同搜索结果 —— 派活那句常在另一个窗口
|
|
331
|
+
// (私聊派的活, 回执落在群里)。会话键随视角: 公开的在那个群; 私聊在视角与对端的私聊里,
|
|
332
|
+
// 视角不是两端之一就开「这两方之间」的往来。服务端找不到那句时不会画成可点的。
|
|
333
|
+
inner.addEventListener('click', function (e) {
|
|
334
|
+
var c = e.target.closest && e.target.closest('[data-goto][data-gid]');
|
|
335
|
+
if (!c) return;
|
|
336
|
+
var q = inner.querySelector('[data-pturn="' + cssEsc(c.getAttribute('data-goto')) + '"]');
|
|
337
|
+
var row = q && q.closest('.mrow');
|
|
338
|
+
if (row && focusRow(row.getAttribute('data-id'))) return;
|
|
339
|
+
var ch = c.getAttribute('data-gch'), from = c.getAttribute('data-gfrom'), to = c.getAttribute('data-gto');
|
|
340
|
+
var conv = ch ? 'c:' + ch : ROLE === from ? 'p:' + to : ROLE === to ? 'p:' + from : 'a:' + from + '|' + to;
|
|
341
|
+
jumpMsg({ conv: conv, id: c.getAttribute('data-gid'), ts: Number(c.getAttribute('data-gts')) });
|
|
342
|
+
});
|
|
343
|
+
// 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 页面视角不是两端之一就开
|
|
344
|
+
// 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。页面视角不动, 详情区的 viewpoint
|
|
345
|
+
// 换成移交人 (派活那一方)。节点随 reconcile 换, 所以在根上委托。
|
|
346
|
+
// 行尾的 chevron 只管展开原文, 走 <summary> 自己的开合。
|
|
347
|
+
inner.addEventListener('click', function (e) {
|
|
348
|
+
var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto], .to-in[data-hto]');
|
|
349
|
+
if (!b || e.target.closest('.ho-chev')) return;
|
|
350
|
+
e.preventDefault(); // 行是 <summary>: 不顺带展开原文
|
|
351
|
+
e.stopPropagation();
|
|
352
|
+
var from = b.getAttribute('data-hfrom'), to = b.getAttribute('data-hto'), ch = b.getAttribute('data-hch');
|
|
353
|
+
var peer = ROLE === from ? to : ROLE === to ? from : '';
|
|
354
|
+
var conv = !peer ? 'a:' + from + '|' + to : ch ? 'c:' + ch : 'p:' + peer;
|
|
355
|
+
var w = peer && ch ? peer : '';
|
|
356
|
+
markBack(function () { return VIEW === 'msgs' && CONV === conv && WITH === w; });
|
|
357
|
+
jumpMsg({ conv: conv, with: w, id: b.getAttribute('data-gid'), ts: Number(b.getAttribute('data-gts')), viewpoint: from });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// 其余 wezard 调用的提示行: 新生 / 被收的 wizard 开它的全部往来、viewpoint 站到它身上 (页面视角不动),
|
|
361
|
+
// 工单行进工单页, 定时行开日程 —— 日程只有页面视角自己的, 别人的定时行不跳 (换页面视角只走头像 / 名字)。
|
|
362
|
+
// 同移交行: 行是 <summary> 就不顺带展开原文, 行尾 chevron 照旧开合。
|
|
363
|
+
inner.addEventListener('click', function (e) {
|
|
364
|
+
var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hrole], .handoff .ho-line[data-job], .handoff .ho-line[data-hplan]');
|
|
365
|
+
if (!b || e.target.closest('.ho-chev')) return;
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
var role = b.getAttribute('data-hrole'), job = b.getAttribute('data-job'), plan = b.getAttribute('data-hplan');
|
|
368
|
+
if (job) return openJob(job);
|
|
369
|
+
if (plan) return plan === ROLE && setView('plan');
|
|
370
|
+
var conv = 'a:' + role;
|
|
371
|
+
markBack(function () { return VIEW === 'msgs' && CONV === conv; });
|
|
372
|
+
selectConv(conv, '', undefined, role);
|
|
373
|
+
});
|
|
275
374
|
|
|
276
375
|
// ── 左栏: 当前 role 的名片 + 会话列表 ──
|
|
277
376
|
// `a:<x>` / `a:<x>|<p1>,<p2>` 不在列表里: x 参与的全部对话 / x 与这几个对端之间的, 按时间排开
|
|
278
377
|
// (关系图里点卡片看的就是它, 服务端同一个 talkOf) —— 现造一项, 落地时才不会被当成失效的会话退回默认。
|
|
378
|
+
// 工单的进度: 开着 = 已落定几份 / 一共几份, 收了 = 收工。
|
|
379
|
+
var jobTag = function (j) {
|
|
380
|
+
return j ? '<span class="jtag' + (j.status === 'open' ? ' open' : '') + '">' + (j.status === 'open' ? j.done + '/' + j.total : '收工') + '</span>' : '';
|
|
381
|
+
};
|
|
382
|
+
// 阶段 (daemon 现算后推来): 缺省 (老快照) = 不标。
|
|
383
|
+
var STAGE = { plan: '规划', build: '实施', review: '评审', clarify: '澄清', stalled: '卡住', shelved: '搁置' };
|
|
384
|
+
var stageText = function (j) {
|
|
385
|
+
if (!j) return '';
|
|
386
|
+
if (j.status === 'closed') return j.kind === 'req' ? (j.end === 'cancel' ? '取消' : '归档') : '';
|
|
387
|
+
return j.stage === 'deliver' ? (j.kind === 'req' ? '等验收' : '待收工') : STAGE[j.stage] || '';
|
|
388
|
+
};
|
|
389
|
+
var stageTag = function (j) {
|
|
390
|
+
var t = stageText(j);
|
|
391
|
+
return t ? '<span class="jstage' + (j.stage === 'deliver' || j.stage === 'clarify' || j.stage === 'stalled' ? ' wait' : '') + '">' + t + '</span>' : '';
|
|
392
|
+
};
|
|
393
|
+
// 祖先链 (根在前): 按服务端的账走 parent; 环 / 缺失就停。
|
|
394
|
+
var jobAncestors = function (id) {
|
|
395
|
+
var idx = R.jobIndex || {}, out = [], seen = {}, cur = idx[id] && idx[id].parent;
|
|
396
|
+
while (cur && idx[cur] && !seen[cur]) { seen[cur] = 1; out.unshift(cur); cur = idx[cur].parent; }
|
|
397
|
+
return out;
|
|
398
|
+
};
|
|
399
|
+
var jobCrumbs = function (id) {
|
|
400
|
+
var up = jobAncestors(id);
|
|
401
|
+
return up.length ? '<span class="jcrumb">' + up.map(function (a) {
|
|
402
|
+
return '<button data-job="' + esc(a) + '" title="' + esc(a) + '">' + esc((R.jobIndex[a] || {}).title || a) + '</button>';
|
|
403
|
+
}).join('<i>›</i>') + '<i>›</i></span>' : '';
|
|
404
|
+
};
|
|
405
|
+
// 工单列表的树序: 父在前、子缩进跟在后; 父不在这一段里的当根。
|
|
406
|
+
var jobTreeOrder = function (list) {
|
|
407
|
+
var ids = {}; list.forEach(function (c) { ids[c.job.id] = 1; });
|
|
408
|
+
var kids = function (id) { return list.filter(function (c) { return c.job.parent === id; }); };
|
|
409
|
+
var walk = function (c, d) { return [{ c: c, d: d }].concat(kids(c.job.id).reduce(function (a, k) { return a.concat(walk(k, d + 1)); }, [])); };
|
|
410
|
+
return list.filter(function (c) { return !c.job.parent || !ids[c.job.parent]; }).reduce(function (a, c) { return a.concat(walk(c, 0)); }, []);
|
|
411
|
+
};
|
|
412
|
+
// 工单里有人卡住 (停在审批卡 / 反问待答 / 报错停了) 时标出几份卡着 —— 要人或开单者动手的。
|
|
413
|
+
var stuckTag = function (j) {
|
|
414
|
+
var n = j && j.status === 'open' ? (R.inflight || []).filter(function (x) {
|
|
415
|
+
return x.job === j.id && (x.state === 'blocked' || x.state === 'needs-input' || x.state === 'errored');
|
|
416
|
+
}).length : 0;
|
|
417
|
+
return n ? '<span class="jstuck" title="' + n + ' 份停在审批 / 反问 / 报错上">' + n + ' 卡住</span>' : '';
|
|
418
|
+
};
|
|
419
|
+
// 侧栏一行 / 关系图卡片上的小标记: 这处往来里有归在工单名下的话。
|
|
420
|
+
var jobMark = function (ids) {
|
|
421
|
+
return ids && ids.length ? '<span class="jmk" data-jobs="' + esc(ids.join(' ')) + '" title="' + esc((ids.length > 1 ? ids.length + ' 张工单: ' : '工单 ') + ids.join(' ') + ' —— 点开') + '">📋' + (ids.length > 1 ? '<i>' + ids.length + '</i>' : '') + '</span>' : '';
|
|
422
|
+
};
|
|
423
|
+
// 视角不在里面的工单不在 R.convs 里: 按服务端的账 (R.jobIndex, 全部工单) 现造一项; 账里没有 = 已清掉 (job 为空)。
|
|
424
|
+
var jobConv = function (key) {
|
|
425
|
+
var id = key.slice(2), j = (R.jobIndex || {})[id];
|
|
426
|
+
return {
|
|
427
|
+
key: key, kind: 'job', name: j ? j.title : id, label: '📋', base: j ? j.base : '', subs: [], heard: [],
|
|
428
|
+
lastTs: j ? j.closedAt || j.openedAt : 0, preview: '',
|
|
429
|
+
job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total, parent: j.parent, kind: j.kind, stage: j.stage },
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
/** 一个工单号 → 它的一行: 视角有份的取会话项 (带预览), 其余按账现造。 */
|
|
433
|
+
var jobRowOf = function (id) {
|
|
434
|
+
return jobConvs().filter(function (c) { return c.job.id === id; })[0] || jobConv('j:' + id);
|
|
435
|
+
};
|
|
436
|
+
/** 工单列表此刻列哪些: 从 📋 进来 = 入口带的那几张 (按 id, 不按视角筛); 否则视角开的或参与的。标题的数与列表同出这一份。 */
|
|
437
|
+
var jobListRows = function () {
|
|
438
|
+
return JOB_ONLY ? JOB_ONLY.map(jobRowOf).filter(function (c) { return c.job; }) : jobConvs();
|
|
439
|
+
};
|
|
440
|
+
var jobConvs = function () { return R.convs.filter(function (c) { return c.kind === 'job' && c.job; }); };
|
|
279
441
|
var convOf = function (key) {
|
|
280
442
|
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) +
|
|
283
|
-
|
|
443
|
+
var part = key.slice(2).split('|'), who = part[0], peers = (part[1] || '').split(',').filter(Boolean), chat = part[2] || '';
|
|
444
|
+
var name = chat ? nameOf(who) + ' 在 ' + chatTitle(chat) + ' 的全部记录'
|
|
445
|
+
: nameOf(who) + (peers.length ? ' 与 ' + peers.map(nameOf).join('、') + ' 的对话' : ' 的全部对话');
|
|
446
|
+
return { key: key, kind: 'all', who: who, peers: peers, chat: chat, name: name, subs: [] };
|
|
284
447
|
}
|
|
285
|
-
|
|
448
|
+
var hit = R.convs.filter(function (c) { return c.key === key; })[0];
|
|
449
|
+
return hit || (key && key.indexOf('j:') === 0 ? jobConv(key) : undefined);
|
|
450
|
+
};
|
|
451
|
+
var chatTitle = function (base) {
|
|
452
|
+
var c = R.convs.filter(function (x) { return x.key === 'c:' + base; })[0];
|
|
453
|
+
return c ? c.name : base;
|
|
286
454
|
};
|
|
287
455
|
// 人的单聊 (user:…) 在数据上是频道, 但对端只有那一个人时同样是一对一。
|
|
288
456
|
var dmOf = function (c) {
|
|
@@ -307,15 +475,17 @@
|
|
|
307
475
|
// ── 状态: wizard 是否在执行中 ──
|
|
308
476
|
// 服务端只给事实 (busy / alive / runningUntil), 亮不亮在这里按本地时钟判 ——
|
|
309
477
|
// 到点没有新写入就自己熄 (同 expireRows), 不必等下一次推送。
|
|
310
|
-
var ST = { run: '执行中', idle: '空闲', off: '已关闭' };
|
|
311
|
-
|
|
478
|
+
var ST = { wait: '等人点', run: '执行中', idle: '空闲', off: '已关闭' };
|
|
479
|
+
// 等人点 (停在审批卡 / 提问卡上) 先于执行中: 审批长轮询期间 pane 照样在转圈。
|
|
480
|
+
var stateOf = function (s) { return s.waiting && s.waiting.length ? 'wait' : wRunning(s) ? 'run' : s.alive ? 'idle' : 'off'; };
|
|
312
481
|
// 忙闲只有这一个渲染点, 永远挂在名字右边 —— 名片 / 关系树 / 侧栏共用。
|
|
313
|
-
// quiet:
|
|
482
|
+
// quiet: 只在执行中 / 等人点时出现、只要灯不要字 (侧栏: 列表里安静是常态, 亮着的才值得看一眼)。
|
|
314
483
|
var stTag = function (s, quiet) {
|
|
315
484
|
if (!s) return '';
|
|
316
485
|
var k = stateOf(s);
|
|
317
|
-
if (quiet && k !== 'run') return '';
|
|
318
|
-
|
|
486
|
+
if (quiet && k !== 'run' && k !== 'wait') return '';
|
|
487
|
+
var tip = k === 'wait' ? '停在 ' + s.waiting.join(' / ') + ' 上等人点 (审批卡 / 提问卡)' : ST[k];
|
|
488
|
+
return '<span class="wst ' + k + '" title="' + esc(tip) + '">' + (quiet ? '' : esc(k === 'wait' ? ST[k] + ' · ' + s.waiting[0] : ST[k])) + '</span>';
|
|
319
489
|
};
|
|
320
490
|
var paintStatus = function () {
|
|
321
491
|
var el = $('#rb-st');
|
|
@@ -329,11 +499,6 @@
|
|
|
329
499
|
ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
|
|
330
500
|
};
|
|
331
501
|
|
|
332
|
-
// ends = [[id, 已知头像]]; 没带头像的按 role 查。
|
|
333
|
-
var pairOf = function (ends) {
|
|
334
|
-
return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
|
|
335
|
-
};
|
|
336
|
-
|
|
337
502
|
var line = function (title, ts, pv, lamp, unread) {
|
|
338
503
|
return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
|
|
339
504
|
'<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
|
|
@@ -342,34 +507,34 @@
|
|
|
342
507
|
};
|
|
343
508
|
|
|
344
509
|
// ── 未读: 别人说完、我还没读到的话 ──
|
|
345
|
-
// 服务端给每个会话一份 heard = [说完的时刻,
|
|
346
|
-
// 最后开口的时刻 (mine)
|
|
347
|
-
// 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
var
|
|
351
|
-
|
|
352
|
-
return { at: {} };
|
|
353
|
-
})();
|
|
354
|
-
var saveRead = function () { try { localStorage.setItem(READ_KEY, JSON.stringify(READ)); } catch (e) { } };
|
|
510
|
+
// 服务端给每个会话一份 heard = [说完的时刻, 发话方, 收信方], 以及我在群里 / 每一对里
|
|
511
|
+
// 最后开口的时刻 (mine)。说给我的那句属于「我与发话方」那一对。一句话的已读水位 = 它所在那一处
|
|
512
|
+
// 「我最后开口」与「我看过」的较晚者: 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
|
|
513
|
+
// 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 之后新说完的才累加,
|
|
514
|
+
// 刷新即重置 —— 所以「看过」只活在内存里, 不落本地。按视角分账 (同一个群换个 role 看, 未读是另一回事)。
|
|
515
|
+
var BASE = 0;
|
|
516
|
+
var READ = { at: {} };
|
|
355
517
|
var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
|
|
356
518
|
var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
|
|
357
519
|
var unreadOf = function (c, withRole) {
|
|
358
520
|
var g = seenAt(c.key);
|
|
359
521
|
var mark = {};
|
|
360
|
-
(c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, s.mine || 0, seenAt(c.key, s.role)); });
|
|
361
|
-
var groupMark = Math.max(g, c.mine || 0);
|
|
522
|
+
(c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, BASE, s.mine || 0, seenAt(c.key, s.role)); });
|
|
523
|
+
var groupMark = Math.max(g, BASE, c.mine || 0);
|
|
362
524
|
return (c.heard || []).filter(function (h) {
|
|
363
|
-
|
|
364
|
-
|
|
525
|
+
var p = h[2] === ROLE ? h[1] : '';
|
|
526
|
+
if (withRole && p !== withRole) return false;
|
|
527
|
+
return h[0] > (p && mark[p] !== undefined ? mark[p] : groupMark);
|
|
365
528
|
}).length;
|
|
366
529
|
};
|
|
367
530
|
var reading = function () { return VIEW === 'msgs' && !document.hidden; };
|
|
368
531
|
// 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
|
|
369
532
|
// 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
|
|
370
|
-
var talkCovers = function (p) {
|
|
533
|
+
var talkCovers = function (p, c) {
|
|
371
534
|
var t = convOf(CONV);
|
|
372
535
|
if (!t || t.kind !== 'all') return false;
|
|
536
|
+
// 限定了频道的窗口 (某 role 在一个群里的全部记录) 只覆盖那个群里的往来。
|
|
537
|
+
if (t.chat && (!c || c.base !== t.chat)) return false;
|
|
373
538
|
var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
|
|
374
539
|
return hit(ROLE, p) || hit(p, ROLE);
|
|
375
540
|
};
|
|
@@ -377,8 +542,8 @@
|
|
|
377
542
|
// 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
|
|
378
543
|
var readPairs = function (c) {
|
|
379
544
|
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; });
|
|
545
|
+
if (c.kind !== 'group') return c.peer && talkCovers(c.peer, c) ? [''] : [];
|
|
546
|
+
return (c.subs || []).filter(function (s) { return talkCovers(s.role, c); }).map(function (s) { return s.role; });
|
|
382
547
|
};
|
|
383
548
|
// 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
|
|
384
549
|
var markRead = function (c) {
|
|
@@ -386,26 +551,61 @@
|
|
|
386
551
|
var h = c.heard && c.heard[c.heard.length - 1];
|
|
387
552
|
var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
|
|
388
553
|
hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
|
|
389
|
-
if (hit.length) saveRead();
|
|
390
554
|
};
|
|
391
555
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
var
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
var
|
|
398
|
-
|
|
556
|
+
// 跨会话通用的个人偏好 (看全部、列表 / 关系、侧栏群节点的展开、session 选择): 记在本地, 刷新后还在。
|
|
557
|
+
// 取不到 / 写不进 (隐私窗口、禁存) 就按默认值, 页面照常。
|
|
558
|
+
var pref = function (key, def) {
|
|
559
|
+
try { var v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (e) { return def; }
|
|
560
|
+
};
|
|
561
|
+
var setPref = function (key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) { } };
|
|
562
|
+
var prefObj = function (key) { var v = pref(key, {}); return v && typeof v === 'object' ? v : {}; };
|
|
563
|
+
var WORLD_KEY = 'wezard.role.world';
|
|
564
|
+
if (!qs.has('side')) WORLD = pref(WORLD_KEY, false) === true;
|
|
565
|
+
// 早先也记过 ping 与日程卡片的展开 (只活在内存里了)、「往来 / 相关 / 全部」开关 (已去掉, 固定看全部 / 相关) —— 旧 key 顺手清掉。
|
|
566
|
+
try { ['wezard.role.pingOpen', 'wezard.role.planOpen', 'wezard.role.showAll', 'wezard.role.subScope'].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) { }
|
|
567
|
+
|
|
568
|
+
// 展开态按群各记各的 (键 = 群节点的会话键): 点开一个不收起别的, 轮询重画也不动它。
|
|
569
|
+
// OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算; null = 刚载入。
|
|
570
|
+
// 默认只展开选中的那个群; 本地只记偏离默认的: 没选中却展开着的 = true, 选中却被收起的 = false
|
|
571
|
+
// (载入时自动展开要让着它)。回到默认的那一项写的时候就删, 不越存越多。
|
|
572
|
+
var OPEN_KEY = 'wezard.role.groupOpen';
|
|
573
|
+
var OPEN = prefObj(OPEN_KEY), OPEN_AT = null;
|
|
574
|
+
// 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按群记, 只留展开着的。
|
|
575
|
+
var MORE_KEY = 'wezard.role.groupMore';
|
|
576
|
+
var SUB_FOLD = 5, MORE = prefObj(MORE_KEY);
|
|
577
|
+
// 某 role 在一个群里的全部记录挂在那个群下: CONV 落在哪个群节点上。
|
|
578
|
+
var groupOf = function (key) { var t = key && convOf(key); return t && t.chat ? 'c:' + t.chat : key; };
|
|
579
|
+
var saveOpen = function () {
|
|
580
|
+
var sel = groupOf(CONV);
|
|
581
|
+
setPref(OPEN_KEY, Object.keys(OPEN).reduce(function (m, k) {
|
|
582
|
+
if (!!OPEN[k] !== (k === sel)) m[k] = !!OPEN[k];
|
|
583
|
+
return m;
|
|
584
|
+
}, {}));
|
|
399
585
|
};
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
586
|
+
var setMore = function (key, v) {
|
|
587
|
+
if (v) MORE[key] = true; else delete MORE[key];
|
|
588
|
+
setPref(MORE_KEY, MORE);
|
|
589
|
+
};
|
|
590
|
+
var reveal = function () {
|
|
591
|
+
if (!CONV || CONV === OPEN_AT) return;
|
|
592
|
+
var g = groupOf(CONV);
|
|
593
|
+
if (!(OPEN_AT === null && OPEN[g] === false)) OPEN[g] = true;
|
|
594
|
+
OPEN_AT = CONV;
|
|
595
|
+
saveOpen();
|
|
596
|
+
};
|
|
597
|
+
// 点侧栏项 / 关系图卡片 (两边同一份): 没选中它 → 选中 (会话顺带展开); 已选中再点 → 取消选中, 右边回到
|
|
598
|
+
// 页面视角的全部对话 (`a:<role>`, 即「看全部」那一份), 页面视角不动; 取消的是会话本身就顺带收起它。
|
|
599
|
+
// viewpoint: 站在这一项的主人身上 —— 会话与成对的子项是页面视角自己的, .far 子项与卡片是那个 role 的。
|
|
600
|
+
var clickItem = function (key, withRole, viewpoint) {
|
|
601
|
+
var vp = viewpoint && viewpoint !== ROLE ? viewpoint : '';
|
|
602
|
+
if (key !== CONV || (withRole || '') !== WITH || VIEW !== 'msgs' || VIEWPOINT !== vp) return selectConv(key, withRole, undefined, viewpoint);
|
|
603
|
+
// 窄屏退回列表后再点它是要回去读, 不是要取消。
|
|
405
604
|
if (!app.classList.contains('reading')) return app.classList.add('reading');
|
|
406
|
-
|
|
407
|
-
OPEN[key] =
|
|
408
|
-
|
|
605
|
+
var fold = !withRole && OPEN[key];
|
|
606
|
+
if (fold) OPEN[key] = false;
|
|
607
|
+
selectConv('a:' + ROLE, '', undefined, '');
|
|
608
|
+
if (fold) saveOpen(); // 选中项换过之后再记: 没选中的收着是默认态, 不占一条
|
|
409
609
|
};
|
|
410
610
|
|
|
411
611
|
// ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
|
|
@@ -417,26 +617,60 @@
|
|
|
417
617
|
// 同层按最近活动排, 有新话就上浮。
|
|
418
618
|
var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
|
|
419
619
|
// 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
|
|
420
|
-
|
|
620
|
+
// 名字悬停时紧跟一枚 ⇄ —— 同名片上名字悬停露出 ⧉: 点名字做的事, 先亮给人看。
|
|
621
|
+
var SWAP = '<i class="sw" aria-hidden="true">⇄</i>';
|
|
622
|
+
var roleRow = function (id, name, label, g, status, tail, badge) {
|
|
421
623
|
return goSpan('av', id, esc(label)) +
|
|
422
|
-
line(nm(id, name, true) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
|
|
624
|
+
line(nm(id, name, true, SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
|
|
423
625
|
};
|
|
626
|
+
// 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
|
|
627
|
+
var moreTag = function (n, tip) {
|
|
628
|
+
return n ? '<span class="oc" title="' + esc(tip) + '">+' + n + '</span>' : '';
|
|
629
|
+
};
|
|
630
|
+
// 会话列表: 除了这一项 (here = 它的绝对 key), 它还对应 N 个叶子项 (各群里它参与的成对子项 + 它的私聊)。服务端没给这份 = 不知道, 不画。
|
|
631
|
+
var otherChats = function (id, here) {
|
|
632
|
+
var ks = (R.chatKeys || {})[id];
|
|
633
|
+
var n = ks ? ks.filter(function (k) { return k !== here; }).length : 0;
|
|
634
|
+
return moreTag(n, nameOf(id) + ' 在另外 ' + n + ' 处 (群里的一对 / 私聊) 还有往来, 切到它的视角可见');
|
|
635
|
+
};
|
|
636
|
+
// 侧栏的 key 是相对视角的 (p:<对端> / 群 + 选中的对端), 服务端那份是绝对的 (两端排序)。
|
|
637
|
+
var pairKey = function (peer) { return [ROLE, peer].sort().join('|'); };
|
|
638
|
+
var dmKey = function (peer) { return 'p:' + pairKey(peer); };
|
|
424
639
|
var convRow = function (c) {
|
|
425
|
-
if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status);
|
|
426
|
-
return
|
|
640
|
+
if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status, jobMark(c.jobs), otherChats(c.peer, dmKey(c.peer)));
|
|
641
|
+
if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
|
|
642
|
+
return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs), c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
|
|
643
|
+
};
|
|
644
|
+
var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status, jobMark(s.jobs), otherChats(s.role, c.key + '|' + pairKey(s.role))); };
|
|
645
|
+
|
|
646
|
+
// 「chat 内全部」下与我无往来的那几项: 一行的数据是它在群里的全部记录 (服务端 whole, 与关系图卡片同一个
|
|
647
|
+
// glanceOfTalk), 点开看的也是那一份 (`a:<它>||<群>`, 对端不限)。没有成对的往来, 也就没有我的未读 —— 同关系图里
|
|
648
|
+
// 视角没有会话项的那张卡片。+N 数它的全部叶子, 这一项本身不是叶子。
|
|
649
|
+
var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
|
|
650
|
+
var farRow = function (s) {
|
|
651
|
+
var w = s.whole;
|
|
652
|
+
return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0 }, s.status, '', otherChats(s.role, ''));
|
|
427
653
|
};
|
|
428
|
-
var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status); };
|
|
429
654
|
|
|
430
655
|
var convItem = function (c) {
|
|
431
656
|
var on = c.key === CONV;
|
|
432
|
-
//
|
|
433
|
-
|
|
657
|
+
// 与我有往来的成对子项在前; chat 内其余有记录的接在后面 (.far), 各自按最近排。
|
|
658
|
+
// 时间范围按子项自己的时刻裁 (成对 = 我与它的往来, .far = 它在群里的全部) —— 群的时刻是群里任何人的动静, 放不得行。
|
|
659
|
+
var pairs = c.subs.filter(function (s) { return s.count && inWin(s.lastTs); }).sort(recentFirst);
|
|
660
|
+
var fars = c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count && inWin(s.whole.lastTs); })
|
|
661
|
+
.sort(function (a, b) { return recentFirst(a.whole, b.whole); });
|
|
662
|
+
var talked = OPEN[c.key] ? pairs.concat(fars) : [];
|
|
434
663
|
var hidden = talked.length - SUB_FOLD;
|
|
435
664
|
var bar = hidden > 0
|
|
436
665
|
? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
|
|
437
666
|
: '';
|
|
438
667
|
// 展开条钉在第 SUB_FOLD+1 位, 展开与折叠都不挪: 其余子项展开后接在它下面。
|
|
439
668
|
var sub = function (s) {
|
|
669
|
+
if (!s.count) {
|
|
670
|
+
var k = farKey(c, s);
|
|
671
|
+
return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" data-vp="' + esc(s.role) + '" ' +
|
|
672
|
+
'title="' + esc(nameOf(s.role) + ' 在这里的 ' + s.whole.count + ' 条记录 (与我无往来)') + '">' + farRow(s) + '</button>';
|
|
673
|
+
}
|
|
440
674
|
var sel = on && s.role === WITH;
|
|
441
675
|
return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
|
|
442
676
|
'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
|
|
@@ -447,11 +681,21 @@
|
|
|
447
681
|
? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
|
|
448
682
|
: '';
|
|
449
683
|
var sel = on && !WITH;
|
|
684
|
+
|
|
685
|
+
// 群聊项特殊处理: 把 chevron 挪到行内部, 在时间左边
|
|
686
|
+
if (c.kind === 'group' && c.subs && c.subs.length) {
|
|
687
|
+
// 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
|
|
688
|
+
var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
|
|
689
|
+
var content = avatarOf(c) + '<span class="b"><span class="l1"><span class="t"><span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs) + '</span>' + chevron + '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
|
|
690
|
+
'<span class="l2"><span class="pv">' + esc(c.preview) + '</span>' + (unreadOf(c) ? '<b class="ub">' + (unreadOf(c) > 99 ? '99+' : unreadOf(c)) + '</b>' : '') + '</span></span>';
|
|
691
|
+
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
|
|
692
|
+
}
|
|
693
|
+
|
|
450
694
|
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
|
|
451
695
|
convRow(c) + '</button>' + subs;
|
|
452
696
|
};
|
|
453
697
|
|
|
454
|
-
//
|
|
698
|
+
// 搜索入口挂在名片下 —— 侧栏的公共区, 会话列表与关系图下都在。⌘K 见下方「搜索」。
|
|
455
699
|
var MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
456
700
|
var KBD = MAC ? '⌘K' : 'Ctrl K';
|
|
457
701
|
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>';
|
|
@@ -459,10 +703,12 @@
|
|
|
459
703
|
sbox.className = 'sbox'; sbox.type = 'button';
|
|
460
704
|
sbox.innerHTML = '<span class="ic">' + SEARCH_SVG + '</span><span class="lb">搜索 role、会话、消息</span><kbd>' + KBD + '</kbd>';
|
|
461
705
|
sbox.onclick = function () { openSearch(); };
|
|
462
|
-
$('#
|
|
706
|
+
$('#rolebar').parentNode.insertBefore(sbox, $('#rolebar').nextSibling);
|
|
707
|
+
// 老外壳 (daemon 未重启) 里还留着名片下那条入口栏 —— 日程已并进名片, 它整条不要了。
|
|
708
|
+
if ($('#rb-acts')) $('#rb-acts').remove();
|
|
463
709
|
var renderConvs = function () {
|
|
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
|
|
710
|
+
var groups = R.convs.filter(function (c) { return c.kind === 'group' && inWin(c.lastTs); }).sort(recentFirst);
|
|
711
|
+
var dms = R.convs.filter(function (c) { return c.kind === 'wizard' && inWin(c.lastTs); }).sort(recentFirst);
|
|
466
712
|
R.convs.forEach(markRead);
|
|
467
713
|
if (WORLD) return renderWorld();
|
|
468
714
|
// 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
|
|
@@ -470,18 +716,24 @@
|
|
|
470
716
|
return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
|
|
471
717
|
};
|
|
472
718
|
// 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
|
|
473
|
-
|
|
719
|
+
// 开关挂在第一个标题行的右端; 一个会话都没有也留一行标题给它。
|
|
720
|
+
var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', winSelect() + worldToggle() + '</h2>');
|
|
474
721
|
if (convsEl._html === html) return;
|
|
475
722
|
convsEl._html = html; convsEl.innerHTML = html;
|
|
476
723
|
convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
|
|
477
|
-
var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '';
|
|
478
|
-
b.onclick = function () { clickItem(key, w); };
|
|
724
|
+
var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '', vp = b.getAttribute('data-vp') || '';
|
|
725
|
+
b.onclick = function () { clickItem(key, w, vp); };
|
|
479
726
|
});
|
|
480
727
|
convsEl.querySelectorAll('[data-more]').forEach(function (b) {
|
|
481
728
|
var key = b.getAttribute('data-more');
|
|
482
|
-
b.onclick = function () {
|
|
729
|
+
b.onclick = function () { setMore(key, !MORE[key]); renderConvs(); };
|
|
730
|
+
});
|
|
731
|
+
convsEl.querySelectorAll('[data-toggle]').forEach(function (b) {
|
|
732
|
+
var key = b.getAttribute('data-toggle');
|
|
733
|
+
b.onclick = function (e) { e.stopPropagation(); OPEN[key] = !OPEN[key]; saveOpen(); renderConvs(); };
|
|
483
734
|
});
|
|
484
735
|
bindGo(convsEl);
|
|
736
|
+
bindWorldToggle(convsEl); bindWin(convsEl);
|
|
485
737
|
};
|
|
486
738
|
|
|
487
739
|
var shortCwd = function (p) {
|
|
@@ -490,69 +742,117 @@
|
|
|
490
742
|
};
|
|
491
743
|
// session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
|
|
492
744
|
var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
|
|
745
|
+
// 选中的 session 按「role + 会话 (+ 群里的对端)」记; 选回默认 (全部时间, 不切) 就删那一项。
|
|
746
|
+
// 只在 URL 没指定 session 时恢复 —— 落地窗口要等第一份摘要才定, SESS_RESTORE 守到那时。
|
|
747
|
+
var SESS_KEY = 'wezard.role.session';
|
|
748
|
+
var SESS_RESTORE = !qs.get('session');
|
|
749
|
+
var sessKey = function () { return ROLE + '\n' + CONV + (WITH ? '\n' + WITH : ''); };
|
|
750
|
+
var savedSess = function () { return prefObj(SESS_KEY)[sessKey()] || ''; };
|
|
751
|
+
var saveSess = function (sid) {
|
|
752
|
+
var m = prefObj(SESS_KEY);
|
|
753
|
+
if (sid && sid !== 'all') m[sessKey()] = sid; else delete m[sessKey()];
|
|
754
|
+
setPref(SESS_KEY, m);
|
|
755
|
+
};
|
|
756
|
+
// 第一份摘要到手: 记下的那段还在列表里就换过去 (换了要重取一次), 不在了就回默认并删掉。
|
|
757
|
+
var restoreSess = function () {
|
|
758
|
+
if (!SESS_RESTORE) return false;
|
|
759
|
+
SESS_RESTORE = false;
|
|
760
|
+
var want = savedSess();
|
|
761
|
+
if (!want || want === SESSION) return false;
|
|
762
|
+
if (R.sessions.some(function (x) { return x.sessionId === want; })) { SESSION = want; return true; }
|
|
763
|
+
saveSess('');
|
|
764
|
+
return false;
|
|
765
|
+
};
|
|
493
766
|
var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
|
|
494
767
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
var
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
'<span class="
|
|
503
|
-
'<
|
|
768
|
+
// 自定义下拉: 小号文字触发器 + 浮层列表 (.sp)。名片里的 session 与侧栏标题行的时间范围共用这一份。
|
|
769
|
+
// 宿主每次刷新都重画, 展开的是哪一个记在 DD_OPEN 里 (按 id) 才不会被轮询收起。
|
|
770
|
+
// items: { v, html, on, cls?, tip? }; `ic` = 触发器左侧的图标; `end` = 浮层以触发器右沿为锚向左下展开 (贴着侧栏右沿的那种)。
|
|
771
|
+
var DD_OPEN = '';
|
|
772
|
+
var dropdown = function (id, o) {
|
|
773
|
+
var open = DD_OPEN === id;
|
|
774
|
+
return '<span class="sp' + (o.end ? ' end' : '') + (open ? ' open' : '') + '" data-dd="' + id + '">' +
|
|
775
|
+
(o.ic ? '<span class="sp-ic" aria-hidden="true">' + o.ic + '</span>' : '') +
|
|
776
|
+
'<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + open + '" title="' + esc(o.title) + '">' +
|
|
777
|
+
esc(o.label) + '<span class="car" aria-hidden="true"></span></button>' +
|
|
778
|
+
'<span class="sp-list" role="listbox">' + o.items.map(function (it) {
|
|
779
|
+
return '<button class="sp-it' + (it.cls ? ' ' + it.cls : '') + (it.on ? ' on' : '') + '" role="option" aria-selected="' + !!it.on + '" data-v="' + esc(it.v) + '"' +
|
|
780
|
+
(it.tip ? ' title="' + esc(it.tip) + '"' : '') + '>' + it.html + '</button>';
|
|
781
|
+
}).join('') + '</span></span>';
|
|
782
|
+
};
|
|
783
|
+
var setDdOpen = function (id) {
|
|
784
|
+
DD_OPEN = id;
|
|
785
|
+
document.querySelectorAll('.sp[data-dd]').forEach(function (sp) {
|
|
786
|
+
var on = sp.getAttribute('data-dd') === id;
|
|
787
|
+
sp.classList.toggle('open', on);
|
|
788
|
+
sp.querySelector('.sp-btn').setAttribute('aria-expanded', on);
|
|
789
|
+
});
|
|
504
790
|
};
|
|
505
|
-
var
|
|
506
|
-
var
|
|
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 +
|
|
514
|
-
R.sessions.slice().reverse().map(function (s) { return sessRow(s, s.sessionId === SESSION); }).join('') +
|
|
515
|
-
'</span></span>';
|
|
516
|
-
};
|
|
517
|
-
var setSessOpen = function (open) {
|
|
518
|
-
SESS_OPEN = open;
|
|
519
|
-
var sp = $('#rb-sp .sp');
|
|
791
|
+
var bindDropdown = function (root, id, pick) {
|
|
792
|
+
var sp = root && root.querySelector('.sp[data-dd="' + id + '"]');
|
|
520
793
|
if (!sp) return;
|
|
521
|
-
sp.
|
|
522
|
-
sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
|
|
523
|
-
};
|
|
524
|
-
var bindSessPicker = function () {
|
|
525
|
-
var sp = $('#rb-sp .sp');
|
|
526
|
-
if (!sp) return;
|
|
527
|
-
sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
|
|
794
|
+
sp.querySelector('.sp-btn').onclick = function () { setDdOpen(DD_OPEN === id ? '' : id); };
|
|
528
795
|
sp.querySelectorAll('.sp-it').forEach(function (it) {
|
|
529
|
-
it.onclick = function () {
|
|
796
|
+
it.onclick = function () { setDdOpen(''); pick(it.getAttribute('data-v')); };
|
|
530
797
|
});
|
|
531
798
|
};
|
|
532
799
|
document.addEventListener('click', function (e) {
|
|
533
|
-
if (
|
|
800
|
+
if (DD_OPEN && !e.target.closest('.sp[data-dd="' + DD_OPEN + '"]')) setDdOpen('');
|
|
534
801
|
});
|
|
535
802
|
document.addEventListener('keydown', function (e) {
|
|
536
|
-
if (
|
|
803
|
+
if (DD_OPEN && e.key === 'Escape') setDdOpen('');
|
|
537
804
|
});
|
|
538
805
|
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
(sub ? ' title="' + esc(sub) + '"' : '') + '>' + label + '</button>';
|
|
806
|
+
// session 切换: name 右边那个下拉。
|
|
807
|
+
var sessItem = function (s, on) {
|
|
808
|
+
var last = s === latestSess();
|
|
809
|
+
return { v: s.sessionId || '', on: on, tip: s.sessionId || '',
|
|
810
|
+
html: '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span><span class="n">' + s.turns + ' 轮</span>' };
|
|
545
811
|
};
|
|
546
|
-
var
|
|
547
|
-
var
|
|
548
|
-
return
|
|
812
|
+
var sessPicker = function () {
|
|
813
|
+
var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
|
|
814
|
+
return dropdown('sess', {
|
|
815
|
+
title: '切换 session',
|
|
816
|
+
label: !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur),
|
|
817
|
+
items: [{ v: 'all', on: !cur, cls: 'all', html: '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span>' }]
|
|
818
|
+
.concat(R.sessions.slice().reverse().map(function (s) { return sessItem(s, s.sessionId === SESSION); })),
|
|
819
|
+
});
|
|
549
820
|
};
|
|
550
|
-
var
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
821
|
+
var bindSessPicker = function () {
|
|
822
|
+
bindDropdown($('#rb-sp'), 'sess', function (v) { SESSION = v; saveSess(SESSION); refresh(); });
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// 会话列表 ↔ 关系图: 侧栏第一行右上角同一个小开关, 标的是「点了去哪」。
|
|
826
|
+
var LIST_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
827
|
+
var TREE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 3v10M4 6h5M4 11h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="11" cy="6" r="1.6" fill="currentColor"/><circle cx="11" cy="11" r="1.6" fill="currentColor"/></svg>';
|
|
828
|
+
// 时间范围: 只看最近 N 分钟内有动静的 (0 = 全部)。会话列表与关系图共用这一个值。
|
|
829
|
+
var WIN_KEY = 'wezard.role.winMin';
|
|
830
|
+
var WINS = [[0, '全部'], [30, '半小时'], [60, '1小时'], [120, '2小时'], [1440, '1天']];
|
|
831
|
+
var WIN = pref(WIN_KEY, 0);
|
|
832
|
+
if (!WINS.some(function (w) { return w[0] === WIN; })) WIN = 0;
|
|
833
|
+
var winCut = function () { return WIN ? srvNow() - WIN * 60000 : 0; };
|
|
834
|
+
/** 一个时刻落没落在时间范围里 —— 侧栏的会话 / 子项与关系图的边 (rangeOf) 都按 winCut 这一刀。 */
|
|
835
|
+
var inWin = function (ts) { return !WIN || ts >= winCut(); };
|
|
836
|
+
var winSelect = function () {
|
|
837
|
+
return dropdown('win', {
|
|
838
|
+
ic: '⏱', end: true, title: '时间范围: 只看这段时间内有动静的',
|
|
839
|
+
label: (WINS.filter(function (w) { return w[0] === WIN; })[0] || WINS[0])[1],
|
|
840
|
+
items: WINS.map(function (w) { return { v: String(w[0]), on: w[0] === WIN, html: '<span class="id">' + w[1] + '</span>' }; }),
|
|
841
|
+
});
|
|
842
|
+
};
|
|
843
|
+
var bindWin = function (scope) {
|
|
844
|
+
bindDropdown(scope, 'win', function (v) {
|
|
845
|
+
WIN = +v; W.treeFor = ''; setPref(WIN_KEY, WIN);
|
|
846
|
+
convsEl._html = ''; convsEl._tree = '';
|
|
847
|
+
renderConvs();
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
var worldToggle = function () {
|
|
851
|
+
return '<button class="vt" data-vt title="' + (WORLD ? '换回会话列表' : '换成关系') + '">' + (WORLD ? LIST_SVG + '列表' : TREE_SVG + '关系') + '</button>';
|
|
852
|
+
};
|
|
853
|
+
var bindWorldToggle = function (scope) {
|
|
854
|
+
var b = scope.querySelector('[data-vt]');
|
|
855
|
+
if (b) b.onclick = function () { setWorld(!WORLD); };
|
|
556
856
|
};
|
|
557
857
|
|
|
558
858
|
// 轻提示: 底部居中一枚, 新的顶掉旧的。
|
|
@@ -599,61 +899,89 @@
|
|
|
599
899
|
for (var el = e.target; el && el.nodeType === 1 && el !== document.body && !clipTip(el); el = el.parentElement);
|
|
600
900
|
});
|
|
601
901
|
|
|
602
|
-
// 名片: 头像 · 名字 (忙闲 / session) · 名字下一行低调的描述 (
|
|
603
|
-
//
|
|
902
|
+
// 名片: 头像 · 名字 (忙闲 / session) · 名字下一行低调的描述 (🏠 home · 🐣 出生 · 📜 宪章大小 · 🗂️ cwd · 📅 日程) · 职责。
|
|
903
|
+
// 身份与出身 (谁的分身 / 子 wizard) 交给关系图 (侧栏标题右端的开关), 名片不写。
|
|
904
|
+
// 日程: 下一枪几点, 定时出错时染红改写出错数; 没有排期就只写条数 (定时 + 工单)。
|
|
905
|
+
var planFact = function () {
|
|
906
|
+
var p = R.plan || {};
|
|
907
|
+
var txt = p.broken ? '日程 ⚠ ' + p.broken + ' 出错' : p.nextAt ? '日程 ' + fmtClock(p.nextAt).replace(/^今天 /, '') : '日程 ' + R.schedules;
|
|
908
|
+
return ['📅', txt, (p.tasks ? p.tasks + ' 条定时 · ' : '') + '点开看日程', 'plan', p.broken ? 'bad' : ''];
|
|
909
|
+
};
|
|
604
910
|
var renderRole = function () {
|
|
605
911
|
var r = R.role;
|
|
606
912
|
if (!r) return;
|
|
607
913
|
// 出生 = 注册表记的 bornAt; 没记 (老 wizard / 人) 就退到最早一段 session 的开始。
|
|
608
914
|
var born = r.bornAt || R.sessions.reduce(function (m, x) { return x.start && (!m || x.start < m) ? x.start : m; }, 0);
|
|
609
915
|
var facts = [
|
|
610
|
-
r.cwd ? ['📁', shortCwd(r.cwd), r.cwd] : null,
|
|
611
916
|
r.chat ? ['🏠', r.chat, 'home'] : null,
|
|
612
|
-
born ? ['🐣',
|
|
613
|
-
|
|
614
|
-
|
|
917
|
+
born ? ['🐣', fmtAgo(born), '出生于 ' + fmtDay(born)] : null,
|
|
918
|
+
// 宪章紧跟出生: 出生时被交代了多少 (≈token), 只写大小; 可点开看全文。
|
|
919
|
+
R.charter ? ['📜', fmtTok(R.charter.tokens), '宪章 ≈' + fmtTok(R.charter.tokens) + ' token · ' + fmtClock(R.charter.at) + ' 压进系统提示 · 点开看全文', 'charter'] : null,
|
|
920
|
+
r.cwd ? ['🗂️', shortCwd(r.cwd), r.cwd] : null,
|
|
921
|
+
// 日程排最后, 可点: 名下排了什么。
|
|
922
|
+
R.schedules ? planFact() : null
|
|
923
|
+
].filter(Boolean).map(function (f) {
|
|
924
|
+
return '<span' + (f[3] ? ' class="fx' + (VIEW === f[3] ? ' on' : '') + (f[4] ? ' ' + f[4] : '') + '" data-view="' + f[3] + '"' : '') + ' title="' + esc(f[2]) + '">' + f[0] + ' ' + esc(f[1]) + '</span>';
|
|
925
|
+
});
|
|
926
|
+
// 只有一段 session 就没什么可选, 不挂选择器。
|
|
615
927
|
$('#rb-who').innerHTML =
|
|
616
928
|
'<div class="id"><span class="av">' + esc(r.label) + '</span>' +
|
|
617
|
-
'<span class="l"><span class="nl"><span class="cp" title="' + esc('
|
|
618
|
-
'<span id="rb-sp">' + (R.sessions.length ? sessPicker() : '') + '</span></span>' +
|
|
929
|
+
'<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>' : '') +
|
|
930
|
+
'<span id="rb-sp">' + (R.sessions.length > 1 ? sessPicker() : '') + '</span></span>' +
|
|
619
931
|
(facts.length ? '<span class="facts">' + facts.join('') + '</span>' : '') + '</span></div>' +
|
|
620
932
|
(r.description ? '<p class="job">' + esc(r.description) + '</p>' : '');
|
|
621
933
|
paintStatus();
|
|
622
934
|
$('#rb-who').querySelector('.cp').onclick = function () { copyText(nameOf(r.id)); };
|
|
935
|
+
$('#rb-who').querySelectorAll('.fx').forEach(function (x) {
|
|
936
|
+
x.onclick = function () { var v = x.getAttribute('data-view'); setView(VIEW === v ? 'msgs' : v); };
|
|
937
|
+
});
|
|
623
938
|
$('#rb-who').querySelectorAll('.go').forEach(function (g) {
|
|
624
939
|
g.onclick = function () { switchRole(g.getAttribute('data-r')); };
|
|
625
940
|
});
|
|
626
941
|
// 老外壳的底栏选择框已并进 name 行。
|
|
627
942
|
if ($('#rb-foot')) $('#rb-foot').hidden = true;
|
|
628
943
|
bindSessPicker();
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
document.
|
|
944
|
+
// 标题就是这一页的主张: 你此刻站在谁的位置上。换视角 → 标题与 favicon 跟着换成它的头像。
|
|
945
|
+
document.title = (r.label ? r.label + ' ' : '') + nameOf(r.id) + ' 的视角';
|
|
946
|
+
setFavicon(r.label);
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// favicon = 头像 emoji 画成的 SVG data URI; 没有头像就不动。
|
|
950
|
+
var setFavicon = function (emoji) {
|
|
951
|
+
if (!emoji) return;
|
|
952
|
+
var link = document.querySelector('link[rel="icon"]') || document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }));
|
|
953
|
+
link.type = 'image/svg+xml';
|
|
954
|
+
link.href = 'data:image/svg+xml,' + encodeURIComponent(
|
|
955
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y="50" font-size="86" text-anchor="middle" dominant-baseline="central">' +
|
|
956
|
+
esc(emoji) + '</text></svg>');
|
|
638
957
|
};
|
|
639
958
|
|
|
640
959
|
// ── 用量条: 一组轮次的总账 ──
|
|
641
960
|
// usageHTML 只管「一本账长什么样」(TagSummary → 一条), 账是谁的、挂在哪由 renderUsage 定。
|
|
642
|
-
// 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半)
|
|
643
|
-
// 输入 (1×) 沉稳蓝 →
|
|
961
|
+
// 按单价从省到贵排, 颜色随之由冷转暖: 缓存读 (约 0.1×, 通常占了大半) 直接用边线色, 色带贴顶时这段就像普通上边线 →
|
|
962
|
+
// 输入 (1×) 沉稳蓝 → 输出 (约 5×, 最贵) 绿 → 缓存写 (1.25×) 红, 与蓝/绿都拉开。注意力留给后三段。
|
|
644
963
|
var SEGS = [
|
|
645
|
-
|
|
646
|
-
|
|
964
|
+
// in = API 的 input_tokens: 只是没命中缓存、也没写进缓存的那点新输入 (Claude 上每次请求常只有个位数),
|
|
965
|
+
// 不是「送进模型的输入」—— 那是 in + cr + cw, 在 bar 的悬停提示里单列。
|
|
966
|
+
['cacheRead', '缓存读', 'var(--line)', 'cr'], ['input', '新输入 (未走缓存)', '#4c6fd6', 'in'],
|
|
967
|
+
['output', '输出', '#2f9e5b', 'out'], ['cacheWrite', '缓存写', '#d64545', 'cw'],
|
|
647
968
|
];
|
|
648
969
|
var TIP = {
|
|
649
970
|
turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
|
|
650
|
-
ctx: '
|
|
971
|
+
ctx: '上下文 — 最近一次请求送入的 input + 缓存',
|
|
651
972
|
};
|
|
652
|
-
var ICON = { turns: '💬', tools: '🛠️', api: '
|
|
653
|
-
var
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
973
|
+
var ICON = { cost: '💰', turns: '💬', tools: '🛠️', api: '⇄', ctx: '📄' };
|
|
974
|
+
var fmtUsd = function (n) {
|
|
975
|
+
return n < .01 ? '<$0.01' : '$' + (n < 10 ? n.toFixed(2) : n < 1000 ? n.toFixed(1) : Math.round(n));
|
|
976
|
+
};
|
|
977
|
+
// 费用: 有认不出价格的模型就在数前加「≥」—— 实际只多不少; 一分钱都没算出来就不占位。
|
|
978
|
+
var costKv = function (u) {
|
|
979
|
+
if (!u.cost) return '';
|
|
980
|
+
var txt = (u.unpriced ? '≥' : '') + fmtUsd(u.cost);
|
|
981
|
+
var tip = '估算费用 — 每轮按模型单价 (LiteLLM 价格表快照) 计 input / output / 缓存写 / 缓存读' +
|
|
982
|
+
(u.unpriced ? '\n另有 ' + u.unpriced + ' 次 API 请求的模型不在价格表里, 未计入' : '');
|
|
983
|
+
return '<span class="kv kv-cost" title="' + esc(tip) + '" data-tip="' + esc(ICON.cost + ' ' + txt) +
|
|
984
|
+
'"><i class="u-ic">' + ICON.cost + '</i><b>' + esc(txt) + '</b></span>';
|
|
657
985
|
};
|
|
658
986
|
var usageHTML = function (t) {
|
|
659
987
|
var u = t.usage || {};
|
|
@@ -666,15 +994,16 @@
|
|
|
666
994
|
return n ? '<span class="kv kv-' + k + '" title="' + esc(TIP[k] || k) + '" data-tip="' + esc(ICON[k] + ' ' + text) +
|
|
667
995
|
'"><i class="u-ic">' + ICON[k] + '</i><b>' + esc(text) + '</b></span>' : '';
|
|
668
996
|
};
|
|
669
|
-
// 输出 / 缓存不再单列成指标 ——
|
|
670
|
-
// I/O 分布: 贴着整条 bar
|
|
997
|
+
// 输出 / 缓存不再单列成指标 —— 它们就是顶边色带的那几段。
|
|
998
|
+
// I/O 分布: 贴着整条 bar 的顶边 (替代上边线) 画一条横跨整宽的细色带; 各段的量作为图例常显在 bar 里 (占比在悬停提示), 色点与色带同色。
|
|
671
999
|
// 两头留一位小数: 99.9% 不该被四舍五入成 100%, 一丁点也不该成 0%。
|
|
672
1000
|
var pct = function (n) {
|
|
673
1001
|
var p = n / total * 100;
|
|
674
1002
|
return p < .1 ? '<0.1%' : (p < 1 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%';
|
|
675
1003
|
};
|
|
676
1004
|
var io = total > 0
|
|
677
|
-
? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total)
|
|
1005
|
+
? '<span class="bar" title="' + esc(['累计 token I/O · 共 ' + fmtTok(total),
|
|
1006
|
+
'送入合计 (in + cr + cw) ' + fmtTok((u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0))].concat(segs.map(function (s) {
|
|
678
1007
|
return s[1] + ' ' + fmtTok(u[s[0]]) + ' · ' + pct(u[s[0]]);
|
|
679
1008
|
})).join('\n')) + '">' + segs.map(function (s) {
|
|
680
1009
|
return '<span class="seg" style="flex-grow:' + u[s[0]] + ';background:' + s[2] + '" title="' +
|
|
@@ -692,16 +1021,17 @@
|
|
|
692
1021
|
return '<span class="u-who" title="' + esc(nameOf(t.target)) + '">' + esc(roleLabel(t.target) || t.label) +
|
|
693
1022
|
'<span class="u-nm" data-tip="' + esc(nameOf(t.target)) + '">' + esc(nameOf(t.target)) + '</span></span>' +
|
|
694
1023
|
'<span class="u-lb" title="' + esc(t.model || '') + '">' +
|
|
695
|
-
esc(t.
|
|
1024
|
+
esc(t.modelLabel || t.model || '用量') + '</span>' +
|
|
1025
|
+
(t.effort ? '<span class="u-ef" title="effort 档位">' + esc(t.effort) + '</span>' : '') +
|
|
696
1026
|
'<span class="u-kvs">' +
|
|
697
|
-
kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
|
|
698
|
-
kv('ctx', u.
|
|
1027
|
+
costKv(u) + kv('turns', t.turns, t.turns) + kv('tools', u.tools, u.tools) + kv('api', u.calls, u.calls) +
|
|
1028
|
+
kv('ctx', u.ctx, fmtTok(u.ctx)) +
|
|
699
1029
|
'</span>' + io;
|
|
700
1030
|
};
|
|
701
|
-
// 永远单行: 排不下就按 FOLD 的顺序一级级藏 (
|
|
1031
|
+
// 永远单行: 排不下就按 FOLD 的顺序一级级藏 (消息/工具/API 计数 → 分布文字 → 名字), 宽度回来再按反序放出来。
|
|
702
1032
|
// 判定看的是容器自己溢没溢出, 不看视口 —— 同一个组件挂在整页页脚和右栏底, 宽度各不相同。
|
|
703
|
-
var FOLD = ['f-
|
|
704
|
-
var FOLDED = { 'f-
|
|
1033
|
+
var FOLD = ['f-cnt', 'f-leg', 'f-nm'];
|
|
1034
|
+
var FOLDED = { 'f-cnt': '.kv-turns, .kv-tools, .kv-api', 'f-leg': '.leg', 'f-nm': '.u-nm' };
|
|
705
1035
|
var fitUsage = function (el) {
|
|
706
1036
|
if (el.hidden) return;
|
|
707
1037
|
var over = function () { return el.scrollWidth > el.clientWidth; };
|
|
@@ -709,10 +1039,9 @@
|
|
|
709
1039
|
var n = 0;
|
|
710
1040
|
while (n < FOLD.length && over()) el.classList.add(FOLD[n++]);
|
|
711
1041
|
// 藏起来的那几样进整条 bar 的 title, hover 照样读得到。
|
|
712
|
-
el.title = FOLD.slice(0, n).
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}).filter(Boolean).join('\n');
|
|
1042
|
+
el.title = FOLD.slice(0, n).reduce(function (a, c) {
|
|
1043
|
+
return a.concat([].map.call(el.querySelectorAll(FOLDED[c]), function (x) { return x.getAttribute('data-tip'); }));
|
|
1044
|
+
}, []).filter(Boolean).join('\n');
|
|
716
1045
|
};
|
|
717
1046
|
var watchUsage = function (el) {
|
|
718
1047
|
if (el._ro || typeof ResizeObserver === 'undefined') return;
|
|
@@ -744,35 +1073,154 @@
|
|
|
744
1073
|
};
|
|
745
1074
|
|
|
746
1075
|
// ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
|
|
1076
|
+
// 跳走之前在看的那段会话 —— 顶栏的「‹ 回到 …」回这里。on: 在哪些窗口上露出这个按钮;
|
|
1077
|
+
// at: 跳走时点的那条消息, 回来落在它上面。进工单页 (只在从非工单的窗口进来时记) 与
|
|
1078
|
+
// 移交行 / 提示行点过去都记它, 同一个按钮。点过去会换详情区的 viewpoint, 所以连它一起记 (页面视角不动)。
|
|
1079
|
+
var BACK = null;
|
|
1080
|
+
var inJobs = function () { return VIEW === 'jobs' || CONV.indexOf('j:') === 0; };
|
|
1081
|
+
// 回来 = 原样恢复那一页: 会话、viewpoint、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
|
|
1082
|
+
var markBack = function (on) {
|
|
1083
|
+
var r = !atBottom() && firstShown();
|
|
1084
|
+
var at = r && { id: r.getAttribute('data-id'), off: r.getBoundingClientRect().top - thread.getBoundingClientRect().top };
|
|
1085
|
+
BACK = { conv: CONV, with: WITH, session: SESSION, viewpoint: VIEWPOINT, at: at || null, on: on };
|
|
1086
|
+
};
|
|
1087
|
+
var restoreAt = function (at) {
|
|
1088
|
+
if (!at) return undefined;
|
|
1089
|
+
var put = function () {
|
|
1090
|
+
var row = rowNode(at.id);
|
|
1091
|
+
if (!row) return false;
|
|
1092
|
+
thread.scrollTop += row.getBoundingClientRect().top - thread.getBoundingClientRect().top - at.off;
|
|
1093
|
+
S.pinned = atBottom();
|
|
1094
|
+
return true;
|
|
1095
|
+
};
|
|
1096
|
+
return function () { if (!put()) loadMsgs('0', put); return true; };
|
|
1097
|
+
};
|
|
1098
|
+
var openJob = function (id) {
|
|
1099
|
+
if (CONV.indexOf('j:') !== 0) markBack(inJobs);
|
|
1100
|
+
selectConv('j:' + id, '');
|
|
1101
|
+
};
|
|
1102
|
+
// 一处标记背后可能有好几张: 一张直接进那张, 多张进工单列表且只列这几张 (JOB_ONLY)。
|
|
1103
|
+
var JOB_ONLY = null;
|
|
1104
|
+
var openJobs = function (ids) {
|
|
1105
|
+
if (ids.length === 1) return openJob(ids[0]);
|
|
1106
|
+
if (CONV.indexOf('j:') !== 0) markBack(inJobs);
|
|
1107
|
+
JOB_ONLY = ids;
|
|
1108
|
+
if (VIEW === 'jobs') renderJobList(); else setView('jobs');
|
|
1109
|
+
renderHead();
|
|
1110
|
+
};
|
|
1111
|
+
// 标记长在会话项 / 关系图卡片这些按钮里: 捕获阶段截下来, 不让外层按钮再打开那段对话。
|
|
1112
|
+
document.addEventListener('click', function (e) {
|
|
1113
|
+
var m = e.target.closest && e.target.closest('.jmk[data-jobs]');
|
|
1114
|
+
if (!m) return;
|
|
1115
|
+
e.stopPropagation(); e.preventDefault();
|
|
1116
|
+
openJobs(m.getAttribute('data-jobs').split(' ').filter(Boolean));
|
|
1117
|
+
}, true);
|
|
1118
|
+
// 关系图的 clone 徽标: 开 origin 的全部往来、落到它分叉出这个分身的那一处; viewpoint 站到 origin, 页面视角不动。
|
|
1119
|
+
// 同 📋: 长在卡片按钮里, 捕获阶段截下来。
|
|
1120
|
+
document.addEventListener('click', function (e) {
|
|
1121
|
+
var m = e.target.closest && e.target.closest('.ek.clone[data-cpoint]');
|
|
1122
|
+
if (!m) return;
|
|
1123
|
+
e.stopPropagation(); e.preventDefault();
|
|
1124
|
+
var pt = JSON.parse(m.getAttribute('data-cpoint')), conv = 'a:' + pt.origin;
|
|
1125
|
+
markBack(function () { return VIEW === 'msgs' && CONV === conv; });
|
|
1126
|
+
jumpMsg({ conv: conv, id: pt.id, use: pt.use, ts: pt.ts, viewpoint: pt.origin });
|
|
1127
|
+
}, true);
|
|
1128
|
+
var backBtn = function () {
|
|
1129
|
+
var c = BACK && BACK.on() && convOf(BACK.conv);
|
|
1130
|
+
return c ? '<button class="back jb" id="ch-jback" title="' + esc('回到 ' + (BACK.with ? nameOf(BACK.with) : c.name)) + '" aria-label="返回">‹</button>' : '';
|
|
1131
|
+
};
|
|
1132
|
+
// 返回钮钉在标题栏最左 (标题前面), 不进右侧的按钮组 —— 标题写好之后再插进去。
|
|
1133
|
+
var bindBack = function () {
|
|
1134
|
+
var h = backBtn();
|
|
1135
|
+
if (h) $('#ch-who').insertAdjacentHTML('afterbegin', h);
|
|
1136
|
+
var b = $('#ch-jback');
|
|
1137
|
+
if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.viewpoint); };
|
|
1138
|
+
};
|
|
1139
|
+
// 一对一的标题: 与关系图卡片窗口 (convOf 的 a:) 同一种写法。
|
|
1140
|
+
var pairTitle = function (peer) { return nameOf(ROLE) + ' 与 ' + nameOf(peer) + ' 的对话'; };
|
|
747
1141
|
var renderHead = function () {
|
|
748
1142
|
var who = $('#ch-who'), acts = $('#ch-acts');
|
|
1143
|
+
if (VIEW === 'jobs') {
|
|
1144
|
+
who.innerHTML = '<span class="t">工单</span><span class="sub">' + (JOB_ONLY ? '这一处经手的 ' + jobListRows().length + ' 张' : esc(nameOf(ROLE)) + ' 开的或参与的') + ' · 进行中在前</span>';
|
|
1145
|
+
acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
|
|
1146
|
+
bindBack();
|
|
1147
|
+
$('#ch-back').onclick = function () { setView('msgs'); };
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
749
1150
|
if (VIEW !== 'msgs') {
|
|
750
|
-
who.innerHTML =
|
|
1151
|
+
who.innerHTML = VIEW === 'charter'
|
|
1152
|
+
? '<span class="t">宪章</span><span class="sub">wezard 出生时压进 ' + esc(nameOf(ROLE)) + ' 系统提示的那份身份</span>'
|
|
1153
|
+
: '<span class="t">日程</span><span class="sub">' + esc(nameOf(ROLE)) + ' 名下的定时任务</span>';
|
|
751
1154
|
acts.innerHTML = '<button class="vb" id="ch-back">‹ 对话</button>';
|
|
752
1155
|
$('#ch-back').onclick = function () { setView('msgs'); };
|
|
753
1156
|
return;
|
|
754
1157
|
}
|
|
755
1158
|
var c = convOf(CONV);
|
|
756
1159
|
if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
|
|
1160
|
+
// 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏只写它, 副标题「在 <群> 的全部对话」——
|
|
1161
|
+
// 与成对子项 (「我 ⇄ 它」) 一眼分得开。群名点回整个群。
|
|
1162
|
+
if (c.kind === 'all' && c.chat) {
|
|
1163
|
+
who.innerHTML = '<span class="t">' + nm(c.who, '', true) + '</span>' +
|
|
1164
|
+
'<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button> 的全部对话</span>';
|
|
1165
|
+
acts.innerHTML = '';
|
|
1166
|
+
bindGo(who);
|
|
1167
|
+
$('#ch-up').onclick = function () { selectConv('c:' + c.chat, ''); };
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
757
1170
|
if (c.kind === 'all') {
|
|
758
|
-
who.innerHTML =
|
|
1171
|
+
who.innerHTML = '<span class="t" title="' + esc(c.name) + '"></span>';
|
|
759
1172
|
acts.innerHTML = '';
|
|
1173
|
+
bindBack();
|
|
1174
|
+
fitTalk(who.querySelector('.t'), c);
|
|
760
1175
|
bindGo(who);
|
|
761
1176
|
return;
|
|
762
1177
|
}
|
|
763
|
-
//
|
|
764
|
-
|
|
1178
|
+
// 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏同关系图卡片的窗口写成「我 与 它 的对话」, 不画头像;
|
|
1179
|
+
// 群名退到副标题, 点它 = 选回这个群本身 (整个群的视图)。
|
|
1180
|
+
if (WITH && c.kind === 'group') {
|
|
1181
|
+
var withName = pairTitle(WITH);
|
|
1182
|
+
who.innerHTML = '<span class="t" title="' + esc(withName) + '">' + esc(withName) + '</span>' +
|
|
1183
|
+
'<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + c.name + ' 的全部记录') + '">' + esc(c.name) + '</button></span>';
|
|
1184
|
+
acts.innerHTML = '';
|
|
1185
|
+
bindBack();
|
|
1186
|
+
bindGo(who);
|
|
1187
|
+
$('#ch-up').onclick = function () { selectConv(CONV, ''); };
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (c.kind === 'job') {
|
|
1191
|
+
who.innerHTML = '<span class="av">📋</span><span class="t">' + jobCrumbs(c.key.slice(2)) + esc(c.name) + '</span>' +
|
|
1192
|
+
'<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job) + '</span>';
|
|
1193
|
+
who.querySelectorAll('.jcrumb [data-job]').forEach(function (b) { b.onclick = function () { openJob(b.getAttribute('data-job')); }; });
|
|
1194
|
+
acts.innerHTML = '';
|
|
1195
|
+
bindBack();
|
|
1196
|
+
bindGo(who);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
// 一对一 (私聊, 或群里「只看我与 X」) 与关系图卡片的窗口同一种写法: 「我 与 它 的对话」, 不画头像。
|
|
765
1200
|
var peer = pairPeer(c);
|
|
766
|
-
var
|
|
767
|
-
who.innerHTML =
|
|
768
|
-
'<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
|
|
769
|
-
acts.innerHTML =
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1201
|
+
var pairName = peer && pairTitle(peer);
|
|
1202
|
+
who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
|
|
1203
|
+
: '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
|
|
1204
|
+
acts.innerHTML = '';
|
|
1205
|
+
bindBack();
|
|
1206
|
+
bindGo(who);
|
|
1207
|
+
};
|
|
1208
|
+
// 多人对话的标题放不下时, 收成「X 与 .a、.b、.c 等 N 个 role 之间的对话」—— 放得下几个列几个, 至少一个。
|
|
1209
|
+
// 量的是真宽度: 没上屏 (窄屏还停在列表) 量不出来, 就先给全称, 等 resize / 下一次重画。
|
|
1210
|
+
var fitTalk = function (el, c) {
|
|
1211
|
+
var n = c.peers.length;
|
|
1212
|
+
var at = function (k) {
|
|
1213
|
+
return k >= n ? c.name : nameOf(c.who) + ' 与 ' + c.peers.slice(0, k).map(nameOf).join('、') + ' 等 ' + n + ' 个 role 之间的对话';
|
|
1214
|
+
};
|
|
1215
|
+
var k = n;
|
|
1216
|
+
el.textContent = at(k);
|
|
1217
|
+
if (!el.clientWidth) return;
|
|
1218
|
+
while (k > 1 && el.scrollWidth > el.clientWidth + 1) el.textContent = at(--k);
|
|
1219
|
+
};
|
|
1220
|
+
window.addEventListener('resize', function () {
|
|
1221
|
+
var c = VIEW === 'msgs' && convOf(CONV);
|
|
1222
|
+
if (c && c.kind === 'all') renderHead();
|
|
1223
|
+
});
|
|
776
1224
|
|
|
777
1225
|
// ── 消息行 ──
|
|
778
1226
|
// 同一条消息从发话方看靠右、从收信方看靠左 —— 片段不带方向, 由这里按视角包装。
|
|
@@ -790,43 +1238,74 @@
|
|
|
790
1238
|
var signCut = function (html, id, name) {
|
|
791
1239
|
return html.replace(CUT_L, function (_, open, l) { return open + nm(id, name, true) + l; });
|
|
792
1240
|
};
|
|
1241
|
+
// 消息上的工单 badge (服务端只写 data-job): 按此刻的账填成 📋 标题 + 进度, 点了进那张工单页。
|
|
1242
|
+
// 工单页自己的消息不挂 —— 点了还是这一页。
|
|
1243
|
+
var JOB_CHIP = /<button class="mchip job" data-job="([^"]*)"><\/button>/g;
|
|
1244
|
+
var jobChips = function (html) {
|
|
1245
|
+
return html.replace(JOB_CHIP, function (_, id) {
|
|
1246
|
+
if (CONV === 'j:' + id) return '';
|
|
1247
|
+
var c = jobRowOf(id);
|
|
1248
|
+
return '<button class="mchip job" data-job="' + id + '" title="' + esc(id + ' · ' + c.name + ' —— 进工单页') + '">📋 <span class="jt">' + esc(c.name) + '</span>' + jobTag(c.job) + '</button>';
|
|
1249
|
+
});
|
|
1250
|
+
};
|
|
1251
|
+
var refillJobChips = function () {
|
|
1252
|
+
inner.querySelectorAll('.mchip.job[data-job]').forEach(function (b) {
|
|
1253
|
+
b.outerHTML = jobChips('<button class="mchip job" data-job="' + b.getAttribute('data-job') + '"></button>');
|
|
1254
|
+
});
|
|
1255
|
+
};
|
|
793
1256
|
var rowHTML = function (m) {
|
|
794
1257
|
learn(m.from, m.fromName, m.fromLabel);
|
|
795
1258
|
learn(m.to, m.toName, m.toLabel);
|
|
796
1259
|
if (m.dir === 'mark') {
|
|
797
|
-
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
|
|
1260
|
+
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) + '</div>';
|
|
798
1261
|
}
|
|
799
|
-
var
|
|
1262
|
+
var me = viewpointOf();
|
|
1263
|
+
var mine = m.from === me;
|
|
800
1264
|
var other = mine ? m.to : m.from;
|
|
801
1265
|
// 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
|
|
802
1266
|
var group = CONV.indexOf('p:') !== 0;
|
|
1267
|
+
// 顶栏那个对端是相对 ROLE 的; 详情区的 viewpoint 换成了它时, 它的对端就是 ROLE。
|
|
1268
|
+
var peer = group ? pairPeer(convOf(CONV)) : CONV.slice(2);
|
|
1269
|
+
if (VIEWPOINT && peer === VIEWPOINT) peer = ROLE;
|
|
803
1270
|
// 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
|
|
804
1271
|
// (一对一里收信方就是顶栏那个对端, 不再重复写)。
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
var
|
|
813
|
-
? '<span class="
|
|
814
|
-
'
|
|
815
|
-
(
|
|
1272
|
+
// 两个 role 之间的对话 (标题「X 与 Y 的对话」) 里对象就是另一方, 不标; 多方视图才标。
|
|
1273
|
+
var c = convOf(CONV);
|
|
1274
|
+
var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
|
|
1275
|
+
var dst = two ? '' : mine
|
|
1276
|
+
? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
|
|
1277
|
+
: (m.to !== me && group ? m.to : '');
|
|
1278
|
+
// 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
|
|
1279
|
+
var toIn = dst
|
|
1280
|
+
? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
|
|
1281
|
+
'" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
|
|
1282
|
+
esc(roleLabel(m.to)) + '</span><span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span></span>'
|
|
816
1283
|
: '';
|
|
1284
|
+
var withTo = function (html) {
|
|
1285
|
+
if (!toIn) return html;
|
|
1286
|
+
// 只挂进这条消息自己的正文: 移交体也是 .md-body, 但它发给被移交的那个 wizard, 服务端已挂好它自己的 .to-in。
|
|
1287
|
+
var t = document.createElement('template');
|
|
1288
|
+
t.innerHTML = html;
|
|
1289
|
+
var body = [].find.call(t.content.querySelectorAll('.q-body, .md-body'), function (b) { return !b.closest('.handoff'); });
|
|
1290
|
+
if (!body) return '<div class="to-row">' + toIn + '</div>' + html;
|
|
1291
|
+
body.insertAdjacentHTML('afterbegin', toIn);
|
|
1292
|
+
return t.innerHTML;
|
|
1293
|
+
};
|
|
817
1294
|
var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
|
|
818
|
-
// 本轮的账 (呼吸点 + 模型 /
|
|
819
|
-
|
|
1295
|
+
// 本轮的账 (呼吸点 + 模型 / 上下文 / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
|
|
1296
|
+
// 模型 / ctx 只在多方会话里有用 (分得清谁跑的什么模型); 两个 role 之间的对话里整行挂 .two, 头像行 (.mstat)、
|
|
1297
|
+
// 终句气泡的时间行 (.say-cap) 与移交行的账 (.ho-acct) 里藏掉这两样, 耗时、呼吸点与回执留着。
|
|
1298
|
+
var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
|
|
820
1299
|
var who = mine
|
|
821
|
-
?
|
|
822
|
-
: avBtn(m.from
|
|
1300
|
+
? stamp(m.ts) + stat + avBtn(m.from)
|
|
1301
|
+
: avBtn(m.from) + nm(m.from, '', true) + priv + stamp(m.ts) + stat;
|
|
823
1302
|
var sw = canSwitch(other);
|
|
824
1303
|
var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
|
|
825
1304
|
' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
|
|
826
|
-
|
|
827
|
-
return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
828
|
-
(m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.
|
|
829
|
-
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + (m.dir === 'out' ? signCut(m.html, m.from
|
|
1305
|
+
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
1306
|
+
return '<div class="mrow ' + (mine ? 'mine' : 'them') + (two ? ' two' : '') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
1307
|
+
(m.ping ? ' data-ping="1" data-ping-who="' + esc(roleName(m.dir === 'in' ? m.to : m.from)) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
1308
|
+
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(withTo(m.dir === 'out' ? signCut(m.html, m.from) : m.html)) + '</div></div>' +
|
|
830
1309
|
flip +
|
|
831
1310
|
'</div>';
|
|
832
1311
|
};
|
|
@@ -881,7 +1360,14 @@
|
|
|
881
1360
|
};
|
|
882
1361
|
|
|
883
1362
|
var bindRow = function (el) {
|
|
884
|
-
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r]');
|
|
1363
|
+
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r], .tg-job .go[data-r]');
|
|
1364
|
+
// 定时行开的是页面视角自己的日程: 片段不随视角 (服务端只标发话方), 能不能点在这里按 ROLE 定;
|
|
1365
|
+
// 别人的那行不可点, 只剩 chevron 展开 prompt。换视角就地重包时也走这里。
|
|
1366
|
+
el.querySelectorAll('.handoff .ho-line[data-hplan]').forEach(function (b) {
|
|
1367
|
+
var mine = b.getAttribute('data-hplan') === ROLE;
|
|
1368
|
+
b.classList.toggle('plan-go', mine);
|
|
1369
|
+
if (mine) b.title = '打开 ' + nameOf(ROLE) + ' 的日程'; else b.removeAttribute('title');
|
|
1370
|
+
});
|
|
885
1371
|
};
|
|
886
1372
|
var rowNode = function (id) {
|
|
887
1373
|
var list = inner.querySelectorAll('.mrow');
|
|
@@ -904,25 +1390,61 @@
|
|
|
904
1390
|
};
|
|
905
1391
|
};
|
|
906
1392
|
|
|
1393
|
+
var moreBtn = function (d) {
|
|
1394
|
+
return d.truncated ? '<button class="more-btn" id="more">载入更早的 ' + (d.older != null ? d.older : d.total - d.msgs.length) + ' 条</button>' : '';
|
|
1395
|
+
};
|
|
1396
|
+
var bindMore = function () {
|
|
1397
|
+
var btn = $('#more');
|
|
1398
|
+
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; btn.disabled = true; loadOlder(); };
|
|
1399
|
+
};
|
|
1400
|
+
// 载入更早: 以此刻最早那一行为游标往前取一页, 插在最前面; 视口锚住原来顶上那一行。
|
|
1401
|
+
var loadOlder = function () {
|
|
1402
|
+
var gen = S.gen, first = inner.querySelector('.mrow');
|
|
1403
|
+
if (!first) return Promise.resolve();
|
|
1404
|
+
var cursor = { before: first.getAttribute('data-id'), beforeTs: first.getAttribute('data-ts') };
|
|
1405
|
+
// 失败就把按钮还原, 别让它永远停在「载入中…」; 换了窗口 (gen 变了) 按钮已随旧内容消失。
|
|
1406
|
+
var retry = function () {
|
|
1407
|
+
var b = $('#more'); if (gen === S.gen && b) { b.disabled = false; b.textContent = '载入失败, 点此重试'; }
|
|
1408
|
+
};
|
|
1409
|
+
return api('api/msgs', viewParams(cursor)).then(function (d) {
|
|
1410
|
+
if (gen !== S.gen) return;
|
|
1411
|
+
if (!d.ok) return retry();
|
|
1412
|
+
var keep = keepView();
|
|
1413
|
+
// 游标切不出「之前」(服务端回 reset): 退回整窗全量重载, 视口照旧锚住。
|
|
1414
|
+
if (d.reset) return loadMsgs('0', keep);
|
|
1415
|
+
var old = $('#more'); if (old) old.remove();
|
|
1416
|
+
// 按时刻兜底的那一页可能带回同一毫秒、已经画着的几条 —— 按 id 去重。
|
|
1417
|
+
var fresh = d.msgs.filter(function (m) { return !rowNode(m.id); });
|
|
1418
|
+
fresh.forEach(function (m) { S.frags[m.id] = m; });
|
|
1419
|
+
unfoldPings(inner);
|
|
1420
|
+
var page = frag(moreBtn(d) + fresh.map(rowHTML).join(''));
|
|
1421
|
+
bindRow(page); render(page);
|
|
1422
|
+
while (page.lastChild) inner.insertBefore(page.lastChild, inner.firstChild);
|
|
1423
|
+
bindMore();
|
|
1424
|
+
foldPings(inner);
|
|
1425
|
+
expireRows();
|
|
1426
|
+
if (keep) keep();
|
|
1427
|
+
}).catch(retry);
|
|
1428
|
+
};
|
|
1429
|
+
|
|
907
1430
|
// land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
|
|
908
|
-
|
|
1431
|
+
// early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
|
|
1432
|
+
var loadMsgs = function (limit, land, early) {
|
|
909
1433
|
var gen = S.gen;
|
|
910
1434
|
if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
|
|
911
|
-
return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
|
|
1435
|
+
return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
|
|
912
1436
|
if (!d.ok || gen !== S.gen) return;
|
|
913
1437
|
S.frags = {};
|
|
914
1438
|
d.msgs.forEach(function (m) { S.frags[m.id] = m; });
|
|
915
1439
|
if (!d.msgs.length) { inner.innerHTML = '<div class="empty">这里还没有消息</div>'; return; }
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
: '';
|
|
919
|
-
inner.innerHTML = more + d.msgs.map(rowHTML).join('');
|
|
920
|
-
var btn = $('#more');
|
|
921
|
-
if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadMsgs('0', keepView()); };
|
|
1440
|
+
inner.innerHTML = moreBtn(d) + d.msgs.map(rowHTML).join('');
|
|
1441
|
+
bindMore();
|
|
922
1442
|
bindRow(inner);
|
|
923
1443
|
render(inner);
|
|
924
1444
|
foldPings(inner);
|
|
925
1445
|
expireRows();
|
|
1446
|
+
settleAbove();
|
|
1447
|
+
applyFold(inner);
|
|
926
1448
|
if (land && land()) return;
|
|
927
1449
|
S.pinned = true; toBottom(true);
|
|
928
1450
|
// CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
|
|
@@ -961,6 +1483,8 @@
|
|
|
961
1483
|
}
|
|
962
1484
|
}
|
|
963
1485
|
foldPings(inner);
|
|
1486
|
+
settleAbove();
|
|
1487
|
+
applyFold(inner);
|
|
964
1488
|
if (stick) toBottom(true);
|
|
965
1489
|
};
|
|
966
1490
|
|
|
@@ -973,6 +1497,9 @@
|
|
|
973
1497
|
g.setAttribute('data-stale-at', '0');
|
|
974
1498
|
g.querySelectorAll('.tg-dot').forEach(function (d) { d.classList.remove('live'); });
|
|
975
1499
|
g.querySelectorAll('.typing').forEach(function (t) { t.remove(); });
|
|
1500
|
+
// 静默到点 = 这一轮结束, 末尾那个过程框也不再是「进行中」。
|
|
1501
|
+
g.querySelectorAll('.steps').forEach(settle);
|
|
1502
|
+
applyFold(g);
|
|
976
1503
|
});
|
|
977
1504
|
};
|
|
978
1505
|
|
|
@@ -981,16 +1508,21 @@
|
|
|
981
1508
|
// 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
|
|
982
1509
|
var takeRole = function (d) {
|
|
983
1510
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
984
|
-
|
|
1511
|
+
if (!BASE) BASE = R.at;
|
|
1512
|
+
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys;
|
|
985
1513
|
SESSION = d.session || '';
|
|
986
|
-
R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null;
|
|
1514
|
+
R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null; R.inflight = d.inflight || [];
|
|
1515
|
+
// 工单的账变了 (或比消息晚到): 已上屏的工单 badge 按新账重填一遍 —— 片段里只写了工单号。
|
|
1516
|
+
var jx = JSON.stringify(d.jobIndex || {});
|
|
1517
|
+
if (jx !== R.jobIndexSig) { R.jobIndexSig = jx; R.jobIndex = d.jobIndex || {}; refillJobChips(); }
|
|
1518
|
+
R.charter = d.charter || null;
|
|
987
1519
|
R.stats = d.stats || null; R.winStats = d.winStats || null;
|
|
988
1520
|
ROLE = d.role.id;
|
|
989
|
-
learn(d.role.id, d.role.name, d.role.label)
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1521
|
+
var renamed = [learn(d.role.id, d.role.name, d.role.label, true)].concat.apply([], R.convs.map(function (c) {
|
|
1522
|
+
return [c.peer ? learn(c.peer, c.name, c.label, true) : false].concat(c.subs.map(function (s) { return learn(s.role, s.name, s.label, true); }));
|
|
1523
|
+
})).some(Boolean);
|
|
1524
|
+
// 名册晚到 (svr 刚起) 或有人改了名: 已上屏的行按新名字就地重包, 正文不动。
|
|
1525
|
+
if (renamed && inner.querySelector('.mrow')) rewrapRows(false);
|
|
994
1526
|
// 没带 conv 的链接 (群里点名字进来) 落在服务端挑的窗口上, 连同它挑的「只看我与谁」;
|
|
995
1527
|
// 手里的 conv 失效则只退回整个会话。
|
|
996
1528
|
if (!CONV || !convOf(CONV)) { WITH = CONV ? '' : d['with'] || ''; CONV = d.conv || ''; }
|
|
@@ -1026,16 +1558,32 @@
|
|
|
1026
1558
|
syncUrl();
|
|
1027
1559
|
// 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
|
|
1028
1560
|
if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
|
|
1561
|
+
// 关系图上换视角: 自己那张卡片的窗口要等新树画出来才知道 (W.pickSelf), 正文等它定了只取一次;
|
|
1562
|
+
// 否则窗口已定, 正文与摘要并行取 —— 摘要改了窗口 (落点 / session) 就作废重取。
|
|
1563
|
+
var hold = WORLD && W.pickSelf === ROLE;
|
|
1564
|
+
// 窗口的指纹: 不带 session 与 session=all 取的是同一份 (摘要会把空的回填成 all)。
|
|
1565
|
+
var winSig = function () { var p = viewParams(); if (p.session === 'all') delete p.session; return new URLSearchParams(p).toString(); };
|
|
1566
|
+
var want = hold || !CONV ? '' : winSig();
|
|
1567
|
+
var early = want ? api('api/msgs', viewParams()) : null;
|
|
1568
|
+
W.pickHold = hold;
|
|
1029
1569
|
return api('api/role', viewParams()).then(function (d) {
|
|
1030
1570
|
if (!d.ok) {
|
|
1031
1571
|
document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
|
|
1032
1572
|
return;
|
|
1033
1573
|
}
|
|
1034
|
-
// 带 land 的 (换视角)
|
|
1035
|
-
|
|
1574
|
+
// 带 land 的 (换视角) 只先画侧栏 (名片 + 会话列表, 不碰详情区的几何): 页头 / 页脚换了高度,
|
|
1575
|
+
// 旧行会在新行到来前先被顶一下, FLIP 的起点就错了 —— 它们等新行到手的同一帧画。
|
|
1576
|
+
if (land) { takeRole(d); reveal(); renderRole(); if (!WORLD) renderConvs(); } else applyRole(d);
|
|
1577
|
+
if (restoreSess()) return refresh(land);
|
|
1036
1578
|
syncUrl();
|
|
1037
|
-
|
|
1038
|
-
|
|
1579
|
+
var world = WORLD || VIEW === 'plan' ? loadWorld() : null;
|
|
1580
|
+
if (VIEW === 'charter') loadCharter();
|
|
1581
|
+
return Promise.resolve(hold && world).then(function () {
|
|
1582
|
+
W.pickHold = false;
|
|
1583
|
+
syncUrl();
|
|
1584
|
+
var fresh = early && want === winSig() ? early : null;
|
|
1585
|
+
return loadMsgs(undefined, land && function () { paintRole(); return land(); }, fresh);
|
|
1586
|
+
}).then(function () {
|
|
1039
1587
|
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
1040
1588
|
connect();
|
|
1041
1589
|
});
|
|
@@ -1043,8 +1591,8 @@
|
|
|
1043
1591
|
};
|
|
1044
1592
|
|
|
1045
1593
|
// land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
|
|
1046
|
-
var selectConv = function (key, withRole, land) {
|
|
1047
|
-
CONV = key; WITH = withRole || '';
|
|
1594
|
+
var selectConv = function (key, withRole, land, viewpoint) {
|
|
1595
|
+
CONV = key; WITH = withRole || ''; VIEWPOINT = viewpoint && viewpoint !== ROLE ? viewpoint : '';
|
|
1048
1596
|
reveal();
|
|
1049
1597
|
if (VIEW !== 'msgs') setView('msgs');
|
|
1050
1598
|
app.classList.add('reading');
|
|
@@ -1065,11 +1613,11 @@
|
|
|
1065
1613
|
W.treeFor = '';
|
|
1066
1614
|
var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
|
|
1067
1615
|
var from = ROLE;
|
|
1068
|
-
//
|
|
1069
|
-
var
|
|
1070
|
-
//
|
|
1071
|
-
var
|
|
1072
|
-
ROLE = id; WITH = withBack; SESSION = '';
|
|
1616
|
+
// 群里「我与 X」时换到 X, 对面看到的是「X 与 from」—— 同一段往来, 选中这一对而不是整个群。
|
|
1617
|
+
var withBack = keep && WITH === id ? from : '';
|
|
1618
|
+
// 整个频道 / 两人私聊 / 群里的一对, 消息集合都与视角无关 —— 集合没变就就地翻面。
|
|
1619
|
+
var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0);
|
|
1620
|
+
ROLE = id; WITH = withBack; SESSION = ''; VIEWPOINT = '';
|
|
1073
1621
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
1074
1622
|
// 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
|
|
1075
1623
|
// 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
|
|
@@ -1187,6 +1735,21 @@
|
|
|
1187
1735
|
if (nb) nb.replaceWith(k.mb);
|
|
1188
1736
|
});
|
|
1189
1737
|
};
|
|
1738
|
+
/** 已上屏的行拿手里的片段就地重包 (左右、署名), 已渲染的正文整块搬过去。dropMarks: 断点一并撤下。 */
|
|
1739
|
+
var rewrapRows = function (dropMarks) {
|
|
1740
|
+
unfoldPings(inner);
|
|
1741
|
+
[].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
|
|
1742
|
+
var m = S.frags[row.getAttribute('data-id')];
|
|
1743
|
+
if (!m || (dropMarks && m.dir === 'mark')) { if (dropMarks) row.remove(); return; }
|
|
1744
|
+
var next = frag(rowHTML(m)).firstElementChild;
|
|
1745
|
+
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
1746
|
+
if (ob && nb) nb.replaceWith(ob);
|
|
1747
|
+
row.replaceWith(next);
|
|
1748
|
+
bindRow(next);
|
|
1749
|
+
});
|
|
1750
|
+
foldPings(inner);
|
|
1751
|
+
expireRows();
|
|
1752
|
+
};
|
|
1190
1753
|
/** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去。
|
|
1191
1754
|
* 先等 role 摘要到手、页头侧栏按新视角画完再重包 —— 页头换了高度 (人没有职责行、有的 wizard 有),
|
|
1192
1755
|
* 在动画起跑之后才变, 整列就会在半路被顶一下。旧行在这几十毫秒里原样留着。 */
|
|
@@ -1197,19 +1760,8 @@
|
|
|
1197
1760
|
api('api/role', viewParams()).then(function (d) {
|
|
1198
1761
|
if (!d.ok || gen !== S.gen) return;
|
|
1199
1762
|
applyRole(d);
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
var m = S.frags[row.getAttribute('data-id')];
|
|
1203
|
-
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
1204
|
-
if (!m || m.dir === 'mark') { row.remove(); return; }
|
|
1205
|
-
var next = frag(rowHTML(m)).firstElementChild;
|
|
1206
|
-
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
1207
|
-
if (ob && nb) nb.replaceWith(ob);
|
|
1208
|
-
row.replaceWith(next);
|
|
1209
|
-
bindRow(next);
|
|
1210
|
-
});
|
|
1211
|
-
foldPings(inner);
|
|
1212
|
-
expireRows();
|
|
1763
|
+
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
1764
|
+
rewrapRows(true);
|
|
1213
1765
|
landFlip(snap);
|
|
1214
1766
|
connect();
|
|
1215
1767
|
return api('api/msgs', viewParams());
|
|
@@ -1240,13 +1792,16 @@
|
|
|
1240
1792
|
// · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
|
|
1241
1793
|
// 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
|
|
1242
1794
|
var KIND = {
|
|
1243
|
-
|
|
1244
|
-
clone
|
|
1245
|
-
|
|
1795
|
+
// 生它、归它管的那一位下面挂的是 clone (带上下文) 或 spawn (白板) —— 与调用框下的提示行同一套字。
|
|
1796
|
+
// clone 徽标点了落到 origin 里的分叉处。
|
|
1797
|
+
clone: { mark: 'clone', tip: 'clone_wizard 生的分身: fork 了上下文, 开局带着那一刻读过的一切; 归生它的那位管 —— 点了看它在哪一处分叉' },
|
|
1798
|
+
spawn: { mark: 'spawn', tip: 'spawn_wizard / dispatch 白板生的: 不继承上下文, 只是归生它的那位管 (不带 detached)' },
|
|
1799
|
+
fork: { mark: '⑂ 上下文', tip: '分身的上下文 fork 自它 —— 生它、归它管的是另一位' },
|
|
1800
|
+
peer: { mark: '💬', tip: 'send_peer 发起的对话' },
|
|
1246
1801
|
job: { mark: '工单', tip: '它开的工单里有这位成员' },
|
|
1247
1802
|
graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
|
|
1248
1803
|
};
|
|
1249
|
-
var LINEAGE = { spawn: 1, clone: 1 };
|
|
1804
|
+
var LINEAGE = { spawn: 1, clone: 1, fork: 1 };
|
|
1250
1805
|
|
|
1251
1806
|
// 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
|
|
1252
1807
|
var cssEsc = function (v) {
|
|
@@ -1262,7 +1817,8 @@
|
|
|
1262
1817
|
// ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
|
|
1263
1818
|
var rangeOf = function () {
|
|
1264
1819
|
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1265
|
-
|
|
1820
|
+
var cut = winCut();
|
|
1821
|
+
return s ? { from: Math.max(s.start, cut), to: s.end || Infinity, s: s } : cut ? { from: cut, to: Infinity, s: null } : null;
|
|
1266
1822
|
};
|
|
1267
1823
|
var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
|
|
1268
1824
|
|
|
@@ -1278,12 +1834,19 @@
|
|
|
1278
1834
|
|
|
1279
1835
|
/** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
|
|
1280
1836
|
var relations = function (rg) {
|
|
1281
|
-
var
|
|
1282
|
-
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] };
|
|
1283
|
-
}).concat(jobEdges())
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1837
|
+
var all = W.edges.map(function (e) {
|
|
1838
|
+
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], point: e.point };
|
|
1839
|
+
}).concat(jobEdges());
|
|
1840
|
+
// 时间范围里有过往来 (任一种非家谱边, 任一端) 的 role。
|
|
1841
|
+
var active = all.reduce(function (m, e) {
|
|
1842
|
+
if (!LINEAGE[e.kind] && e.ts.some(function (t) { return inRange(rg, t); })) { m[e.from] = 1; m[e.to] = 1; }
|
|
1843
|
+
return m;
|
|
1844
|
+
}, {});
|
|
1845
|
+
var pairs = all.reduce(function (m, e) {
|
|
1846
|
+
// 家谱是身份, 不是发生在某段 session 里的事 —— 不受 session 裁剪, 否则切到子 wizard 时
|
|
1847
|
+
// 它的出生早于自己的 session, 父亲那条边就被裁掉了。时间范围 (WIN) 照裁: 子 wizard 在范围里没有
|
|
1848
|
+
// 往来, 它那条家谱边也不画 —— 与侧栏同一刀 (inWin), 闲了一小时的分身不该因为出身留在「半小时」里。
|
|
1849
|
+
var ts = LINEAGE[e.kind] ? (!WIN || active[e.to] ? e.ts : []) : e.ts.filter(function (t) { return inRange(rg, t); });
|
|
1287
1850
|
if (!ts.length) return m;
|
|
1288
1851
|
var k = e.from + '\u0000' + e.to;
|
|
1289
1852
|
var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
|
|
@@ -1292,6 +1855,7 @@
|
|
|
1292
1855
|
p.first = Math.min(p.first, Math.min.apply(null, ts));
|
|
1293
1856
|
p.last = Math.max(p.last, Math.max.apply(null, ts));
|
|
1294
1857
|
e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
|
|
1858
|
+
if (e.point) p.point = e.point;
|
|
1295
1859
|
return m;
|
|
1296
1860
|
}, {});
|
|
1297
1861
|
// 派活本身带着工单号时, 「工单」标记就是重复的。
|
|
@@ -1303,7 +1867,8 @@
|
|
|
1303
1867
|
var forestOf = function (pairs) {
|
|
1304
1868
|
var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
|
|
1305
1869
|
var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
|
|
1306
|
-
|
|
1870
|
+
// 主父亲: 生它的那位 (spawn / clone) 先于上下文来源 (fork), 再先于派活。
|
|
1871
|
+
var weight = function (p) { return (p.kinds.spawn || p.kinds.clone ? 3e6 : p.kinds.fork ? 2e6 : 0) + p.n; };
|
|
1307
1872
|
var pp = Object.keys(inc).reduce(function (m, t) {
|
|
1308
1873
|
m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
|
|
1309
1874
|
return m;
|
|
@@ -1339,15 +1904,15 @@
|
|
|
1339
1904
|
close();
|
|
1340
1905
|
// 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
|
|
1341
1906
|
var kids = kidsIn();
|
|
1342
|
-
|
|
1907
|
+
// 人直接对 wizard 说话的那条边不是它的主父亲 (主父亲多是生它的那位) 时, 在人下面再挂一张它的引用卡:
|
|
1908
|
+
// 同一个节点、同一个窗口, 只是承载的是人 → 它这条边; 引用卡不再往下展开 (后代在它的本位上画)。
|
|
1909
|
+
var refs = list.filter(function (p) { return kindOf(p.from) === 'human' && pp[p.to] !== p; })
|
|
1910
|
+
.reduce(function (m, p) { (m[p.from] = m[p.from] || []).push(p); return m; }, {});
|
|
1911
|
+
return { pairs: pairs, inc: inc, pp: pp, kids: kids, refs: refs, roots: roots, ends: ends };
|
|
1343
1912
|
};
|
|
1344
1913
|
|
|
1345
1914
|
// F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
|
|
1346
1915
|
var shows = function (F, t) { return !F.vis || !!F.vis[t]; };
|
|
1347
|
-
var countSub = function (F, t) {
|
|
1348
|
-
return (F.kids[t] || []).filter(function (k) { return shows(F, k); })
|
|
1349
|
-
.reduce(function (n, k) { return n + 1 + countSub(F, k); }, 0);
|
|
1350
|
-
};
|
|
1351
1916
|
var chainUp = function (F, t, seen) {
|
|
1352
1917
|
seen = seen || {};
|
|
1353
1918
|
var p = F.pp[t];
|
|
@@ -1360,19 +1925,21 @@
|
|
|
1360
1925
|
var labelHTML = function (p) {
|
|
1361
1926
|
if (!p) return '';
|
|
1362
1927
|
return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1928
|
+
// 工单这一种关系由 📋 那一枚说 (可点, 列的就是这几张), 不再另挂「工单 ×N」重复一遍。
|
|
1929
|
+
Object.keys(KIND).filter(function (k) { return p.kinds[k] && !(k === 'job' && p.jobs.length); }).map(function (k) {
|
|
1930
|
+
return '<span class="ek ' + k + '"' + (k === 'clone' && p.point ? ' data-cpoint="' + esc(JSON.stringify(p.point)) + '"' : '') + ' title="' + KIND[k].tip + '">' + KIND[k].mark +
|
|
1931
|
+
(!LINEAGE[k] ? ' ' + p.kinds[k] : '') + '</span>';
|
|
1366
1932
|
}).join('') +
|
|
1367
|
-
(p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') +
|
|
1933
|
+
(p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') +
|
|
1934
|
+
(p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? ' ' + p.jobs.length : '') + '</span>' : '') + '</span>';
|
|
1368
1935
|
};
|
|
1369
1936
|
// 一条边的主色: 家谱优先, 决定树上那道线的颜色。
|
|
1370
1937
|
var domKind = function (p) {
|
|
1371
|
-
return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1938
|
+
return !p ? 'root' : ['spawn', 'clone', 'fork', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1372
1939
|
};
|
|
1373
1940
|
|
|
1374
1941
|
// ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
|
|
1375
|
-
var edgeEnds = function (F, t) {
|
|
1942
|
+
var edgeEnds = function (F, t, p) { p = p || F.pp[t]; return p ? [p.from, t] : [t]; };
|
|
1376
1943
|
// 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
|
|
1377
1944
|
var pairConv = function (other) {
|
|
1378
1945
|
if (convOf('p:' + other)) return ['p:' + other, ''];
|
|
@@ -1382,23 +1949,30 @@
|
|
|
1382
1949
|
return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
|
|
1383
1950
|
};
|
|
1384
1951
|
// 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
|
|
1385
|
-
var edgeConv = function (F, t) {
|
|
1386
|
-
var ends = edgeEnds(F, t);
|
|
1952
|
+
var edgeConv = function (F, t, e) {
|
|
1953
|
+
var ends = edgeEnds(F, t, e);
|
|
1387
1954
|
var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
|
|
1388
1955
|
return other ? pairConv(other) : null;
|
|
1389
1956
|
};
|
|
1390
|
-
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role
|
|
1957
|
+
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话。整页视角不换,
|
|
1958
|
+
// 只有详情区的 viewpoint 站到被点的那个 role 上: 这段往来是它的, 它说的靠右。侧栏子项是页面视角自己与对端的那一对, 不带 viewpoint。
|
|
1391
1959
|
// links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
|
|
1392
1960
|
var talkKey = function (links, t) {
|
|
1393
1961
|
var ls = links[t] || [];
|
|
1394
1962
|
return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
|
|
1395
1963
|
};
|
|
1396
|
-
// 图上相连 =
|
|
1964
|
+
// 图上相连 = 画出来的边: 它的父亲 + 它的孩子 + 引用卡两端 (人 ↔ 它), 两端都画出来了才算。
|
|
1397
1965
|
var linksOf = function (F, shown) {
|
|
1398
1966
|
var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
|
|
1967
|
+
var refIn = Object.keys(F.refs).reduce(function (m, h) {
|
|
1968
|
+
F.refs[h].forEach(function (p) { (m[p.to] = m[p.to] || []).push(h); });
|
|
1969
|
+
return m;
|
|
1970
|
+
}, {});
|
|
1971
|
+
var refOut = function (t) { return (F.refs[t] || []).map(function (p) { return p.to; }); };
|
|
1399
1972
|
return shown.reduce(function (m, n) {
|
|
1400
1973
|
var t = n.target, p = F.pp[t];
|
|
1401
|
-
m[t] = (p && on[p.from] ? [p.from] : []).concat(
|
|
1974
|
+
m[t] = (p && on[p.from] ? [p.from] : []).concat(F.kids[t] || [], refIn[t] || [], refOut(t))
|
|
1975
|
+
.filter(function (k, i, a) { return on[k] && a.indexOf(k) === i; });
|
|
1402
1976
|
return m;
|
|
1403
1977
|
}, {});
|
|
1404
1978
|
};
|
|
@@ -1410,10 +1984,10 @@
|
|
|
1410
1984
|
// (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
|
|
1411
1985
|
// 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
|
|
1412
1986
|
// 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
|
|
1413
|
-
var cardGlance = function (F, t) {
|
|
1414
|
-
var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
|
|
1987
|
+
var cardGlance = function (F, t, e) {
|
|
1988
|
+
var hit = edgeConv(F, t, e), c = hit && convOf(hit[0]);
|
|
1415
1989
|
var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
|
|
1416
|
-
var n = nodeOf(t) || {}, p = F.pp[t];
|
|
1990
|
+
var n = nodeOf(t) || {}, p = e || F.pp[t];
|
|
1417
1991
|
var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
|
|
1418
1992
|
var w = F.links && W.glance[talkKey(F.links, t)];
|
|
1419
1993
|
return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
|
|
@@ -1431,108 +2005,139 @@
|
|
|
1431
2005
|
var byCard = function (F) {
|
|
1432
2006
|
return function (a, b) { return recentFirst(cardGlance(F, a), cardGlance(F, b)); };
|
|
1433
2007
|
};
|
|
1434
|
-
|
|
2008
|
+
// 关系图: 它与 N 个 role 有关系, 但它们的卡片不在这一档画出来的图上 (画着就不算, 连没连线都一样)。
|
|
2009
|
+
// 关系 = 服务端裁节点之前的全部对端 (node.rels) ∪ 这段范围里画得出的边 —— 前者补上没下发的节点,
|
|
2010
|
+
// 后者兜住老 svr 没给 rels 的情况。F.on = 这一档画出来的卡片, 「相关 | 全部」各算各的。
|
|
2011
|
+
var otherRels = function (F, t) {
|
|
2012
|
+
var on = F.on || {};
|
|
2013
|
+
var all = Object.keys(F.pairs).reduce(function (m, k) {
|
|
2014
|
+
var p = F.pairs[k], o = p.from === t ? p.to : p.to === t ? p.from : '';
|
|
2015
|
+
if (o) m[o] = 1;
|
|
2016
|
+
return m;
|
|
2017
|
+
}, ((nodeOf(t) || {}).rels || []).reduce(function (m, o) { m[o] = 1; return m; }, {}));
|
|
2018
|
+
var n = Object.keys(all).filter(function (o) { return o !== t && !on[o]; }).length;
|
|
2019
|
+
return moreTag(n, nameOf(t) + ' 还和 ' + n + ' 个 role 有关系, 不在这张图上, 切到它的视角可见');
|
|
2020
|
+
};
|
|
2021
|
+
// e = 引用卡承载的那条边 (人 → 它); 没有 = 本位卡片, 承载主父亲边。
|
|
2022
|
+
var tnodeHTML = function (F, n, e) {
|
|
1435
2023
|
var me = n.target === ROLE;
|
|
1436
|
-
var
|
|
1437
|
-
var
|
|
1438
|
-
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, tail);
|
|
2024
|
+
var p = e || F.pp[n.target];
|
|
2025
|
+
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target, e), n, '', otherRels(F, n.target));
|
|
1439
2026
|
var via = F.vis && F.vis[n.target] === 'via';
|
|
1440
|
-
|
|
1441
|
-
|
|
2027
|
+
var jin = F.job && (F.job.in[n.target] ? ' jin' : ' jout');
|
|
2028
|
+
return '<button class="ci tci' + (me ? ' me' : '') + (e ? ' ref' : '') + (via ? ' via' : '') + (n.ghost ? ' ghost' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
|
|
2029
|
+
(n.ghost ? ' title="' + esc(nameOf(n.target) + ' 已停且没有记录, 这里只是占位, 代表它挂着下面的后代') + '"' : via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
|
|
1442
2030
|
labelHTML(p) + row + '</button>';
|
|
1443
2031
|
};
|
|
1444
2032
|
|
|
1445
|
-
//
|
|
1446
|
-
var treeHTML = function (F, t, depth,
|
|
2033
|
+
// 只画 F.vis 里的 (没有 = 全画)。shown 收集画出来的节点。
|
|
2034
|
+
var treeHTML = function (F, t, depth, shown, seen, z) {
|
|
1447
2035
|
var n = nodeOf(t);
|
|
1448
2036
|
if (!n || seen[t] || depth > 32) return '';
|
|
1449
2037
|
seen[t] = 1;
|
|
1450
2038
|
shown.push(n);
|
|
1451
|
-
|
|
1452
|
-
|
|
2039
|
+
// 孩子 = 本位的主孩子 + 引用卡 (它是人时, 它直接说过话、但主父亲另有其人的 wizard), 一起按卡片时刻排。
|
|
2040
|
+
var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).map(function (k) { return { t: k }; })
|
|
2041
|
+
.concat((F.refs[t] || []).filter(function (p) { return shows(F, p.to) && nodeOf(p.to); }).map(function (p) { return { t: p.to, e: p }; }))
|
|
2042
|
+
.sort(function (a, b) { return recentFirst(cardGlance(F, a.t, a.e), cardGlance(F, b.t, b.e)); });
|
|
2043
|
+
// 选中工单时: 两端都是它的当事人 (含开单者) 的那道线才亮。
|
|
2044
|
+
var jl = function (u, p) { return F.job ? (F.job.in[u] && p && F.job.in[p.from] ? ' jline' : ' jdim') : ''; };
|
|
2045
|
+
var zs = function (z) { return z ? ' style="z-index:' + z + '"' : ''; };
|
|
2046
|
+
return '<li class="' + domKind(F.pp[t]) + jl(t, F.pp[t]) + '"' + zs(z) + '>' + tnodeHTML(F, n) +
|
|
1453
2047
|
// 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
|
|
1454
2048
|
(kids.length ? '<ul>' + kids.map(function (k, i) {
|
|
1455
|
-
return
|
|
2049
|
+
return k.e
|
|
2050
|
+
? '<li class="' + domKind(k.e) + jl(k.t, k.e) + '"' + zs(kids.length - i) + '>' + tnodeHTML(F, nodeOf(k.t), k.e) + '</li>'
|
|
2051
|
+
: treeHTML(F, k.t, depth + 1, shown, seen, kids.length - i);
|
|
1456
2052
|
}).join('') + '</ul>' : '') +
|
|
1457
2053
|
'</li>';
|
|
1458
2054
|
};
|
|
1459
|
-
// 只看相关: 主父亲链一条直线下来, 到它自己再整枝展开; 同一个主父亲下的兄弟只画本人。
|
|
1460
|
-
var focusKeep = function (path) {
|
|
1461
|
-
var on = path.reduce(function (m, t) { m[t] = 1; return m; }, {});
|
|
1462
|
-
var parent = path[path.length - 2];
|
|
1463
|
-
return function (t, k) {
|
|
1464
|
-
if (!on[t] || t === ROLE) return 'full';
|
|
1465
|
-
return on[k] ? 'full' : t === parent ? 'leaf' : '';
|
|
1466
|
-
};
|
|
1467
|
-
};
|
|
1468
|
-
|
|
1469
2055
|
// 关系图替换的是侧栏的会话列表 —— 右边照旧是选中的那段对话。
|
|
1470
|
-
// 只看相关时画谁: 视角自己 +
|
|
1471
|
-
//
|
|
1472
|
-
var talks = function (F, a, b) {
|
|
1473
|
-
var p = F.pairs[a + '\u0000' + b];
|
|
1474
|
-
return !!p && !!(p.kinds.peer || p.kinds.graph);
|
|
1475
|
-
};
|
|
2056
|
+
// 只看相关时画谁: 视角自己 + 和视角有直接关系的 (任一种, 任一方向; 'rel'); 它们通向根的主父亲链上
|
|
2057
|
+
// 与视角无关的祖先留作连接 ('via', 画淡) —— 抹掉它, 有关系的后代就从家谱上脱开, 成了一棵来历不明的孤树。
|
|
1476
2058
|
var visibleOf = function (F) {
|
|
1477
|
-
|
|
1478
|
-
var
|
|
1479
|
-
|
|
2059
|
+
// 相关 = 沿关系边 (任一种, 任一方向) 一路走得到的整个连通块, 不止一跳: 间接、多跳的也展开。
|
|
2060
|
+
var nbr = Object.keys(F.pairs).reduce(function (m, k) {
|
|
2061
|
+
var p = F.pairs[k];
|
|
2062
|
+
(m[p.from] = m[p.from] || []).push(p.to);
|
|
2063
|
+
(m[p.to] = m[p.to] || []).push(p.from);
|
|
2064
|
+
return m;
|
|
2065
|
+
}, {});
|
|
2066
|
+
var reach = function (seen, frontier) {
|
|
2067
|
+
if (!frontier.length) return seen;
|
|
2068
|
+
var next = frontier.reduce(function (acc, t) {
|
|
2069
|
+
(nbr[t] || []).forEach(function (o) { if (!seen[o]) { seen[o] = 1; acc.push(o); } });
|
|
2070
|
+
return acc;
|
|
2071
|
+
}, []);
|
|
2072
|
+
return reach(seen, next);
|
|
2073
|
+
};
|
|
2074
|
+
var linked = reach((function (o) { o[ROLE] = 1; return o; })({}), [ROLE]);
|
|
2075
|
+
var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || linked[t]; });
|
|
2076
|
+
var vis = keep.reduce(function (m, t) { m[t] = 'rel'; return m; }, {});
|
|
2077
|
+
if (!vis[ROLE]) vis[ROLE] = 'rel';
|
|
1480
2078
|
keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
|
|
1481
2079
|
return vis;
|
|
1482
2080
|
};
|
|
1483
2081
|
|
|
2082
|
+
// 正在看一张工单 (CONV = j:<id>) 时, 它的当事人; 选中态就是 CONV 本身, 不另记一份。
|
|
2083
|
+
var jobOfConv = function () {
|
|
2084
|
+
var id = CONV.indexOf('j:') === 0 ? CONV.slice(2) : '';
|
|
2085
|
+
var j = id && (W.jobs || []).filter(function (x) { return x.id === id; })[0];
|
|
2086
|
+
return j ? { id: id, in: j.members.reduce(function (m, mm) { m[mm.target] = 1; return m; }, (function (o) { o[j.owner] = 1; return o; })({})) } : null;
|
|
2087
|
+
};
|
|
2088
|
+
|
|
1484
2089
|
var renderWorld = function () {
|
|
1485
2090
|
if (!WORLD) return;
|
|
1486
2091
|
if (!W.loaded) { convsEl._tree = ''; convsEl.innerHTML = '<div class="empty">加载中…</div>'; return; }
|
|
1487
2092
|
var rg = rangeOf();
|
|
1488
2093
|
var F = forestOf(relations(rg));
|
|
2094
|
+
F.job = jobOfConv();
|
|
1489
2095
|
var me = nodeOf(ROLE);
|
|
1490
|
-
|
|
2096
|
+
// 只画相关的 (visibleOf); 视角不是图上的节点 (没有关系可言) 才全画。
|
|
2097
|
+
var all = !me;
|
|
1491
2098
|
var shown = [];
|
|
1492
2099
|
var path = me ? chainUp(F, ROLE) : [];
|
|
1493
|
-
// 和视角之间没有对话的关系只在「看全部」里画。
|
|
1494
2100
|
F.vis = all ? null : visibleOf(F);
|
|
1495
2101
|
var draw = function (into) {
|
|
1496
|
-
return
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
}).map(function (r) { return treeHTML(F, r, 0, null, false, into, {}); }).join('')
|
|
1501
|
-
: treeHTML(F, path[0], 0, focusKeep(path), false, into, {});
|
|
2102
|
+
return F.roots.filter(function (r) { return shows(F, r); }).sort(function (a, b) {
|
|
2103
|
+
var mine = path[0];
|
|
2104
|
+
return (b === mine) - (a === mine) || byCard(F)(a, b);
|
|
2105
|
+
}).map(function (r) { return treeHTML(F, r, 0, into, {}); }).join('');
|
|
1502
2106
|
};
|
|
1503
2107
|
// 两遍: 先量出画了谁 (卡片的窗口要知道它在图上连着谁), 再带着连线画 —— 选中态也就进了 html。
|
|
1504
2108
|
draw(shown);
|
|
1505
2109
|
F.links = linksOf(F, shown);
|
|
2110
|
+
F.on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
|
|
1506
2111
|
var keys = shown.map(function (n) { return talkKey(F.links, n.target); }).join('\n');
|
|
1507
2112
|
if (keys !== W.gKeys) { W.gKeys = keys; loadGlance(); }
|
|
1508
2113
|
// 换视角后的第一张新树: 世界快照与摘要都已是新视角的, 才知道它自己那张卡片连着谁。
|
|
1509
2114
|
if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
|
|
1510
2115
|
W.pickSelf = '';
|
|
1511
2116
|
var self = talkKey(F.links, ROLE);
|
|
1512
|
-
|
|
2117
|
+
// refresh 正等着它定窗口 (W.pickHold): 只改窗口, 正文由 refresh 取一次 —— 不再先取全部对话、再收窄重取。
|
|
2118
|
+
if (CONV !== self && W.pickHold) { CONV = self; WITH = ''; }
|
|
2119
|
+
else if (CONV !== self) return selectConv(self, '');
|
|
1513
2120
|
}
|
|
1514
2121
|
var trees = draw([]);
|
|
1515
|
-
var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '
|
|
2122
|
+
var span = rg ? (!rg.s ? '最近' + (WINS.filter(function (w) { return w[0] === WIN; })[0] || [0, ''])[1] : rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '';
|
|
1516
2123
|
var alone = !all && shown.length < 2;
|
|
1517
2124
|
var html = '<div class="tview">' +
|
|
1518
|
-
'<h2
|
|
2125
|
+
'<h2>关系<span title="在名片里的 session 下拉切换范围">' + (span ? esc(span) + ' · ' : '') + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
|
|
1519
2126
|
(W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
|
|
1520
|
-
(
|
|
1521
|
-
|
|
1522
|
-
(
|
|
1523
|
-
(shown.length ? '<ul class="tree' + (all ? ' all' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
|
|
2127
|
+
winSelect() + worldToggle() + '</h2>' +
|
|
2128
|
+
(alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁有关系</div>' : '') +
|
|
2129
|
+
(shown.length ? '<ul class="tree' + (all ? ' all' : '') + (F.job ? ' jsel' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
|
|
1524
2130
|
'</div>';
|
|
1525
2131
|
// 心跳每 3s 重算一次 (状态灯 / 几分钟前) —— 没变就不碰 DOM, 免得蹭掉悬停与滚动。
|
|
1526
2132
|
if (convsEl._tree === html && convsEl.querySelector('.tview')) return;
|
|
1527
2133
|
convsEl._tree = html; convsEl._html = '';
|
|
1528
2134
|
convsEl.innerHTML = html;
|
|
1529
2135
|
convsEl.querySelectorAll('.tci').forEach(function (el) {
|
|
1530
|
-
el.onclick = function () {
|
|
2136
|
+
el.onclick = function () { var t = el.getAttribute('data-t'); clickItem(talkKey(F.links, t), '', t); };
|
|
1531
2137
|
});
|
|
1532
2138
|
bindGo(convsEl);
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
var cur = convsEl.querySelector('.tci.me');
|
|
2139
|
+
bindWorldToggle(convsEl); bindWin(convsEl);
|
|
2140
|
+
var cur = convsEl.querySelector('.tci.me:not(.ref)');
|
|
1536
2141
|
if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
|
|
1537
2142
|
};
|
|
1538
2143
|
|
|
@@ -1637,11 +2242,6 @@
|
|
|
1637
2242
|
// 日程跟着 wizard 走: 只列归当前 role 的定时任务, 与它有关的工单 (它开的 / 它在里面)。
|
|
1638
2243
|
var renderPlan = function () {
|
|
1639
2244
|
var ss = (W.schedules || []).filter(function (x) { return (x.owner || x.createdBy || x.target) === ROLE; });
|
|
1640
|
-
var js = (W.jobs || []).filter(function (j) {
|
|
1641
|
-
return j.owner === ROLE || j.members.some(function (mm) { return mm.target === ROLE; });
|
|
1642
|
-
});
|
|
1643
|
-
var open = js.filter(function (j) { return j.status === 'open'; });
|
|
1644
|
-
var closed = js.filter(function (j) { return j.status !== 'open'; });
|
|
1645
2245
|
var bad = ss.filter(broken);
|
|
1646
2246
|
var next = ss[0];
|
|
1647
2247
|
var stat = function (k, v, sub, tone) {
|
|
@@ -1650,37 +2250,14 @@
|
|
|
1650
2250
|
var stats = '<div class="pstats">' +
|
|
1651
2251
|
stat('下一次', next ? esc(fmtHM(next.nextAt)) : '—', next ? esc(fmtIn(next.nextAt) + ' · ' + (next.note || next.id)) : '没有排期') +
|
|
1652
2252
|
stat('定时任务', ss.length, ss.length ? esc(ss.filter(function (x) { return x.hasGate; }).length + ' 条带 gate') : '—') +
|
|
1653
|
-
stat('工单', open.length + '<small> / ' + js.length + '</small>', '进行中 / 全部') +
|
|
1654
2253
|
(bad.length ? stat('出错', bad.length, '见下方红色卡片', 'bad') : '') +
|
|
1655
2254
|
'</div>';
|
|
1656
2255
|
|
|
1657
|
-
var jobRow = function (j) {
|
|
1658
|
-
return '<div class="jrow' + (j.status === 'open' ? ' open' : '') + '">' +
|
|
1659
|
-
'<div class="jh"><span class="jst">' + (j.status === 'open' ? '进行中' : '已收工') + '</span>' +
|
|
1660
|
-
'<span class="jt">' + esc(j.title) + '</span>' +
|
|
1661
|
-
'<span class="jid">' + esc(j.id) + '</span>' +
|
|
1662
|
-
'<span class="jts">' + esc(fmtAgo(j.closedAt || j.openedAt)) + '</span></div>' +
|
|
1663
|
-
'<div class="jm">' + (j.members.length
|
|
1664
|
-
? j.members.map(function (mm) {
|
|
1665
|
-
var n = nodeOf(mm.target);
|
|
1666
|
-
return '<span class="jmm">' + wizChip(mm.target) + stTag(n) +
|
|
1667
|
-
(mm.spawned ? '<i class="tmp" title="为这个工单临时生的 wizard (分身或子 wizard), 收工时回收">临时</i>' : '') +
|
|
1668
|
-
'<em>' + esc((mm.task || '').split('\n')[0].slice(0, 90)) + '</em></span>';
|
|
1669
|
-
}).join('')
|
|
1670
|
-
: '<span class="jmm none">还没有成员</span>') + '</div>' +
|
|
1671
|
-
(j.summary ? '<div class="jsum">' + esc(j.summary.slice(0, 300)) + '</div>' : '') +
|
|
1672
|
-
'</div>';
|
|
1673
|
-
};
|
|
1674
|
-
|
|
1675
2256
|
var html = stats +
|
|
1676
2257
|
'<section class="psec">' +
|
|
1677
2258
|
'<h3>定时任务<span>' + ss.length + '</span></h3>' +
|
|
1678
2259
|
(ss.length ? renderStrip(ss) + bad.concat(ss.filter(function (x) { return !broken(x); })).map(taskHTML).join('')
|
|
1679
2260
|
: '<div class="pempty">' + esc(roleName(ROLE)) + ' 名下没有定时任务 —— 让它 schedule_task 排一个</div>') +
|
|
1680
|
-
'</section>' +
|
|
1681
|
-
'<section class="psec">' +
|
|
1682
|
-
'<h3>工单<span>' + open.length + ' 开 / ' + closed.length + ' 收</span></h3>' +
|
|
1683
|
-
(js.length ? open.concat(closed).map(jobRow).join('') : '<div class="pempty">没有工单 —— 一次派出两个以上 wizard 时 open_job 开一个</div>') +
|
|
1684
2261
|
'</section>';
|
|
1685
2262
|
// 心跳每 3s 重画一次 —— 没变就不碰 DOM (展开着的 prompt、悬停的提示都留着)。
|
|
1686
2263
|
if (planEl._html === html) return;
|
|
@@ -1707,11 +2284,20 @@
|
|
|
1707
2284
|
// 不经过 detail store。改成"看得见才轮询": 不在关系/日程栏、或者页面在后台, 就一次都不请求。
|
|
1708
2285
|
var WORLD_MS = 6000;
|
|
1709
2286
|
var worldTimer = null;
|
|
2287
|
+
// 带上次的 ETag 问: 304 = 名册没变, 不重画 (卡片窗口的 glance 照样顺带刷新)。
|
|
1710
2288
|
var loadWorld = function () {
|
|
1711
2289
|
var asked = ROLE;
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
2290
|
+
var p = new URLSearchParams({ role: asked, id: TOKEN });
|
|
2291
|
+
var tag = W.role === asked ? W.etag : '';
|
|
2292
|
+
var etag = '';
|
|
2293
|
+
return fetch('api/world?' + p.toString(), { cache: 'no-store', headers: tag ? { 'If-None-Match': tag } : {} }).then(function (r) {
|
|
2294
|
+
if (r.status === 304) { if (asked === ROLE) loadGlance(); return null; }
|
|
2295
|
+
etag = r.headers.get('etag') || '';
|
|
2296
|
+
return r.json();
|
|
2297
|
+
}).then(function (d) {
|
|
2298
|
+
// 迟到的回包 (这期间已换了视角) 丢掉, 免得盖掉新视角的图; etag 与 role 成对记。
|
|
2299
|
+
if (!d || !d.ok || asked !== ROLE) return;
|
|
2300
|
+
W.at = d.at; W.loaded = true; W.role = asked; W.etag = etag;
|
|
1715
2301
|
W.nodes = d.nodes || []; W.edges = d.edges || []; W.chats = d.chats || [];
|
|
1716
2302
|
W.jobs = d.jobs || []; W.schedules = d.schedules || []; W.degraded = !!d.degraded;
|
|
1717
2303
|
renderWorld();
|
|
@@ -1721,7 +2307,7 @@
|
|
|
1721
2307
|
};
|
|
1722
2308
|
var pollWorld = function () {
|
|
1723
2309
|
if (worldTimer) { clearInterval(worldTimer); worldTimer = null; }
|
|
1724
|
-
if (VIEW
|
|
2310
|
+
if (VIEW !== 'plan' && !WORLD) return;
|
|
1725
2311
|
worldTimer = setInterval(function () {
|
|
1726
2312
|
if (!document.hidden) loadWorld();
|
|
1727
2313
|
}, WORLD_MS);
|
|
@@ -1731,11 +2317,15 @@
|
|
|
1731
2317
|
VIEW = v;
|
|
1732
2318
|
thread.hidden = v !== 'msgs';
|
|
1733
2319
|
$('#pane-plan').hidden = v !== 'plan';
|
|
2320
|
+
charterPane().hidden = v !== 'charter';
|
|
2321
|
+
jobsPane().hidden = v !== 'jobs';
|
|
1734
2322
|
app.classList.toggle('outer', v !== 'msgs');
|
|
1735
2323
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
1736
2324
|
if (v !== 'msgs') app.classList.add('reading');
|
|
1737
2325
|
renderRole(); renderHead(); renderUsage();
|
|
1738
2326
|
if (v === 'msgs') { toBottom(true); renderConvs(); }
|
|
2327
|
+
else if (v === 'charter') loadCharter();
|
|
2328
|
+
else if (v === 'jobs') renderJobList();
|
|
1739
2329
|
else {
|
|
1740
2330
|
if (!W.loaded) { planEl._html = ''; planEl.innerHTML = '<div class="empty">加载中…</div>'; }
|
|
1741
2331
|
else renderPlan();
|
|
@@ -1743,22 +2333,114 @@
|
|
|
1743
2333
|
}
|
|
1744
2334
|
pollWorld();
|
|
1745
2335
|
};
|
|
2336
|
+
// ── 宪章: 出生快照, 不轮询 —— 换视角或重新点开时取一次。 ──
|
|
2337
|
+
// 面板由脚本挂载: 外壳 HTML 要等服务重启才换, 老外壳里没有这一格。
|
|
2338
|
+
var charterPane = function () {
|
|
2339
|
+
var p = $('#pane-charter');
|
|
2340
|
+
if (p) return p;
|
|
2341
|
+
p = document.createElement('div');
|
|
2342
|
+
p.className = 'pane'; p.id = 'pane-charter'; p.hidden = true;
|
|
2343
|
+
p.innerHTML = '<div class="plan-in" id="charter-in"></div>';
|
|
2344
|
+
$('#pane-plan').after(p);
|
|
2345
|
+
return p;
|
|
2346
|
+
};
|
|
2347
|
+
var jobsPane = function () {
|
|
2348
|
+
var p = $('#pane-jobs');
|
|
2349
|
+
if (p) return p;
|
|
2350
|
+
p = document.createElement('div');
|
|
2351
|
+
p.className = 'pane'; p.id = 'pane-jobs'; p.hidden = true;
|
|
2352
|
+
p.innerHTML = '<div class="plan-in jlist" id="jobs-in"></div>';
|
|
2353
|
+
$('#pane-plan').after(p);
|
|
2354
|
+
return p;
|
|
2355
|
+
};
|
|
2356
|
+
// 视角开的或在里面的工单: 进行中 (最近有动静的在前) / 已收工 (淡一档, 最近收的在前)。一行与会话列表同一个 convItem。
|
|
2357
|
+
var renderJobList = function () {
|
|
2358
|
+
var el = jobsPane().firstChild;
|
|
2359
|
+
var js = jobListRows();
|
|
2360
|
+
// 视角里没有的祖先也照账补上, 树才连得起来 (JOB_ONLY 是点名的几张, 不补)。
|
|
2361
|
+
if (!JOB_ONLY) js = js.concat(js.reduce(function (a, c) {
|
|
2362
|
+
return a.concat(jobAncestors(c.job.id).filter(function (id) { return !js.some(function (x) { return x.job.id === id; }); }));
|
|
2363
|
+
}, []).filter(function (id, i, a) { return a.indexOf(id) === i; }).map(jobRowOf).filter(function (c) { return c.job; }));
|
|
2364
|
+
var open = js.filter(function (c) { return c.job.status === 'open'; }).sort(recentFirst);
|
|
2365
|
+
var closed = js.filter(function (c) { return c.job.status !== 'open'; }).sort(recentFirst);
|
|
2366
|
+
var sec = function (title, list, cls) {
|
|
2367
|
+
return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + jobTreeOrder(list).map(function (x) { return x.d ? '<div class="jind" style="margin-left:' + (x.d * 16) + 'px">' + convItem(x.c) + '</div>' : convItem(x.c); }).join('') + '</div>';
|
|
2368
|
+
};
|
|
2369
|
+
var html = js.length
|
|
2370
|
+
? sec('进行中', open, 'jopen') + (closed.length ? sec('已收工', closed, 'jclosed') : '')
|
|
2371
|
+
: JOB_ONLY
|
|
2372
|
+
? '<div class="pempty">这几张工单已经不在账上了 (收工满 24 小时会清掉) —— ' + esc(JOB_ONLY.join(' ')) + '</div>'
|
|
2373
|
+
: '<div class="pempty">' + esc(nameOf(ROLE)) + ' 没有开过、也不在任何工单里 —— 一次派出两个以上 wizard 时 open_job 开一个 (收工满 24 小时的不再列出)</div>';
|
|
2374
|
+
if (el._html === html) return;
|
|
2375
|
+
el._html = html; el.innerHTML = html;
|
|
2376
|
+
el.querySelectorAll('[data-conv]').forEach(function (b) { b.onclick = function () { selectConv(b.getAttribute('data-conv'), ''); }; });
|
|
2377
|
+
};
|
|
2378
|
+
var loadCharter = function () {
|
|
2379
|
+
var el = charterPane().firstChild, asked = ROLE;
|
|
2380
|
+
el.innerHTML = '<div class="empty">加载中…</div>';
|
|
2381
|
+
return api('api/charter', { role: asked }).then(function (d) {
|
|
2382
|
+
if (asked !== ROLE || VIEW !== 'charter') return;
|
|
2383
|
+
el.innerHTML = d.ok && !d.none ? charterHTML(d) : '<div class="empty">' + esc(roleName(asked)) + ' 没有宪章记录 (人, 或在这项记录出现前出生、还没重生过的 wizard)</div>';
|
|
2384
|
+
bindCharter(el);
|
|
2385
|
+
}).catch(function () { el.innerHTML = '<div class="empty">载入失败</div>'; });
|
|
2386
|
+
};
|
|
2387
|
+
var charterHTML = function (d) {
|
|
2388
|
+
var stat = function (k, v, sub) {
|
|
2389
|
+
return '<div class="pst"><span class="k">' + k + '</span><b>' + v + '</b><span class="s" title="' + esc(sub) + '">' + esc(sub) + '</span></div>';
|
|
2390
|
+
};
|
|
2391
|
+
var b = d.baseline;
|
|
2392
|
+
var max = Math.max.apply(null, d.sections.map(function (x) { return x.tokens; }));
|
|
2393
|
+
var big = d.sections.filter(function (x) { return x.tokens === max; })[0];
|
|
2394
|
+
var pct = function (n) { return Math.round(100 * n / d.tokens) + '%'; };
|
|
2395
|
+
var mdOk = mdReady();
|
|
2396
|
+
// 组成条: 一节一段, 宽度按体量 —— 一眼看出谁在吃宪章; 最大那节加深, 与下面的行同色。
|
|
2397
|
+
var mix = '<div class="cmix">' + d.sections.map(function (x) {
|
|
2398
|
+
return '<i class="' + (x === big ? 'top' : '') + '" style="flex:' + x.tokens + '" title="' + esc(x.title + ' ≈' + fmtTok(x.tokens) + ' · ' + pct(x.tokens)) + '"></i>';
|
|
2399
|
+
}).join('') + '</div>';
|
|
2400
|
+
var sec = function (x) {
|
|
2401
|
+
return '<details class="csec' + (x === big ? ' top' : '') + '"><summary><span class="ct">' + esc(x.title) + '</span>' +
|
|
2402
|
+
'<span class="cbar"><i style="width:' + Math.round(100 * x.tokens / max) + '%"></i></span>' +
|
|
2403
|
+
'<span class="cn">≈' + fmtTok(x.tokens) + '<small>' + pct(x.tokens) + '</small></span></summary>' +
|
|
2404
|
+
'<div class="md-body">' + (mdOk ? md.render(x.body) : '<pre>' + esc(x.body) + '</pre>') + '</div></details>';
|
|
2405
|
+
};
|
|
2406
|
+
// 没有实测底座就不摆一张「—」: 换成最大的那一节, 同样回答「钱花在哪」。
|
|
2407
|
+
var right = b
|
|
2408
|
+
? stat('开局实测', fmtTok(b.ctx), (b.resumed ? '续接的老会话, 含此前对话 · ' : '') + '此后首轮第一次调用送入的上下文') +
|
|
2409
|
+
stat('宪章占开局', Math.round(100 * d.tokens / b.ctx) + '%', '其余是 CLI 系统提示、工具、CLAUDE.md、skills 与第一句话')
|
|
2410
|
+
: stat('最大一节', pct(max), big.title + ' ≈' + fmtTok(max));
|
|
2411
|
+
return '<div class="pstats">' +
|
|
2412
|
+
stat('宪章', '≈' + fmtTok(d.tokens) + '<small> tokens</small>', fmtClock(d.at) + ' 压进系统提示 · 随进程终身不变') + right +
|
|
2413
|
+
'</div>' +
|
|
2414
|
+
'<section class="psec"><h3>按节<span>' + d.sections.length + ' 节</span>' +
|
|
2415
|
+
'<button class="cx" id="charter-all">全部展开</button></h3>' +
|
|
2416
|
+
mix + d.sections.map(sec).join('') + '</section>';
|
|
2417
|
+
};
|
|
2418
|
+
var bindCharter = function (el) {
|
|
2419
|
+
var btn = el.querySelector('#charter-all');
|
|
2420
|
+
if (!btn) return;
|
|
2421
|
+
var all = function () { return el.querySelectorAll('.csec'); };
|
|
2422
|
+
var sync = function () { btn.textContent = [].every.call(all(), function (x) { return x.open; }) ? '全部收起' : '全部展开'; };
|
|
2423
|
+
btn.onclick = function () { var open = btn.textContent === '全部展开'; all().forEach(function (x) { x.open = open; }); sync(); };
|
|
2424
|
+
all().forEach(function (x) { x.ontoggle = sync; });
|
|
2425
|
+
};
|
|
2426
|
+
|
|
1746
2427
|
// 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
|
|
1747
2428
|
var setWorld = function (on) {
|
|
1748
2429
|
WORLD = on; W.treeFor = ''; W.pickSelf = '';
|
|
2430
|
+
setPref(WORLD_KEY, on);
|
|
1749
2431
|
convsEl._html = convsEl._tree = '';
|
|
1750
2432
|
syncUrl();
|
|
1751
2433
|
// 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
|
|
1752
2434
|
if (on) app.classList.remove('reading');
|
|
1753
|
-
renderRole();
|
|
2435
|
+
renderRole(); renderHead();
|
|
1754
2436
|
if (on) { renderWorld(); loadWorld(); }
|
|
1755
2437
|
else {
|
|
1756
2438
|
// 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
|
|
1757
2439
|
var c = convOf(CONV);
|
|
1758
2440
|
if (c) {
|
|
1759
|
-
OPEN[CONV] = true; OPEN_AT = CONV;
|
|
2441
|
+
OPEN[CONV] = true; OPEN_AT = CONV; saveOpen();
|
|
1760
2442
|
var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
|
|
1761
|
-
if (rank >= SUB_FOLD)
|
|
2443
|
+
if (rank >= SUB_FOLD) setMore(CONV, true);
|
|
1762
2444
|
}
|
|
1763
2445
|
renderConvs();
|
|
1764
2446
|
var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
|
|
@@ -1766,6 +2448,14 @@
|
|
|
1766
2448
|
}
|
|
1767
2449
|
pollWorld();
|
|
1768
2450
|
};
|
|
2451
|
+
// 进行中那一轮的耗时: 服务端只给开始时刻, 这里每秒按它走表 (取整秒, 免得小数位抖)。
|
|
2452
|
+
var tickDur = function () {
|
|
2453
|
+
document.querySelectorAll('.tg-dur.live[data-since]').forEach(function (el) {
|
|
2454
|
+
var ms = Math.max(0, srvNow() - Number(el.getAttribute('data-since')));
|
|
2455
|
+
el.textContent = fmtDur(Math.floor(ms / 1000) * 1000);
|
|
2456
|
+
});
|
|
2457
|
+
};
|
|
2458
|
+
setInterval(tickDur, 1000);
|
|
1769
2459
|
// 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
|
|
1770
2460
|
setInterval(function () {
|
|
1771
2461
|
if (!R.role) return;
|
|
@@ -1774,6 +2464,7 @@
|
|
|
1774
2464
|
// 侧栏的「几分钟前」与状态灯: 内容没变时 renderConvs 不碰 DOM。
|
|
1775
2465
|
paintStatus(); renderConvs();
|
|
1776
2466
|
if (VIEW === 'plan') renderPlan();
|
|
2467
|
+
if (VIEW === 'jobs') renderJobList();
|
|
1777
2468
|
}, TICK_MS);
|
|
1778
2469
|
|
|
1779
2470
|
// ══ 搜索 (⌘K / Ctrl+K) ═══════════════════════════════════════════════
|
|
@@ -1823,7 +2514,7 @@
|
|
|
1823
2514
|
return {
|
|
1824
2515
|
kind: 'conv',
|
|
1825
2516
|
html: '<span class="av">' + esc(c.label) + '</span><span class="b"><span class="l1">' + title +
|
|
1826
|
-
'<span class="tag">' + (c.kind === 'wizard' ? '私聊' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
|
|
2517
|
+
'<span class="tag">' + (c.kind === 'wizard' ? '私聊' : c.kind === 'job' ? '工单' : '群聊') + '</span>' + skTs(c.lastTs) + '</span>' +
|
|
1827
2518
|
(c.preview ? '<span class="pv">' + esc(c.preview) + '</span>' : '') + '</span>',
|
|
1828
2519
|
go: function () { selectConv(c.key, ''); },
|
|
1829
2520
|
};
|
|
@@ -1857,20 +2548,32 @@
|
|
|
1857
2548
|
row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
|
|
1858
2549
|
return true;
|
|
1859
2550
|
};
|
|
1860
|
-
|
|
2551
|
+
// 落到一行里的某一次工具调用 (`use`): 它所在的过程框摊开, 调用框滚到中间闪一下。
|
|
2552
|
+
var focusUse = function (id, use) {
|
|
2553
|
+
if (!focusRow(id)) return false;
|
|
2554
|
+
var b = use && rowNode(id).querySelector('[data-use="' + cssEsc(use) + '"]');
|
|
2555
|
+
if (!b) return true;
|
|
2556
|
+
var g = b.closest('.steps');
|
|
2557
|
+
if (g) { g.classList.remove('folded'); FOLD[g.getAttribute('data-key')] = false; }
|
|
2558
|
+
b.scrollIntoView({ block: 'center' });
|
|
2559
|
+
S.pinned = atBottom();
|
|
2560
|
+
b.classList.remove('hit'); void b.offsetWidth; b.classList.add('hit');
|
|
2561
|
+
return true;
|
|
2562
|
+
};
|
|
2563
|
+
var landOn = function (id, use) {
|
|
1861
2564
|
return function () {
|
|
1862
|
-
if (
|
|
1863
|
-
loadMsgs('0', function () { return
|
|
2565
|
+
if (focusUse(id, use)) return true;
|
|
2566
|
+
loadMsgs('0', function () { return focusUse(id, use); });
|
|
1864
2567
|
return true;
|
|
1865
2568
|
};
|
|
1866
2569
|
};
|
|
1867
2570
|
var jumpMsg = function (h) {
|
|
1868
|
-
if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, '', landOn(h.id));
|
|
1869
|
-
SESSION = 'all'; CONV = h.conv; WITH = '';
|
|
2571
|
+
if ((!h.id || inSession(h.ts)) && convOf(h.conv)) return selectConv(h.conv, h.with || '', h.id ? landOn(h.id, h.use) : undefined, h.viewpoint);
|
|
2572
|
+
SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; VIEWPOINT = h.viewpoint && h.viewpoint !== ROLE ? h.viewpoint : '';
|
|
1870
2573
|
if (VIEW !== 'msgs') setView('msgs');
|
|
1871
2574
|
app.classList.add('reading');
|
|
1872
2575
|
reveal();
|
|
1873
|
-
refresh(landOn(h.id));
|
|
2576
|
+
refresh(h.id ? landOn(h.id, h.use) : undefined);
|
|
1874
2577
|
};
|
|
1875
2578
|
|
|
1876
2579
|
var paintSearch = function (sections) {
|
|
@@ -1970,5 +2673,7 @@
|
|
|
1970
2673
|
// ── boot ──
|
|
1971
2674
|
// 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
|
|
1972
2675
|
if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
|
|
2676
|
+
// 链接已带着 role 与会话: 记下的 session 先放进第一次请求, 省一次重取 (不在列表里由 restoreSess 收拾)。
|
|
2677
|
+
if (SESS_RESTORE && ROLE && CONV && savedSess()) SESSION = savedSess();
|
|
1973
2678
|
refresh();
|
|
1974
2679
|
})();
|