dsh-any-agent 0.6.1 → 0.6.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.
@@ -54,7 +54,8 @@ button { font: inherit; color: inherit; }
54
54
  .stats { display: flex; align-items: stretch; height: 100%; margin-left: 4px; }
55
55
  .stat { display: flex; flex-direction: column; justify-content: center; padding: 0 16px; border-left: 1px solid var(--line); min-width: 74px; }
56
56
  .stat b { font-size: 15px; font-weight: 650; line-height: 1.1; }
57
- .stat span { font-size: 11px; color: var(--text-3); }
57
+ .stat span { font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
+ .stat { min-width: 0; }
58
59
  .stat.hot b { color: var(--run); }
59
60
  .stat.warn b { color: var(--warn); }
60
61
  .stat.zero b { color: var(--text-3); font-weight: 500; }
@@ -63,6 +64,9 @@ button { font: inherit; color: inherit; }
63
64
  .search input { font: inherit; font-size: 12.5px; height: 30px; width: 240px; padding: 0 10px 0 28px; border: 1px solid var(--line); border-radius: 7px; background: var(--sunken); color: var(--text); outline: none; }
64
65
  .search input:focus { border-color: var(--accent); background: var(--surface); }
65
66
  .search svg { position: absolute; left: 9px; top: 9px; color: var(--text-3); }
67
+ .lang-btn { height: 30px; min-width: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--sunken); color: var(--text-2); font-size: 12px; font-weight: 600; cursor: pointer; }
68
+ .lang-btn:hover { color: var(--text); border-color: var(--line-2); }
69
+ .lang-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
66
70
 
67
71
  /* ── 布局 ───────────────────────────────── */
68
72
  .layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: calc(100% - 52px); }
@@ -92,7 +96,66 @@ button { font: inherit; color: inherit; }
92
96
  @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
93
97
 
94
98
  /* ── 主区 ───────────────────────────────── */
95
- .wrap { max-width: 1080px; padding: 22px 28px 48px; }
99
+ /* 不再限制最大宽度:超宽屏上调用链和右栏一起铺满,右栏随窗口在 300–460px 之间伸缩。 */
100
+ .wrap { padding: 22px 28px 48px; }
101
+ .cols { display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, 24vw, 460px); gap: 24px; align-items: start; }
102
+ .inspector { position: sticky; top: 22px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; }
103
+ .panel { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px 14px; }
104
+ .panel h3 { margin: 0 0 10px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); }
105
+ .kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; }
106
+ .kpi b { display: block; font-size: 17px; font-weight: 650; line-height: 1.15; }
107
+ .kpi span { font-size: 11px; color: var(--text-3); }
108
+ .kpi.bad b { color: var(--bad); }
109
+ .kpi.warn b { color: var(--warn); }
110
+ .kpi.zero b { color: var(--text-3); font-weight: 500; }
111
+ .dist { display: grid; gap: 7px; }
112
+ .dist-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; font-size: 12px; }
113
+ .dist-row .n { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114
+ .dist-row .v { color: var(--text-3); white-space: nowrap; }
115
+ .dist-row .bar { grid-column: 1 / 3; height: 5px; border-radius: 3px; background: var(--sunken); overflow: hidden; }
116
+ .dist-row .bar b { display: block; height: 100%; border-radius: 3px; background: var(--bar-run); opacity: .75; }
117
+ .files { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
118
+ .files li { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 6px; align-items: baseline; font-size: 12px; }
119
+ .files .op { font-size: 10.5px; font-weight: 600; text-align: center; border-radius: 4px; padding: 0 3px; background: var(--sunken); color: var(--text-2); }
120
+ .files .op.w { background: var(--ok-soft); color: var(--ok); }
121
+ .files .p { font-family: var(--mono); font-size: 11.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
122
+ .aps, .dist, .files { grid-template-columns: minmax(0, 1fr); }
123
+ .aps { display: grid; gap: 8px; }
124
+ .ap { min-width: 0; font-size: 12px; border-left: 2px solid var(--warn); padding: 2px 0 2px 8px; }
125
+ .ap .m { font-weight: 600; color: var(--warn); }
126
+ .ap .c { font-family: var(--mono); font-size: 11.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
127
+ .muted { font-size: 12px; color: var(--text-3); }
128
+ /* Token 面板 */
129
+ .tok-big { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
130
+ .tok-big b { display: block; font-size: 18px; font-weight: 650; line-height: 1.15; }
131
+ .tok-big span { font-size: 11px; color: var(--text-3); }
132
+ .stack { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--sunken); margin-bottom: 6px; }
133
+ .stack i { display: block; height: 100%; min-width: 2px; }
134
+ .c-unc { background: var(--accent); }
135
+ .c-read { background: var(--ok); opacity: .75; }
136
+ .c-write { background: var(--warn); opacity: .8; }
137
+ .c-out { background: var(--bad); opacity: .7; }
138
+ .legend { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 10px; font-size: 11.5px; color: var(--text-2); margin-bottom: 12px; }
139
+ .legend span { display: flex; align-items: center; gap: 6px; min-width: 0; white-space: nowrap; }
140
+ .legend i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
141
+ .legend em { font-style: normal; margin-left: auto; color: var(--text-3); }
142
+ .ctx-h { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--text-2); margin-bottom: 4px; }
143
+ .ctx-h span:last-child { color: var(--text-3); }
144
+ .ctx { height: 6px; border-radius: 3px; background: var(--sunken); overflow: hidden; margin-bottom: 12px; }
145
+ .ctx b { display: block; height: 100%; border-radius: 3px; background: var(--accent); }
146
+ .ctx.hi b { background: var(--warn); }
147
+ .ctx.crit b { background: var(--bad); }
148
+ .spark-h { font-size: 11.5px; color: var(--text-2); margin-bottom: 6px; display: flex; justify-content: space-between; }
149
+ .spark-h span:last-child { color: var(--text-3); }
150
+ .spark { display: flex; align-items: flex-end; gap: 3px; height: 56px; }
151
+ .spark i { flex: 1; min-width: 4px; max-width: 26px; border-radius: 3px 3px 0 0; background: var(--bar); position: relative; }
152
+ .spark i.cur { background: var(--bar-run); }
153
+ .spark i:hover { background: var(--accent); }
154
+ .tok-run { font-family: var(--mono); font-size: 11.5px; }
155
+ @media (max-width: 1180px) {
156
+ .cols { grid-template-columns: minmax(0, 1fr); }
157
+ .inspector { position: static; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
158
+ }
96
159
  .hd h1 { font-size: 19px; font-weight: 650; margin: 0 0 10px; letter-spacing: -.1px; word-break: break-word; }
97
160
  .facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px 24px; padding: 12px 16px; margin: 0 0 24px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); }
98
161
  .fact { min-width: 0; }
@@ -135,7 +198,17 @@ button { font: inherit; color: inherit; }
135
198
  .collapsed .body { display: none; }
136
199
 
137
200
  /* ── 调用链:工具 / 审批(带时间条) ─────── */
138
- .spans { border-top: 1px solid var(--line); }
201
+ .spans-box { position: relative; border-top: 1px solid var(--line); }
202
+ /* 工具列表过长时在卡片内部滚动,滚动条隐藏;上下边缘用渐隐提示还有内容。 */
203
+ .spans { max-height: min(60vh, 520px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; }
204
+ .spans::-webkit-scrollbar, .rail::-webkit-scrollbar, .main::-webkit-scrollbar { width: 0; height: 0; display: none; }
205
+ .rail, .main { scrollbar-width: none; -ms-overflow-style: none; }
206
+ .spans-box::before, .spans-box::after { content: ""; position: absolute; left: 0; right: 0; height: 28px; pointer-events: none; opacity: 0; transition: opacity .15s ease; z-index: 1; }
207
+ .spans-box::before { top: 0; background: linear-gradient(var(--surface), transparent); }
208
+ .spans-box::after { bottom: 0; background: linear-gradient(transparent, var(--surface)); border-radius: 0 0 var(--r) var(--r); }
209
+ .spans-box.more-up::before, .spans-box.more-down::after { opacity: 1; }
210
+ .spans-count { position: absolute; right: 12px; bottom: 6px; z-index: 2; font-size: 11px; color: var(--text-3); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; opacity: 0; transition: opacity .15s ease; pointer-events: none; }
211
+ .spans-box.more-down .spans-count { opacity: 1; }
139
212
  .span { display: grid; grid-template-columns: 22px 92px minmax(0, 1fr) minmax(120px, 26%) 62px; align-items: center; gap: 10px; padding: 0 12px; height: 32px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
140
213
  .span:last-child { border-bottom: 0; }
141
214
  .span:hover { background: var(--sunken); }
@@ -175,7 +248,7 @@ button { font: inherit; color: inherit; }
175
248
  /* ── 窄屏 ───────────────────────────────── */
176
249
  @media (max-width: 820px) {
177
250
  .top { height: auto; flex-wrap: wrap; gap: 10px 16px; padding: 10px 16px; position: static; }
178
- .stats { height: 40px; margin-left: 0; order: 3; width: 100%; }
251
+ .stats { height: 40px; margin-left: 0; order: 3; width: 100%; overflow: hidden; }
179
252
  .stat { flex: 1; min-width: 0; padding: 0 10px; }
180
253
  .stat span { white-space: nowrap; }
181
254
  .stat:first-child { border-left: 0; padding-left: 0; }
@@ -198,16 +271,17 @@ button { font: inherit; color: inherit; }
198
271
  </head>
199
272
  <body>
200
273
  <header class="top">
201
- <div class="brand"><span class="brand-mark">DS</span>DSH Live <span class="conn" id="conn"><i></i><span>连接中</span></span></div>
274
+ <div class="brand"><span class="brand-mark">DS</span>DSH Live <span class="conn" id="conn"><i></i><span id="conn-txt">…</span></span></div>
202
275
  <div class="stats" id="stats"></div>
203
276
  <div class="grow"></div>
204
277
  <label class="search">
205
278
  <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="7" cy="7" r="5"/><path d="M11 11l4 4"/></svg>
206
- <input type="search" id="q" placeholder="筛选标题、id、目录" aria-label="筛选会话">
279
+ <input type="search" id="q">
207
280
  </label>
281
+ <button type="button" class="lang-btn" id="lang-btn"></button>
208
282
  </header>
209
283
  <div class="layout">
210
- <nav class="rail" id="rail" aria-label="会话"></nav>
284
+ <nav class="rail" id="rail"></nav>
211
285
  <main class="main" id="main" aria-live="polite"></main>
212
286
  </div>
213
287
  <script>
@@ -223,6 +297,110 @@ button { font: inherit; color: inherit; }
223
297
  let selected = store.get('dsh-live-sel');
224
298
  const collapsed = new Set();
225
299
  let q = '';
300
+ let connected = null;
301
+
302
+ // ── 界面语言:?lang= > 上次的手动选择 > 浏览器语言 ──
303
+ const I18N = {
304
+ zh: {
305
+ htmlLang: 'zh-CN', switchTo: 'EN', switchLabel: 'Switch to English',
306
+ connecting: '连接中', live: '实时', reconnecting: '重连中',
307
+ search: '筛选标题、id、目录', searchLabel: '筛选会话', railLabel: '会话',
308
+ justNow: '刚刚', minAgo: (n) => n + ' 分钟前', hrAgo: (n) => n + ' 小时前', dayAgo: (n) => n + ' 天前',
309
+ STATE: { running: '运行中', queued: '排队中', idle: '空闲', closed: '已释放' },
310
+ RUN: { running: '运行中', queued: '排队', done: '完成', error: '出错', cancelled: '已取消', interrupted: '中断' },
311
+ VIA: { send: '派活', queue: '排队', interject: '插话' },
312
+ TOOL: { pending: '等待', in_progress: '进行中', unfinished: '未完成', completed: '完成', failed: '失败' },
313
+ MODE: { 'auto-allow': '自动放行', 'auto-deny': '自动拒绝', 'read-only-deny': '只读拒绝', ask: '待人工裁决' },
314
+ sConversations: '会话', sRunning: '运行中', sQueued: '排队', sPending: '待审批', sEscalations: '提权',
315
+ tokAllTitle: (i, cr, o) => '全部会话 token:输入 ' + i + '(缓存读 ' + cr + ')· 输出 ' + o,
316
+ tokStat: (o) => 'token · 出 ' + o,
317
+ unknownClient: '未知客户端', exited: '已退出', untitledShort: (id) => '无标题 · ' + id,
318
+ pendN: (n) => n + ' 待审批', noMatch: '没有匹配的会话', noEvents: '还没有事件',
319
+ noEventsHint: ['用 ', ' 派一次活,调用链会出现在这里。'],
320
+ approval: '审批', waiting: '等待', interrupted: '中断', interject: '插话',
321
+ trackTitle: (a, b) => a + ' 起 · ' + b,
322
+ waitingFirst: '等待第一条事件',
323
+ firstHint: ['这是只读视图。服务端写入 ', ' 事件流后,这里按 客户端 → 会话 → 回合 → 工具 展开调用链。'],
324
+ untitledConv: '无标题会话',
325
+ fState: '状态', fClient: '客户端', unknown: '未知', exitedParen: '(已退出)', fPermission: '权限', fModel: '模型', fConvId: '会话 id', fDir: '目录',
326
+ chain: '调用链', runsCount: (n) => n + ' 个回合 · 最新在上', noRuns: '这个会话还没有派过活',
327
+ turnN: (n) => '第 ' + n + ' 轮 · ', escN: (n) => '⚠ ' + n + ' 次提权', toolsSuffix: () => ' 个工具',
328
+ tokRunTitle: (i, cr, o) => '本回合 token:输入 ' + i + '(缓存读 ' + cr + ')· 输出 ' + o,
329
+ toolList: '工具调用列表',
330
+ foot: '只读视图 · 数据来自本机 live 事件流 · 思考内容从不写入 · 点击回合标题折叠',
331
+ tokTitle: 'Token 消耗', tokNoData: '暂无数据:派一次活之后,这里会实时显示本会话的 token 用量。',
332
+ tIn: '输入', tOut: '输出', tHit: '缓存命中', tUncached: '未缓存输入', tCacheRead: '缓存读', tCacheWrite: '缓存写',
333
+ ctxWindow: '上下文窗口', pctParen: (p) => '(' + p + '%)', perRun: '每回合用量',
334
+ lastN: (n, max) => '最近 ' + n + ' 轮 · 最高 ' + max, barTail: (i, o) => ':输入 ' + i + ' · 输出 ' + o,
335
+ tokFoot: '数字来自 DSH 自己的会话记录(累计,含 live 之前的回合)。',
336
+ overview: '概览', kRuns: '回合', kToolCalls: '工具调用', kFailed: '失败', kToolTime: '工具耗时', kApprovals: '审批', kEscalations: '提权',
337
+ toolDist: '工具分布', noTools: '还没有工具调用', failN: (n) => ' · ' + n + ' 失败',
338
+ files: '涉及文件', noFiles: '还没有读写文件', opW: '写', opR: '读', approvalsTitle: '审批',
339
+ moreBelow: (n) => '↓ ' + n + ' 条',
340
+ },
341
+ en: {
342
+ htmlLang: 'en', switchTo: '中', switchLabel: '切换到中文',
343
+ connecting: 'connecting', live: 'live', reconnecting: 'reconnecting',
344
+ search: 'Filter by title, id, directory', searchLabel: 'Filter conversations', railLabel: 'Conversations',
345
+ justNow: 'just now', minAgo: (n) => n + ' min ago', hrAgo: (n) => n + ' h ago', dayAgo: (n) => n + ' d ago',
346
+ STATE: { running: 'running', queued: 'queued', idle: 'idle', closed: 'released' },
347
+ RUN: { running: 'running', queued: 'queued', done: 'done', error: 'error', cancelled: 'cancelled', interrupted: 'interrupted' },
348
+ VIA: { send: 'send', queue: 'queue', interject: 'interject' },
349
+ TOOL: { pending: 'pending', in_progress: 'in progress', unfinished: 'unfinished', completed: 'done', failed: 'failed' },
350
+ MODE: { 'auto-allow': 'auto-allowed', 'auto-deny': 'auto-denied', 'read-only-deny': 'denied (read-only)', ask: 'awaiting decision' },
351
+ sConversations: 'conversations', sRunning: 'running', sQueued: 'queued', sPending: 'awaiting approval', sEscalations: 'escalations',
352
+ tokAllTitle: (i, cr, o) => 'All conversations: input ' + i + ' (cache read ' + cr + ') · output ' + o,
353
+ tokStat: (o) => 'tokens · out ' + o,
354
+ unknownClient: 'unknown client', exited: 'exited', untitledShort: (id) => 'untitled · ' + id,
355
+ pendN: (n) => n + ' awaiting approval', noMatch: 'No matching conversations', noEvents: 'No events yet',
356
+ noEventsHint: ['Dispatch something with ', ' and its call chain shows up here.'],
357
+ approval: 'approval', waiting: 'waiting', interrupted: 'interrupted', interject: 'interject',
358
+ trackTitle: (a, b) => 'starts at ' + a + ' · ' + b,
359
+ waitingFirst: 'Waiting for the first event',
360
+ firstHint: ['Read-only view. Once the server writes the ', ' event stream, the call chain appears here: client → conversation → run → tools.'],
361
+ untitledConv: 'Untitled conversation',
362
+ fState: 'State', fClient: 'Client', unknown: 'unknown', exitedParen: ' (exited)', fPermission: 'Permission', fModel: 'Model', fConvId: 'Conversation id', fDir: 'Directory',
363
+ chain: 'Call chain', runsCount: (n) => n + (n === 1 ? ' run' : ' runs') + ' · newest first', noRuns: 'Nothing dispatched in this conversation yet',
364
+ turnN: (n) => 'turn ' + n + ' · ', escN: (n) => '⚠ ' + n + (n === 1 ? ' escalation' : ' escalations'), toolsSuffix: (n) => (n === 1 ? ' tool' : ' tools'),
365
+ tokRunTitle: (i, cr, o) => 'This run: input ' + i + ' (cache read ' + cr + ') · output ' + o,
366
+ toolList: 'Tool calls',
367
+ foot: 'Read-only · data from the local live event stream · reasoning is never written · click a run header to collapse',
368
+ tokTitle: 'Token usage', tokNoData: 'No data yet: dispatch something and this conversation’s token usage shows up here, live.',
369
+ tIn: 'input', tOut: 'output', tHit: 'cache hit', tUncached: 'uncached input', tCacheRead: 'cache read', tCacheWrite: 'cache write',
370
+ ctxWindow: 'Context window', pctParen: (p) => ' (' + p + '%)', perRun: 'Per run',
371
+ lastN: (n, max) => 'last ' + n + ' · max ' + max, barTail: (i, o) => ': input ' + i + ' · output ' + o,
372
+ tokFoot: 'From DSH’s own session record (cumulative, including runs from before the live view).',
373
+ overview: 'Overview', kRuns: 'runs', kToolCalls: 'tool calls', kFailed: 'failed', kToolTime: 'tool time', kApprovals: 'approvals', kEscalations: 'escalations',
374
+ toolDist: 'Tools', noTools: 'No tool calls yet', failN: (n) => ' · ' + n + ' failed',
375
+ files: 'Files', noFiles: 'No files read or written yet', opW: 'W', opR: 'R', approvalsTitle: 'Approvals',
376
+ moreBelow: (n) => '↓ ' + n + ' more',
377
+ },
378
+ };
379
+ function pickLang() {
380
+ const fromUrl = (new URLSearchParams(location.search).get('lang') || '').toLowerCase();
381
+ if (fromUrl === 'zh' || fromUrl === 'en') return fromUrl;
382
+ const saved = store.get('dsh-live-lang');
383
+ if (saved === 'zh' || saved === 'en') return saved;
384
+ return /^zh/i.test(navigator.language || '') ? 'zh' : 'en';
385
+ }
386
+ let lang = pickLang();
387
+ let L = I18N[lang];
388
+ /** 切换语言:文案表、静态文字、标签映射一起换,然后重画。 */
389
+ function applyLang(next) {
390
+ lang = next;
391
+ L = I18N[lang];
392
+ ({ STATE, RUN, VIA, TOOL, MODE } = L);
393
+ document.documentElement.lang = L.htmlLang;
394
+ const qi = $('q');
395
+ qi.placeholder = L.search;
396
+ qi.setAttribute('aria-label', L.searchLabel);
397
+ $('rail').setAttribute('aria-label', L.railLabel);
398
+ const btn = $('lang-btn');
399
+ btn.textContent = L.switchTo;
400
+ btn.setAttribute('aria-label', L.switchLabel);
401
+ btn.title = L.switchLabel;
402
+ $('conn-txt').textContent = connected === null ? L.connecting : connected ? L.live : L.reconnecting;
403
+ }
226
404
 
227
405
  // 只用 textContent 建 DOM:事件内容来自磁盘文件,绝不当 HTML 解析。
228
406
  function h(tag, cls, text) {
@@ -243,17 +421,21 @@ button { font: inherit; color: inherit; }
243
421
  function ago(ts) {
244
422
  if (!ts) return '';
245
423
  const s = Math.max(0, (Date.now() - Date.parse(ts)) / 1000);
246
- if (s < 45) return '刚刚';
247
- if (s < 3600) return Math.round(s / 60) + ' 分钟前';
248
- if (s < 86400) return Math.round(s / 3600) + ' 小时前';
249
- return Math.round(s / 86400) + ' 天前';
424
+ if (s < 45) return L.justNow;
425
+ if (s < 3600) return L.minAgo(Math.round(s / 60));
426
+ if (s < 86400) return L.hrAgo(Math.round(s / 3600));
427
+ return L.dayAgo(Math.round(s / 86400));
250
428
  }
251
429
  const t = (ts) => (ts ? Date.parse(ts) : NaN);
252
- const STATE = { running: '运行中', queued: '排队中', idle: '空闲', closed: '已释放' };
253
- const RUN = { running: '运行中', queued: '排队', done: '完成', error: '出错', cancelled: '已取消', interrupted: '中断' };
254
- const VIA = { send: '派活', queue: '排队', interject: '插话' };
255
- const TOOL = { pending: '等待', in_progress: '进行中', unfinished: '未完成', completed: '完成', failed: '失败' };
256
- const MODE = { 'auto-allow': '自动放行', 'auto-deny': '自动拒绝', 'read-only-deny': '只读拒绝', ask: '待人工裁决' };
430
+ function ftok(n) {
431
+ if (!isFinite(n)) return '';
432
+ if (n < 1000) return String(Math.round(n));
433
+ if (n < 1e6) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + 'k';
434
+ return (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M';
435
+ }
436
+ const tin = (k) => (k ? k.input + k.cache_read + k.cache_write : 0);
437
+ const hit = (k) => { const a = tin(k); return a > 0 ? k.cache_read / a : null; };
438
+ let STATE, RUN, VIA, TOOL, MODE;
257
439
 
258
440
  function convs() {
259
441
  const out = [];
@@ -271,11 +453,17 @@ button { font: inherit; color: inherit; }
271
453
  d.append(h('b', null, String(n)), h('span', null, label));
272
454
  box.append(d);
273
455
  };
274
- stat(s.conversations, '会话', '');
275
- stat(s.running, '运行中', 'hot');
276
- stat(s.queued, '排队', 'warn');
277
- stat(s.pending_approvals, '待审批', 'warn');
278
- stat(s.escalations, '提权', 'warn');
456
+ stat(s.conversations, L.sConversations, '');
457
+ stat(s.running, L.sRunning, 'hot');
458
+ stat(s.queued, L.sQueued, 'warn');
459
+ stat(s.pending_approvals, L.sPending, 'warn');
460
+ stat(s.escalations, L.sEscalations, 'warn');
461
+ if (s.tokens) {
462
+ const d = h('div', 'stat');
463
+ d.title = L.tokAllTitle(tin(s.tokens).toLocaleString(), s.tokens.cache_read.toLocaleString(), s.tokens.output.toLocaleString());
464
+ d.append(h('b', null, ftok(tin(s.tokens) + s.tokens.output)), h('span', null, L.tokStat(ftok(s.tokens.output))));
465
+ box.append(d);
466
+ }
279
467
  }
280
468
 
281
469
  function renderRail() {
@@ -288,9 +476,9 @@ button { font: inherit; color: inherit; }
288
476
  any = true;
289
477
  const grp = h('section', 'grp');
290
478
  const gh = h('div', 'grp-h');
291
- gh.append(h('b', null, g.client || '未知客户端'));
479
+ gh.append(h('b', null, g.client || L.unknownClient));
292
480
  gh.append(h('span', 'mono', 'pid ' + (g.pid || '?')));
293
- if (!g.alive) gh.append(h('span', 'gone', '已退出'));
481
+ if (!g.alive) gh.append(h('span', 'gone', L.exited));
294
482
  grp.append(gh);
295
483
  for (const c of list) {
296
484
  const row = h('div', 'cv' + (c.id === selected ? ' sel' : ''));
@@ -298,13 +486,13 @@ button { font: inherit; color: inherit; }
298
486
  row.setAttribute('role', 'button');
299
487
  row.setAttribute('aria-pressed', String(c.id === selected));
300
488
  row.append(h('i', 'sd ' + c.state));
301
- row.append(h('div', 'ti', c.title || '无标题 · ' + short(c.id)));
489
+ row.append(h('div', 'ti', c.title || L.untitledShort(short(c.id))));
302
490
  row.append(h('div', 'ago', ago(c.last_ts)));
303
491
  const sub = h('div', 'sub');
304
492
  const active = c.runs.find((r) => r.status === 'running');
305
493
  const pend = c.runs.reduce((n, r) => n + r.pending_approvals, 0);
306
494
  sub.append(document.createTextNode(active && active.current_tool ? '▸ ' + active.current_tool : STATE[c.state] || c.state));
307
- if (pend) sub.append(h('span', 'flag', ' · ' + pend + ' 待审批'));
495
+ if (pend) sub.append(h('span', 'flag', ' · ' + L.pendN(pend)));
308
496
  sub.append(document.createTextNode(' · '));
309
497
  const p = h('span', 'mono', base(c.cwd));
310
498
  p.title = c.cwd || '';
@@ -319,11 +507,11 @@ button { font: inherit; color: inherit; }
319
507
  }
320
508
  if (!any) {
321
509
  const e = h('div', 'empty');
322
- if (q) e.append(h('h3', null, '没有匹配的会话'));
510
+ if (q) e.append(h('h3', null, L.noMatch));
323
511
  else {
324
- e.append(h('h3', null, '还没有事件'));
512
+ e.append(h('h3', null, L.noEvents));
325
513
  const p = h('p');
326
- p.append('用 ', h('code', null, 'dsh_send'), ' 派一次活,调用链会出现在这里。');
514
+ p.append(L.noEventsHint[0], h('code', null, 'dsh_send'), L.noEventsHint[1]);
327
515
  e.append(p);
328
516
  }
329
517
  rail.append(e);
@@ -359,7 +547,7 @@ button { font: inherit; color: inherit; }
359
547
  if (it.kind === 'approval') {
360
548
  const row = h('div', 'span flagged');
361
549
  row.append(h('span', 'st', '◆'));
362
- row.append(h('span', 'nm', '审批'));
550
+ row.append(h('span', 'nm', L.approval));
363
551
  const inp = h('span', 'in');
364
552
  if (it.escalation) inp.append(h('span', 'esc', '⚠ ' + it.escalation));
365
553
  const txt = it.command_preview || it.title || it.tool || '';
@@ -368,12 +556,12 @@ button { font: inherit; color: inherit; }
368
556
  row.append(inp);
369
557
  const d = it.decided;
370
558
  row.append(h('span', 'in', d ? '→ ' + (d.decision || d.effective || '') + (d.by ? '(' + d.by + ')' : '') : MODE[it.mode] || it.mode || ''));
371
- row.append(h('span', 'tm', it.mode === 'ask' && !d ? '等待' : (MODE[it.mode] ? '' : '')));
559
+ row.append(h('span', 'tm', it.mode === 'ask' && !d ? L.waiting : ''));
372
560
  return row;
373
561
  }
374
562
  const row = h('div', 'span note' + (it.kind === 'interrupt' ? ' stop' : ''));
375
563
  row.append(h('span', 'st', it.kind === 'interrupt' ? '■' : '↪'));
376
- row.append(h('span', 'nm', it.kind === 'interrupt' ? '中断' : '插话' + (it.mode ? ' · ' + it.mode : '')));
564
+ row.append(h('span', 'nm', it.kind === 'interrupt' ? L.interrupted : L.interject + (it.mode ? ' · ' + it.mode : '')));
377
565
  row.append(h('span', 'in', it.text_preview || ''));
378
566
  row.append(h('span'));
379
567
  row.append(h('span', 'tm', ''));
@@ -391,7 +579,7 @@ button { font: inherit; color: inherit; }
391
579
  const b = h('b');
392
580
  b.style.left = left + '%';
393
581
  b.style.width = width + '%';
394
- tr.title = dur(start - t0) + ' 起 · ' + dur(end - start);
582
+ tr.title = L.trackTitle(dur(start - t0), dur(end - start));
395
583
  tr.append(b);
396
584
  return tr;
397
585
  }
@@ -413,9 +601,9 @@ button { font: inherit; color: inherit; }
413
601
  if (!c) c = all.find((x) => x.state === 'running') || all[0];
414
602
  if (!c) {
415
603
  const e = h('div', 'empty');
416
- e.append(h('h3', null, '等待第一条事件'));
604
+ e.append(h('h3', null, L.waitingFirst));
417
605
  const p = h('p');
418
- p.append('这是只读视图。服务端写入 ', h('code', null, 'live/'), ' 事件流后,这里按 客户端 → 会话 → 回合 → 工具 展开调用链。');
606
+ p.append(L.firstHint[0], h('code', null, 'live/'), L.firstHint[1]);
419
607
  e.append(p);
420
608
  main.append(e);
421
609
  return;
@@ -423,7 +611,7 @@ button { font: inherit; color: inherit; }
423
611
  selected = c.id;
424
612
  const wrap = h('div', 'wrap');
425
613
  const hd = h('div', 'hd');
426
- hd.append(h('h1', null, c.title || '无标题会话'));
614
+ hd.append(h('h1', null, c.title || L.untitledConv));
427
615
  const facts = h('dl', 'facts');
428
616
  const fact = (k, v, cls, full) => {
429
617
  if (!v) return;
@@ -438,24 +626,26 @@ button { font: inherit; color: inherit; }
438
626
  f.append(dd);
439
627
  facts.append(f);
440
628
  };
441
- fact('状态', STATE[c.state] || c.state);
442
- fact('客户端', (c.client || '未知') + ' · pid ' + (c.writer ? String(c.writer).split(':')[0] : '?') + (c.writer_alive ? '' : '(已退出)'));
443
- fact('权限', [c.permission, c.on_approval].filter(Boolean).join(' · '));
444
- fact('模型', c.model);
445
- fact('会话 id', short(c.id), 'mono', c.id);
446
- fact('目录', c.cwd, 'mono path');
629
+ fact(L.fState, STATE[c.state] || c.state);
630
+ fact(L.fClient, (c.client || L.unknown) + ' · pid ' + (c.writer ? String(c.writer).split(':')[0] : '?') + (c.writer_alive ? '' : L.exitedParen));
631
+ fact(L.fPermission, [c.permission, c.on_approval].filter(Boolean).join(' · '));
632
+ fact(L.fModel, c.model);
633
+ fact(L.fConvId, short(c.id), 'mono', c.id);
634
+ fact(L.fDir, c.cwd, 'mono path');
447
635
  hd.append(facts);
448
636
  wrap.append(hd);
449
637
 
638
+ const cols = h('div', 'cols');
639
+ const left = h('div');
450
640
  const sh = h('div', 'sec-h');
451
- sh.append(h('h2', null, '调用链'), h('span', null, c.runs.length + ' 个回合 · 最新在上'));
452
- wrap.append(sh);
641
+ sh.append(h('h2', null, L.chain), h('span', null, L.runsCount(c.runs.length)));
642
+ left.append(sh);
453
643
 
454
644
  const turns = h('div', 'turns');
455
645
  const runs = [...c.runs].reverse();
456
646
  if (!runs.length) {
457
647
  const e = h('div', 'empty');
458
- e.append(h('h3', null, '这个会话还没有派过活'));
648
+ e.append(h('h3', null, L.noRuns));
459
649
  turns.append(e);
460
650
  }
461
651
  for (const r of runs) {
@@ -469,15 +659,21 @@ button { font: inherit; color: inherit; }
469
659
  head.append(chev());
470
660
  head.append(h('span', 'tag ' + r.status, RUN[r.status] || r.status));
471
661
  if (r.via && !(r.status === 'queued' && r.via === 'queue')) head.append(h('span', 'tag ghost', VIA[r.via] || r.via));
472
- head.append(h('span', 'rid', (r.turn ? '第 ' + r.turn + ' 轮 · ' : '') + r.run));
473
- if (r.escalations) head.append(h('span', 'tag warn', '⚠ ' + r.escalations + ' 次提权'));
474
- if (r.pending_approvals) head.append(h('span', 'tag warn', r.pending_approvals + ' 待审批'));
662
+ head.append(h('span', 'rid', (r.turn ? L.turnN(r.turn) : '') + r.run));
663
+ if (r.escalations) head.append(h('span', 'tag warn', L.escN(r.escalations)));
664
+ if (r.pending_approvals) head.append(h('span', 'tag warn', L.pendN(r.pending_approvals)));
475
665
  const right = h('span', 'right');
476
666
  const tools = r.items.filter((x) => x.kind === 'tool').length;
477
667
  const n = r.tool_count != null ? r.tool_count : tools;
478
- const rb = h('span'); rb.append(h('b', null, String(n)), ' 个工具'); right.append(rb);
668
+ const rb = h('span'); rb.append(h('b', null, String(n)), L.toolsSuffix(n)); right.append(rb);
479
669
  const t0 = t(r.started_at);
480
670
  const total = r.duration_ms != null ? r.duration_ms : r.status === 'running' && isFinite(t0) ? Date.now() - t0 : null;
671
+ if (r.tokens) {
672
+ const tk = h('span', 'tok-run');
673
+ tk.title = L.tokRunTitle(tin(r.tokens), r.tokens.cache_read, r.tokens.output);
674
+ tk.append(h('b', null, ftok(tin(r.tokens))), '↑ ', h('b', null, ftok(r.tokens.output)), '↓');
675
+ right.append(tk);
676
+ }
481
677
  if (total != null) { const d = h('span'); d.append(h('b', null, dur(total))); right.append(d); }
482
678
  right.append(h('span', null, ago(r.ended_at || r.started_at || r.queued_at)));
483
679
  head.append(right);
@@ -489,19 +685,238 @@ button { font: inherit; color: inherit; }
489
685
  if (r.prompt_preview) body.append(h('div', 'prompt', r.prompt_preview));
490
686
  if (r.error) body.append(h('div', 'errline', r.error));
491
687
  if (r.items.length) {
688
+ const boxEl = h('div', 'spans-box');
492
689
  const spans = h('div', 'spans');
690
+ spans.dataset.key = key;
691
+ spans.tabIndex = 0; // 键盘也能滚(方向键 / PageDown)
692
+ spans.setAttribute('aria-label', L.toolList);
493
693
  for (const it of r.items) spans.append(spanRow(it, r, t0, total, c.cwd));
494
- body.append(spans);
694
+ boxEl.append(spans, h('span', 'spans-count'));
695
+ body.append(boxEl);
696
+ pendingScroll.push({ spans, box: boxEl, key, running: r.status === 'running' });
495
697
  }
496
698
  card.append(body);
497
699
  turn.append(card);
498
700
  turns.append(turn);
499
701
  }
500
- wrap.append(turns);
501
- wrap.append(h('div', 'foot', '只读视图 · 数据来自本机 live 事件流 · 思考内容从不写入 · 点击回合标题折叠'));
702
+ left.append(turns);
703
+ left.append(h('div', 'foot', L.foot));
704
+ cols.append(left, inspector(c));
705
+ wrap.append(cols);
502
706
  main.append(wrap);
503
707
  }
504
708
 
709
+ /** Token 面板:会话累计(DSH 记录)、构成、上下文窗口、每回合用量。 */
710
+ function tokenPanel(c) {
711
+ const p = h('section', 'panel');
712
+ p.append(h('h3', null, L.tokTitle));
713
+ const k = c.tokens;
714
+ if (!k) {
715
+ p.append(h('div', 'muted', L.tokNoData));
716
+ return p;
717
+ }
718
+ const big = h('div', 'tok-big');
719
+ const cell = (v, label) => { const d = h('div'); d.append(h('b', null, v), h('span', null, label)); big.append(d); };
720
+ cell(ftok(tin(k)), L.tIn);
721
+ cell(ftok(k.output), L.tOut);
722
+ const hr = hit(k);
723
+ cell(hr == null ? '—' : Math.round(hr * 100) + '%', L.tHit);
724
+ p.append(big);
725
+
726
+ // 构成:未命中缓存的输入 / 缓存读 / 缓存写 / 输出
727
+ const parts = [
728
+ ['c-unc', L.tUncached, k.input],
729
+ ['c-read', L.tCacheRead, k.cache_read],
730
+ ['c-write', L.tCacheWrite, k.cache_write],
731
+ ['c-out', L.tOut, k.output],
732
+ ];
733
+ const sum = parts.reduce((n, x) => n + x[2], 0) || 1;
734
+ const stack = h('div', 'stack');
735
+ stack.setAttribute('role', 'img');
736
+ stack.setAttribute('aria-label', parts.map((x) => x[1] + ' ' + x[2]).join(','));
737
+ for (const [cls, label, v] of parts) {
738
+ if (!v) continue;
739
+ const i = h('i', cls);
740
+ i.style.width = (v / sum) * 100 + '%';
741
+ i.title = label + ' ' + v.toLocaleString();
742
+ stack.append(i);
743
+ }
744
+ p.append(stack);
745
+ const lg = h('div', 'legend');
746
+ for (const [cls, label, v] of parts) {
747
+ const s = h('span');
748
+ s.append(h('i', cls), label, h('em', null, ftok(v)));
749
+ lg.append(s);
750
+ }
751
+ p.append(lg);
752
+
753
+ if (c.context && c.context.window) {
754
+ const pct = Math.min(100, (c.context.used / c.context.window) * 100);
755
+ const hh = h('div', 'ctx-h');
756
+ hh.append(h('span', null, L.ctxWindow), h('span', null, ftok(c.context.used) + ' / ' + ftok(c.context.window) + L.pctParen(pct < 1 ? pct.toFixed(1) : Math.round(pct))));
757
+ p.append(hh);
758
+ const bar = h('div', 'ctx' + (pct > 85 ? ' crit' : pct > 60 ? ' hi' : ''));
759
+ const b = h('b'); b.style.width = Math.max(1, pct) + '%'; bar.append(b);
760
+ p.append(bar);
761
+ }
762
+
763
+ const runs = c.runs.filter((r) => r.tokens).slice(-16);
764
+ if (runs.length) {
765
+ const per = runs.map((r) => tin(r.tokens) + r.tokens.output);
766
+ const max = Math.max(...per) || 1;
767
+ const sh = h('div', 'spark-h');
768
+ sh.append(h('span', null, L.perRun), h('span', null, L.lastN(runs.length, ftok(max))));
769
+ p.append(sh);
770
+ const sp = h('div', 'spark');
771
+ runs.forEach((r, i) => {
772
+ const bar = h('i', r.status === 'running' ? 'cur' : '');
773
+ bar.style.height = Math.max(6, (per[i] / max) * 100) + '%';
774
+ bar.title = (r.turn ? L.turnN(r.turn) : '') + r.run + L.barTail(ftok(tin(r.tokens)), ftok(r.tokens.output));
775
+ sp.append(bar);
776
+ });
777
+ p.append(sp);
778
+ }
779
+ p.append(h('div', 'foot', L.tokFoot));
780
+ return p;
781
+ }
782
+
783
+ /** 读写类工具:用来从输入预览里认出"涉及的文件"。 */
784
+ // 只认真正读文件的工具;grep / glob / search / ls 的输入是目录或模式,不算"涉及的文件"。
785
+ const READ_TOOLS = /^(read|view|cat|open)/i;
786
+ const WRITE_TOOLS = /^(write|edit|create|patch|apply|replace|delete|move|rename|mkdir)/i;
787
+ const looksLikePath = (s) => !!s && !/\s/.test(s) && (/[\\/]/.test(s) || /\.[A-Za-z0-9]{1,8}$/.test(s));
788
+
789
+ /** 右侧检查器:这个会话的总量、工具分布、涉及文件、审批。 */
790
+ function inspector(c) {
791
+ const box = h('aside', 'inspector');
792
+ const tools = [];
793
+ const approvals = [];
794
+ for (const r of c.runs) for (const it of r.items) {
795
+ if (it.kind === 'tool') tools.push(it);
796
+ else if (it.kind === 'approval') approvals.push(it);
797
+ }
798
+ const failed = tools.filter((x) => x.status === 'failed').length;
799
+ const toolMs = tools.reduce((n, x) => n + (x.elapsed_ms || 0), 0);
800
+ const esc = approvals.filter((x) => x.escalation).length;
801
+
802
+ const ov = h('section', 'panel');
803
+ ov.append(h('h3', null, L.overview));
804
+ const kpis = h('div', 'kpis');
805
+ const kpi = (v, label, cls) => {
806
+ const k = h('div', 'kpi ' + (v && v !== '0' ? cls || '' : 'zero'));
807
+ k.append(h('b', null, String(v)), h('span', null, label));
808
+ kpis.append(k);
809
+ };
810
+ kpi(c.runs.length, L.kRuns);
811
+ kpi(tools.length, L.kToolCalls);
812
+ kpi(failed, L.kFailed, 'bad');
813
+ kpi(toolMs ? dur(toolMs) : '0', L.kToolTime);
814
+ kpi(approvals.length, L.kApprovals, 'warn');
815
+ kpi(esc, L.kEscalations, 'warn');
816
+ ov.append(kpis);
817
+ box.append(ov);
818
+ box.append(tokenPanel(c));
819
+
820
+ const byName = new Map();
821
+ for (const x of tools) {
822
+ const k = x.name || x.title || 'tool';
823
+ const e = byName.get(k) || { n: 0, ms: 0, fail: 0 };
824
+ e.n++; e.ms += x.elapsed_ms || 0; if (x.status === 'failed') e.fail++;
825
+ byName.set(k, e);
826
+ }
827
+ const dist = h('section', 'panel');
828
+ dist.append(h('h3', null, L.toolDist));
829
+ if (!byName.size) dist.append(h('div', 'muted', L.noTools));
830
+ else {
831
+ const rows = [...byName.entries()].sort((a, b) => b[1].n - a[1].n || b[1].ms - a[1].ms);
832
+ const max = rows[0][1].n;
833
+ const list = h('div', 'dist');
834
+ for (const [name, e] of rows.slice(0, 8)) {
835
+ const row = h('div', 'dist-row');
836
+ row.append(h('span', 'n', name));
837
+ row.append(h('span', 'v', '×' + e.n + (e.ms ? ' · ' + dur(e.ms) : '') + (e.fail ? L.failN(e.fail) : '')));
838
+ const bar = h('span', 'bar'); const b = h('b'); b.style.width = Math.max(4, (e.n / max) * 100) + '%'; bar.append(b); row.append(bar);
839
+ list.append(row);
840
+ }
841
+ dist.append(list);
842
+ }
843
+ box.append(dist);
844
+
845
+ const files = new Map();
846
+ for (const x of tools) {
847
+ const name = x.name || x.title || '';
848
+ const p = x.input_preview && x.input_preview.split(' · ')[0];
849
+ if (!looksLikePath(p)) continue;
850
+ const op = WRITE_TOOLS.test(name) ? 'w' : READ_TOOLS.test(name) ? 'r' : null;
851
+ if (!op) continue;
852
+ const key = rel(p, c.cwd) || p;
853
+ const prev = files.get(key);
854
+ files.set(key, prev === 'w' || op === 'w' ? 'w' : 'r');
855
+ }
856
+ const fp = h('section', 'panel');
857
+ fp.append(h('h3', null, L.files));
858
+ if (!files.size) fp.append(h('div', 'muted', L.noFiles));
859
+ else {
860
+ const ul = h('ul', 'files');
861
+ for (const [p, op] of [...files.entries()].slice(-14).reverse()) {
862
+ const li = h('li');
863
+ li.append(h('span', 'op ' + op, op === 'w' ? L.opW : L.opR));
864
+ const ps = h('span', 'p'); const bdi = document.createElement('bdi'); bdi.textContent = p; ps.append(bdi); ps.title = p;
865
+ li.append(ps);
866
+ ul.append(li);
867
+ }
868
+ fp.append(ul);
869
+ }
870
+ box.append(fp);
871
+
872
+ if (approvals.length) {
873
+ const ap = h('section', 'panel');
874
+ ap.append(h('h3', null, L.approvalsTitle));
875
+ const list = h('div', 'aps');
876
+ for (const a of approvals.slice(-6).reverse()) {
877
+ const d = h('div', 'ap');
878
+ const m = h('div', 'm', (MODE[a.mode] || a.mode || '') + (a.escalation ? ' · ⚠ ' + a.escalation : ''));
879
+ d.append(m);
880
+ const cmd = rel(a.command_preview || a.title || a.tool || '', c.cwd);
881
+ if (cmd) { const cc = h('div', 'c', cmd); cc.title = cmd; d.append(cc); }
882
+ if (a.decided) d.append(h('div', 'muted', '→ ' + (a.decided.decision || a.decided.effective || '') + (a.decided.by ? '(' + a.decided.by + ')' : '')));
883
+ list.append(d);
884
+ }
885
+ ap.append(list);
886
+ box.append(ap);
887
+ }
888
+ return box;
889
+ }
890
+
891
+ /**
892
+ * 每个回合工具列表的滚动状态(按 会话/回合 记):整块重建 DOM 后恢复位置。
893
+ * 停在底部的(或从没滚过的运行中回合)跟随最新工具;用户往上翻过就停在原处。
894
+ */
895
+ const scrollState = new Map();
896
+ let pendingScroll = [];
897
+ function updateEdges(spans, box) {
898
+ const max = spans.scrollHeight - spans.clientHeight;
899
+ box.classList.toggle('more-up', spans.scrollTop > 2);
900
+ box.classList.toggle('more-down', spans.scrollTop < max - 2);
901
+ const hiddenBelow = Math.max(0, Math.round((max - spans.scrollTop) / 32));
902
+ box.lastElementChild.textContent = hiddenBelow ? L.moreBelow(hiddenBelow) : '';
903
+ }
904
+ function restoreScroll() {
905
+ for (const { spans, box, key, running } of pendingScroll) {
906
+ const st = scrollState.get(key);
907
+ const max = spans.scrollHeight - spans.clientHeight;
908
+ if (st && !st.atBottom) spans.scrollTop = Math.min(st.top, max);
909
+ else if (st || running) spans.scrollTop = max;
910
+ updateEdges(spans, box);
911
+ spans.addEventListener('scroll', () => {
912
+ const m = spans.scrollHeight - spans.clientHeight;
913
+ scrollState.set(key, { top: spans.scrollTop, atBottom: spans.scrollTop >= m - 4 });
914
+ updateEdges(spans, box);
915
+ }, { passive: true });
916
+ }
917
+ pendingScroll = [];
918
+ }
919
+
505
920
  function render() {
506
921
  if (!view) return;
507
922
  const rt = $('rail').scrollTop, mt = $('main').scrollTop, wt = window.scrollY;
@@ -513,11 +928,20 @@ button { font: inherit; color: inherit; }
513
928
  }
514
929
  renderStats();
515
930
  renderRail();
931
+ pendingScroll = [];
516
932
  renderMain();
933
+ restoreScroll();
517
934
  $('rail').scrollTop = rt; $('main').scrollTop = mt; window.scrollTo(0, wt);
518
935
  }
519
936
 
520
937
  $('q').addEventListener('input', (e) => { q = e.target.value.trim().toLowerCase(); render(); });
938
+ $('lang-btn').addEventListener('click', () => {
939
+ const next = lang === 'zh' ? 'en' : 'zh';
940
+ store.set('dsh-live-lang', next);
941
+ applyLang(next);
942
+ render();
943
+ });
944
+ applyLang(lang);
521
945
 
522
946
  function setConn(on, text) {
523
947
  const c = $('conn');
@@ -525,9 +949,9 @@ button { font: inherit; color: inherit; }
525
949
  c.lastElementChild.textContent = text;
526
950
  }
527
951
  const es = new EventSource('/events?t=' + encodeURIComponent(token));
528
- es.onopen = () => setConn(true, '实时');
952
+ es.onopen = () => { connected = true; setConn(true, L.live); };
529
953
  es.onmessage = (e) => { try { view = JSON.parse(e.data); } catch { return; } render(); };
530
- es.onerror = () => setConn(false, '重连中');
954
+ es.onerror = () => { connected = false; setConn(false, L.reconnecting); };
531
955
  setInterval(render, 15000);
532
956
  })();
533
957
  </script>