dsh-remote-plugin 0.7.1 → 0.7.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.
package/public/app.js CHANGED
@@ -27,10 +27,15 @@ const CLIENT_ID = (() => {
27
27
  })()
28
28
  function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIENT_ID } : {} }
29
29
 
30
- /* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
30
+ /* 按连接身份隔离:本地缓存先显示,远端历史在后台校验与合并。 */
31
31
  const CACHE = {
32
- sessions: 'sessionsCacheV1',
33
- history: 'historyCacheV1'
32
+ get sessions() { return connectionCacheKey('sessions') },
33
+ get history() { return connectionCacheKey('history') }
34
+ }
35
+ function connectionCacheKey(kind) {
36
+ const hash = new SHA256()
37
+ hash.update(new TextEncoder().encode(JSON.stringify([state.server || location.origin, state.token])))
38
+ return kind + 'CacheV2:' + hash.hex()
34
39
  }
35
40
  function cacheRead(key, d = null) {
36
41
  try { return JSON.parse(LS.get(key, '')) || d } catch { return d }
@@ -38,18 +43,6 @@ function cacheRead(key, d = null) {
38
43
  function cacheWrite(key, value) {
39
44
  try { LS.set(key, JSON.stringify(value)) } catch { LS.del(key) }
40
45
  }
41
- function readHistoryCache() { return cacheRead(CACHE.history, {}) || {} }
42
- function writeHistoryCache(cache) {
43
- try { LS.set(CACHE.history, JSON.stringify(cache)); return }
44
- catch {
45
- // localStorage 配额不足: 每会话只留最近 50 条再试一次
46
- try {
47
- for (const k of Object.keys(cache)) cache[k].events = (cache[k].events || []).slice(-50)
48
- LS.set(CACHE.history, JSON.stringify(cache))
49
- } catch { LS.del(CACHE.history) }
50
- }
51
- }
52
-
53
46
  const state = {
54
47
  token: '',
55
48
  wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
@@ -112,6 +105,47 @@ const state = {
112
105
  subagentExpandedSession: ''
113
106
  }
114
107
 
108
+ // Increment on every identity transition, including A -> B -> A and token edits.
109
+ let connectionGeneration = 0, sessionsRequest = 0, filesRequest = 0, workbenchRequest = 0
110
+ for (const key of ['server', 'token']) {
111
+ let value = state[key]
112
+ Object.defineProperty(state, key, {
113
+ enumerable: true,
114
+ get: () => value,
115
+ set(next) {
116
+ if (next === value) return
117
+ value = next; connectionGeneration++
118
+ state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
119
+ state.hostInfo = null
120
+ if(typeof closeStream==='function'&&typeof streamMeta!=='undefined'){closeStream('mux');closeStream('host')}
121
+ state.current = null; state.history = emptyHistory()
122
+ state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
123
+ state.wb = null; state.wbProjects = []; state.wbArchived = []; state.workspaceFilter = ''; state.fs.workspaceId = ''; clearTimeout(scheduleHistoryCacheSave._t)
124
+ window.DshPluginCenter?.close()
125
+ window.DshInsights?.close()
126
+ if(typeof document!=='undefined') {
127
+ const identityGeneration=connectionGeneration
128
+ queueMicrotask(()=>{if(identityGeneration===connectionGeneration&&typeof startOverviewDetection==='function')startOverviewDetection(true,true)})
129
+ document.getElementById('history')?.replaceChildren?.()
130
+ const generation=connectionGeneration
131
+ queueMicrotask(()=>{if(generation!==connectionGeneration)return;if(typeof conversationView!=='undefined')conversationView?.reset();restoreCachedSessionList()})
132
+ }
133
+ if (typeof document !== 'undefined') {
134
+ document.getElementById('modal-workspace')?.classList.add('hidden')
135
+ const generation = connectionGeneration
136
+ queueMicrotask(() => {
137
+ if (generation === connectionGeneration && document.getElementById('view-plugins')?.classList.contains('hidden') === false) openPluginPage()
138
+ })
139
+ }
140
+ },
141
+ })
142
+ }
143
+ function captureConnection() {
144
+ const generation = connectionGeneration, server = state.server, token = state.token
145
+ return { server, token, valid: () => generation === connectionGeneration && server === state.server && token === state.token }
146
+ }
147
+
148
+
115
149
  const $ = (id) => document.getElementById(id)
116
150
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
117
151
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
@@ -294,6 +328,9 @@ function openFeedbackModal() {
294
328
  $('fb-msg').value = ''
295
329
  $('fb-contact').value = ''
296
330
  $('fb-include-diagnostics').checked = false
331
+ $('fb-include-crashes').checked = false
332
+ $('fb-crash-details').open = false
333
+ void loadCrashPreview()
297
334
  $('modal-feedback').classList.remove('hidden')
298
335
  setTimeout(() => $('fb-msg').focus(), 50)
299
336
  }
@@ -306,11 +343,22 @@ function closeFeedbackSuccess() {
306
343
  $('modal-feedback-success').classList.add('hidden')
307
344
  $('btn-feedback')?.focus()
308
345
  }
346
+ async function loadCrashPreview() {
347
+ const connection=captureConnection(),preview=$('fb-crash-preview')
348
+ preview.textContent=t('feedback.crashLoading')
349
+ try {
350
+ const response=await fetch(updateBase()+'/crash-logs',{headers:{authorization:'Bearer '+connection.token},signal:AbortSignal.timeout(5000),cache:'no-store'})
351
+ if(!response.ok)throw new Error('unavailable')
352
+ const data=await response.json()
353
+ if(connection.valid())preview.textContent=JSON.stringify(data.upload,null,2)
354
+ }catch{if(connection.valid())preview.textContent=t('feedback.crashUnavailable')}
355
+ }
309
356
  async function submitFeedback() {
310
357
  const type = state.feedbackType || 'bug'
311
358
  const message = $('fb-msg').value.trim()
312
359
  const contact = $('fb-contact').value.trim()
313
360
  const includeDiagnostics = $('fb-include-diagnostics').checked
361
+ const includeCrashLogs = $('fb-include-crashes').checked
314
362
  if (!message) { toast(t('feedback.empty'), 'err'); return }
315
363
  if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
316
364
  const btn = $('fb-submit')
@@ -321,7 +369,7 @@ async function submitFeedback() {
321
369
  const res = await fetch(base + '/feedback', {
322
370
  method: 'POST',
323
371
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
324
- body: JSON.stringify({ type, message, contact, appVersion: state.localVersion, includeDiagnostics })
372
+ body: JSON.stringify({ type, message, contact, appVersion: state.localVersion, includeDiagnostics, includeCrashLogs })
325
373
  })
326
374
  let json = {}
327
375
  try { json = await res.json() } catch {}
@@ -437,160 +485,6 @@ function bucketTokens(b) {
437
485
  return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
438
486
  }
439
487
 
440
- /* ---------------- Token 统计页 ---------------- */
441
- let statsChartMode = LS.get('statsChartModeV1', 'token') === 'cost' ? 'cost' : 'token'
442
-
443
- function applyStatsChartMode() {
444
- document.querySelectorAll('[data-stats-panel]').forEach(panel => {
445
- const active = panel.dataset.statsPanel === statsChartMode
446
- panel.classList.toggle('hidden', !active)
447
- panel.setAttribute('aria-hidden', active ? 'false' : 'true')
448
- })
449
- document.querySelectorAll('[data-stats-mode]').forEach(button => {
450
- const active = button.dataset.statsMode === statsChartMode
451
- button.classList.toggle('active', active)
452
- button.setAttribute('aria-pressed', active ? 'true' : 'false')
453
- })
454
- }
455
-
456
- function setStatsChartMode(mode) {
457
- if (mode !== 'token' && mode !== 'cost') return
458
- statsChartMode = mode
459
- LS.set('statsChartModeV1', mode)
460
- applyStatsChartMode()
461
- }
462
-
463
- async function loadStats() {
464
- const cards = $('stats-cards')
465
- const chart = $('stats-chart')
466
- const tokenChart = $('stats-token-chart')
467
- const note = $('stats-note')
468
- if (!state.token) {
469
- cards.innerHTML = ''
470
- chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
471
- tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
472
- note.textContent = ''
473
- $('stats-sub').textContent = ''
474
- $('stats-sub').textContent = ''
475
- $('stats-legend').innerHTML = ''
476
- $('stats-token-legend').innerHTML = ''
477
- return
478
- }
479
- try {
480
- const res = await fetch(apiUrl('/stats/summary?days=7'), {
481
- headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
482
- })
483
- if (res.status === 401) { authFailure(); return }
484
- if (!res.ok) throw new Error('HTTP ' + res.status)
485
- const json = await res.json()
486
- renderStats(json.days || [])
487
- } catch (e) {
488
- cards.innerHTML = ''
489
- chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
490
- tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
491
- note.textContent = ''
492
- $('stats-sub').textContent = ''
493
- $('stats-sub').textContent = ''
494
- $('stats-legend').innerHTML = ''
495
- $('stats-token-legend').innerHTML = ''
496
- }
497
- }
498
-
499
- function renderStats(days) {
500
- const cards = $('stats-cards')
501
- const chart = $('stats-chart')
502
- const tokenChart = $('stats-token-chart')
503
- const note = $('stats-note')
504
- if (!days.length) {
505
- cards.innerHTML = ''
506
- chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
507
- tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
508
- note.textContent = t('statsPage.note')
509
- $('stats-sub').textContent = ''
510
- $('stats-legend').innerHTML = ''
511
- $('stats-token-legend').innerHTML = ''
512
- return
513
- }
514
- const today = days[days.length - 1]
515
- const totalTokens = bucketTokens(today.total)
516
- const peakCost = today.peak.cost || 0
517
- const offCost = today.off.cost || 0
518
- const totalCost = peakCost + offCost
519
- const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
520
- const todayLabel = fmtStatsDate(today.date)
521
- const todayFullLabel = fmtStatsDate(today.date, true)
522
- cards.innerHTML = `
523
- <article class="scard scard-primary">
524
- <div class="scard-head">
525
- <div class="scard-label"><span class="scard-icon token" aria-hidden="true">↗</span><span>${t('statsPage.todayTokens')}</span></div>
526
- <span class="scard-badge">${esc(t('statsPage.today'))} · ${esc(todayLabel)}</span>
527
- </div>
528
- <div class="scard-value">${fmtTokens(totalTokens)}<span class="scard-unit">${esc(t('statsPage.tokenUnit'))}</span></div>
529
- <div class="scard-helper">${esc(t('statsPage.breakdown'))}</div>
530
- <div class="bucket-grid">
531
- <div class="b"><span class="bucket-dot input" aria-hidden="true"></span><span class="n">${t('statsPage.input')}</span><strong class="t">${fmtTokens(today.total.input)}</strong></div>
532
- <div class="b"><span class="bucket-dot cache-read" aria-hidden="true"></span><span class="n">${t('statsPage.cacheRead')}</span><strong class="t">${fmtTokens(today.total.cacheRead)}</strong></div>
533
- <div class="b"><span class="bucket-dot cache-write" aria-hidden="true"></span><span class="n">${t('statsPage.cacheWrite')}</span><strong class="t">${fmtTokens(today.total.cacheWrite)}</strong></div>
534
- <div class="b"><span class="bucket-dot output" aria-hidden="true"></span><span class="n">${t('statsPage.output')}</span><strong class="t">${fmtTokens(today.total.output)}</strong></div>
535
- </div>
536
- </article>
537
- <article class="scard scard-cost">
538
- <div class="scard-head">
539
- <div class="scard-label"><span class="scard-icon cost" aria-hidden="true">¥</span><span>${t('statsPage.todayCost')}</span></div>
540
- <span class="scard-badge">${esc(t('statsPage.estimated'))}</span>
541
- </div>
542
- <div class="scard-value">${fmtCost(totalCost)}</div>
543
- <div class="stats-split">
544
- <div class="stats-split-item"><span><i class="split-dot peak" aria-hidden="true"></i>${t('statsPage.peak')}</span><strong>${fmtCost(peakCost)}</strong></div>
545
- <div class="stats-split-item"><span><i class="split-dot off" aria-hidden="true"></i>${t('statsPage.off')}</span><strong>${fmtCost(offCost)}</strong></div>
546
- </div>
547
- </article>
548
- <article class="scard scard-share">
549
- <div class="scard-head">
550
- <div class="scard-label"><span class="scard-icon share" aria-hidden="true">%</span><span>${t('statsPage.peakShare')}</span></div>
551
- </div>
552
- <div class="share-layout">
553
- <div class="share-ring" style="--share:${peakShare}%" role="img" aria-label="${esc(t('statsPage.peakShareLabel', { n: peakShare }))}"><span>${peakShare}%</span></div>
554
- <div class="share-copy"><strong>${t('statsPage.peak')}</strong><span>${t('statsPage.days', { n: days.length })}</span><small>${esc(todayFullLabel)}</small></div>
555
- </div>
556
- </article>`
557
- $('stats-sub').textContent = todayFullLabel
558
- note.textContent = t('statsPage.note')
559
- $('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('statsPage.peak')}</span><span class="lg"><span class="sw off"></span>${t('statsPage.off')}</span>`
560
- $('stats-token-legend').innerHTML = `<span class="lg"><span class="sw token"></span>${t('statsPage.tokenTotal')}</span>`
561
- chart.setAttribute('aria-label', t('statsPage.costChartLabel'))
562
- tokenChart.setAttribute('aria-label', t('statsPage.tokenChartLabel'))
563
- const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
564
- chart.innerHTML = days.map(d => {
565
- const cost = d.total.cost || 0
566
- // 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
567
- const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
568
- const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
569
- const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
570
- const label = fmtStatsDate(d.date)
571
- const fullLabel = `${fmtStatsDate(d.date, true)} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · ${t('statsPage.tokenTotal')} ${fmtTokens(bucketTokens(d.total))}`
572
- return `<div class="stats-bar" role="listitem" title="${esc(fullLabel)}" aria-label="${esc(fullLabel)}">
573
- <div class="bars" style="height:${totalH}%">
574
- <div class="seg peak" style="height:${peakH}%"></div>
575
- <div class="seg off" style="height:${offH}%"></div>
576
- </div>
577
- <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
578
- <div class="lbl">${label}</div>
579
- </div>`
580
- }).join('')
581
- const maxTokens = Math.max(...days.map(d => bucketTokens(d.total)), 1)
582
- tokenChart.innerHTML = days.map(d => {
583
- const tokens = bucketTokens(d.total)
584
- const totalH = tokens > 0 ? Math.max(3, Math.round(tokens / maxTokens * 100)) : 0
585
- const label = fmtStatsDate(d.date)
586
- const tip = `${fmtStatsDate(d.date, true)} · ${t('statsPage.tokenTotal')} ${fmtTokens(tokens)} · ${t('statsPage.input')} ${fmtTokens(d.total.input)} · ${t('statsPage.output')} ${fmtTokens(d.total.output)}`
587
- return `<div class="stats-bar" role="listitem" title="${esc(tip)}" aria-label="${esc(tip)}">
588
- <div class="bars token" style="height:${totalH}%"></div>
589
- <div class="val">${tokens > 0 ? fmtTokens(tokens) : ''}</div>
590
- <div class="lbl">${label}</div>
591
- </div>`
592
- }).join('')
593
- }
594
488
  async function rpc(method, payload = {}, timeoutMs = 45000) {
595
489
  const opts = {
596
490
  method: 'POST',
@@ -630,8 +524,10 @@ async function respond(rpcId, value) {
630
524
  }
631
525
 
632
526
  async function safeRpc(method, payload, errText) {
527
+ const connection = captureConnection()
633
528
  try { return await rpc(method, payload) }
634
529
  catch (e) {
530
+ if (!connection.valid()) return null
635
531
  if (e.message === 'AUTH') authFailure()
636
532
  else toast(errText ? `${errText}:${e.message}` : e.message, 'err')
637
533
  return null
@@ -639,6 +535,7 @@ async function safeRpc(method, payload, errText) {
639
535
  }
640
536
 
641
537
  let hostDescribePromise = null
538
+ let hostDescribeConnection = null
642
539
  let hostDescribeRetryTimer = null
643
540
  let hostDescribeFailures = 0
644
541
 
@@ -654,12 +551,13 @@ function scheduleHostDescribeRetry() {
654
551
 
655
552
  async function refreshHostDescription({ notify = false } = {}) {
656
553
  if (!state.token) return null
657
- if (hostDescribePromise) return hostDescribePromise
658
- const server = state.server
659
- hostDescribePromise = (async () => {
554
+ if (hostDescribePromise && hostDescribeConnection?.valid()) return hostDescribePromise
555
+ const connection = captureConnection()
556
+ hostDescribeConnection = connection
557
+ const job = (async () => {
660
558
  try {
661
559
  const host = await rpc('host.describe', {}, 5000)
662
- if (server !== state.server) return null
560
+ if (!connection.valid()) return null
663
561
  state.hostInfo = host
664
562
  const health = activeGatewayHealth()
665
563
  if (health) health.upstreamReachable = true
@@ -671,7 +569,7 @@ async function refreshHostDescription({ notify = false } = {}) {
671
569
  renderOverview()
672
570
  return host
673
571
  } catch (error) {
674
- if (server !== state.server) return null
572
+ if (!connection.valid()) return null
675
573
  if (error.message === 'AUTH') authFailure()
676
574
  else {
677
575
  hostDescribeFailures++
@@ -680,11 +578,11 @@ async function refreshHostDescription({ notify = false } = {}) {
680
578
  }
681
579
  return null
682
580
  } finally {
683
- hostDescribePromise = null
684
- if (server !== state.server) scheduleHostDescribeRetry()
581
+ if (hostDescribePromise === job) { hostDescribePromise = null; hostDescribeConnection = null }
685
582
  }
686
583
  })()
687
- return hostDescribePromise
584
+ hostDescribePromise = job
585
+ return job
688
586
  }
689
587
 
690
588
  function authFailure() {
@@ -1325,12 +1223,12 @@ function openStreams() {
1325
1223
  function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1326
1224
  if (!state.token) return
1327
1225
  if (ticket === null) {
1328
- const token = state.token
1226
+ const token = state.token, connection=captureConnection()
1329
1227
  void getWsTicket().then((value) => {
1330
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
1228
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, value)
1331
1229
  }).catch(() => {
1332
1230
  // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
1333
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
1231
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, '')
1334
1232
  })
1335
1233
  return
1336
1234
  }
@@ -1535,13 +1433,13 @@ document.addEventListener('visibilitychange', () => {
1535
1433
  if (document.visibilityState === 'visible') {
1536
1434
  onResume()
1537
1435
  if (state.servers.length) selectFastestServer({ silent: true })
1538
- else if (state.token && (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) openStreams()
1436
+ else if (state.token) repairMissingStreams()
1539
1437
  }
1540
1438
  })
1541
1439
  window.addEventListener('pageshow', onResume)
1542
1440
  setInterval(() => {
1543
1441
  if (document.visibilityState === 'visible' && state.token) {
1544
- if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
1442
+ repairMissingStreams()
1545
1443
  }
1546
1444
  }, 15000)
1547
1445
  // 多服务器: 每 5 分钟重测一次延迟, 网络环境变化(离开 Wi-Fi / 挂上 Tailscale)时自动换线
@@ -1743,7 +1641,10 @@ async function refreshAll() {
1743
1641
  }
1744
1642
 
1745
1643
  async function refreshSessions() {
1644
+ if(typeof restoreCachedSessionList==='function')restoreCachedSessionList()
1645
+ const connection = captureConnection(), request = ++sessionsRequest
1746
1646
  const v = await safeRpc('session.list', {}, t('err.sessionList'))
1647
+ if (!connection.valid() || request !== sessionsRequest) return
1747
1648
  if (!v) {
1748
1649
  // 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
1749
1650
  if (!state.sessions.length) {
@@ -1767,6 +1668,7 @@ async function refreshSessions() {
1767
1668
 
1768
1669
  function removeLocalSessionRecord(sessionId) {
1769
1670
  if (!sessionId) return
1671
+ void window.DshHistory.cacheRemove(historyCacheScope(),sessionId)
1770
1672
  state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
1771
1673
  state.byId.delete(sessionId)
1772
1674
  state.pendingProjections.delete(sessionId)
@@ -1774,15 +1676,15 @@ function removeLocalSessionRecord(sessionId) {
1774
1676
  state.pendingPrompts.delete(sessionId)
1775
1677
  delete state.queues[sessionId]
1776
1678
  delete state.jobs[sessionId]
1777
- const historyCache = readHistoryCache()
1778
- if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
1779
- delete historyCache[sessionId]
1780
- writeHistoryCache(historyCache)
1781
- }
1679
+ LS.del(historyCacheScope()) // Also discard the obsolete monolithic cache.
1782
1680
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1783
1681
  }
1784
1682
 
1785
1683
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1684
+ function openInsights(tab = 'context') {
1685
+ window.DshInsights?.open({ tab, connection: captureConnection(), url: apiUrl,
1686
+ getSession: () => state.byId.get(state.current), getTitle: () => sessionTitleValue(state.byId.get(state.current)) })
1687
+ }
1786
1688
  function hydrateSessionProjections(sessionId, projections) {
1787
1689
  const s = state.byId.get(sessionId)
1788
1690
  if (!s || !projections || typeof projections !== 'object') return
@@ -1858,7 +1760,11 @@ function short(id) { return '…' + String(id).slice(-8) }
1858
1760
  function isTopLevelSession(session) {
1859
1761
  return !!session && !session.parentSessionId && session.origin !== 'subagent'
1860
1762
  }
1861
- function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1763
+ // DSH marks sessions created via "new session" but never prompted as blank and hides
1764
+ // them in its own UI; keep the same semantics here so they do not occupy the list,
1765
+ // the workspace tree or the home stats.
1766
+ function isVisibleSession(session) { return isTopLevelSession(session) && !session?.blank }
1767
+ function topLevelSessions() { return state.sessions.filter(isVisibleSession) }
1862
1768
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1863
1769
  function isGoalTerminal(goal) {
1864
1770
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -2046,21 +1952,28 @@ function renderWorkspaceNavigation() {
2046
1952
  return items
2047
1953
  }
2048
1954
  async function refreshWorkbench() {
1955
+ const connection = captureConnection(), request = ++workbenchRequest
1956
+ const valid = () => connection.valid() && request === workbenchRequest
2049
1957
  if (!state.token) return
2050
1958
  try {
2051
1959
  const res = await fetch(apiUrl('/workbench'), {
2052
1960
  headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
2053
1961
  })
1962
+ if (!valid()) return
2054
1963
  if (res.ok) {
2055
1964
  const value = await res.json().catch(() => null)
1965
+ if (!valid()) return
2056
1966
  if (value && typeof value.bound === 'boolean') state.wb = value
2057
1967
  }
2058
1968
  } catch {}
1969
+ if (!valid()) return
2059
1970
  try {
2060
1971
  const value = await rpc('workspace.list', {})
1972
+ if (!valid()) return
2061
1973
  state.wbProjects = Array.isArray(value?.items) ? value.items : []
2062
1974
  state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
2063
1975
  } catch {
1976
+ if (!valid()) return
2064
1977
  state.wbProjects = []
2065
1978
  state.wbArchived = []
2066
1979
  }
@@ -2068,8 +1981,10 @@ async function refreshWorkbench() {
2068
1981
  if (state.wb?.bound && state.wb.path) {
2069
1982
  try {
2070
1983
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1984
+ if (!valid()) return
2071
1985
  if (listRes.ok) {
2072
1986
  const listData = await listRes.json().catch(() => ({}))
1987
+ if (!valid()) return
2073
1988
  if (Array.isArray(listData.entries)) {
2074
1989
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
2075
1990
  // 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
@@ -2080,14 +1995,18 @@ async function refreshWorkbench() {
2080
1995
  if (entry.type !== 'dir') continue
2081
1996
  const projectPath = wbJoin(state.wb.path, entry.name)
2082
1997
  if (have.has(wbPathKey(projectPath))) continue
1998
+ if (!valid()) return
2083
1999
  try {
2084
2000
  const created = await rpc('workspace.create', { path: projectPath })
2001
+ if (!valid()) return
2085
2002
  if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
2086
2003
  } catch {}
2004
+ if (!valid()) return
2087
2005
  }
2088
2006
  }
2089
2007
  }
2090
2008
  } catch {}
2009
+ if (!valid()) return
2091
2010
  }
2092
2011
  renderWorkspaceNavigation()
2093
2012
  renderWorkbench()
@@ -2121,7 +2040,7 @@ function renderWorkbench() {
2121
2040
  const projectHtml = projects.map(w => {
2122
2041
  const id = String(w.workspaceId || '')
2123
2042
  const open = !!state.wbOpenProjects[id]
2124
- const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
2043
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isVisibleSession).filter(s => !archivedSet.has(s.sessionId)))
2125
2044
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
2126
2045
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
2127
2046
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
@@ -2292,9 +2211,9 @@ function renderSessions() {
2292
2211
 
2293
2212
  /* ---------------- 会话详情 ---------------- */
2294
2213
  const emptySessionCleanup = new Set()
2295
- async function archiveEmptySessionOnLeave(sessionId) {
2296
- const base = state.server
2297
- const protectedSession = () => state.server !== base || state.current !== sessionId
2214
+ async function archiveEmptySessionOnLeave(sessionId, leaving = false) {
2215
+ const base = state.server, connection=captureConnection()
2216
+ const protectedSession = () => !connection.valid() || state.server !== base || (!leaving && state.current !== sessionId)
2298
2217
  || !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
2299
2218
  || state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
2300
2219
  || (state.queues[sessionId] || []).length > 0
@@ -2315,10 +2234,11 @@ async function archiveEmptySessionOnLeave(sessionId) {
2315
2234
  }
2316
2235
 
2317
2236
  async function openSession(id) {
2318
- if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
2237
+ if (state.current && state.current !== id) void archiveEmptySessionOnLeave(state.current,true)
2319
2238
  state.current = id
2320
2239
  setSessionRecovery('loading')
2321
2240
  state.history = emptyHistory()
2241
+ const openingHistory=state.history, openingConnection=captureConnection()
2322
2242
  document.body.classList.add('in-session')
2323
2243
  showView('view-session')
2324
2244
  $('btn-rename-session').classList.remove('hidden')
@@ -2329,7 +2249,9 @@ async function openSession(id) {
2329
2249
  renderQueue()
2330
2250
  renderSessionPending()
2331
2251
  void refreshCompactionStatus(id)
2332
- restoreCachedHistory()
2252
+ getConversationView().reset()
2253
+ await restoreCachedHistory()
2254
+ if(state.current!==id||state.history!==openingHistory||!openingConnection.valid())return
2333
2255
  await loadHistory(true)
2334
2256
  renderSessionCards()
2335
2257
  refreshSessions()
@@ -2408,13 +2330,10 @@ function renderSessionTitle() {
2408
2330
  function renderSessionSub() {
2409
2331
  const s = state.byId.get(state.current)
2410
2332
  if (!s) { $('session-sub').textContent = ''; return }
2411
- const parts = [short(s.sessionId)]
2412
- if (s.cwd) parts.push(s.cwd)
2413
- if (s.running) parts.push(t('session.running'))
2414
- else if (s.error) parts.push(t('session.interrupted'))
2415
- const recovery = recoveryLabel()
2416
- if (recovery) parts.push(recovery)
2417
- $('session-sub').textContent = parts.join(' · ')
2333
+ const workspace = s.cwd || ''
2334
+ const name = workspace.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || workspace
2335
+ $('session-sub').textContent = '📁 ' + (name || t('session.noWorkspace')) + ' ›'
2336
+ $('session-sub').title = workspace || t('session.noWorkspace')
2418
2337
  }
2419
2338
 
2420
2339
  /** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
@@ -2426,7 +2345,8 @@ function updateSessionStatus() {
2426
2345
  const composerStatus = $('composer-status')
2427
2346
  const compact = activeCompaction()
2428
2347
  if (composerStatus) {
2429
- composerStatus.classList.toggle('hidden', !s?.running && !compact)
2348
+ const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
2349
+ composerStatus.classList.toggle('hidden', !s?.running && !compact && !queued)
2430
2350
  composerStatus.classList.toggle('compacting', !!compact)
2431
2351
  }
2432
2352
  const composerText = $('composer-status-text')
@@ -2445,6 +2365,7 @@ function updateCancelBtn() {
2445
2365
  const s = state.byId.get(state.current)
2446
2366
  const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
2447
2367
  $('btn-cancel').classList.toggle('hidden', !running)
2368
+ $('composer-status')?.classList.toggle('hidden', !running && !activeCompaction())
2448
2369
  }
2449
2370
 
2450
2371
  const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/工具/状态, 不含 chunk)
@@ -2539,135 +2460,102 @@ function partialReasoningHtml() {
2539
2460
  .join('')
2540
2461
  }
2541
2462
 
2542
- function trimVisible() {
2543
- const h = state.history
2544
- if (h.visible.length <= HISTORY_MAX_VISIBLE) return
2545
- const drop = h.visible.splice(0, h.visible.length - HISTORY_MAX_VISIBLE)
2546
- for (const e of drop) h.seqs.delete(e.seq)
2547
- h.renderStart = Math.max(0, h.renderStart - drop.length)
2548
- h.renderEnd = Math.max(h.renderStart, h.renderEnd - drop.length)
2463
+
2464
+ function restoreCachedSessionList() {
2465
+ if(state.sessions.length)return
2466
+ try{const items=JSON.parse(LS.get(CACHE.sessions,''));if(!Array.isArray(items))return;state.sessions=items.filter(item=>typeof item?.sessionId==='string').slice(0,80);state.byId=new Map(state.sessions.map(item=>[item.sessionId,item]));renderSessions()}catch{}
2549
2467
  }
2550
2468
 
2551
- /* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
2469
+ let conversationView = null
2470
+ function historyCacheScope() { return CACHE.history }
2471
+ function historySource(value) {
2472
+ state.history.source=value
2473
+ const el=$('history-source')
2474
+ if(el)el.textContent=t(({cache:'history.cached',syncing:'history.syncing',synced:'history.synced',saved:'history.saved',offline:'history.offline',saveFailed:'history.cacheFailed'})[value]||'history.synced')
2475
+ }
2476
+ function getConversationView() {
2477
+ if(!conversationView)conversationView=new window.DshHistory.HistoryView($('history'),{
2478
+ html:entry=>eventHtml(entry, {toolNames:state.history.toolNames}),
2479
+ older:()=>loadHistory(false),olderLabel:()=>t('history.earlier'),loadingLabel:()=>t('history.loadingEarlier'),live:partialReasoningHtml,
2480
+ changed:view=>{state.history.renderStart=view.start;state.history.renderEnd=view.end;$('btn-history-latest')?.classList.toggle('hidden',!view.reading&&view.end>=view.entries.length&&!state.history.tailEvicted);updateRail(view.domChanged)},
2481
+ scrolled:()=>{updateRail()}
2482
+ })
2483
+ return conversationView
2484
+ }
2552
2485
  function scheduleHistoryCacheSave() {
2553
2486
  clearTimeout(scheduleHistoryCacheSave._t)
2554
- scheduleHistoryCacheSave._t = setTimeout(saveHistoryCache, 400)
2555
- }
2556
-
2557
- function saveHistoryCache() {
2558
- const id = state.current
2559
- if (!id || !state.history.visible.length) return
2560
- const s = state.byId.get(id)
2561
- const cache = readHistoryCache()
2562
- cache[id] = {
2563
- title: s ? titleOf(s) : '',
2564
- updatedAt: Date.now(),
2565
- events: state.history.visible.slice(-250).map(e => ({ seq: e.seq, event: e.event }))
2566
- }
2567
- const keys = Object.entries(cache)
2568
- .sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
2569
- .slice(0, 10)
2570
- .map(([k]) => k)
2571
- const pruned = {}
2572
- for (const k of keys) pruned[k] = cache[k]
2573
- writeHistoryCache(pruned)
2574
- }
2575
-
2576
- /** 网关不可达时回填本地缓存的历史; 返回是否命中。 */
2577
- function restoreCachedHistory() {
2578
- const id = state.current
2579
- if (!id) return false
2580
- const cached = readHistoryCache()[id]
2581
- if (!cached?.events?.length) return false
2582
- if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
2583
- const h = emptyHistory()
2584
- for (const e of cached.events) {
2585
- if (e?.seq == null) continue
2586
- h.seqs.add(e.seq)
2587
- h.visible.push(e)
2588
- }
2589
- h.visible.sort((a, b) => a.seq - b.seq)
2590
- h.loaded = true
2591
- state.history = h
2592
- $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
2593
- renderHistory(true)
2487
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
2488
+ scheduleHistoryCacheSave._t=setTimeout(()=>{if(connection.valid()&&state.current===id&&state.history===history)void saveHistoryCache(scope,id,history)},500)
2489
+ }
2490
+ async function saveHistoryCache(scope=historyCacheScope(),id=state.current,history=state.history) {
2491
+ if(!id||!history.visible.length||history.tailEvicted)return
2492
+ const saved=await window.DshHistory.cacheSave(scope,id,{title:titleOf(state.byId.get(id)),events:history.visible.slice(-250),minSeq:history.visible.slice(-250)[0]?.seq??history.minSeq,hasMore:history.hasMore||history.visible.length>250,partialReasoning:[...history.partialReasoning.values()]})
2493
+ if(!saved&&state.current===id&&state.history===history&&scope===historyCacheScope())historySource('saveFailed')
2494
+ if(saved&&state.current===id&&state.history===history&&scope===historyCacheScope()&&['synced','saveFailed'].includes(history.source))historySource('saved')
2495
+ }
2496
+ async function restoreCachedHistory() {
2497
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
2498
+ if(!id)return false
2499
+ const cached=await window.DshHistory.cacheLoad(scope,id,legacyId=>window.DshHistory.decodeLegacy(LS.get(scope,''),legacyId))
2500
+ if(!connection.valid()||state.current!==id||state.history!==history||!cached?.events?.length)return false
2501
+ history.visible=cached.events.slice(-250).filter(entry=>entry?.seq!=null&&shouldShowEvent(entry.event?.type,entry.event)).sort((a,b)=>a.seq-b.seq)
2502
+ history.seqs=new Set(history.visible.map(entry=>entry.seq));history.loaded=true;history.cached=true;history.hasMore=cached.events.length>250||cached.hasMore!==false;history.minSeq=cached.events.length>250?(history.visible[0]?.seq??Infinity):Number.isFinite(cached.minSeq)?cached.minSeq:history.visible[0]?.seq??Infinity
2503
+ if(cached.title&&!hasSessionTitle(state.byId.get(id)))hydrateSessionProjections(id,{values:{title:cached.title},asOfSeq:0})
2504
+ applyReasoningBaseline(cached.partialReasoning||[]);historySource('cache');setSessionRecovery('cached');renderHistory(true)
2594
2505
  return true
2595
2506
  }
2596
-
2597
- async function loadHistory(reset) {
2598
- const id = state.current
2599
- if (!id || state.history.loading) return
2600
- const history = state.history
2601
- const reasoningVersion = history.reasoningVersion || 0
2602
- history.loading = true
2603
- if (reset) setSessionRecovery('loading')
2604
- const moreBtn = $('history-more')
2605
- if (moreBtn) moreBtn.classList.add('hidden')
2606
- const payload = { sessionId: id, maxMessages: 60 }
2607
- if (!reset && state.history.minSeq !== Infinity) payload.beforeSeq = state.history.minSeq
2608
-
2507
+ function trimVisible(direction='tail') {
2508
+ const h=state.history,excess=h.visible.length-5000
2509
+ if(excess<=0)return
2510
+ const older=direction==='older'||conversationView?.reading
2511
+ const removed=older?h.visible.splice(5000):h.visible.splice(0,excess)
2512
+ for(const entry of removed)h.seqs.delete(entry.seq)
2513
+ if(older)h.tailEvicted=true
2514
+ else{h.minSeq=h.visible[0]?.seq??Infinity;h.headEvicted=true;h.hasMore=true}
2515
+ }
2516
+
2517
+ async function loadHistory(reset = true) {
2518
+ const connection=captureConnection(),id=state.current,history=state.history
2519
+ if(!id||history.loading||(!reset&&Date.now()<(history.retryAfter||0)))return
2520
+ const reasoningVersion=history.reasoningVersion||0,hadContent=history.visible.length>0
2521
+ history.loading=true
2522
+ if(reset){if(hadContent)historySource('syncing');else setSessionRecovery('loading')}
2523
+ const payload={sessionId:id,maxMessages:60}
2524
+ if(!reset&&Number.isFinite(history.minSeq))payload.beforeSeq=history.minSeq
2525
+ getConversationView().more.disabled=true
2526
+ getConversationView().more.textContent=getConversationView().options.loadingLabel()
2609
2527
  let v
2610
- try {
2611
- v = await rpc('session.history', payload)
2612
- } catch (e) {
2613
- if (state.current !== id || state.history !== history) return
2614
- history.loading = false
2615
- if (e.message === 'AUTH') { authFailure(); return }
2616
- if (restoreCachedHistory()) {
2617
- setSessionRecovery('cached', e.message)
2618
- toast(t('history.cacheFallback'), 'ok')
2619
- return
2620
- }
2621
- const msg = e.message || t('err.dshError')
2622
- setSessionRecovery('error', msg)
2623
- const box = $('history')
2624
- if (box && (reset || !state.history.visible.length)) {
2625
- box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
2626
- const retry = $('btn-history-retry')
2627
- if (retry) retry.addEventListener('click', () => loadHistory(true))
2628
- } else {
2629
- toast(t('history.loadFailed', { msg }), 'err')
2630
- }
2528
+ try{v=await rpc('session.history',payload)}catch(error){
2529
+ if(!connection.valid()||state.current!==id||state.history!==history)return
2530
+ history.loading=false;history.retryAfter=Date.now()+1500
2531
+ if(error.message==='AUTH'){authFailure();return}
2532
+ if(hadContent){historySource('offline');setSessionRecovery('cached',error.message);renderHistory(false,'fixed');return}
2533
+ setSessionRecovery('error',error.message)
2534
+ $('history').innerHTML='<div class="history-empty">'+esc(error.message)+'</div>'
2535
+ const retry=document.createElement('button');retry.type='button';retry.className='history-page-control';retry.textContent=t('history.retry');retry.addEventListener('click',()=>{void loadHistory(true)});$('history').append(retry)
2631
2536
  return
2632
2537
  }
2633
-
2634
- if (state.current !== id || state.history !== history) return
2635
- const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
2636
- hydrateSessionProjections(id, v.projections)
2637
- history.loaded = true
2638
- const incoming = v.events || []
2639
- let added = 0
2640
- if (reset) state.history.partialReasoning.clear()
2538
+ if(!connection.valid()||state.current!==id||state.history!==history)return
2539
+ const liveReasoning=(history.reasoningVersion||0)!==reasoningVersion?new Map(history.partialReasoning):null
2540
+ hydrateSessionProjections(id,v.projections);history.loaded=true
2541
+ const incoming=v.events||[],prepared=[]
2641
2542
  for (const entry of incoming) {
2642
- const ev = entry?.event
2643
- const seq = ev?.seq
2644
- if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
2543
+ const ev=entry?.event
2645
2544
  applyReasoningStreamEvent(ev)
2646
- if (seq == null || state.history.seqs.has(seq)) continue
2647
- if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
2648
- state.history.seqs.add(seq)
2649
- state.history.visible.push({ seq, event: ev, view: entry.view })
2650
- added++
2651
- }
2652
- // 向前翻页游标 = 本页最旧的 raw seq(即使它本身被过滤)
2653
- if (liveReasoning) history.partialReasoning = liveReasoning
2654
- else applyReasoningBaseline(v.partialReasoning)
2655
- const firstSeq = incoming[0]?.event?.seq
2656
- if (firstSeq != null) state.history.minSeq = Math.min(state.history.minSeq, firstSeq)
2657
- state.history.visible.sort((a, b) => a.seq - b.seq)
2658
- trimVisible()
2659
- state.history.hasMore = !!v.hasMore
2660
- history.loading = false
2661
- setSessionRecovery('ready')
2662
- renderSessionTitle(); renderSessionSub(); renderSessionCards()
2663
- try {
2664
- if (reset) renderHistory(true)
2665
- else if (added) renderHistory(false, 'keep')
2666
- } catch (e) {
2667
- console.error('renderHistory failed', e)
2668
- }
2669
- if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
2670
- $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
2545
+ if(ev?.type==='turn/start'||ev?.type==='turn/end')noteSessionTurnTime(id,ev)
2546
+ if(ev?.seq==null||!shouldShowEvent(ev.type,ev))continue
2547
+ prepared.push({seq:ev.seq,event:ev,view:entry.view})
2548
+ }
2549
+ const rawSeqs=incoming.map(entry=>entry?.event?.seq).filter(Number.isFinite),floor=rawSeqs.length?Math.min(...rawSeqs):Infinity
2550
+ if(reset){const retainedOlder=!!v.hasMore&&history.visible.some(entry=>entry.seq<floor);const ceiling=rawSeqs.length?Math.max(...rawSeqs)+1:Infinity;history.visible=rawSeqs.length?window.DshHistory.reconcile(history.visible,prepared,floor,!!v.hasMore,ceiling):[];history.minSeq=retainedOlder?Math.min(history.minSeq,floor):floor;history.tailEvicted=false}
2551
+ else{history.visible=window.DshHistory.reconcile(history.visible,prepared,floor,true,payload.beforeSeq);history.minSeq=Math.min(history.minSeq,floor)}
2552
+ history.seqs=new Set(history.visible.map(entry=>entry.seq));history.headEvicted=false;trimVisible(reset?'tail':'older')
2553
+ if(liveReasoning)history.partialReasoning=liveReasoning;else applyReasoningBaseline(v.partialReasoning)
2554
+ history.hasMore=(!!v.hasMore||(reset&&history.headEvicted)) && rawSeqs.length>0 && (reset || floor<payload.beforeSeq)
2555
+ history.loading=false;history.cached=false;historySource('synced');setSessionRecovery('ready')
2556
+ renderSessionTitle();renderSessionSub();renderSessionCards()
2557
+ renderHistory(!hadContent,reset?'fixed':'keep')
2558
+ $('history-hint').textContent=t('history.count',{n:history.visible.length})
2671
2559
  scheduleHistoryCacheSave()
2672
2560
  }
2673
2561
 
@@ -2687,18 +2575,7 @@ function insertLiveEvent(event) {
2687
2575
  h.visible.push({ seq, event })
2688
2576
  h.visible.sort((a, b) => a.seq - b.seq)
2689
2577
  trimVisible()
2690
- const box = $('history')
2691
- const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
2692
- if (nearBottom) {
2693
- h.renderEnd = h.visible.length
2694
- h.renderStart = Math.max(0, h.renderEnd - 200)
2695
- renderHistory(false, 'bottom')
2696
- } else {
2697
- // 图片异步撑高历史区后,用户可能瞬间不再满足 nearBottom。新回复仍须
2698
- // 扩展可见窗口,但保持当前阅读位置,不能等到重新进入会话才出现。
2699
- h.renderEnd = h.visible.length
2700
- renderHistory(false, 'fixed')
2701
- }
2578
+ renderHistory(false, 'fixed')
2702
2579
  scheduleHistoryCacheSave()
2703
2580
  }
2704
2581
 
@@ -2711,74 +2588,33 @@ function filteredEntries() {
2711
2588
  return f
2712
2589
  }
2713
2590
 
2714
- function renderHistory(reset, mode = 'bottom') {
2715
- const box = $('history')
2716
- const h = state.history
2717
- const filtered = filteredEntries()
2718
- const len = filtered.length
2719
- const reasoningHtml = partialReasoningHtml()
2720
- if (!len && !reasoningHtml) {
2721
- box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
2722
- h.renderStart = 0; h.renderEnd = 0
2723
- updateRail()
2724
- return
2725
- }
2726
- if (reset) {
2727
- h.renderEnd = len
2728
- h.renderStart = Math.max(0, len - 200)
2729
- }
2730
- const start = Math.min(h.renderStart, len)
2731
- const end = Math.min(h.renderEnd, len) || len
2732
- const oldH = box.scrollHeight
2733
- const oldTop = box.scrollTop
2734
- // callId → 工具名, 供 tool/result 折叠标题显示
2735
- const toolNames = new Map()
2736
- for (const e of state.history.visible) {
2737
- if (e.event?.type !== 'tool/call') continue
2738
- const d = e.event.data || {}
2739
- if (d.callId && d.name) toolNames.set(d.callId, d.name)
2740
- }
2741
- box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('') + reasoningHtml
2742
- if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
2743
- else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
2744
- else if (mode === 'fixed') box.scrollTop = oldTop
2745
- updateRail()
2591
+ function renderHistory(reset=false,mode='auto') {
2592
+ const filtered=filteredEntries(),toolNames=new Map()
2593
+ for(const entry of state.history.visible){const d=entry.event?.data;if(entry.event?.type==='tool/call'&&d?.callId&&d.name)toolNames.set(d.callId,d.name)}
2594
+ state.history.toolNames=toolNames
2595
+ $('history-hint').textContent=t('history.count',{n:state.history.visible.length})
2596
+ getConversationView().set(filtered,{reset,follow:mode==='bottom',live:partialReasoningHtml(),hasMore:state.history.hasMore,loading:state.history.loading,emptyLabel:t('history.empty')})
2746
2597
  }
2747
2598
 
2748
2599
  /* 右侧导航条: 用户发言节点 + 拖动快速定位 */
2749
- function updateRail() {
2750
- const box = $('history')
2751
- const thumb = $('rail-thumb')
2752
- const nodesBox = $('rail-nodes')
2753
- if (!box || !thumb || !nodesBox) return
2754
- const sh = box.scrollHeight
2755
- const ch = box.clientHeight
2756
- if (sh <= ch) {
2757
- thumb.style.display = 'none'
2758
- nodesBox.innerHTML = ''
2759
- return
2760
- }
2761
- thumb.style.display = ''
2762
- const trackH = Math.max(1, ch - 8)
2763
- const thumbH = Math.max(32, ch / sh * trackH)
2764
- const maxTop = trackH - thumbH
2765
- const ratio = box.scrollTop / Math.max(1, sh - ch)
2766
- thumb.style.height = thumbH + 'px'
2767
- thumb.style.top = (4 + ratio * maxTop) + 'px'
2768
-
2769
- const boxTop = box.getBoundingClientRect().top
2770
- const userNodes = [...box.querySelectorAll('.msg.user')]
2771
- nodesBox.innerHTML = userNodes.map(el => {
2772
- const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
2773
- const pos = Math.min(4 + trackH, 4 + off / Math.max(1, sh) * trackH)
2774
- return `<div class="rail-node" data-offset="${Math.round(off)}" style="top:${pos}px"></div>`
2775
- }).join('')
2776
- let activeIdx = -1
2777
- userNodes.forEach((el, i) => {
2778
- const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
2779
- if (off <= box.scrollTop + 60) activeIdx = i
2780
- })
2781
- if (activeIdx >= 0) nodesBox.children[activeIdx]?.classList.add('active')
2600
+ let historyRailLayout=null
2601
+ function updateRail(rebuild=false) {
2602
+ const box=$('history'),thumb=$('rail-thumb'),nodesBox=$('rail-nodes')
2603
+ if(!box||!thumb||!nodesBox)return
2604
+ const sh=box.scrollHeight,ch=box.clientHeight
2605
+ if(sh<=ch){thumb.style.display='none';nodesBox.replaceChildren();historyRailLayout=null;return}
2606
+ thumb.style.display=''
2607
+ const trackH=Math.max(1,ch-8),thumbH=Math.max(32,ch/sh*trackH),ratio=box.scrollTop/Math.max(1,sh-ch)
2608
+ thumb.style.height=thumbH+'px';thumb.style.top=(4+ratio*(trackH-thumbH))+'px'
2609
+ if(rebuild||!historyRailLayout||historyRailLayout.height!==sh||historyRailLayout.width!==box.clientWidth){
2610
+ const top=box.getBoundingClientRect().top
2611
+ const allOffsets=[...box.querySelectorAll('.msg.user')].map(el=>el.getBoundingClientRect().top-top+box.scrollTop)
2612
+ const count=Math.min(24,allOffsets.length),offsets=Array.from({length:count},(_,i)=>allOffsets[Math.round(i*(allOffsets.length-1)/Math.max(1,count-1))])
2613
+ nodesBox.innerHTML=offsets.map(off=>'<div class="rail-node" data-offset="'+Math.round(off)+'" style="top:'+Math.min(4+trackH,4+off/Math.max(1,sh)*trackH)+'px"></div>').join('')
2614
+ historyRailLayout={height:sh,width:box.clientWidth,offsets}
2615
+ }
2616
+ let active=-1;historyRailLayout.offsets.forEach((offset,i)=>{if(offset<=box.scrollTop+60)active=i})
2617
+ for(let i=0;i<nodesBox.children.length;i++)nodesBox.children[i].classList.toggle('active',i===active)
2782
2618
  }
2783
2619
 
2784
2620
  function bindRail() {
@@ -2789,6 +2625,8 @@ function bindRail() {
2789
2625
  nodesBox.addEventListener('click', (e) => {
2790
2626
  const node = e.target.closest('.rail-node')
2791
2627
  if (!node) return
2628
+ getConversationView().intentUntil=Date.now()+1500
2629
+ getConversationView().reading=true
2792
2630
  box.scrollTo({ top: Math.max(0, Number(node.dataset.offset) - 10), behavior: 'smooth' })
2793
2631
  })
2794
2632
  let drag = null
@@ -2798,6 +2636,8 @@ function bindRail() {
2798
2636
  })
2799
2637
  thumb.addEventListener('pointermove', (e) => {
2800
2638
  if (!drag) return
2639
+ getConversationView().intentUntil=Date.now()+1500
2640
+ getConversationView().reading=true
2801
2641
  const trackH = Math.max(1, box.clientHeight - 8)
2802
2642
  const delta = (e.clientY - drag.y) / trackH * Math.max(1, box.scrollHeight - box.clientHeight)
2803
2643
  box.scrollTop = Math.max(0, Math.min(box.scrollHeight - box.clientHeight, drag.top + delta))
@@ -2816,8 +2656,7 @@ const INTERESTING_EVENTS = new Set([
2816
2656
  'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
2817
2657
  'todo/updated', 'plan/updated',
2818
2658
  'question/asked', 'question/resolved',
2819
- 'approval/asked', 'approval/resolved',
2820
- 'session/title', 'title'
2659
+ 'approval/asked', 'approval/resolved'
2821
2660
  ])
2822
2661
  function messageSource(data) {
2823
2662
  const source = data?.source ?? data?.message?.source
@@ -2934,6 +2773,7 @@ function statsHtml(s) {
2934
2773
 
2935
2774
  let sessionCardsRenderGeneration = 0
2936
2775
  async function renderSessionCards() {
2776
+ window.DshInsights?.update()
2937
2777
  const renderGeneration = ++sessionCardsRenderGeneration
2938
2778
  const sessionId = state.current
2939
2779
  const s = state.byId.get(sessionId)
@@ -3741,36 +3581,88 @@ function bindSessionSwipe() {
3741
3581
  }
3742
3582
 
3743
3583
  /* ---------------- 系统总览 / 待办 ---------------- */
3584
+ let overviewDetector = null
3585
+ let overviewPulse = null
3586
+ function setOverviewText(id,value){const node=$(id);if(overviewPulse)overviewPulse.text(node,value);else if(node)node.textContent=value}
3587
+ let overviewStarting = true
3588
+ function overviewChecks() {
3589
+ const confirmed=overviewDetector?.model.gateway===true,health=activeGatewayHealth()
3590
+ return {
3591
+ gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)) && confirmed,
3592
+ dsh: dshReachable() && confirmed,
3593
+ mux: !!state.streamsOk?.mux && confirmed && health?.events?.mux?.connected!==false,
3594
+ host: !!state.streamsOk?.host && confirmed && health?.events?.host?.connected!==false
3595
+ }
3596
+ }
3597
+ function repairMissingStreams() {
3598
+ if(!state.token||!navigator.onLine||state.selectingServer||overviewStarting)return
3599
+ if(state.streamMode==='poll'){tryRestoreWs();return}
3600
+ for(const [kind,handler,refresh] of [['mux',onMuxFrame,true],['host',onHostFrame,false]]) {
3601
+ const ws=streams[kind]
3602
+ if((!ws||ws.readyState===WebSocket.CLOSED)&&!streamMeta[kind].retryTimer)openStream(kind,handler,refresh)
3603
+ }
3604
+ }
3605
+ async function probeOverviewLinks(signal) {
3606
+ const connection=captureConnection(),base=String(connection.server||location.origin).replace(/\/+$/,'')
3607
+ let timer
3608
+ const controller=new AbortController(),cancel=()=>controller.abort()
3609
+ signal.addEventListener('abort',cancel,{once:true});if(signal.aborted)controller.abort()
3610
+ timer=setTimeout(cancel,4000)
3611
+ const healthRequest=fetch(base+'/health?t='+Date.now(),{signal:controller.signal,cache:'no-store'}).then(async response=>{
3612
+ if(!response.ok)throw Error('health')
3613
+ const health=await response.json();if(health?.ok!==true)throw Error('health')
3614
+ return health
3615
+ }).catch(()=>null).finally(()=>{clearTimeout(timer);signal.removeEventListener('abort',cancel)})
3616
+ const [health,host]=await Promise.all([healthRequest,refreshHostDescription()])
3617
+ if(!connection.valid()||signal.aborted)throw Error('stale check')
3618
+ if(health){if(host)health.upstreamReachable=true;state.gatewayHealth[base]=health}
3619
+ else delete state.gatewayHealth[base]
3620
+ return {gateway:!!health}
3621
+ }
3622
+ function startOverviewDetection(force=false,identityChanged=false) {
3623
+ if(identityChanged)overviewDetector?.pause()
3624
+ if(!window.DshLinkCheck)return
3625
+ if(!overviewDetector)overviewDetector=new window.DshLinkCheck.LinkCheck({
3626
+ active:()=>document.visibilityState==='visible'&&!$('view-activity').classList.contains('hidden'),
3627
+ configured:()=>!!state.token&&(!!state.server||/^https?:$/.test(location.protocol)),online:()=>navigator.onLine,
3628
+ snapshot:overviewChecks,repair:repairMissingStreams,probe:probeOverviewLinks,changed:renderOverview
3629
+ })
3630
+ overviewDetector.start(force)
3631
+ }
3632
+ window.addEventListener('online',()=>startOverviewDetection(true))
3633
+ window.addEventListener('offline',()=>startOverviewDetection(true))
3634
+ document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')startOverviewDetection(true);else {overviewDetector?.pause();overviewPulse?.pause()}})
3635
+
3744
3636
  function renderOverview() {
3745
3637
  const ring = $('overview-pulse-ring')
3746
3638
  if (!ring) return
3747
- const checks = {
3748
- // 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
3749
- // 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
3750
- gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
3751
- dsh: dshReachable(),
3752
- mux: !!state.streamsOk?.mux,
3753
- host: !!state.streamsOk?.host
3754
- }
3639
+ const checks = overviewChecks()
3640
+ const detection=overviewDetector?.model,checking=detection?.phase==='checking'||(detection?.phase==='degraded'&&detection?.probing===true)
3755
3641
  const online = Object.values(checks).filter(Boolean).length
3642
+ $('overview-refresh').disabled=detection?.probing===true
3756
3643
  const status = online === 4 ? 'nominal' : online > 0 ? 'degraded' : 'offline'
3757
3644
  const pulseCard = document.querySelector('.overview-pulse-card')
3758
3645
  if (pulseCard) {
3646
+ if(!overviewPulse&&window.DshLinkCheck?.PulseVisual)overviewPulse=new window.DshLinkCheck.PulseVisual({card:pulseCard,active:()=>document.visibilityState==='visible'&&!$('view-activity').classList.contains('hidden')})
3647
+ overviewPulse?.setChecking(checking)
3648
+ overviewPulse?.setLinks(checks)
3759
3649
  pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
3650
+ pulseCard.classList.toggle('status-checking',checking)
3651
+ pulseCard.setAttribute('aria-busy',String(checking))
3760
3652
  pulseCard.classList.add('status-' + status)
3761
3653
  }
3762
- ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
3763
- $('overview-health').textContent = online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore')
3764
- $('overview-health-caption').textContent = online === 4 ? t('overview.allLinked') : online ? t('overview.components', { n: online }) : t('overview.offlineShort')
3765
- $('overview-status').textContent = t(`overview.${status}`)
3766
- $('overview-status-desc').textContent = t('overview.components', { n: online })
3654
+ setOverviewText('overview-health',checking ? t('overview.checking') : online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore'))
3655
+ setOverviewText('overview-health-caption',checking ? `${online}/4` : online === 4 ? t('overview.allLinked') : online ? t('overview.confirmedShort') : t('overview.offlineShort'))
3656
+ setOverviewText('overview-status',checking ? t('overview.checking') : t(`overview.${status}`))
3657
+ setOverviewText('overview-status-desc',checking ? t('overview.autoChecking',{n:online}) : detection?.phase==='degraded' ? t('overview.autoRetry',{n:online}) : t('overview.components',{n:online}))
3767
3658
  for (const [name, ok] of Object.entries(checks)) {
3768
3659
  const item = document.querySelector(`[data-overview-link="${name}"]`)
3769
3660
  if (!item) continue
3770
3661
  item.classList.toggle('ok', ok)
3771
- item.classList.toggle('off', !ok)
3662
+ item.classList.toggle('off', !ok&&!checking)
3663
+ item.classList.toggle('pending',!ok&&checking)
3772
3664
  const value = item.querySelector('b')
3773
- if (value) value.textContent = ok ? t('overview.online') : t('overview.offlineShort')
3665
+ if (value) setOverviewText(value.id || (value.id=`overview-link-${name}`), ok ? t('overview.online') : checking ? t('overview.checkingShort') : t('overview.offlineShort'))
3774
3666
  }
3775
3667
 
3776
3668
  const pending = [
@@ -4134,8 +4026,10 @@ function openWorkspaceFiles(workspaceId) {
4134
4026
  }
4135
4027
 
4136
4028
  async function openWorkspaceModal() {
4029
+ const connection = captureConnection()
4137
4030
  if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
4138
4031
  if (!state.fs.path) await loadFs(null, { silent: true })
4032
+ if (!connection.valid()) return
4139
4033
  $('workspace-parent-path').textContent = state.fs.path || '~'
4140
4034
  $('workspace-name').value = ''
4141
4035
  $('modal-workspace').classList.remove('hidden')
@@ -4146,27 +4040,34 @@ async function createWorkspace() {
4146
4040
  if (createWorkspace.busy) return
4147
4041
  const name = $('workspace-name').value.trim()
4148
4042
  if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
4043
+ const connection = captureConnection()
4149
4044
  createWorkspace.busy = true
4150
4045
  const parent = state.fs.path || ''
4151
4046
  const button = $('workspace-create')
4152
4047
  button.disabled = true
4153
4048
  try {
4154
4049
  const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
4050
+ if (!connection.valid()) return
4155
4051
  if (res.status === 401) { fsAuthError(401); return }
4156
4052
  const data = await res.json().catch(() => ({}))
4053
+ if (!connection.valid()) return
4157
4054
  if (!res.ok) {
4158
4055
  const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
4159
4056
  throw new Error(msg)
4160
4057
  }
4161
4058
  closeWorkspaceModal()
4162
4059
  await loadFs(parent || null, { silent: true })
4060
+ if (!connection.valid()) return
4163
4061
  let workspace = null
4164
4062
  try {
4165
4063
  const created = await rpc('workspace.create', { path: data.path })
4064
+ if (!connection.valid()) return
4166
4065
  workspace = created?.workspace || null
4167
4066
  } catch {}
4067
+ if (!connection.valid()) return
4168
4068
  const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
4169
4069
  const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
4070
+ if (!connection.valid()) return
4170
4071
  if (workspace?.workspaceId) {
4171
4072
  state.workspaceFilter = workspace.workspaceId
4172
4073
  state.fs.workspaceId = workspace.workspaceId
@@ -4174,6 +4075,7 @@ async function createWorkspace() {
4174
4075
  LS.set('fsWorkspaceIdV1', workspace.workspaceId)
4175
4076
  }
4176
4077
  await refreshSessions()
4078
+ if (!connection.valid()) return
4177
4079
  if (v?.sessionId) {
4178
4080
  toast(t('workspace.created'), 'ok')
4179
4081
  openSession(v.sessionId)
@@ -4181,6 +4083,7 @@ async function createWorkspace() {
4181
4083
  toast(t('workspace.createdNoSession'), 'ok')
4182
4084
  }
4183
4085
  } catch (e) {
4086
+ if (!connection.valid()) return
4184
4087
  if (e.message === 'AUTH') fsAuthError(401)
4185
4088
  else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
4186
4089
  } finally {
@@ -4202,6 +4105,8 @@ function fsAuthError(status) {
4202
4105
  }
4203
4106
 
4204
4107
  async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4108
+ const connection = captureConnection(), request = ++filesRequest
4109
+ const valid = () => connection.valid() && request === filesRequest
4205
4110
  if (!state.token) {
4206
4111
  $('fs-path').textContent = t('fs.noToken')
4207
4112
  $('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
@@ -4215,8 +4120,10 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4215
4120
  }
4216
4121
  try {
4217
4122
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
4123
+ if (!valid()) return
4218
4124
  if (res.status === 401) { fsAuthError(401); return }
4219
4125
  const data = await res.json().catch(() => ({}))
4126
+ if (!valid()) return
4220
4127
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
4221
4128
  state.fs.path = data.path
4222
4129
  if (!state.fs.initial) state.fs.initial = data.path
@@ -4230,6 +4137,7 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4230
4137
  renderWorkspaceNavigation()
4231
4138
  renderFs(data)
4232
4139
  } catch (e) {
4140
+ if (!valid()) return
4233
4141
  if (e.message === 'AUTH') return
4234
4142
  $('fs-path').textContent = target || '~'
4235
4143
  $('fs-list').innerHTML = `<div class="empty">${esc(t('fs.loadFailed', { msg: e.message || t('fs.networkError') }))}</div>`
@@ -5343,11 +5251,9 @@ async function verifyUpdateApk(info, url) {
5343
5251
  phase = 'verifying'
5344
5252
  updateDownloadProgress({ phase, received, total: total || received })
5345
5253
  const expected = String(info.sha256 || '').trim().toLowerCase()
5346
- if (expected) {
5347
- if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: false, corrupted: true }
5348
- const actual = await sha256Hex(await blob.arrayBuffer())
5349
- if (actual !== expected) return { ok: false, corrupted: true }
5350
- }
5254
+ if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: false, corrupted: true }
5255
+ const actual = await sha256Hex(await blob.arrayBuffer())
5256
+ if (actual !== expected) return { ok: false, corrupted: true }
5351
5257
  return { ok: true, blob }
5352
5258
  } catch (err) { return { ok: false, network: true, msg: err?.message || '' } }
5353
5259
  finally { clearTimeout(timer); clearInterval(progressTimer) }
@@ -5357,13 +5263,19 @@ async function downloadUpdate() {
5357
5263
  const info = state.updateInfo
5358
5264
  if (!info || updateDownloadBusy) return
5359
5265
  const url = new URL(info.apkUrl || 'dsh-remote.apk', updateBase() + '/').href
5266
+ const sha256 = String(info.sha256 || '').trim().toLowerCase()
5267
+ if (!/^[0-9a-f]{64}$/.test(sha256)) {
5268
+ // 无 SHA-256 的更新包一律不下载、不安装
5269
+ updateDownloadProgress({ phase: 'error', error: t('update.hashMissing') })
5270
+ return
5271
+ }
5360
5272
  updateDownloadBusy = true
5361
5273
  updateDownloadSample = null
5362
5274
  $('btn-download-update').disabled = true
5363
5275
  updateDownloadProgress({ phase: 'downloading', received: 0, total: 0 })
5364
5276
  if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadVerifiedAndInstall && window.NativeUpdate?.getDownloadStatus) {
5365
5277
  try {
5366
- if (!window.NativeUpdate.downloadVerifiedAndInstall(url, String(info.sha256 || '').trim())) throw new Error(t('update.busy'))
5278
+ if (!window.NativeUpdate.downloadVerifiedAndInstall(url, sha256)) throw new Error(t('update.busy'))
5367
5279
  const poll = () => {
5368
5280
  try {
5369
5281
  const value = JSON.parse(window.NativeUpdate.getDownloadStatus())
@@ -5379,11 +5291,8 @@ async function downloadUpdate() {
5379
5291
  try {
5380
5292
  const result = await verifyUpdateApk(info, url)
5381
5293
  if (!result.ok) throw new Error(result.corrupted ? t('update.corrupted') : result.status ? t('update.serverFileMissing') : result.msg || t('fs.networkError'))
5382
- if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadAndInstall) {
5383
- // 旧壳不具备进度桥;安装本次新版后即可使用单次下载和完整进度。
5384
- window.NativeUpdate.downloadAndInstall(url)
5385
- updateDownloadProgress({ phase: 'legacy', received: 0, total: 0 })
5386
- } else {
5294
+ {
5295
+ // 无校验的原生安装入口已移除: 新旧壳统一走下方"下载并本地校验"路径。
5387
5296
  const objectUrl = URL.createObjectURL(result.blob)
5388
5297
  const link = document.createElement('a')
5389
5298
  link.href = objectUrl
@@ -6158,9 +6067,11 @@ async function openModelConfigDocument() {
6158
6067
  /* ---------------- 视图切换 ---------------- */
6159
6068
  function showView(id) {
6160
6069
  if (id !== 'view-session' && document.body.classList.contains('in-session') && state.current) void archiveEmptySessionOnLeave(state.current)
6161
- for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6070
+ for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-plugins', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6162
6071
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
6163
6072
  document.body.classList.toggle('in-session', id === 'view-session')
6073
+ $('session-actions')?.close()
6074
+ $('session-info')?.close()
6164
6075
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
6165
6076
  // 主页已有上下文明确的刷新按钮,避免顶栏出现第二个同义入口。
6166
6077
  $('btn-refresh')?.classList.toggle('hidden', id === 'view-activity')
@@ -6170,8 +6081,10 @@ function showView(id) {
6170
6081
  const workspace = workspaceById(state.fs.workspaceId)
6171
6082
  loadFs(workspace?.path || null, { silent: true, resetRoot: true })
6172
6083
  }
6173
- if (id === 'view-stats') loadStats()
6084
+ if (id === 'view-plugins') openPluginPage()
6085
+ else window.DshPluginCenter?.close()
6174
6086
  if (id === 'view-settings') showSettingsHome()
6087
+ if(id==='view-activity')startOverviewDetection(true);else {overviewDetector?.pause();overviewPulse?.pause()}
6175
6088
  }
6176
6089
 
6177
6090
  const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
@@ -6192,6 +6105,7 @@ function showSettingsPage(name) {
6192
6105
  }
6193
6106
 
6194
6107
  function updateConn() {
6108
+ overviewDetector?.observe()
6195
6109
  const el = $('conn-badge')
6196
6110
  // mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
6197
6111
  // 通道只更新顶栏、总览却永久停留在 3/4。
@@ -6262,7 +6176,8 @@ function updateComposerFullscreenButton() {
6262
6176
  function setComposerFullscreen(on) {
6263
6177
  const wrap = $('composer-wrap')
6264
6178
  if (!wrap) return
6265
- $('btn-stats')?.classList.toggle('hidden', !!on)
6179
+ $('btn-session-more')?.classList.toggle('hidden', !!on)
6180
+ $('session-actions')?.close()
6266
6181
  $('btn-fs-send')?.classList.toggle('hidden', !on)
6267
6182
  if (on) {
6268
6183
  $('composer-image-menu')?.classList.add('hidden')
@@ -6314,7 +6229,7 @@ function bindComposerFullscreenGesture() {
6314
6229
 
6315
6230
  /* ---------------- 初始化 ---------------- */
6316
6231
  /** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
6317
- function applyPairUrl(url) {
6232
+ function applyPairUrl(url, options = {}) {
6318
6233
  try {
6319
6234
  const u = new URL(String(url).trim())
6320
6235
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
@@ -6323,6 +6238,13 @@ function applyPairUrl(url) {
6323
6238
  .map(value => value.trim().replace(/\/+$/, ''))
6324
6239
  .filter(value => /^https?:\/\//i.test(value)))]
6325
6240
  if (!tok || !servers.length) return false
6241
+ const alreadyPaired = (state.servers || []).some(s => normalizedServerToken(s.token) === tok
6242
+ && servers.includes(String(s.url || '').replace(/\/+$/, '')))
6243
+ if (options.confirmExisting && !alreadyPaired && (state.token || (state.servers || []).length)) {
6244
+ // 深链可被任意应用/网页触发: 已有配置时必须由用户确认,不能静默换服务器和令牌
6245
+ const confirmed = window.confirm(t('scan.pairConfirm', { servers: servers.join('\n') }))
6246
+ if (!confirmed) return false
6247
+ }
6326
6248
  const previousServer = state.server
6327
6249
  const previousToken = state.token
6328
6250
  state.token = tok
@@ -6610,14 +6532,14 @@ function bindNativeLinks() {
6610
6532
  if (!CAP?.isNativePlatform?.()) return
6611
6533
  try {
6612
6534
  CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
6613
- if (data?.url && applyPairUrl(data.url)) {
6535
+ if (data?.url && applyPairUrl(data.url, { confirmExisting: true })) {
6614
6536
  toast(t('scan.pairedLink'), 'ok')
6615
6537
  openStreams()
6616
6538
  refreshAll()
6617
6539
  }
6618
6540
  })
6619
6541
  CAP.Plugins?.App?.getLaunchUrl?.().then((data) => {
6620
- if (data?.url) applyPairUrl(data.url)
6542
+ if (data?.url) applyPairUrl(data.url, { confirmExisting: true })
6621
6543
  }).catch(() => {})
6622
6544
  } catch {}
6623
6545
  }
@@ -6763,7 +6685,10 @@ function renderDshControlStatus(value) {
6763
6685
  return
6764
6686
  }
6765
6687
  dshControlButtonsBusy(false, true)
6688
+ $('btn-dsh-start').disabled = value.running === true || value.canStart === false
6689
+ $('btn-dsh-restart').disabled = value.canRestart === false || value.running === false
6766
6690
  if (box && !dshControlPollPromise) box.classList.add('hidden')
6691
+ if (value.canStart === false && value.code) { desc.textContent=dshControlFailureText(value); return }
6767
6692
  const service = value.service || 'dsh-web'
6768
6693
  const serviceState = `${value.activeState || value.state || 'unknown'}/${value.subState || 'unknown'}`
6769
6694
  desc.textContent = value.running
@@ -6888,7 +6813,31 @@ function openDonateModal() {
6888
6813
  if (m) m.classList.remove('hidden')
6889
6814
  }
6890
6815
 
6816
+
6817
+ function bindSessionDialogs() {
6818
+ if (typeof ResizeObserver !== 'undefined') {
6819
+ const wrap=$('composer-wrap')
6820
+ const observer=new ResizeObserver(()=>{
6821
+ if(!wrap.classList.contains('fs'))document.documentElement.style.setProperty('--composer-height', wrap.getBoundingClientRect().height+'px')
6822
+ if(document.body.classList.contains('in-session'))document.documentElement.style.setProperty('--composer-top', $('session-sub').getBoundingClientRect().bottom+'px')
6823
+ })
6824
+ observer.observe(wrap)
6825
+ observer.observe($('session-head'))
6826
+ observer.observe($('session-sub'))
6827
+ }
6828
+ const actions=$('session-actions'), info=$('session-info');
6829
+ $('btn-session-more').addEventListener('click',()=>actions.showModal());
6830
+ actions.addEventListener('click',e=>{if(e.target===actions)actions.close();else if(e.target.closest('button'))actions.close()},true);
6831
+ const openInfo=()=>{const s=state.byId.get(state.current);if(!s)return; $('session-info-name').textContent=titleOf(s);$('session-info-path').textContent=s.cwd||t('session.noWorkspace');$('session-info-id').textContent=s.sessionId;$('btn-copy-session-workspace').disabled=!s.cwd;info.showModal()};
6832
+ $('session-title').addEventListener('click',openInfo);
6833
+ $('session-sub').addEventListener('click',openInfo);
6834
+ $('btn-session-info').addEventListener('click',openInfo);
6835
+ info.addEventListener('click',e=>{if(e.target===info||e.target.closest('[data-session-dialog-close]'))info.close()});
6836
+ $('btn-copy-session-workspace').addEventListener('click',async()=>{const value=state.byId.get(state.current)?.cwd;if(value&&await copyText(value))toast(t('feedback.copied'),'ok')});
6837
+ }
6838
+
6891
6839
  function bindUi() {
6840
+ bindSessionDialogs()
6892
6841
  renderLangBtn()
6893
6842
  renderThemeBtn()
6894
6843
  $('btn-lang').addEventListener('click', () => {
@@ -6923,9 +6872,6 @@ function bindUi() {
6923
6872
  $('notes-pages').addEventListener('scroll', updateNotesPage)
6924
6873
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
6925
6874
  renderServers()
6926
- document.querySelectorAll('[data-stats-mode]').forEach(button =>
6927
- button.addEventListener('click', () => setStatsChartMode(button.dataset.statsMode)))
6928
- applyStatsChartMode()
6929
6875
  // 底部导航
6930
6876
  document.querySelectorAll('.nav-btn').forEach(b =>
6931
6877
  b.addEventListener('click', () => showView(b.dataset.view)))
@@ -7001,11 +6947,12 @@ function bindUi() {
7001
6947
  })
7002
6948
  $('btn-back').addEventListener('click', () => { void closeSession() })
7003
6949
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
6950
+ $('btn-insights').addEventListener('click', () => openInsights())
7004
6951
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
7005
6952
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
7006
6953
  $('overview-refresh').addEventListener('click', () => {
7007
6954
  toast(t('common.refreshing'))
7008
- openStreams()
6955
+ startOverviewDetection(true)
7009
6956
  void Promise.all([refreshAll(), checkAnnouncements()])
7010
6957
  })
7011
6958
  // 反馈
@@ -7267,8 +7214,10 @@ function bindUi() {
7267
7214
  $('modal-app-version-warning').addEventListener('click', (e) => {
7268
7215
  if (e.target === $('modal-app-version-warning')) closeAppVersionWarning(false)
7269
7216
  })
7270
- $('btn-reset').addEventListener('click', () => {
7217
+ $('btn-reset').addEventListener('click', async () => {
7271
7218
  if (!confirm(t('settings.confirmReset'))) return
7219
+ clearTimeout(scheduleHistoryCacheSave._t)
7220
+ await window.DshHistory.cacheClear()
7272
7221
  LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del('steerSendingEnabled'); LS.del('busySendMode'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
7273
7222
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
7274
7223
  location.reload()
@@ -7382,27 +7331,8 @@ function bindUi() {
7382
7331
 
7383
7332
  initCustomSelects()
7384
7333
 
7385
- // 向上翻历史 / 向下回最新
7386
- $('history').addEventListener('scroll', () => {
7387
- const box = $('history')
7388
- const h = state.history
7389
- updateRail()
7390
- if (!state.current || !h.filtered?.length) return
7391
- if (box.scrollTop < 80) {
7392
- if (h.renderStart > 0) {
7393
- h.renderStart = Math.max(0, h.renderStart - 100)
7394
- renderHistory(false, 'keep')
7395
- } else if (h.hasMore && !h.loading) {
7396
- loadHistory(false)
7397
- }
7398
- } else if (box.scrollHeight - box.scrollTop - box.clientHeight < 240) {
7399
- if (h.renderEnd < h.filtered.length) {
7400
- h.renderEnd = h.filtered.length
7401
- h.renderStart = Math.max(0, h.renderEnd - 200)
7402
- renderHistory(false, 'bottom')
7403
- }
7404
- }
7405
- })
7334
+ $('btn-history-latest').addEventListener('click',()=>{getConversationView().latest();if(state.history.tailEvicted)void loadHistory(true)})
7335
+ $('history').addEventListener('load',()=>updateRail(true),true)
7406
7336
  }
7407
7337
 
7408
7338
  /* App 内真实系统栏 inset(刘海/状态栏/手势条) */
@@ -7429,14 +7359,15 @@ async function boot() {
7429
7359
  await loadLocalVersion()
7430
7360
  if (!state.token) {
7431
7361
  showView('view-activity')
7362
+ overviewStarting=false
7432
7363
  $('token-desc').textContent = t('token.notSetHint')
7433
7364
  } else {
7434
7365
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
7435
7366
  await selectFastestServer({ silent: true, reconnect: false })
7436
7367
  await maybeWarnAppBehindGateway({ probe: true })
7368
+ overviewStarting=false
7437
7369
  openStreams()
7438
- await refreshAll()
7439
- await refreshHostDescription()
7370
+ await Promise.all([refreshAll(),refreshHostDescription()])
7440
7371
  loadDshControl()
7441
7372
  }
7442
7373
  // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
@@ -7448,13 +7379,15 @@ async function boot() {
7448
7379
 
7449
7380
  document.addEventListener('DOMContentLoaded', boot)
7450
7381
 
7451
- // Capture the connection so switching servers cannot redirect a plugin mutation.
7452
- document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
7453
- const server = state.server || ''
7454
- const token = state.token
7455
- window.DshPluginCenter.open({
7456
- url: path => server + path,
7457
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
7458
- valid: () => (state.server || '') === server && state.token === token,
7459
- })
7460
- })
7382
+ function openPluginPage() {
7383
+ const connection = captureConnection()
7384
+ if (!connection.token) {
7385
+ $('plugin-page').textContent = '请先在设置中连接 DSH 主机。'
7386
+ return
7387
+ }
7388
+ window.DshPluginCenter.mount({
7389
+ url: path => connection.server + path,
7390
+ headers: { authorization: 'Bearer ' + connection.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
7391
+ valid: connection.valid,
7392
+ }, $('plugin-page'))
7393
+ }