wezard 0.3.3

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.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/CLAUDE.md +83 -0
  4. package/LICENSE +21 -0
  5. package/README.md +410 -0
  6. package/cli/weclaude.sh +236 -0
  7. package/commands/audit.md +7 -0
  8. package/commands/wrc.md +7 -0
  9. package/config.example.jsonc +102 -0
  10. package/dist/cli/audit.js +156 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/init.js +311 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/sync.js +175 -0
  15. package/dist/cli/sync.js.map +1 -0
  16. package/dist/daemon/approval.js +1301 -0
  17. package/dist/daemon/approval.js.map +1 -0
  18. package/dist/daemon/ask.js +101 -0
  19. package/dist/daemon/ask.js.map +1 -0
  20. package/dist/daemon/audit.js +168 -0
  21. package/dist/daemon/audit.js.map +1 -0
  22. package/dist/daemon/cc-bridge.js +204 -0
  23. package/dist/daemon/cc-bridge.js.map +1 -0
  24. package/dist/daemon/cfg-sync.js +240 -0
  25. package/dist/daemon/cfg-sync.js.map +1 -0
  26. package/dist/daemon/claim.js +95 -0
  27. package/dist/daemon/claim.js.map +1 -0
  28. package/dist/daemon/detail.js +153 -0
  29. package/dist/daemon/detail.js.map +1 -0
  30. package/dist/daemon/graph.js +176 -0
  31. package/dist/daemon/graph.js.map +1 -0
  32. package/dist/daemon/http.js +83 -0
  33. package/dist/daemon/http.js.map +1 -0
  34. package/dist/daemon/inbound.js +919 -0
  35. package/dist/daemon/inbound.js.map +1 -0
  36. package/dist/daemon/index.js +569 -0
  37. package/dist/daemon/index.js.map +1 -0
  38. package/dist/daemon/last-response.js +59 -0
  39. package/dist/daemon/last-response.js.map +1 -0
  40. package/dist/daemon/mirror-bridge.js +3512 -0
  41. package/dist/daemon/mirror-bridge.js.map +1 -0
  42. package/dist/daemon/mirror-store.js +39 -0
  43. package/dist/daemon/mirror-store.js.map +1 -0
  44. package/dist/daemon/outbound.js +42 -0
  45. package/dist/daemon/outbound.js.map +1 -0
  46. package/dist/daemon/peers.js +146 -0
  47. package/dist/daemon/peers.js.map +1 -0
  48. package/dist/daemon/pending.js +90 -0
  49. package/dist/daemon/pending.js.map +1 -0
  50. package/dist/daemon/quota.js +164 -0
  51. package/dist/daemon/quota.js.map +1 -0
  52. package/dist/daemon/redact.js +27 -0
  53. package/dist/daemon/redact.js.map +1 -0
  54. package/dist/daemon/session-cache.js +149 -0
  55. package/dist/daemon/session-cache.js.map +1 -0
  56. package/dist/daemon/session-scan.js +273 -0
  57. package/dist/daemon/session-scan.js.map +1 -0
  58. package/dist/daemon/sessions.js +35 -0
  59. package/dist/daemon/sessions.js.map +1 -0
  60. package/dist/daemon/spawn-tmux.js +213 -0
  61. package/dist/daemon/spawn-tmux.js.map +1 -0
  62. package/dist/daemon/tips.js +25 -0
  63. package/dist/daemon/tips.js.map +1 -0
  64. package/dist/daemon/topics.js +197 -0
  65. package/dist/daemon/topics.js.map +1 -0
  66. package/dist/daemon/usage.js +358 -0
  67. package/dist/daemon/usage.js.map +1 -0
  68. package/dist/daemon/wedoc.js +210 -0
  69. package/dist/daemon/wedoc.js.map +1 -0
  70. package/dist/daemon/ws.js +67 -0
  71. package/dist/daemon/ws.js.map +1 -0
  72. package/dist/mcp/server.js +385 -0
  73. package/dist/mcp/server.js.map +1 -0
  74. package/dist/shared/ansi.js +134 -0
  75. package/dist/shared/ansi.js.map +1 -0
  76. package/dist/shared/chat-http.js +195 -0
  77. package/dist/shared/chat-http.js.map +1 -0
  78. package/dist/shared/chat-render.js +53 -0
  79. package/dist/shared/chat-render.js.map +1 -0
  80. package/dist/shared/chat-view.js +116 -0
  81. package/dist/shared/chat-view.js.map +1 -0
  82. package/dist/shared/cli-backends.js +304 -0
  83. package/dist/shared/cli-backends.js.map +1 -0
  84. package/dist/shared/config-writer.js +39 -0
  85. package/dist/shared/config-writer.js.map +1 -0
  86. package/dist/shared/config.js +278 -0
  87. package/dist/shared/config.js.map +1 -0
  88. package/dist/shared/detail-render.js +836 -0
  89. package/dist/shared/detail-render.js.map +1 -0
  90. package/dist/shared/detail-store.js +205 -0
  91. package/dist/shared/detail-store.js.map +1 -0
  92. package/dist/shared/highlight.js +154 -0
  93. package/dist/shared/highlight.js.map +1 -0
  94. package/dist/shared/lan-ip.js +35 -0
  95. package/dist/shared/lan-ip.js.map +1 -0
  96. package/dist/shared/log.js +18 -0
  97. package/dist/shared/log.js.map +1 -0
  98. package/dist/shared/md-chunk.js +99 -0
  99. package/dist/shared/md-chunk.js.map +1 -0
  100. package/dist/shared/paths.js +12 -0
  101. package/dist/shared/paths.js.map +1 -0
  102. package/dist/shared/session-label.js +67 -0
  103. package/dist/shared/session-label.js.map +1 -0
  104. package/dist/shared/web-assets.js +51 -0
  105. package/dist/shared/web-assets.js.map +1 -0
  106. package/dist/svr/index.js +246 -0
  107. package/dist/svr/index.js.map +1 -0
  108. package/hooks/hooks.json +16 -0
  109. package/hooks/pre-tool-use.sh +167 -0
  110. package/launchd/com.weclaude.daemon.plist.template +45 -0
  111. package/package.json +97 -0
  112. package/scripts/install.sh +73 -0
  113. package/scripts/postinstall.sh +12 -0
  114. package/scripts/uninstall.sh +44 -0
  115. package/systemd/weclaude.service.template +17 -0
  116. package/web/chat.css +171 -0
  117. package/web/chat.js +367 -0
package/web/chat.js ADDED
@@ -0,0 +1,367 @@
1
+ // Chat 详情 SPA。数据全部来自 /api/chat (侧栏 + 总账) 与 /api/thread (线程),
2
+ // 增量走 /api/events (SSE)。turn 正文是服务端渲染好的 HTML 片段 —— diff / ANSI /
3
+ // 语法高亮只在 shared/detail-render 实现一次, 这里只做 DOM 增量 reconcile。
4
+ //
5
+ // 无构建步骤: 保持 var / function 写法, 直接被浏览器加载。
6
+ (function () {
7
+ var qs = new URLSearchParams(location.search);
8
+ var TOKEN = qs.get('id') || '';
9
+ var TICK_MS = 3000;
10
+
11
+ // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
12
+ // 服务端时钟, 否则客户端时钟偏几分钟就会把运行中的会话判成已结束。
13
+ var S = { base: '', target: '', tags: [], at: 0, recvAt: 0, es: null, pinned: true };
14
+ var $ = function (s) { return document.querySelector(s); };
15
+ var thread = $('#thread'), tagsEl = $('#tags'), sbEl = $('#sb'), connEl = $('#conn');
16
+
17
+ var srvNow = function () { return S.at ? S.at + (Date.now() - S.recvAt) : Date.now(); };
18
+
19
+ var fmtTok = function (n) {
20
+ if (!n) return '0';
21
+ if (n < 1000) return String(n);
22
+ if (n < 1e6) { var v = n / 1000; return (v >= 10 ? v.toFixed(0) : v.toFixed(1).replace(/\.0$/, '')) + 'k'; }
23
+ return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
24
+ };
25
+ var fmtDur = function (ms) {
26
+ if (ms < 1000) return ms + 'ms';
27
+ var s = Math.round(ms / 1000);
28
+ if (s < 60) return s + 's';
29
+ var m = Math.floor(s / 60);
30
+ if (m < 60) return m + 'm' + (s - m * 60) + 's';
31
+ return Math.floor(m / 60) + 'h' + (m % 60) + 'm';
32
+ };
33
+ // 聊天列表用的相对时间 — 一眼看出"刚刚 / 5 分钟前"。
34
+ var fmtAgo = function (ts) {
35
+ if (!ts) return '';
36
+ var d = srvNow() - ts;
37
+ if (d < 60000) return '刚刚';
38
+ if (d < 3600000) return Math.floor(d / 60000) + '分钟前';
39
+ if (d < 86400000) return Math.floor(d / 3600000) + '小时前';
40
+ var x = new Date(ts), p = function (n) { return n < 10 ? '0' + n : '' + n; };
41
+ return p(x.getMonth() + 1) + '-' + p(x.getDate()) + ' ' + p(x.getHours()) + ':' + p(x.getMinutes());
42
+ };
43
+ var esc = function (s) {
44
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
45
+ return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;';
46
+ });
47
+ };
48
+ var api = function (path, params) {
49
+ var p = new URLSearchParams(params || {}); p.set('id', TOKEN);
50
+ return fetch(path + '?' + p.toString(), { cache: 'no-store' }).then(function (r) { return r.json(); });
51
+ };
52
+
53
+ // ── 运行状态: 服务端只给"到这个时刻自动算结束", 由客户端定时判定 ──
54
+ // SSE 只在有写入时推送, 一个真正停下来的 turn 不会再产生任何事件 —— 没有本地
55
+ // 判定, 页面上的「运行中 / 正在思考」就永远熄不掉。
56
+ var isRunning = function (t) { return !!t.runningUntil && srvNow() < t.runningUntil; };
57
+ var curTag = function () {
58
+ return S.tags.filter(function (x) { return x.target === S.target; })[0];
59
+ };
60
+
61
+ // ── markdown 渲染 (只对未渲染过的节点做, 复用节点不重绘) ──
62
+ var md = null;
63
+ var render = function (scope) {
64
+ if (!window.markdownit) return;
65
+ if (!md) {
66
+ md = window.markdownit({
67
+ html: false, linkify: true, breaks: true, highlight: function (str, lang) {
68
+ if (lang && window.hljs) {
69
+ try { return window.hljs.highlight(str, { language: lang, ignoreIllegals: true }).value; } catch (e) { }
70
+ }
71
+ if (window.hljs) { try { return window.hljs.highlightAuto(str).value; } catch (e) { } }
72
+ return '';
73
+ }
74
+ });
75
+ }
76
+ scope.querySelectorAll('.bubble').forEach(function (b) {
77
+ var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
78
+ if (src && body && body.dataset.rendered !== '1') {
79
+ body.innerHTML = md.render(src.textContent || '');
80
+ body.dataset.rendered = '1';
81
+ }
82
+ });
83
+ };
84
+
85
+ // ── 增量 reconcile (与 turn 页同款, 但会递归进 .bubbles) ──
86
+ var snapOpen = function (scope) {
87
+ var m = {};
88
+ scope.querySelectorAll('[data-key]').forEach(function (b) {
89
+ var k = b.getAttribute('data-key');
90
+ b.querySelectorAll('details').forEach(function (d, i) { m[k + '#' + i] = d.open; });
91
+ });
92
+ return m;
93
+ };
94
+ var restoreOpen = function (scope, m) {
95
+ scope.querySelectorAll('[data-key]').forEach(function (b) {
96
+ var k = b.getAttribute('data-key');
97
+ b.querySelectorAll('details').forEach(function (d, i) {
98
+ var v = m[k + '#' + i]; if (v !== undefined) d.open = v;
99
+ });
100
+ });
101
+ };
102
+ var childBy = function (el, cls) {
103
+ for (var i = 0; i < el.children.length; i++) if (el.children[i].classList.contains(cls)) return el.children[i];
104
+ return null;
105
+ };
106
+ // sig 相同 → 原节点原样留下; 不同但两边都是 turn-group → 只换头 + 递归气泡层,
107
+ // 这样一次 tool_result 到达不会重建整轮 DOM (滚动位置 / 展开态全部保住)。
108
+ var reconcile = function (cur, next) {
109
+ var open = snapOpen(cur), existing = {};
110
+ Array.prototype.forEach.call(cur.children, function (c) {
111
+ var k = c.getAttribute('data-key'); if (k) existing[k] = c;
112
+ });
113
+ var nodes = Array.prototype.map.call(next.children, function (nc) {
114
+ var k = nc.getAttribute('data-key'), ex = k ? existing[k] : null;
115
+ if (!ex) return document.importNode(nc, true);
116
+ if (ex.getAttribute('data-sig') === nc.getAttribute('data-sig')) return ex;
117
+ var eb = childBy(ex, 'bubbles'), nb = childBy(nc, 'bubbles');
118
+ if (eb && nb) {
119
+ var eh = childBy(ex, 'tg-head'), nh = childBy(nc, 'tg-head');
120
+ if (eh && nh) eh.innerHTML = nh.innerHTML;
121
+ reconcile(eb, nb);
122
+ ex.setAttribute('data-sig', nc.getAttribute('data-sig') || '');
123
+ return ex;
124
+ }
125
+ return document.importNode(nc, true);
126
+ });
127
+ cur.replaceChildren.apply(cur, nodes);
128
+ restoreOpen(cur, open);
129
+ render(cur);
130
+ };
131
+ var frag = function (html) {
132
+ var d = document.createElement('div'); d.innerHTML = html; return d;
133
+ };
134
+
135
+ // ── 滚动: 默认到底; 用户手动上翻后解除吸附, 回到底部再吸附 ──
136
+ var atBottom = function () {
137
+ return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
138
+ };
139
+ var toBottom = function (force) {
140
+ if (force || S.pinned) thread.scrollTop = thread.scrollHeight;
141
+ };
142
+ thread.addEventListener('scroll', function () { S.pinned = atBottom(); });
143
+
144
+ // ── 侧栏 (聊天列表) ──
145
+ var tagMeta = function (t) {
146
+ var u = t.usage || {};
147
+ var bits = [];
148
+ if (t.turns) bits.push(t.turns + ' 轮');
149
+ if (u.tools) bits.push(u.tools + ' 工具');
150
+ var tok = (u.output || 0) + (u.input || 0);
151
+ if (tok) bits.push(fmtTok(tok) + ' tok');
152
+ if (!bits.length) return '';
153
+ return '<div class="tag-meta">' + bits.map(esc).join('<span class="sep">·</span>') + '</div>';
154
+ };
155
+ var renderTags = function () {
156
+ if (!S.tags.length) {
157
+ tagsEl.innerHTML = '<div class="empty-side">这个 chat 还没有会话记录</div>';
158
+ return;
159
+ }
160
+ $('#side-n').textContent = S.tags.length + ' 个会话';
161
+ tagsEl.replaceChildren.apply(tagsEl, S.tags.map(function (t) {
162
+ var run = isRunning(t);
163
+ var el = document.createElement('div');
164
+ el.className = 'tag-row' + (t.target === S.target ? ' on' : '') + (run ? ' running' : '');
165
+ el.innerHTML =
166
+ '<div class="tag-av">' + esc(t.label) + (run ? '<span class="live"></span>' : '') + '</div>' +
167
+ '<div class="tag-main">' +
168
+ '<div class="tag-l1">' +
169
+ '<span class="tag-name">' + esc(t.tag ? '#' + t.tag : 'default') + '</span>' +
170
+ '<span class="tag-ts">' + esc(fmtAgo(t.lastTs)) + '</span>' +
171
+ '</div>' +
172
+ '<div class="tag-prev">' + esc(t.preview || '(暂无对话)') + '</div>' +
173
+ tagMeta(t) +
174
+ '</div>';
175
+ el.onclick = function () { select(t.target); };
176
+ return el;
177
+ }));
178
+ };
179
+
180
+ var topbar = function () {
181
+ var t = curTag();
182
+ $('#tb-em').textContent = t ? t.label : '💬';
183
+ $('#tb-h').textContent = t ? (t.tag ? '#' + t.tag : 'default') : '';
184
+ $('#tb-sub').textContent = S.target;
185
+ };
186
+
187
+ // ── 页脚总账 ──
188
+ var COLORS = { input: '#0a7d6b', cacheRead: '#8250df', cacheWrite: '#953800', output: '#1a7f37' };
189
+ // 缩写键本身没有自解释性, 悬停给出中文口径 (尤其 ctx 是峰值、cache 是累计读写)。
190
+ var TIP = {
191
+ turns: '对话轮数', tools: '工具调用次数', api: 'API 请求次数',
192
+ ctx: '上下文峰值 — 单次请求送入的 input + 缓存 的最高值',
193
+ out: '累计输出 token', cache: '累计缓存 token (读 + 写)', time: '累计耗时',
194
+ };
195
+ // 进行中的会话, 耗时要跟着走: durationMs 只统计到快照时刻 at, 之后的补上;
196
+ // runningUntil 一过就冻住, 不会像旧版那样把静默期一路累加成几十小时。
197
+ var liveDur = function (t) {
198
+ var d = (t.usage && t.usage.durationMs) || 0;
199
+ if (!t.runningUntil) return d;
200
+ return d + Math.max(0, Math.min(srvNow(), t.runningUntil) - S.at);
201
+ };
202
+ var renderStatus = function (t) {
203
+ if (!t) { sbEl.innerHTML = ''; return; }
204
+ var u = t.usage || {}, run = isRunning(t);
205
+ var segs = [['input', '输入'], ['cacheRead', '缓存读'], ['cacheWrite', '缓存写'], ['output', '输出']]
206
+ .filter(function (s) { return u[s[0]] > 0; });
207
+ var total = segs.reduce(function (a, s) { return a + u[s[0]]; }, 0);
208
+ // token 一条都没采到就别画那条空槽 —— 空进度条看着像"用了 0%", 是误导。
209
+ // 光有色块看不出哪段是什么 (tooltip 要悬停才知道), 所以条后面always跟图例。
210
+ var io = total > 0
211
+ ? '<span class="sb-io" title="累计 token I/O · 共 ' + fmtTok(total) + '">' +
212
+ '<span class="k">token i/o</span>' +
213
+ '<span class="sb-bar">' + segs.map(function (s) {
214
+ return '<span class="seg" style="width:' + (u[s[0]] / total * 100).toFixed(2) + '%;background:' +
215
+ COLORS[s[0]] + '" title="' + s[1] + ': ' + fmtTok(u[s[0]]) + '"></span>';
216
+ }).join('') + '</span>' +
217
+ '<span class="sb-leg">' + segs.map(function (s) {
218
+ return '<span class="lg"><i style="background:' + COLORS[s[0]] + '"></i>' + s[1] +
219
+ '<b>' + fmtTok(u[s[0]]) + '</b></span>';
220
+ }).join('') + '</span>' +
221
+ '</span>'
222
+ : '';
223
+ // 值为 0 = 该指标没有数据 (老记录 / 网关不报 usage), 压暗成 "–" 与真实的 0 区分。
224
+ var st = function (k, n, text) {
225
+ return '<span class="st' + (n ? '' : ' void') + '" title="' + esc(TIP[k] || k) + '">' +
226
+ '<span class="k">' + k + '</span><span class="v">' + (n ? esc(text) : '–') + '</span></span>';
227
+ };
228
+ var cache = (u.cacheRead || 0) + (u.cacheWrite || 0);
229
+ sbEl.innerHTML =
230
+ '<span class="pill' + (run ? ' run' : '') + '"><span class="d"></span>' + (run ? '进行中' : '空闲') + '</span>' +
231
+ (t.model ? '<span class="model" title="' + esc(t.model) + '">' + esc(t.model) + '</span>' : '') +
232
+ st('turns', t.turns, t.turns) +
233
+ st('tools', u.tools, u.tools) +
234
+ st('api', u.calls, u.calls) +
235
+ st('ctx', u.ctxPeak, fmtTok(u.ctxPeak)) +
236
+ st('out', u.output, fmtTok(u.output)) +
237
+ st('cache', cache, fmtTok(cache)) +
238
+ st('time', liveDur(t), fmtDur(liveDur(t))) +
239
+ io;
240
+ };
241
+
242
+ // ── 线程 ──
243
+ var applySummary = function (d) {
244
+ S.base = d.base; S.tags = d.tags || [];
245
+ S.at = d.at || Date.now(); S.recvAt = Date.now();
246
+ $('#side-sub').textContent = d.base || '';
247
+ renderTags(); topbar(); renderStatus(curTag());
248
+ };
249
+
250
+ var loadThread = function (target, limit) {
251
+ return api('api/thread', limit ? { target: target, limit: limit } : { target: target }).then(function (d) {
252
+ if (!d.ok || target !== S.target) return;
253
+ var inner = $('#thread-in');
254
+ if (!d.turns.length) { inner.innerHTML = '<div class="empty">这个会话还没有记录</div>'; return; }
255
+ // 默认只取最近若干轮 (贴底阅读), 更早的按需一次性拉全。
256
+ var more = d.truncated
257
+ ? '<button class="more-btn" id="more">载入更早的 ' + (d.total - d.turns.length) + ' 轮</button>'
258
+ : '';
259
+ inner.innerHTML = more + d.turns.map(function (t) { return t.html; }).join('');
260
+ d.turns.forEach(function (t) { markStale(t); });
261
+ var btn = $('#more');
262
+ if (btn) btn.onclick = function () { btn.textContent = '载入中…'; loadThread(target, '0'); };
263
+ render(inner);
264
+ expireTurns();
265
+ S.pinned = true; toBottom(true);
266
+ // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
267
+ setTimeout(function () { toBottom(); }, 60);
268
+ });
269
+ };
270
+
271
+ // staleAt 由 JSON 单独送来 (不在 html 里, 否则会打乱服务端的 sig 去重), 落到
272
+ // DOM 上供 expireTurns 定时判定。
273
+ var turnNode = function (id) {
274
+ var inner = $('#thread-in'), hit = null;
275
+ Array.prototype.forEach.call(inner.children, function (c) {
276
+ if (c.getAttribute('data-key') === 't:' + id) hit = c;
277
+ });
278
+ return hit;
279
+ };
280
+ var markStale = function (t) {
281
+ var n = turnNode(t.id);
282
+ if (n) n.setAttribute('data-stale-at', t.staleAt || 0);
283
+ };
284
+
285
+ var upsertTurn = function (t) {
286
+ var inner = $('#thread-in');
287
+ var empty = inner.querySelector('.empty'); if (empty) empty.remove();
288
+ var stick = S.pinned;
289
+ var cur = turnNode(t.id);
290
+ if (!cur) {
291
+ inner.insertAdjacentHTML('beforeend', t.html);
292
+ render(inner);
293
+ } else if (cur.getAttribute('data-sig') !== t.sig) {
294
+ var box = frag(t.html).firstElementChild;
295
+ var eb = childBy(cur, 'bubbles'), nb = box && childBy(box, 'bubbles');
296
+ if (eb && nb) {
297
+ var eh = childBy(cur, 'tg-head'), nh = childBy(box, 'tg-head');
298
+ if (eh && nh) eh.innerHTML = nh.innerHTML;
299
+ reconcile(eb, nb);
300
+ cur.setAttribute('data-sig', t.sig);
301
+ } else if (box) {
302
+ cur.replaceWith(box); render(inner);
303
+ }
304
+ }
305
+ markStale(t);
306
+ if (stick) toBottom(true);
307
+ };
308
+
309
+ // 到点了还没有新写入 → 这一轮已经结束: 熄掉呼吸点、移除"正在思考"。
310
+ var expireTurns = function () {
311
+ var now = srvNow();
312
+ $('#thread-in').querySelectorAll('.turn-group[data-stale-at]').forEach(function (g) {
313
+ var until = Number(g.getAttribute('data-stale-at') || 0);
314
+ if (!until || now <= until) return;
315
+ g.setAttribute('data-stale-at', '0');
316
+ var dot = g.querySelector('.tg-dot'); if (dot) dot.classList.remove('live');
317
+ var typing = g.querySelector('.typing'); if (typing) typing.remove();
318
+ });
319
+ };
320
+
321
+ var select = function (target) {
322
+ if (!target) return;
323
+ S.target = target;
324
+ document.querySelector('.app').classList.add('reading');
325
+ renderTags(); topbar(); renderStatus(curTag());
326
+ $('#thread-in').innerHTML = '<div class="empty">加载中…</div>';
327
+ loadThread(target).then(function () { connect(); });
328
+ };
329
+ $('#tb-back').onclick = function () { document.querySelector('.app').classList.remove('reading'); };
330
+
331
+ // ── SSE: chat 摘要 + 当前 tag 的 turn 增量。断线指数退避重连。 ──
332
+ var backoff = 1000;
333
+ var connect = function () {
334
+ if (S.es) { S.es.close(); S.es = null; }
335
+ var p = new URLSearchParams({ id: TOKEN, target: S.target });
336
+ var es = new EventSource('api/events?' + p.toString());
337
+ S.es = es;
338
+ es.addEventListener('chat', function (e) { try { applySummary(JSON.parse(e.data)); } catch (err) { } });
339
+ es.addEventListener('turn', function (e) { try { upsertTurn(JSON.parse(e.data)); } catch (err) { } });
340
+ es.onopen = function () { backoff = 1000; connEl.className = 'conn'; connEl.title = '实时连接'; };
341
+ es.onerror = function () {
342
+ connEl.className = 'conn off'; connEl.title = '连接断开, 重连中';
343
+ es.close(); if (S.es === es) S.es = null;
344
+ setTimeout(function () { if (!S.es) connect(); }, backoff);
345
+ backoff = Math.min(backoff * 2, 15000);
346
+ };
347
+ };
348
+
349
+ // 本地心跳: 相对时间、运行中判定、耗时都随时间变化, 但服务端没有新事件可推。
350
+ setInterval(function () {
351
+ if (!S.tags.length) return;
352
+ renderTags();
353
+ renderStatus(curTag());
354
+ expireTurns();
355
+ }, TICK_MS);
356
+
357
+ // ── boot ──
358
+ api('api/chat', {}).then(function (d) {
359
+ if (!d.ok) {
360
+ document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
361
+ return;
362
+ }
363
+ applySummary(d);
364
+ // 卡片链接带来的那条 turn 决定默认选中的 tag; 否则取最近活跃的。
365
+ select(d.self && d.self.target ? d.self.target : (S.tags[0] && S.tags[0].target));
366
+ });
367
+ })();