dsh-remote-plugin 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -29,8 +29,13 @@ function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIE
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 }
@@ -112,6 +117,38 @@ const state = {
112
117
  subagentExpandedSession: ''
113
118
  }
114
119
 
120
+ // Increment on every identity transition, including A -> B -> A and token edits.
121
+ let connectionGeneration = 0, sessionsRequest = 0, filesRequest = 0, workbenchRequest = 0
122
+ for (const key of ['server', 'token']) {
123
+ let value = state[key]
124
+ Object.defineProperty(state, key, {
125
+ enumerable: true,
126
+ get: () => value,
127
+ set(next) {
128
+ if (next === value) return
129
+ value = next; connectionGeneration++
130
+ state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
131
+ state.current = null; state.history = emptyHistory()
132
+ state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
133
+ state.wb = null; state.wbProjects = []; state.wbArchived = []; state.workspaceFilter = ''; state.fs.workspaceId = ''; clearTimeout(scheduleHistoryCacheSave._t)
134
+ window.DshPluginCenter?.close()
135
+ window.DshInsights?.close()
136
+ if (typeof document !== 'undefined') {
137
+ document.getElementById('modal-workspace')?.classList.add('hidden')
138
+ const generation = connectionGeneration
139
+ queueMicrotask(() => {
140
+ if (generation === connectionGeneration && document.getElementById('view-plugins')?.classList.contains('hidden') === false) openPluginPage()
141
+ })
142
+ }
143
+ },
144
+ })
145
+ }
146
+ function captureConnection() {
147
+ const generation = connectionGeneration, server = state.server, token = state.token
148
+ return { server, token, valid: () => generation === connectionGeneration && server === state.server && token === state.token }
149
+ }
150
+
151
+
115
152
  const $ = (id) => document.getElementById(id)
116
153
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
117
154
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
@@ -437,160 +474,6 @@ function bucketTokens(b) {
437
474
  return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
438
475
  }
439
476
 
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
477
  async function rpc(method, payload = {}, timeoutMs = 45000) {
595
478
  const opts = {
596
479
  method: 'POST',
@@ -630,8 +513,10 @@ async function respond(rpcId, value) {
630
513
  }
631
514
 
632
515
  async function safeRpc(method, payload, errText) {
516
+ const connection = captureConnection()
633
517
  try { return await rpc(method, payload) }
634
518
  catch (e) {
519
+ if (!connection.valid()) return null
635
520
  if (e.message === 'AUTH') authFailure()
636
521
  else toast(errText ? `${errText}:${e.message}` : e.message, 'err')
637
522
  return null
@@ -1743,7 +1628,9 @@ async function refreshAll() {
1743
1628
  }
1744
1629
 
1745
1630
  async function refreshSessions() {
1631
+ const connection = captureConnection(), request = ++sessionsRequest
1746
1632
  const v = await safeRpc('session.list', {}, t('err.sessionList'))
1633
+ if (!connection.valid() || request !== sessionsRequest) return
1747
1634
  if (!v) {
1748
1635
  // 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
1749
1636
  if (!state.sessions.length) {
@@ -1783,6 +1670,10 @@ function removeLocalSessionRecord(sessionId) {
1783
1670
  }
1784
1671
 
1785
1672
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1673
+ function openInsights(tab = 'context') {
1674
+ window.DshInsights?.open({ tab, connection: captureConnection(), url: apiUrl,
1675
+ getSession: () => state.byId.get(state.current), getTitle: () => sessionTitleValue(state.byId.get(state.current)) })
1676
+ }
1786
1677
  function hydrateSessionProjections(sessionId, projections) {
1787
1678
  const s = state.byId.get(sessionId)
1788
1679
  if (!s || !projections || typeof projections !== 'object') return
@@ -2046,21 +1937,28 @@ function renderWorkspaceNavigation() {
2046
1937
  return items
2047
1938
  }
2048
1939
  async function refreshWorkbench() {
1940
+ const connection = captureConnection(), request = ++workbenchRequest
1941
+ const valid = () => connection.valid() && request === workbenchRequest
2049
1942
  if (!state.token) return
2050
1943
  try {
2051
1944
  const res = await fetch(apiUrl('/workbench'), {
2052
1945
  headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
2053
1946
  })
1947
+ if (!valid()) return
2054
1948
  if (res.ok) {
2055
1949
  const value = await res.json().catch(() => null)
1950
+ if (!valid()) return
2056
1951
  if (value && typeof value.bound === 'boolean') state.wb = value
2057
1952
  }
2058
1953
  } catch {}
1954
+ if (!valid()) return
2059
1955
  try {
2060
1956
  const value = await rpc('workspace.list', {})
1957
+ if (!valid()) return
2061
1958
  state.wbProjects = Array.isArray(value?.items) ? value.items : []
2062
1959
  state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
2063
1960
  } catch {
1961
+ if (!valid()) return
2064
1962
  state.wbProjects = []
2065
1963
  state.wbArchived = []
2066
1964
  }
@@ -2068,8 +1966,10 @@ async function refreshWorkbench() {
2068
1966
  if (state.wb?.bound && state.wb.path) {
2069
1967
  try {
2070
1968
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1969
+ if (!valid()) return
2071
1970
  if (listRes.ok) {
2072
1971
  const listData = await listRes.json().catch(() => ({}))
1972
+ if (!valid()) return
2073
1973
  if (Array.isArray(listData.entries)) {
2074
1974
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
2075
1975
  // 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
@@ -2080,14 +1980,18 @@ async function refreshWorkbench() {
2080
1980
  if (entry.type !== 'dir') continue
2081
1981
  const projectPath = wbJoin(state.wb.path, entry.name)
2082
1982
  if (have.has(wbPathKey(projectPath))) continue
1983
+ if (!valid()) return
2083
1984
  try {
2084
1985
  const created = await rpc('workspace.create', { path: projectPath })
1986
+ if (!valid()) return
2085
1987
  if (created?.workspace) { state.wbProjects.push(created.workspace); have.add(wbPathKey(projectPath)) }
2086
1988
  } catch {}
1989
+ if (!valid()) return
2087
1990
  }
2088
1991
  }
2089
1992
  }
2090
1993
  } catch {}
1994
+ if (!valid()) return
2091
1995
  }
2092
1996
  renderWorkspaceNavigation()
2093
1997
  renderWorkbench()
@@ -2551,7 +2455,8 @@ function trimVisible() {
2551
2455
  /* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
2552
2456
  function scheduleHistoryCacheSave() {
2553
2457
  clearTimeout(scheduleHistoryCacheSave._t)
2554
- scheduleHistoryCacheSave._t = setTimeout(saveHistoryCache, 400)
2458
+ const connection = captureConnection()
2459
+ scheduleHistoryCacheSave._t = setTimeout(() => { if (connection.valid()) saveHistoryCache() }, 400)
2555
2460
  }
2556
2461
 
2557
2462
  function saveHistoryCache() {
@@ -2595,6 +2500,7 @@ function restoreCachedHistory() {
2595
2500
  }
2596
2501
 
2597
2502
  async function loadHistory(reset) {
2503
+ const connection = captureConnection()
2598
2504
  const id = state.current
2599
2505
  if (!id || state.history.loading) return
2600
2506
  const history = state.history
@@ -2610,7 +2516,7 @@ async function loadHistory(reset) {
2610
2516
  try {
2611
2517
  v = await rpc('session.history', payload)
2612
2518
  } catch (e) {
2613
- if (state.current !== id || state.history !== history) return
2519
+ if (!connection.valid() || state.current !== id || state.history !== history) return
2614
2520
  history.loading = false
2615
2521
  if (e.message === 'AUTH') { authFailure(); return }
2616
2522
  if (restoreCachedHistory()) {
@@ -2631,7 +2537,7 @@ async function loadHistory(reset) {
2631
2537
  return
2632
2538
  }
2633
2539
 
2634
- if (state.current !== id || state.history !== history) return
2540
+ if (!connection.valid() || state.current !== id || state.history !== history) return
2635
2541
  const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
2636
2542
  hydrateSessionProjections(id, v.projections)
2637
2543
  history.loaded = true
@@ -2934,6 +2840,7 @@ function statsHtml(s) {
2934
2840
 
2935
2841
  let sessionCardsRenderGeneration = 0
2936
2842
  async function renderSessionCards() {
2843
+ window.DshInsights?.update()
2937
2844
  const renderGeneration = ++sessionCardsRenderGeneration
2938
2845
  const sessionId = state.current
2939
2846
  const s = state.byId.get(sessionId)
@@ -4134,8 +4041,10 @@ function openWorkspaceFiles(workspaceId) {
4134
4041
  }
4135
4042
 
4136
4043
  async function openWorkspaceModal() {
4044
+ const connection = captureConnection()
4137
4045
  if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
4138
4046
  if (!state.fs.path) await loadFs(null, { silent: true })
4047
+ if (!connection.valid()) return
4139
4048
  $('workspace-parent-path').textContent = state.fs.path || '~'
4140
4049
  $('workspace-name').value = ''
4141
4050
  $('modal-workspace').classList.remove('hidden')
@@ -4146,27 +4055,34 @@ async function createWorkspace() {
4146
4055
  if (createWorkspace.busy) return
4147
4056
  const name = $('workspace-name').value.trim()
4148
4057
  if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
4058
+ const connection = captureConnection()
4149
4059
  createWorkspace.busy = true
4150
4060
  const parent = state.fs.path || ''
4151
4061
  const button = $('workspace-create')
4152
4062
  button.disabled = true
4153
4063
  try {
4154
4064
  const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
4065
+ if (!connection.valid()) return
4155
4066
  if (res.status === 401) { fsAuthError(401); return }
4156
4067
  const data = await res.json().catch(() => ({}))
4068
+ if (!connection.valid()) return
4157
4069
  if (!res.ok) {
4158
4070
  const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
4159
4071
  throw new Error(msg)
4160
4072
  }
4161
4073
  closeWorkspaceModal()
4162
4074
  await loadFs(parent || null, { silent: true })
4075
+ if (!connection.valid()) return
4163
4076
  let workspace = null
4164
4077
  try {
4165
4078
  const created = await rpc('workspace.create', { path: data.path })
4079
+ if (!connection.valid()) return
4166
4080
  workspace = created?.workspace || null
4167
4081
  } catch {}
4082
+ if (!connection.valid()) return
4168
4083
  const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
4169
4084
  const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
4085
+ if (!connection.valid()) return
4170
4086
  if (workspace?.workspaceId) {
4171
4087
  state.workspaceFilter = workspace.workspaceId
4172
4088
  state.fs.workspaceId = workspace.workspaceId
@@ -4174,6 +4090,7 @@ async function createWorkspace() {
4174
4090
  LS.set('fsWorkspaceIdV1', workspace.workspaceId)
4175
4091
  }
4176
4092
  await refreshSessions()
4093
+ if (!connection.valid()) return
4177
4094
  if (v?.sessionId) {
4178
4095
  toast(t('workspace.created'), 'ok')
4179
4096
  openSession(v.sessionId)
@@ -4181,6 +4098,7 @@ async function createWorkspace() {
4181
4098
  toast(t('workspace.createdNoSession'), 'ok')
4182
4099
  }
4183
4100
  } catch (e) {
4101
+ if (!connection.valid()) return
4184
4102
  if (e.message === 'AUTH') fsAuthError(401)
4185
4103
  else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
4186
4104
  } finally {
@@ -4202,6 +4120,8 @@ function fsAuthError(status) {
4202
4120
  }
4203
4121
 
4204
4122
  async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4123
+ const connection = captureConnection(), request = ++filesRequest
4124
+ const valid = () => connection.valid() && request === filesRequest
4205
4125
  if (!state.token) {
4206
4126
  $('fs-path').textContent = t('fs.noToken')
4207
4127
  $('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
@@ -4215,8 +4135,10 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4215
4135
  }
4216
4136
  try {
4217
4137
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
4138
+ if (!valid()) return
4218
4139
  if (res.status === 401) { fsAuthError(401); return }
4219
4140
  const data = await res.json().catch(() => ({}))
4141
+ if (!valid()) return
4220
4142
  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
4143
  state.fs.path = data.path
4222
4144
  if (!state.fs.initial) state.fs.initial = data.path
@@ -4230,6 +4152,7 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
4230
4152
  renderWorkspaceNavigation()
4231
4153
  renderFs(data)
4232
4154
  } catch (e) {
4155
+ if (!valid()) return
4233
4156
  if (e.message === 'AUTH') return
4234
4157
  $('fs-path').textContent = target || '~'
4235
4158
  $('fs-list').innerHTML = `<div class="empty">${esc(t('fs.loadFailed', { msg: e.message || t('fs.networkError') }))}</div>`
@@ -6158,7 +6081,7 @@ async function openModelConfigDocument() {
6158
6081
  /* ---------------- 视图切换 ---------------- */
6159
6082
  function showView(id) {
6160
6083
  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)
6084
+ for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-plugins', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6162
6085
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
6163
6086
  document.body.classList.toggle('in-session', id === 'view-session')
6164
6087
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
@@ -6170,7 +6093,8 @@ function showView(id) {
6170
6093
  const workspace = workspaceById(state.fs.workspaceId)
6171
6094
  loadFs(workspace?.path || null, { silent: true, resetRoot: true })
6172
6095
  }
6173
- if (id === 'view-stats') loadStats()
6096
+ if (id === 'view-plugins') openPluginPage()
6097
+ else window.DshPluginCenter?.close()
6174
6098
  if (id === 'view-settings') showSettingsHome()
6175
6099
  }
6176
6100
 
@@ -6263,6 +6187,7 @@ function setComposerFullscreen(on) {
6263
6187
  const wrap = $('composer-wrap')
6264
6188
  if (!wrap) return
6265
6189
  $('btn-stats')?.classList.toggle('hidden', !!on)
6190
+ $('btn-insights')?.classList.toggle('hidden', !!on)
6266
6191
  $('btn-fs-send')?.classList.toggle('hidden', !on)
6267
6192
  if (on) {
6268
6193
  $('composer-image-menu')?.classList.add('hidden')
@@ -6923,9 +6848,6 @@ function bindUi() {
6923
6848
  $('notes-pages').addEventListener('scroll', updateNotesPage)
6924
6849
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
6925
6850
  renderServers()
6926
- document.querySelectorAll('[data-stats-mode]').forEach(button =>
6927
- button.addEventListener('click', () => setStatsChartMode(button.dataset.statsMode)))
6928
- applyStatsChartMode()
6929
6851
  // 底部导航
6930
6852
  document.querySelectorAll('.nav-btn').forEach(b =>
6931
6853
  b.addEventListener('click', () => showView(b.dataset.view)))
@@ -7001,6 +6923,7 @@ function bindUi() {
7001
6923
  })
7002
6924
  $('btn-back').addEventListener('click', () => { void closeSession() })
7003
6925
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
6926
+ $('btn-insights').addEventListener('click', () => openInsights())
7004
6927
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
7005
6928
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
7006
6929
  $('overview-refresh').addEventListener('click', () => {
@@ -7448,13 +7371,16 @@ async function boot() {
7448
7371
 
7449
7372
  document.addEventListener('DOMContentLoaded', boot)
7450
7373
 
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
- })
7374
+ function openPluginPage() {
7375
+ const connection = captureConnection()
7376
+ if (!connection.token) {
7377
+ $('plugin-page').textContent = '请先在设置中连接 DSH 主机。'
7378
+ return
7379
+ }
7380
+ window.DshPluginCenter.mount({
7381
+ url: path => connection.server + path,
7382
+ headers: { authorization: 'Bearer ' + connection.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
7383
+ valid: connection.valid,
7384
+ }, $('plugin-page'))
7385
+ }
7386
+ document.getElementById('btn-plugin-center')?.addEventListener('click', () => showView('view-plugins'))
@@ -8,6 +8,7 @@
8
8
  <link rel="stylesheet" href="../theme-vars.css">
9
9
  <link rel="stylesheet" href="desktop.css">
10
10
  <link rel="stylesheet" href="../plugin-center.css">
11
+ <link rel="stylesheet" href="../insights.css">
11
12
  </head>
12
13
  <body>
13
14
  <div class="ds-app" id="ds-app">
@@ -107,6 +108,7 @@
107
108
  <button id="btn-rename-session" class="ds-icon-btn hidden" data-i18n-title="ds.sessionRename" data-i18n-aria="ds.sessionRename">✎</button>
108
109
  <button id="btn-archive-session" class="ds-btn hidden" data-i18n="ds.sessionArchive">归档</button>
109
110
  <div class="ds-top-right">
111
+ <button id="btn-insights" class="ds-icon-btn hidden" data-i18n-title="ds.insights" data-i18n-aria="ds.insights"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4v8l6 5M12 12H4"/></svg></button>
110
112
  <span class="ds-conn" id="conn-badge" title="">●</span>
111
113
  <span class="ds-server" id="server-badge"></span>
112
114
  <button id="btn-donate" class="ds-icon-btn" data-i18n-aria="ds.donate" data-i18n-title="ds.donate"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h11v6a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17M9 4c0 1 1 1.2 1 2M13 4c0 1 1 1.2 1 2M5 20h13"/></svg></button>
@@ -498,6 +500,7 @@
498
500
  'ds.gateway': '网关', 'ds.dshUpstream': 'DSH 上游', 'ds.mux': '实时 mux', 'ds.host': '实时 host', 'ds.online': '在线', 'ds.offlineShort': '离线',
499
501
  'ds.attention': '需要你处理', 'ds.runtime': '运行指标', 'ds.recentActivity': '近期活动', 'ds.dshVersion': 'DSH 版本', 'ds.gatewayStatus': '网关状态', 'ds.activeSessions': '活跃会话', 'ds.connectionMode': '连接模式', 'ds.liveWs': '实时 WS', 'ds.poll': '降级轮询', 'ds.running': '运行中', 'ds.noReason': '无说明', 'ds.noActivity': '暂无后台活动', 'ds.noSessions': '暂无会话', 'ds.refreshOverview': '刷新总览', 'ds.pendingCount': '{n} 项待处理', 'ds.activeCount': '{n} 个活跃会话', 'ds.nothingPending': '暂无事项', 'ds.action.openSession': '打开最近会话', 'ds.action.newSession': '新建会话', 'ds.action.attention': '处理待办', 'ds.action.connect': '去设置连接', 'ds.action.refresh': '重新检查连接',
500
502
  'ds.sortLabel': '会话排序', 'ds.sortTime': '按时间', 'ds.sortWorkspace': '按工作区', 'ds.workspace': '工作区', 'ds.workspaceUnknown': '未指定工作区', 'ds.wbOrderSaved': '顺序已保存到当前设备',
503
+ 'ds.insights': '上下文与费用',
501
504
  'ds.files': '文件传输', 'ds.settings': '设置', 'ds.stats': '统计', 'ds.menu': '导航',
502
505
  'ds.send': '发送', 'ds.composerPlaceholder': '输入消息,Enter 发送…', 'ds.composerRunning': '运行中…', 'ds.presets': '预设', 'ds.sessionRename': '重命名会话', 'ds.sessionRenameTitle': '重命名会话', 'ds.sessionRenamePlaceholder': '输入新的会话名称', 'ds.sessionRenameConfirm': '保存', 'ds.sessionRenameFailed': '重命名失败', 'ds.sessionRenameEmpty': '会话名称不能为空', 'ds.sessionRenamed': '会话名称已更新', 'ds.sessionArchive': '归档', 'ds.sessionArchiveConfirm': '归档这个会话?归档后可在“显示已归档”中打开。', 'ds.sessionArchived': '会话已归档', 'ds.sessionStop': '停止本轮', 'ds.sessionStopConfirm': '停止当前回合?排队中的消息不会被删除。', 'ds.sessionStopFailed': '停止失败', 'ds.sessionStopRequested': '已请求停止本轮', 'ds.sessionRecovering': '恢复会话中…', 'ds.sessionReady': '会话已恢复', 'ds.sessionRecoveryFailed': '会话恢复失败',
503
506
  'ds.commands': '指令',
@@ -603,6 +606,7 @@
603
606
  'ds.gateway': 'Gateway', 'ds.dshUpstream': 'DSH upstream', 'ds.mux': 'Live mux', 'ds.host': 'Live host', 'ds.online': 'Online', 'ds.offlineShort': 'Offline',
604
607
  'ds.attention': 'Needs attention', 'ds.runtime': 'Runtime metrics', 'ds.recentActivity': 'Recent activity', 'ds.dshVersion': 'DSH version', 'ds.gatewayStatus': 'Gateway status', 'ds.activeSessions': 'Active sessions', 'ds.connectionMode': 'Connection mode', 'ds.liveWs': 'Live WS', 'ds.poll': 'Degraded polling', 'ds.running': 'Running', 'ds.noReason': 'No reason given', 'ds.noActivity': 'No background activity', 'ds.noSessions': 'No sessions yet', 'ds.refreshOverview': 'Refresh overview', 'ds.pendingCount': '{n} pending', 'ds.activeCount': '{n} active sessions', 'ds.nothingPending': 'Nothing pending', 'ds.action.openSession': 'Open recent session', 'ds.action.newSession': 'New session', 'ds.action.attention': 'Review pending', 'ds.action.connect': 'Set up connection', 'ds.action.refresh': 'Check connection',
605
608
  'ds.sortLabel': 'Session sort', 'ds.sortTime': 'By time', 'ds.sortWorkspace': 'By workspace', 'ds.workspace': 'Workspace', 'ds.workspaceUnknown': 'No workspace', 'ds.wbOrderSaved': 'Order saved on this device',
609
+ 'ds.insights': 'Context & costs',
606
610
  'ds.files': 'Files', 'ds.settings': 'Settings', 'ds.stats': 'Stats', 'ds.menu': 'Menu',
607
611
  'ds.send': 'Send', 'ds.composerPlaceholder': 'Type a message, Enter to send…', 'ds.composerRunning': 'Running…', 'ds.presets': 'Presets', 'ds.sessionRename': 'Rename session', 'ds.sessionRenameTitle': 'Rename session', 'ds.sessionRenamePlaceholder': 'Enter a new session name', 'ds.sessionRenameConfirm': 'Save', 'ds.sessionRenameFailed': 'Rename failed', 'ds.sessionRenameEmpty': 'Session name cannot be empty', 'ds.sessionRenamed': 'Session name updated', 'ds.sessionArchive': 'Archive', 'ds.sessionArchiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'ds.sessionArchived': 'Session archived', 'ds.sessionStop': 'Stop turn', 'ds.sessionStopConfirm': 'Stop the current turn? Queued messages will be kept.', 'ds.sessionStopFailed': 'Stop failed', 'ds.sessionStopRequested': 'Stop requested', 'ds.sessionRecovering': 'Restoring session…', 'ds.sessionReady': 'Session ready', 'ds.sessionRecoveryFailed': 'Session restore failed',
608
612
  'ds.commands': 'Commands',
@@ -712,6 +716,7 @@
712
716
  <script src="../motion.js"></script>
713
717
  <script type="module" src="../morphicons-init.js"></script>
714
718
  <script src="../plugin-center.js"></script>
719
+ <script src="../insights.js"></script>
715
720
  <script src="desktop.js"></script>
716
721
  </body>
717
722
  </html>