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.
@@ -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
@@ -501,9 +527,11 @@ function closeNotesModal() {
501
527
  }
502
528
  async function checkNotesOnStart() {
503
529
  try {
504
- const res = await fetch('../update.json?t=' + Date.now())
505
- if (!res.ok) return
506
- openNotesModal(await res.json())
530
+ const res = await fetch('../update.json?t=' + Date.now(), { cache: 'no-store' })
531
+ if (!res.ok || res.redirected || !String(res.headers.get('content-type') || '').includes('application/json')) return
532
+ const info = await res.json()
533
+ if (!info || typeof info !== 'object' || !info.version) return
534
+ openNotesModal(info)
507
535
  } catch {}
508
536
  }
509
537
  async function submitFeedback() {
@@ -605,8 +633,10 @@ async function respond(rpcId, value) {
605
633
  return receipt?.accepted === true
606
634
  }
607
635
  async function safeRpc(method, payload, errText) {
636
+ const connection = captureConnection()
608
637
  try { return await rpc(method, payload) }
609
638
  catch (e) {
639
+ if (!connection.valid()) return null
610
640
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return null }
611
641
  toast(errText ? `${errText}:${e.message}` : e.message, 'err')
612
642
  return null
@@ -1510,6 +1540,7 @@ function applyProjection(sessionId, key, value, seq) {
1510
1540
  s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1511
1541
  if (state.current === sessionId) {
1512
1542
  renderSessions()
1543
+ window.DshInsights?.update()
1513
1544
  if (['goal', 'todos'].includes(key)) renderSessionCards()
1514
1545
  }
1515
1546
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
@@ -1536,6 +1567,10 @@ function resyncAfterStreamOpen() {
1536
1567
  void refreshSessions().then(() => resyncCurrentSession())
1537
1568
  }
1538
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
+ }
1539
1574
  function sessionTitleValue(s) {
1540
1575
  const value = proj(s, 'title', '')
1541
1576
  return value == null ? '' : String(value).trim()
@@ -1609,7 +1644,9 @@ function onSessionEvent(sessionId, event) {
1609
1644
 
1610
1645
  /* ---------------- 会话 ---------------- */
1611
1646
  async function refreshSessions() {
1647
+ const connection = captureConnection(), request = ++sessionsRequest
1612
1648
  const v = await safeRpc('session.list', {}, '')
1649
+ if (!connection.valid() || request !== sessionsRequest) return
1613
1650
  if (!v) { renderSessions(); renderOverviewDesktop(); return }
1614
1651
  state.sessions = v.items || []
1615
1652
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
@@ -1790,6 +1827,7 @@ async function closeSession() {
1790
1827
  showView('view-sessions')
1791
1828
  }
1792
1829
  async function loadHistory() {
1830
+ const connection = captureConnection()
1793
1831
  const id = state.current
1794
1832
  if (!id || state.history.loading) return
1795
1833
  const history = state.history
@@ -1799,14 +1837,14 @@ async function loadHistory() {
1799
1837
  let v
1800
1838
  try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1801
1839
  catch (e) {
1802
- if (state.current !== id || state.history !== history) return
1840
+ if (!connection.valid() || state.current !== id || state.history !== history) return
1803
1841
  state.history.loading = false
1804
1842
  if (e.message === 'AUTH') return
1805
1843
  setSessionRecovery('error', e.message)
1806
1844
  $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
1807
1845
  return
1808
1846
  }
1809
- if (state.current !== id || state.history !== history) return
1847
+ if (!connection.valid() || state.current !== id || state.history !== history) return
1810
1848
  const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
1811
1849
  hydrateSessionProjections(id, v.projections)
1812
1850
  for (const entry of v.events || []) {
@@ -1970,6 +2008,8 @@ function renderHistory() {
1970
2008
  /* ---------------- 会话信息卡(goal / todo / 子代理) ---------------- */
1971
2009
  let sessionCardsRenderGeneration = 0
1972
2010
  async function renderSessionCards() {
2011
+ window.DshInsights?.update()
2012
+ $('btn-insights')?.classList.toggle('hidden', !state.current)
1973
2013
  const renderGeneration = ++sessionCardsRenderGeneration
1974
2014
  const sessionId = state.current
1975
2015
  const box = $('session-cards')
@@ -2512,8 +2552,10 @@ function resetFsForServerDesktop() {
2512
2552
  if (select) select.classList.add('hidden')
2513
2553
  }
2514
2554
  async function openWorkspaceModal() {
2555
+ const connection = captureConnection()
2515
2556
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
2516
2557
  if (!state.fs.path) await loadFs(null, true)
2558
+ if (!connection.valid()) return
2517
2559
  $('workspace-parent-path').textContent = state.fs.path || '~'
2518
2560
  $('workspace-name').value = ''
2519
2561
  $('modal-workspace').classList.remove('hidden')
@@ -2524,31 +2566,39 @@ async function createWorkspace() {
2524
2566
  if (createWorkspace.busy) return
2525
2567
  const name = $('workspace-name').value.trim()
2526
2568
  if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
2569
+ const connection = captureConnection()
2527
2570
  createWorkspace.busy = true
2528
2571
  const parent = state.fs.path || ''
2529
2572
  const button = $('workspace-create')
2530
2573
  button.disabled = true
2531
2574
  try {
2532
2575
  const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
2576
+ if (!connection.valid()) return
2533
2577
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2534
2578
  const data = await res.json().catch(() => ({}))
2579
+ if (!connection.valid()) return
2535
2580
  if (!res.ok) {
2536
2581
  const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
2537
2582
  throw new Error(msg)
2538
2583
  }
2539
2584
  closeWorkspaceModal()
2540
2585
  await loadFs(parent || null, true)
2586
+ if (!connection.valid()) return
2541
2587
  let workspace = null
2542
2588
  try {
2543
2589
  const created = await rpc('workspace.create', { path: data.path })
2590
+ if (!connection.valid()) return
2544
2591
  workspace = created?.workspace || null
2545
2592
  } catch {}
2593
+ if (!connection.valid()) return
2546
2594
  if (workspace?.workspaceId) {
2547
2595
  state.workspaces = [...state.workspaces.filter(item => item.workspaceId !== workspace.workspaceId), workspace]
2548
2596
  }
2549
2597
  const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2550
2598
  const v = await safeRpc('session.create', sessionPayload, t('ds.toastOpFailed'))
2599
+ if (!connection.valid()) return
2551
2600
  await refreshSessions()
2601
+ if (!connection.valid()) return
2552
2602
  if (v?.sessionId) {
2553
2603
  if (workspace?.workspaceId) LS.set('lastNewSessionWorkspaceV1', workspace.workspaceId)
2554
2604
  toast(t('ds.workspaceCreated'), 'ok')
@@ -2557,6 +2607,7 @@ async function createWorkspace() {
2557
2607
  toast(t('ds.workspaceCreatedNoSession'), 'ok')
2558
2608
  }
2559
2609
  } catch (e) {
2610
+ if (!connection.valid()) return
2560
2611
  toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
2561
2612
  } finally {
2562
2613
  createWorkspace.busy = false
@@ -2564,6 +2615,8 @@ async function createWorkspace() {
2564
2615
  }
2565
2616
  }
2566
2617
  async function loadFs(dir, silent) {
2618
+ const connection = captureConnection(), request = ++filesRequest
2619
+ const valid = () => connection.valid() && request === filesRequest
2567
2620
  if (!state.token) {
2568
2621
  $('fs-path').textContent = t('ds.toastAuth')
2569
2622
  $('fs-list').innerHTML = `<div class="ds-empty">${t('ds.toastAuth')}</div>`
@@ -2576,8 +2629,10 @@ async function loadFs(dir, silent) {
2576
2629
  }
2577
2630
  try {
2578
2631
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
2632
+ if (!valid()) return
2579
2633
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2580
2634
  const data = await res.json().catch(() => ({}))
2635
+ if (!valid()) return
2581
2636
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2582
2637
  state.fs.path = data.path
2583
2638
  if (!state.fs.initial) state.fs.initial = data.path
@@ -2599,6 +2654,7 @@ async function loadFs(dir, silent) {
2599
2654
  else window.open(fsApiUrl('/file', { path: row.dataset.fsPath, token: state.token }), '_blank')
2600
2655
  }))
2601
2656
  } catch (e) {
2657
+ if (!valid()) return
2602
2658
  $('fs-path').textContent = target || '~'
2603
2659
  $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
2604
2660
  }
@@ -2740,17 +2796,22 @@ function commitWorkspaceSessionOrder(workspaceId, order) {
2740
2796
  toast(t('ds.wbOrderSaved'), 'ok')
2741
2797
  }
2742
2798
  async function refreshWorkbench({ silent = false } = {}) {
2799
+ const connection = captureConnection(), request = ++workbenchRequest
2800
+ const valid = () => connection.valid() && request === workbenchRequest
2743
2801
  if (!state.token) { renderWorkbench(); return }
2744
2802
  let wb = null
2745
2803
  try {
2746
2804
  wb = await wbGateway('GET', '/workbench')
2805
+ if (!valid()) return
2747
2806
  state.wb.apiMissing = false
2748
2807
  } catch (e) {
2808
+ if (!valid()) return
2749
2809
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
2750
2810
  if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
2751
2811
  if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
2752
2812
  }
2753
2813
  const wl = await safeRpc('workspace.list', {}, '')
2814
+ if (!valid()) return
2754
2815
  state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
2755
2816
  state.workspaces = wl && Array.isArray(wl.items) ? wl.items.slice() : []
2756
2817
  if (!wb) { renderWorkbench(); renderSessions(); return }
@@ -2767,8 +2828,10 @@ async function refreshWorkbench({ silent = false } = {}) {
2767
2828
  const items = Array.isArray(wl.items) ? wl.items.slice() : []
2768
2829
  try {
2769
2830
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
2831
+ if (!valid()) return
2770
2832
  if (listRes.ok) {
2771
2833
  const listData = await listRes.json().catch(() => ({}))
2834
+ if (!valid()) return
2772
2835
  if (Array.isArray(listData.entries)) {
2773
2836
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
2774
2837
  for (let i = items.length - 1; i >= 0; i--) {
@@ -2781,16 +2844,19 @@ async function refreshWorkbench({ silent = false } = {}) {
2781
2844
  if (have.has(wbPathKey(projectPath))) continue
2782
2845
  try {
2783
2846
  const created = await rpc('workspace.create', { path: projectPath })
2847
+ if (!valid()) return
2784
2848
  if (created?.workspace) {
2785
2849
  items.push(created.workspace)
2786
2850
  state.workspaces.push(created.workspace)
2787
2851
  have.add(wbPathKey(projectPath))
2788
2852
  }
2789
2853
  } catch {}
2854
+ if (!valid()) return
2790
2855
  }
2791
2856
  }
2792
2857
  }
2793
2858
  } catch {}
2859
+ if (!valid()) return
2794
2860
  state.wb.projects = orderedWorkspaceItems(items
2795
2861
  .filter(w => wbStrictInside(w.path, state.wb.path))
2796
2862
  .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true })))
@@ -3367,6 +3433,7 @@ function bindUi() {
3367
3433
  if (!e.target.closest('#model-menu') && !e.target.closest('#btn-model')) $('model-menu')?.classList.add('hidden')
3368
3434
  })
3369
3435
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
3436
+ $('btn-insights').addEventListener('click', () => openInsights())
3370
3437
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
3371
3438
  // 赞赏支持
3372
3439
  $('btn-donate').addEventListener('click', openDonateModal)
@@ -3521,11 +3588,12 @@ start()
3521
3588
 
3522
3589
  // Capture the connection so switching servers cannot redirect a plugin mutation.
3523
3590
  document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
3524
- const server = state.server || ''
3525
- const token = state.token
3591
+ const connection = captureConnection()
3592
+ const server = connection.server || ''
3593
+ const token = connection.token
3526
3594
  window.DshPluginCenter.open({
3527
3595
  url: path => server + path,
3528
3596
  headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
3529
- valid: () => (state.server || '') === server && state.token === token,
3597
+ valid: connection.valid,
3530
3598
  })
3531
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)}