dsh-remote-plugin 0.7.1 → 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.
@@ -102,6 +102,32 @@ const state = {
102
102
  view: 'sessions',
103
103
  subagentExpandedSession: ''
104
104
  }
105
+
106
+ // Increment on every identity transition, including A -> B -> A and token edits.
107
+ let connectionGeneration = 0, sessionsRequest = 0, filesRequest = 0, workbenchRequest = 0
108
+ for (const key of ['server', 'token']) {
109
+ let value = state[key]
110
+ Object.defineProperty(state, key, {
111
+ enumerable: true,
112
+ get: () => value,
113
+ set(next) {
114
+ if (next === value) return
115
+ value = next; connectionGeneration++
116
+ state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
117
+ state.current = null; state.history = emptyDesktopHistory()
118
+ state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
119
+ state.workspaces = []; state.archivedIds = []; state.wb = { bound: false, path: '', projects: null }
120
+ window.DshPluginCenter?.close()
121
+ window.DshInsights?.close()
122
+ if (typeof document !== 'undefined') document.getElementById('modal-workspace')?.classList.add('hidden')
123
+ },
124
+ })
125
+ }
126
+ function captureConnection() {
127
+ const generation = connectionGeneration, server = state.server, token = state.token
128
+ return { server, token, valid: () => generation === connectionGeneration && server === state.server && token === state.token }
129
+ }
130
+
105
131
  const streams = {}
106
132
  let pollTimer = null
107
133
  let wsRetryTimer = null
@@ -607,8 +633,10 @@ async function respond(rpcId, value) {
607
633
  return receipt?.accepted === true
608
634
  }
609
635
  async function safeRpc(method, payload, errText) {
636
+ const connection = captureConnection()
610
637
  try { return await rpc(method, payload) }
611
638
  catch (e) {
639
+ if (!connection.valid()) return null
612
640
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return null }
613
641
  toast(errText ? `${errText}:${e.message}` : e.message, 'err')
614
642
  return null
@@ -1512,6 +1540,7 @@ function applyProjection(sessionId, key, value, seq) {
1512
1540
  s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1513
1541
  if (state.current === sessionId) {
1514
1542
  renderSessions()
1543
+ window.DshInsights?.update()
1515
1544
  if (['goal', 'todos'].includes(key)) renderSessionCards()
1516
1545
  }
1517
1546
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
@@ -1538,6 +1567,10 @@ function resyncAfterStreamOpen() {
1538
1567
  void refreshSessions().then(() => resyncCurrentSession())
1539
1568
  }
1540
1569
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1570
+ function openInsights(tab = 'context') {
1571
+ window.DshInsights?.open({ tab, connection: captureConnection(), url: apiUrl,
1572
+ getSession: () => state.byId.get(state.current), getTitle: () => titleOf(state.byId.get(state.current)) })
1573
+ }
1541
1574
  function sessionTitleValue(s) {
1542
1575
  const value = proj(s, 'title', '')
1543
1576
  return value == null ? '' : String(value).trim()
@@ -1611,7 +1644,9 @@ function onSessionEvent(sessionId, event) {
1611
1644
 
1612
1645
  /* ---------------- 会话 ---------------- */
1613
1646
  async function refreshSessions() {
1647
+ const connection = captureConnection(), request = ++sessionsRequest
1614
1648
  const v = await safeRpc('session.list', {}, '')
1649
+ if (!connection.valid() || request !== sessionsRequest) return
1615
1650
  if (!v) { renderSessions(); renderOverviewDesktop(); return }
1616
1651
  state.sessions = v.items || []
1617
1652
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
@@ -1792,6 +1827,7 @@ async function closeSession() {
1792
1827
  showView('view-sessions')
1793
1828
  }
1794
1829
  async function loadHistory() {
1830
+ const connection = captureConnection()
1795
1831
  const id = state.current
1796
1832
  if (!id || state.history.loading) return
1797
1833
  const history = state.history
@@ -1801,14 +1837,14 @@ async function loadHistory() {
1801
1837
  let v
1802
1838
  try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1803
1839
  catch (e) {
1804
- if (state.current !== id || state.history !== history) return
1840
+ if (!connection.valid() || state.current !== id || state.history !== history) return
1805
1841
  state.history.loading = false
1806
1842
  if (e.message === 'AUTH') return
1807
1843
  setSessionRecovery('error', e.message)
1808
1844
  $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
1809
1845
  return
1810
1846
  }
1811
- if (state.current !== id || state.history !== history) return
1847
+ if (!connection.valid() || state.current !== id || state.history !== history) return
1812
1848
  const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
1813
1849
  hydrateSessionProjections(id, v.projections)
1814
1850
  for (const entry of v.events || []) {
@@ -1972,6 +2008,8 @@ function renderHistory() {
1972
2008
  /* ---------------- 会话信息卡(goal / todo / 子代理) ---------------- */
1973
2009
  let sessionCardsRenderGeneration = 0
1974
2010
  async function renderSessionCards() {
2011
+ window.DshInsights?.update()
2012
+ $('btn-insights')?.classList.toggle('hidden', !state.current)
1975
2013
  const renderGeneration = ++sessionCardsRenderGeneration
1976
2014
  const sessionId = state.current
1977
2015
  const box = $('session-cards')
@@ -2514,8 +2552,10 @@ function resetFsForServerDesktop() {
2514
2552
  if (select) select.classList.add('hidden')
2515
2553
  }
2516
2554
  async function openWorkspaceModal() {
2555
+ const connection = captureConnection()
2517
2556
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
2518
2557
  if (!state.fs.path) await loadFs(null, true)
2558
+ if (!connection.valid()) return
2519
2559
  $('workspace-parent-path').textContent = state.fs.path || '~'
2520
2560
  $('workspace-name').value = ''
2521
2561
  $('modal-workspace').classList.remove('hidden')
@@ -2526,31 +2566,39 @@ async function createWorkspace() {
2526
2566
  if (createWorkspace.busy) return
2527
2567
  const name = $('workspace-name').value.trim()
2528
2568
  if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
2569
+ const connection = captureConnection()
2529
2570
  createWorkspace.busy = true
2530
2571
  const parent = state.fs.path || ''
2531
2572
  const button = $('workspace-create')
2532
2573
  button.disabled = true
2533
2574
  try {
2534
2575
  const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
2576
+ if (!connection.valid()) return
2535
2577
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2536
2578
  const data = await res.json().catch(() => ({}))
2579
+ if (!connection.valid()) return
2537
2580
  if (!res.ok) {
2538
2581
  const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
2539
2582
  throw new Error(msg)
2540
2583
  }
2541
2584
  closeWorkspaceModal()
2542
2585
  await loadFs(parent || null, true)
2586
+ if (!connection.valid()) return
2543
2587
  let workspace = null
2544
2588
  try {
2545
2589
  const created = await rpc('workspace.create', { path: data.path })
2590
+ if (!connection.valid()) return
2546
2591
  workspace = created?.workspace || null
2547
2592
  } catch {}
2593
+ if (!connection.valid()) return
2548
2594
  if (workspace?.workspaceId) {
2549
2595
  state.workspaces = [...state.workspaces.filter(item => item.workspaceId !== workspace.workspaceId), workspace]
2550
2596
  }
2551
2597
  const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2552
2598
  const v = await safeRpc('session.create', sessionPayload, t('ds.toastOpFailed'))
2599
+ if (!connection.valid()) return
2553
2600
  await refreshSessions()
2601
+ if (!connection.valid()) return
2554
2602
  if (v?.sessionId) {
2555
2603
  if (workspace?.workspaceId) LS.set('lastNewSessionWorkspaceV1', workspace.workspaceId)
2556
2604
  toast(t('ds.workspaceCreated'), 'ok')
@@ -2559,6 +2607,7 @@ async function createWorkspace() {
2559
2607
  toast(t('ds.workspaceCreatedNoSession'), 'ok')
2560
2608
  }
2561
2609
  } catch (e) {
2610
+ if (!connection.valid()) return
2562
2611
  toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
2563
2612
  } finally {
2564
2613
  createWorkspace.busy = false
@@ -2566,6 +2615,8 @@ async function createWorkspace() {
2566
2615
  }
2567
2616
  }
2568
2617
  async function loadFs(dir, silent) {
2618
+ const connection = captureConnection(), request = ++filesRequest
2619
+ const valid = () => connection.valid() && request === filesRequest
2569
2620
  if (!state.token) {
2570
2621
  $('fs-path').textContent = t('ds.toastAuth')
2571
2622
  $('fs-list').innerHTML = `<div class="ds-empty">${t('ds.toastAuth')}</div>`
@@ -2578,8 +2629,10 @@ async function loadFs(dir, silent) {
2578
2629
  }
2579
2630
  try {
2580
2631
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
2632
+ if (!valid()) return
2581
2633
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2582
2634
  const data = await res.json().catch(() => ({}))
2635
+ if (!valid()) return
2583
2636
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2584
2637
  state.fs.path = data.path
2585
2638
  if (!state.fs.initial) state.fs.initial = data.path
@@ -2601,6 +2654,7 @@ async function loadFs(dir, silent) {
2601
2654
  else window.open(fsApiUrl('/file', { path: row.dataset.fsPath, token: state.token }), '_blank')
2602
2655
  }))
2603
2656
  } catch (e) {
2657
+ if (!valid()) return
2604
2658
  $('fs-path').textContent = target || '~'
2605
2659
  $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
2606
2660
  }
@@ -2742,17 +2796,22 @@ function commitWorkspaceSessionOrder(workspaceId, order) {
2742
2796
  toast(t('ds.wbOrderSaved'), 'ok')
2743
2797
  }
2744
2798
  async function refreshWorkbench({ silent = false } = {}) {
2799
+ const connection = captureConnection(), request = ++workbenchRequest
2800
+ const valid = () => connection.valid() && request === workbenchRequest
2745
2801
  if (!state.token) { renderWorkbench(); return }
2746
2802
  let wb = null
2747
2803
  try {
2748
2804
  wb = await wbGateway('GET', '/workbench')
2805
+ if (!valid()) return
2749
2806
  state.wb.apiMissing = false
2750
2807
  } catch (e) {
2808
+ if (!valid()) return
2751
2809
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
2752
2810
  if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
2753
2811
  if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
2754
2812
  }
2755
2813
  const wl = await safeRpc('workspace.list', {}, '')
2814
+ if (!valid()) return
2756
2815
  state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
2757
2816
  state.workspaces = wl && Array.isArray(wl.items) ? wl.items.slice() : []
2758
2817
  if (!wb) { renderWorkbench(); renderSessions(); return }
@@ -2769,8 +2828,10 @@ async function refreshWorkbench({ silent = false } = {}) {
2769
2828
  const items = Array.isArray(wl.items) ? wl.items.slice() : []
2770
2829
  try {
2771
2830
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
2831
+ if (!valid()) return
2772
2832
  if (listRes.ok) {
2773
2833
  const listData = await listRes.json().catch(() => ({}))
2834
+ if (!valid()) return
2774
2835
  if (Array.isArray(listData.entries)) {
2775
2836
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
2776
2837
  for (let i = items.length - 1; i >= 0; i--) {
@@ -2783,16 +2844,19 @@ async function refreshWorkbench({ silent = false } = {}) {
2783
2844
  if (have.has(wbPathKey(projectPath))) continue
2784
2845
  try {
2785
2846
  const created = await rpc('workspace.create', { path: projectPath })
2847
+ if (!valid()) return
2786
2848
  if (created?.workspace) {
2787
2849
  items.push(created.workspace)
2788
2850
  state.workspaces.push(created.workspace)
2789
2851
  have.add(wbPathKey(projectPath))
2790
2852
  }
2791
2853
  } catch {}
2854
+ if (!valid()) return
2792
2855
  }
2793
2856
  }
2794
2857
  }
2795
2858
  } catch {}
2859
+ if (!valid()) return
2796
2860
  state.wb.projects = orderedWorkspaceItems(items
2797
2861
  .filter(w => wbStrictInside(w.path, state.wb.path))
2798
2862
  .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true })))
@@ -3369,6 +3433,7 @@ function bindUi() {
3369
3433
  if (!e.target.closest('#model-menu') && !e.target.closest('#btn-model')) $('model-menu')?.classList.add('hidden')
3370
3434
  })
3371
3435
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
3436
+ $('btn-insights').addEventListener('click', () => openInsights())
3372
3437
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
3373
3438
  // 赞赏支持
3374
3439
  $('btn-donate').addEventListener('click', openDonateModal)
@@ -3523,11 +3588,12 @@ start()
3523
3588
 
3524
3589
  // Capture the connection so switching servers cannot redirect a plugin mutation.
3525
3590
  document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
3526
- const server = state.server || ''
3527
- const token = state.token
3591
+ const connection = captureConnection()
3592
+ const server = connection.server || ''
3593
+ const token = connection.token
3528
3594
  window.DshPluginCenter.open({
3529
3595
  url: path => server + path,
3530
3596
  headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
3531
- valid: () => (state.server || '') === server && state.token === token,
3597
+ valid: connection.valid,
3532
3598
  })
3533
3599
  })
package/public/index.html CHANGED
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="styles.css">
15
15
  <link rel="stylesheet" href="genui.css">
16
16
  <link rel="stylesheet" href="plugin-center.css">
17
+ <link rel="stylesheet" href="insights.css">
17
18
  </head>
18
19
  <body>
19
20
  <header class="topbar">
@@ -113,6 +114,7 @@
113
114
  <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
114
115
  <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
115
116
  <button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
117
+ <button id="btn-insights" class="icon-btn" data-i18n-title="insights.title" data-i18n-aria="insights.title"><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>
116
118
  <button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
117
119
  <button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
118
120
  </div>
@@ -248,54 +250,15 @@
248
250
  <div class="overview-legacy hidden" aria-hidden="true"><div id="pending-list"></div><div id="jobs-list"></div><span id="pending-count"></span></div>
249
251
  </section>
250
252
 
251
- <!-- Token 统计 -->
252
- <section id="view-stats" class="view hidden stats-view" aria-labelledby="stats-page-title">
253
- <header class="stats-page-head">
254
- <div class="stats-page-copy">
255
- <div class="stats-eyebrow" data-i18n="statsPage.eyebrow">USAGE OVERVIEW</div>
256
- <h1 id="stats-page-title" data-i18n="statsPage.title">Token 统计</h1>
257
- <p class="stats-page-subtitle" data-i18n="statsPage.subtitle">按日查看 Token 用量与费用估算</p>
258
- </div>
259
- <div class="stats-date-wrap">
260
- <span class="stats-date-label" data-i18n="statsPage.latest">最新日期</span>
261
- <span id="stats-sub" class="stats-date">—</span>
262
- </div>
263
- </header>
264
- <div class="stats-toolbar">
265
- <span class="stats-toolbar-label" data-i18n="statsPage.chartMetric">图表指标</span>
266
- <div id="stats-mode-picker" class="stats-mode-picker" role="group" data-i18n-aria="statsPage.chartMetric">
267
- <button type="button" class="stats-mode-btn active" data-stats-mode="token" aria-pressed="true" data-i18n="statsPage.modeTokens">Token 数</button>
268
- <button type="button" class="stats-mode-btn" data-stats-mode="cost" aria-pressed="false" data-i18n="statsPage.modeCost">费用(官方)</button>
269
- </div>
270
- </div>
271
- <div class="stats-cards" id="stats-cards"></div>
272
- <section class="stats-panel" data-stats-panel="cost" aria-labelledby="stats-cost-title">
273
- <div class="stats-panel-head">
274
- <div>
275
- <div class="stats-panel-kicker" data-i18n="statsPage.costKicker">ESTIMATED COST</div>
276
- <h2 id="stats-cost-title" class="stats-panel-title" data-i18n="statsPage.costTrend">费用趋势</h2>
277
- </div>
278
- <div id="stats-legend" class="stats-legend"></div>
279
- </div>
280
- <div id="stats-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.costChartLabel"></div>
281
- </section>
282
- <section class="stats-panel" data-stats-panel="token" aria-labelledby="stats-token-title">
283
- <div class="stats-panel-head">
284
- <div>
285
- <div class="stats-panel-kicker" data-i18n="statsPage.tokenKicker">TOKEN VOLUME</div>
286
- <h2 id="stats-token-title" class="stats-panel-title" data-i18n="statsPage.tokenTrend">Token 量趋势</h2>
287
- </div>
288
- <div id="stats-token-legend" class="stats-legend"></div>
289
- </div>
290
- <div id="stats-token-chart" class="stats-chart" role="list" aria-live="polite" data-i18n-aria="statsPage.tokenChartLabel"></div>
291
- </section>
292
- <p id="stats-note" class="stats-note"></p>
253
+ <!-- 插件管理:独立页面,与当前连接绑定 -->
254
+ <section id="view-plugins" class="view hidden plugins-view" aria-label="插件管理">
255
+ <div id="plugin-page"></div>
293
256
  </section>
294
257
 
295
258
  <!-- 设置 -->
296
259
  <section id="view-settings" class="view hidden">
297
260
  <div id="settings-home">
298
- <button type="button" id="btn-plugin-center" class="setting-row setting-link"><span>插件中心 · 已安装 / 发现插件</span></button>
261
+ <button type="button" id="btn-plugin-center" class="setting-row setting-link"><span data-i18n="nav.plugins">插件</span></button>
299
262
  <div class="settings-group">
300
263
  <button type="button" class="setting-row setting-link" data-settings-group="general">
301
264
  <div><div class="setting-name" data-i18n="settings.groupGeneral">通用</div><div class="setting-desc" data-i18n="settings.groupGeneralDesc">显示工具调用、预设提示词</div></div>
@@ -619,9 +582,9 @@
619
582
  <span class="nav-home-orb"><span class="nav-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="7.5"/><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3" opacity=".75"/></svg></span></span>
620
583
  <span data-i18n="nav.home">主页</span><b id="nav-pending" class="nav-badge hidden"></b>
621
584
  </button>
622
- <button data-view="view-stats" class="nav-btn">
623
- <span class="nav-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7"/><path d="M3 19h18" opacity=".55"/></svg></span>
624
- <span data-i18n="nav.stats">统计</span>
585
+ <button data-view="view-plugins" class="nav-btn">
586
+ <span class="nav-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 3v4h6V3M7 7h10v6a5 5 0 0 1-10 0V7ZM12 18v3"/></svg></span>
587
+ <span data-i18n="nav.plugins">插件</span>
625
588
  </button>
626
589
  <button data-view="view-settings" class="nav-btn">
627
590
  <span class="nav-ico"><svg class="settings-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.73v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.73l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg></span>
@@ -936,7 +899,7 @@
936
899
  'conn.poll': '轮询', 'conn.pollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
937
900
  'conn.titleGroup': '{group} · {url}({ms})',
938
901
  'common.refreshing': '刷新中…', 'select.choose': '请选择', 'select.cancel': '取消',
939
- 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.overview': '总览', 'nav.home': '主页', 'nav.stats': '统计', 'nav.settings': '设置',
902
+ 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.overview': '总览', 'nav.home': '主页', 'nav.plugins': '插件', 'nav.settings': '设置',
940
903
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': '主页', 'overview.subtitle': '实时状态与需要你处理的事项',
941
904
  'overview.systemPulse': '系统链路', 'overview.nominal': '系统链路正常', 'overview.degraded': '部分链路需要关注', 'overview.offline': '连接已断开', 'overview.checking': '检查中…', 'overview.components': '{n}/4 个模块在线', 'overview.live': 'LIVE', 'overview.allLinked': '全部链路', 'overview.offlineCore': 'OFFLINE',
942
905
  'overview.gateway': '网关', 'overview.dsh': 'DSH 上游', 'overview.mux': '实时 mux', 'overview.host': '实时 host', 'overview.online': '在线', 'overview.offlineShort': '离线',
@@ -946,14 +909,6 @@
946
909
  'overview.action.openSession': '打开最近会话', 'overview.action.newSession': '新建会话', 'overview.action.attention': '处理待办', 'overview.action.connect': '去设置连接', 'overview.action.refresh': '重新检查连接',
947
910
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': '公告与投票', 'overview.communityHistory': '历史公告', 'overview.communityEmpty': '暂无未读公告',
948
911
  'overview.communityPoll': '投票', 'overview.communityNotice': '公告', 'overview.communityNew': '新', 'overview.communityVote': '参与投票', 'overview.communityView': '查看公告', 'overview.communityVoted': '已参与',
949
- 'statsPage.eyebrow': '使用概览', 'statsPage.title': 'Token 统计', 'statsPage.subtitle': '按日查看 Token 用量与费用估算', 'statsPage.latest': '最新日期', 'statsPage.chartMetric': '图表指标', 'statsPage.modeTokens': 'Token 数', 'statsPage.modeCost': '费用(官方)',
950
- 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': '近 7 天费用趋势', 'statsPage.tokenChartLabel': '近 7 天 Token 用量趋势',
951
- 'statsPage.today': '今日', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': '今日用量构成', 'statsPage.estimated': '估算费用', 'statsPage.peakShareLabel': '高峰费用占比 {n}%',
952
- 'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比', 'statsPage.costTrend': '费用趋势', 'statsPage.tokenTrend': 'Token 量趋势', 'statsPage.tokenTotal': 'Token 总量',
953
- 'statsPage.input': '未缓存输入', 'statsPage.cacheRead': '缓存命中', 'statsPage.cacheWrite': '缓存写入', 'statsPage.output': '输出',
954
- 'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
955
- 'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
956
- 'statsPage.note': '注:Token 量按 DSH usage 事件记录,第三方 provider 需提供 usage 才能计入;费用仅对已知 DeepSeek 模型估算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
957
912
  'home.newSession': '+ 新会话', 'home.newWorkspace': '+ 工作区', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
958
913
  'wb.unbound': '未绑定工作台', 'wb.bound': '绑定 {title}', 'wb.noProjects': '暂无项目', 'wb.noSessions': '暂无会话', 'wb.newSession': '新会话', 'wb.orderSaved': '顺序已保存到当前设备', 'wb.flatHidden': '工作台会话已收起({n} 个)', 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------',
959
914
  'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.exists': '该目录已存在', 'workspace.invalidName': '名称不能包含路径分隔符', 'workspace.createFailed': '创建工作区失败:{msg}', 'workspace.created': '工作区已创建', 'workspace.createdNoSession': '工作区已创建,但新会话未能打开',
@@ -1007,6 +962,7 @@
1007
962
  'event.systemReminder': '系统提示',
1008
963
  'block.image': '图片', 'block.thinking': '🧠 思考过程', 'block.thinkingLive': '🧠 思考中…', 'block.toolCall': '工具调用', 'block.toolResult': '工具结果', 'block.unknown': '块 · {type}',
1009
964
  'truncated': '…(截断)',
965
+ 'insights.title': '上下文与费用',
1010
966
  'stats.title': '本轮统计', 'stats.roundTitle': '本轮统计', 'stats.turnsSteps': '轮次 / 步骤',
1011
967
  'stats.llmTime': '模型耗时', 'stats.minutes': ' 分钟', 'stats.outputCache': '输出 / 缓存读',
1012
968
  'stats.ctxPressure': '上下文压力', 'stats.permission': '权限', 'stats.empty': '暂无统计数据',
@@ -1179,7 +1135,7 @@
1179
1135
  'conn.poll': 'Polling', 'conn.pollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
1180
1136
  'conn.titleGroup': '{group} · {url} ({ms})',
1181
1137
  'common.refreshing': 'Refreshing…', 'select.choose': 'Choose an option', 'select.cancel': 'Cancel',
1182
- 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.overview': 'Overview', 'nav.home': 'Home', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
1138
+ 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.overview': 'Overview', 'nav.home': 'Home', 'nav.plugins': 'Plugins', 'nav.settings': 'Settings',
1183
1139
  'overview.eyebrow': 'SYSTEM OVERVIEW', 'overview.title': 'Home', 'overview.subtitle': 'Live status and items needing your attention',
1184
1140
  'overview.systemPulse': 'System pulse', 'overview.nominal': 'System nominal', 'overview.degraded': 'Some links need attention', 'overview.offline': 'Connection offline', 'overview.checking': 'Checking…', 'overview.components': '{n}/4 components online', 'overview.live': 'LIVE', 'overview.allLinked': 'ALL LINKED', 'overview.offlineCore': 'OFFLINE',
1185
1141
  'overview.gateway': 'Gateway', 'overview.dsh': 'DSH upstream', 'overview.mux': 'Live mux', 'overview.host': 'Live host', 'overview.online': 'Online', 'overview.offlineShort': 'Offline',
@@ -1189,14 +1145,6 @@
1189
1145
  'overview.action.openSession': 'Open recent session', 'overview.action.newSession': 'New session', 'overview.action.attention': 'Review pending', 'overview.action.connect': 'Set up connection', 'overview.action.refresh': 'Check connection',
1190
1146
  'overview.communityEyebrow': 'COMMUNITY BOARD', 'overview.communityTitle': 'Announcements & polls', 'overview.communityHistory': 'History', 'overview.communityEmpty': 'No unread announcements',
1191
1147
  'overview.communityPoll': 'Poll', 'overview.communityNotice': 'Notice', 'overview.communityNew': 'New', 'overview.communityVote': 'Vote now', 'overview.communityView': 'View notice', 'overview.communityVoted': 'Voted',
1192
- 'statsPage.eyebrow': 'USAGE OVERVIEW', 'statsPage.title': 'Token stats', 'statsPage.subtitle': 'Daily token volume and estimated costs', 'statsPage.latest': 'Latest date', 'statsPage.chartMetric': 'Chart metric', 'statsPage.modeTokens': 'Token count', 'statsPage.modeCost': 'Cost (official)',
1193
- 'statsPage.costKicker': 'ESTIMATED COST', 'statsPage.tokenKicker': 'TOKEN VOLUME', 'statsPage.costChartLabel': 'Cost trend for the last 7 days', 'statsPage.tokenChartLabel': 'Token volume trend for the last 7 days',
1194
- 'statsPage.today': 'Today', 'statsPage.tokenUnit': 'Tokens', 'statsPage.breakdown': 'Today’s usage breakdown', 'statsPage.estimated': 'Estimated cost', 'statsPage.peakShareLabel': 'Peak cost share {n}%',
1195
- 'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share', 'statsPage.costTrend': 'Cost trend', 'statsPage.tokenTrend': 'Token volume trend', 'statsPage.tokenTotal': 'Total tokens',
1196
- 'statsPage.input': 'Uncached input', 'statsPage.cacheRead': 'Cache read', 'statsPage.cacheWrite': 'Cache write', 'statsPage.output': 'Output',
1197
- 'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
1198
- 'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
1199
- 'statsPage.note': 'Note: token volume uses DSH usage events; a third-party provider must expose usage to be counted. Costs are estimates for known DeepSeek models and may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
1200
1148
  'home.newSession': '+ New session', 'home.newWorkspace': '+ Workspace', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
1201
1149
  'wb.unbound': 'No workbench bound', 'wb.bound': 'Bound {title}', 'wb.noProjects': 'No projects', 'wb.noSessions': 'No sessions', 'wb.newSession': 'New session', 'wb.orderSaved': 'Order saved on this device', 'wb.flatHidden': 'Workbench sessions folded ({n})', 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------',
1202
1150
  'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.exists': 'That folder already exists', 'workspace.invalidName': 'The name cannot contain path separators', 'workspace.createFailed': 'Could not create workspace: {msg}', 'workspace.created': 'Workspace created', 'workspace.createdNoSession': 'Workspace created, but the new session could not be opened',
@@ -1250,6 +1198,7 @@
1250
1198
  'event.systemReminder': 'System reminder',
1251
1199
  'block.image': 'Image', 'block.thinking': '🧠 Thinking', 'block.thinkingLive': '🧠 Thinking…', 'block.toolCall': 'Tool call', 'block.toolResult': 'Tool result', 'block.unknown': 'Block · {type}',
1252
1200
  'truncated': '…(truncated)',
1201
+ 'insights.title': 'Context & costs',
1253
1202
  'stats.title': 'This round', 'stats.roundTitle': 'This round', 'stats.turnsSteps': 'Turns / steps',
1254
1203
  'stats.llmTime': 'LLM time', 'stats.minutes': ' min', 'stats.outputCache': 'Output / cache read',
1255
1204
  'stats.ctxPressure': 'Context pressure', 'stats.permission': 'Permission', 'stats.empty': 'No stats yet',
@@ -1416,6 +1365,7 @@
1416
1365
  <script src="motion.js"></script>
1417
1366
  <script type="module" src="morphicons-init.js"></script>
1418
1367
  <script src="plugin-center.js"></script>
1368
+ <script src="insights.js"></script>
1419
1369
  <script src="app.js"></script>
1420
1370
  </body>
1421
1371
  </html>
@@ -0,0 +1,14 @@
1
+ .ins-dialog{--bg:var(--dsr-bg,#f7f8fa);--surface:var(--dsr-panel,#fff);--border:var(--dsr-line,#d9dde5);--text:var(--dsr-text,#1d2635);--muted:var(--dsr-muted,#687386);--accent:var(--dsr-accent,#3979d6);width:min(820px,calc(100vw - 32px));max-height:88dvh;padding:0;border:1px solid var(--border);border-radius:20px;background:var(--bg);color:var(--text);box-shadow:0 24px 90px #0005}
2
+ .ins-dialog::backdrop{background:#10182780;backdrop-filter:blur(5px)}
3
+ .ins-dialog[open]{display:flex;flex-direction:column}.ins-dialog *{box-sizing:border-box}
4
+ .ins-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 24px 16px}.ins-header h2{font-size:21px;line-height:1.4;margin:0}.ins-header p{font-size:12px;color:var(--muted,#687386);margin:5px 0 0;overflow-wrap:anywhere}
5
+ .ins-dialog button{font:inherit;color:inherit;cursor:pointer;border:1px solid var(--border,#d9dde5);background:var(--surface,var(--card,#fff));border-radius:10px;min-height:40px;padding:8px 14px}.ins-header button{font-size:24px;min-width:42px;padding:2px 10px;flex-shrink:0}
6
+ .ins-dialog button:focus-visible{outline:2px solid var(--accent,#3979d6);outline-offset:3px}
7
+ .ins-tabs{display:flex;gap:8px;align-items:center;padding:0 24px 16px;border-bottom:1px solid var(--border,#d9dde5)}.ins-tabs [aria-selected=true]{background:var(--accent,#3979d6);border-color:var(--accent,#3979d6);color:#fff}.ins-tabs [data-ins-refresh]{margin-left:auto;font-size:12px}
8
+ .ins-content{overflow-y:auto;overscroll-behavior:contain;padding:20px 24px;min-height:280px}.ins-section{padding:18px;background:var(--surface,var(--card,#fff));border:1px solid var(--border,#d9dde5);border-radius:14px;margin-bottom:14px}.ins-section h3{font-size:14px;margin:0 0 14px;font-weight:650}.ins-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.ins-section-head h3{margin:0}.ins-badge{font-size:10px;color:var(--muted,#687386);border:1px solid var(--border,#d9dde5);padding:4px 8px;border-radius:20px;white-space:nowrap}
9
+ .ins-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.ins-metric{display:flex;flex-direction:column;gap:5px;min-width:0}.ins-metric>span{font-size:12px;color:var(--muted,#687386)}.ins-metric strong{font-size:23px;line-height:1.4;font-weight:650;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.ins-metric small,.ins-plan small{font-size:11px;color:var(--muted,#687386);overflow-wrap:anywhere}.ins-note{font-size:12px;line-height:1.7;color:var(--muted,#687386);margin:12px 0 0}.ins-source{font-size:11px;color:var(--muted,#687386);line-height:1.8;margin:16px 0 0}.ins-empty{padding:32px 8px;line-height:1.8;color:var(--muted,#687386);font-size:14px}
10
+ .ins-progress{height:7px;background:var(--border,#e5e9f0);border-radius:9px;overflow:hidden;margin:14px 0 0}.ins-progress>span{display:block;height:100%;background:var(--accent,#3979d6);border-radius:inherit}.ins-category{display:grid;grid-template-columns:85px minmax(0,1fr) 70px;align-items:center;gap:12px;padding:7px 0;font-size:12px}.ins-category strong{text-align:right;font-variant-numeric:tabular-nums;font-weight:500}.ins-track{height:6px;background:var(--border,#e5e9f0);border-radius:5px;overflow:hidden}.ins-track i{display:block;height:100%;background:var(--accent,#3979d6);opacity:.65;border-radius:inherit}
11
+ .ins-events{list-style:none;padding:0;margin:0}.ins-events li{padding:10px 0;border-bottom:1px solid var(--border,#e5e9f0);font-size:12px}.ins-events li:last-child{border:0}.ins-events li>div{display:flex;justify-content:space-between;gap:12px}.ins-events time,.ins-events li>span{font-size:11px;color:var(--muted,#687386)}.ins-events li>span{display:block;margin-top:4px;overflow-wrap:anywhere}.ins-plan{padding:12px 0;border-bottom:1px solid var(--border,#e5e9f0)}.ins-plan:last-child{border:0;padding-bottom:0}.ins-plan>strong{font-size:13px}.ins-plan small{display:block;margin-top:8px}.ins-plan-row{display:flex;justify-content:space-between;gap:12px;font-size:12px;margin-top:14px}.ins-plan .ins-progress{margin-top:7px}
12
+ @media(max-width:560px){.ins-dialog{width:100%;max-width:100%;height:88dvh;max-height:88dvh;margin:auto 0 0;border-radius:20px 20px 0 0;border-bottom:0}.ins-header{padding:20px 18px 14px}.ins-tabs{padding:0 18px 14px;gap:6px}.ins-tabs button{padding:8px 12px;font-size:13px}.ins-content{padding:16px 14px max(20px,env(safe-area-inset-bottom))}.ins-section{padding:16px}.ins-metric strong{font-size:20px}.ins-category{grid-template-columns:76px minmax(0,1fr) 58px;gap:8px}.ins-events li>div{flex-wrap:wrap;gap:4px}.ins-badge{font-size:9px}}
13
+ @media(prefers-reduced-motion:reduce){.ins-dialog::backdrop{backdrop-filter:none}}
14
+ .ins-tabs [aria-selected=true]{color:var(--dsr-on-accent,#fff)}