@goodandready/dsh-cost-meter 0.8.25 → 0.8.27

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/lib/client.js CHANGED
@@ -67,8 +67,38 @@ window.__ModuleLoader__.load({
67
67
  'action.sync_ok': 'Catalog updated',
68
68
  'action.sync_err': 'Sync failed',
69
69
  'action.sync_rate_limited': 'Wait {seconds}s',
70
+ 'action.export_csv': 'Export CSV',
71
+ 'action.export_json': 'Export JSON',
72
+ 'action.export_png': 'PNG Card',
73
+ 'action.exporting_png': 'Generating…',
74
+ 'action.exported': 'Downloaded!',
75
+ 'curr.select': 'Currency',
70
76
  'action.copy_report': 'Copy Summary',
71
77
  'action.copied': 'Copied!',
78
+ 'alert.pre_switch_peak': '⚠️ Peak tariff starts in {time} (higher rates)',
79
+ 'alert.pre_switch_offpeak': '🎉 Off-peak discount starts in {time} (-50% savings)',
80
+ 'section.alerts': 'Tariff Transition Alerts',
81
+ 'section.alerts_desc': 'Visual pulse and pre-switch alerts before peak and off-peak rate changes',
82
+ 'field.alertBeforeSwitch': 'Pre-Switch Tariff Alerts',
83
+ 'hint.alertBeforeSwitch': 'Show visual pulse on chip and alert banner before rate transitions.',
84
+ 'field.alertLeadMinutes': 'Alert Lead Time (minutes)',
85
+ 'hint.alertLeadMinutes': 'Minutes before tariff switch to activate pre-switch alert (1 to 30).',
86
+ 'field.browserNotifications': 'Desktop Notifications',
87
+ 'hint.browserNotifications': 'Send HTML5 browser notification on tariff transitions when tab is in background.',
88
+ 'action.test_notification': 'Test Notification',
89
+ 'action.tested_notification': 'Notification Sent!',
90
+ 'turn.action_btn': 'Turn tokens',
91
+ 'turn.modal_title': 'Message Token & Cost Inspector',
92
+ 'turn.tokens_total': 'Turn Tokens',
93
+ 'turn.cost_title': 'Turn Cost',
94
+ 'turn.cache_hit_rate': 'Cache Hit',
95
+ 'turn.duration': 'Duration',
96
+ 'turn.uncached_in': 'Uncached Input',
97
+ 'turn.cache_read': 'Cache Read',
98
+ 'turn.output': 'Output',
99
+ 'turn.history_title': 'Turn History & Inspector',
100
+ 'turn.show_history': 'Show turn history',
101
+ 'turn.hide_history': 'Hide turn history',
72
102
  'savings.hint': '💡 50% discount starts in {time} ({clock} {zone})',
73
103
  'savings.cache_saved': '⚡ Context cache saved: ≈ {amount} ({percent}%)',
74
104
  'budget.exceeded': '⚠️ Budget limit exceeded: {limit}',
@@ -155,8 +185,38 @@ window.__ModuleLoader__.load({
155
185
  'action.sync_ok': '目录已更新',
156
186
  'action.sync_err': '同步失败',
157
187
  'action.sync_rate_limited': '请等待 {seconds} 秒',
188
+ 'action.export_csv': '导出 CSV',
189
+ 'action.export_json': '导出 JSON',
190
+ 'action.export_png': '生成图片卡片',
191
+ 'action.exporting_png': '生成中…',
192
+ 'action.exported': '已下载!',
193
+ 'curr.select': '货币',
158
194
  'action.copy_report': '复制消费报告',
159
195
  'action.copied': '已复制!',
196
+ 'alert.pre_switch_peak': '⚠️ 高峰期计费将在 {time} 后开始(标准费率)',
197
+ 'alert.pre_switch_offpeak': '🎉 特惠期将在 {time} 后开始(5折优惠)',
198
+ 'section.alerts': '费率切换预警',
199
+ 'section.alerts_desc': '在高峰期与特惠期费率切换前显示脉冲提醒与横幅',
200
+ 'field.alertBeforeSwitch': '费率切换预警',
201
+ 'hint.alertBeforeSwitch': '在费率切换前在状态芯片上显示脉冲光晕与提示横幅。',
202
+ 'field.alertLeadMinutes': '预警提前时间(分钟)',
203
+ 'hint.alertLeadMinutes': '在费率切换前提前多少分钟发出预警(1 至 30 分钟)。',
204
+ 'field.browserNotifications': '桌面系统通知',
205
+ 'hint.browserNotifications': '在费率切换时发送 HTML5 桌面通知(后台标签页亦有效)。',
206
+ 'action.test_notification': '测试通知',
207
+ 'action.tested_notification': '通知已发送!',
208
+ 'turn.action_btn': 'Token 详情',
209
+ 'turn.modal_title': '单次回复 Token 与成本详情',
210
+ 'turn.tokens_total': '本轮 Token 总量',
211
+ 'turn.cost_title': '单次成本',
212
+ 'turn.cache_hit_rate': '缓存命中率',
213
+ 'turn.duration': '耗时',
214
+ 'turn.uncached_in': '未命中输入',
215
+ 'turn.cache_read': '命中缓存读取',
216
+ 'turn.output': '模型输出',
217
+ 'turn.history_title': '轮次明细与检查器',
218
+ 'turn.show_history': '展开轮次明细',
219
+ 'turn.hide_history': '收起轮次明细',
160
220
  'savings.hint': '💡 5 折优惠将在 {time} 后生效 ({clock} {zone})',
161
221
  'savings.cache_saved': '⚡ 上下文缓存节省:≈ {amount} ({percent}%)',
162
222
  'budget.exceeded': '⚠️ 已超出预算上限:{limit}',
@@ -320,6 +380,31 @@ window.__ModuleLoader__.load({
320
380
  .dcm-proj-box { margin: 6px 0; padding: 6px 10px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 6%, transparent); display: flex; justify-content: space-between; align-items: center; font-size: 11px; }
321
381
  .dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
322
382
  .dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
383
+
384
+ .dcm-chip-alert-peak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 50%, transparent); animation: dcm-pulse-peak 2s infinite ease-in-out; }
385
+ .dcm-chip-alert-offpeak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); animation: dcm-pulse-offpeak 2s infinite ease-in-out; }
386
+ @keyframes dcm-pulse-peak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 70%, transparent); } }
387
+ @keyframes dcm-pulse-offpeak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-success-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-success-primary) 70%, transparent); } }
388
+ .dcm-alert-banner { display: flex; align-items: center; gap: 6px; border-radius: 6px; padding: 6px 10px; font-size: 11.5px; line-height: 1.35; margin-top: 6px; }
389
+ .dcm-alert-banner-peak { background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent); color: var(--dsw-alias-state-warning-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 28%, transparent); }
390
+ .dcm-alert-banner-offpeak { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); color: var(--dsw-alias-state-success-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
391
+ .dcm-msg-action-btn { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--dsw-alias-border-l2); padding: 2px 7px; border-radius: 5px; font-size: 11px; cursor: pointer; color: var(--dsw-alias-label-secondary); transition: all .15s ease; user-select: none; }
392
+ .dcm-msg-action-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-state-brand-primary); background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); }
393
+ .dcm-inspector-modal { position: fixed; inset: 0; z-index: 99999; background: color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent); display: flex; align-items: center; justify-content: center; padding: 16px; backdrop-filter: blur(2px); }
394
+ .dcm-inspector-card { background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; max-width: 420px; width: 100%; box-shadow: var(--dsw-alias-shadow-layer-3, 0 16px 36px var(--dsw-alias-bg-base)); padding: 16px; font-size: 12px; }
395
+ .dcm-inspector-title { font-size: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
396
+ .dcm-inspector-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
397
+ .dcm-inspector-stat { padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); }
398
+ .dcm-inspector-stat-lbl { font-size: 11px; color: var(--dsw-alias-label-tertiary); margin-bottom: 2px; }
399
+ .dcm-inspector-stat-val { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
400
+ .dcm-turn-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-height: 180px; overflow-y: auto; }
401
+ .dcm-turn-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 60%, transparent); font-size: 11px; }
402
+ .dcm-curr-switcher { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 80%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
403
+ .dcm-curr-btn { appearance: none; background: transparent; border: 0; padding: 2px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 500; color: var(--dsw-alias-label-secondary); cursor: pointer; transition: all .15s ease; user-select: none; }
404
+ .dcm-curr-btn:hover { color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent); }
405
+ .dcm-curr-btn-active { background: var(--dsw-alias-label-primary) !important; color: var(--dsw-alias-bg-layer-3) !important; font-weight: 600; }
406
+ .dcm-export-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; justify-content: flex-end; }
407
+
323
408
  `
324
409
 
325
410
 
@@ -612,6 +697,9 @@ window.__ModuleLoader__.load({
612
697
  usdRate: stored.usdRate !== undefined ? String(stored.usdRate) : '1',
613
698
  budgetThreshold: stored.budgetThreshold !== undefined ? String(stored.budgetThreshold) : '0',
614
699
  displayTimeZone: stored.displayTimeZone !== undefined ? String(stored.displayTimeZone) : 'Europe/Moscow',
700
+ alertBeforeSwitch: stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : true,
701
+ alertLeadMinutes: stored.alertLeadMinutes !== undefined ? String(stored.alertLeadMinutes) : '5',
702
+ browserNotifications: stored.browserNotifications !== undefined ? Boolean(stored.browserNotifications) : false,
615
703
  useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
616
704
  refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
617
705
  })
@@ -850,6 +938,72 @@ window.__ModuleLoader__.load({
850
938
  ),
851
939
  ),
852
940
 
941
+ // Section: Tariff Transition Alerts (Issue #117)
942
+ React.createElement('div', { className: 'dcm-section-card' },
943
+ React.createElement('div', { className: 'dcm-section-title' }, t('section.alerts')),
944
+ React.createElement('div', { className: 'dcm-section-desc' }, t('section.alerts_desc')),
945
+ React.createElement('div', { className: 'dcm-row-check' },
946
+ React.createElement('input', {
947
+ id: 'dcm-alert-switch',
948
+ type: 'checkbox',
949
+ className: 'dcm-check',
950
+ checked: draft ? Boolean(draft.alertBeforeSwitch) : true,
951
+ onChange: (e) => setDraft({ ...draft, alertBeforeSwitch: e.target.checked }),
952
+ }),
953
+ React.createElement('label', { htmlFor: 'dcm-alert-switch', className: 'dcm-label', style: { cursor: 'pointer' } }, t('field.alertBeforeSwitch')),
954
+ ),
955
+ React.createElement('span', { className: 'dcm-hint', style: { marginTop: -6 } }, t('hint.alertBeforeSwitch')),
956
+ React.createElement('div', { className: 'dcm-grid-2', style: { marginTop: 6 } },
957
+ React.createElement('div', { className: 'dcm-field' },
958
+ React.createElement('label', { className: 'dcm-label' }, t('field.alertLeadMinutes')),
959
+ React.createElement('select', {
960
+ className: 'dcm-input',
961
+ value: (draft && draft.alertLeadMinutes) || '5',
962
+ onChange: (e) => setDraft({ ...draft, alertLeadMinutes: e.target.value }),
963
+ }, [
964
+ React.createElement('option', { key: '1', value: '1' }, '1 min'),
965
+ React.createElement('option', { key: '2', value: '2' }, '2 min'),
966
+ React.createElement('option', { key: '5', value: '5' }, '5 min'),
967
+ React.createElement('option', { key: '10', value: '10' }, '10 min'),
968
+ ]),
969
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.alertLeadMinutes')),
970
+ ),
971
+ React.createElement('div', { className: 'dcm-field' },
972
+ React.createElement('label', { className: 'dcm-label' }, t('field.browserNotifications')),
973
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, height: 34 } },
974
+ React.createElement('input', {
975
+ id: 'dcm-browser-notif',
976
+ type: 'checkbox',
977
+ className: 'dcm-check',
978
+ checked: draft ? Boolean(draft.browserNotifications) : false,
979
+ onChange: (e) => {
980
+ const checked = e.target.checked
981
+ setDraft({ ...draft, browserNotifications: checked })
982
+ if (checked && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
983
+ Notification.requestPermission()
984
+ }
985
+ },
986
+ }),
987
+ React.createElement('button', {
988
+ type: 'button',
989
+ className: 'dcm-btn dcm-btn-sm',
990
+ onClick: () => {
991
+ if (typeof Notification === 'undefined') return
992
+ Notification.requestPermission().then((p) => {
993
+ if (p === 'granted') {
994
+ new Notification('DeepSeek Tariff Alert (Test)', {
995
+ body: t('alert.pre_switch_offpeak', { time: '5m' }),
996
+ })
997
+ }
998
+ })
999
+ },
1000
+ }, t('action.test_notification')),
1001
+ ),
1002
+ React.createElement('span', { className: 'dcm-hint' }, t('hint.browserNotifications')),
1003
+ ),
1004
+ ),
1005
+ ),
1006
+
853
1007
  // Section 2: OpenRouter Pricing Catalog
854
1008
  React.createElement('div', { className: 'dcm-section-card' },
855
1009
  React.createElement('div', { className: 'dcm-section-title' },
@@ -1044,6 +1198,15 @@ window.__ModuleLoader__.load({
1044
1198
  return perMillion / 1e6
1045
1199
  }
1046
1200
 
1201
+ function normalizeCurrencyCode(symbolOrCode) {
1202
+ const str = String(symbolOrCode || '').trim().toUpperCase()
1203
+ if (str === '₽' || str === 'RUB' || str === 'РУБ') return 'RUB'
1204
+ if (str === '€' || str === 'EUR') return 'EUR'
1205
+ if (str === '¥' || str === 'CNY' || str === 'RMB') return 'CNY'
1206
+ if (str === '$' || str === 'USD') return 'USD'
1207
+ return str
1208
+ }
1209
+
1047
1210
  function rate(value) {
1048
1211
  if (!value) return '—'
1049
1212
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
@@ -1174,6 +1337,246 @@ window.__ModuleLoader__.load({
1174
1337
  ])
1175
1338
  }
1176
1339
 
1340
+
1341
+
1342
+ // --------------------------------------------------- Export helpers (Issue #118)
1343
+
1344
+ function sanitizeCsvCell(value) {
1345
+ if (value === null || value === undefined) return ''
1346
+ let str = String(value)
1347
+ if (/^[=+\-@\t\r]/.test(str)) str = "'" + str
1348
+ if (/[",\n\r]/.test(str)) str = '"' + str.replace(/"/g, '""') + '"'
1349
+ return str
1350
+ }
1351
+
1352
+ function generateSessionCsv(data) {
1353
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0 } = data || {}
1354
+ const headers = ['Timestamp', 'ISO_Time', 'Turn', 'Step', 'Model', 'Uncached_Input', 'Cache_Read', 'Cache_Hit_%', 'Output', 'Total_Tokens', 'Cost_USD', 'Cost_Local', 'Currency']
1355
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
1356
+ let sumIn = 0, sumHit = 0, sumOut = 0, sumTot = 0, sumCost = 0
1357
+ for (const t of turns) {
1358
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
1359
+ const iso = new Date(ts).toISOString()
1360
+ const uncached = Number(t.uncachedInputTokens) || 0
1361
+ const hit = Number(t.cacheReadTokens) || 0
1362
+ const out = Number(t.outputTokens) || 0
1363
+ const tot = uncached + hit + out
1364
+ const hitRate = t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1365
+ const costUsd = Number(t.costDeltaUsd) || 0
1366
+ sumIn += uncached; sumHit += hit; sumOut += out; sumTot += tot; sumCost += costUsd
1367
+ rows.push([ts, iso, t.turn ?? '', t.step ?? 0, provider ? (provider + '/' + model) : model, uncached, hit, hitRate, out, tot, costUsd.toFixed(6), (costUsd * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1368
+ }
1369
+ const overallHit = sumIn + sumHit > 0 ? Math.round((sumHit / (sumIn + sumHit)) * 100) : 0
1370
+ rows.push(['TOTAL', new Date().toISOString(), turns.length, '', provider ? (provider + '/' + model) : model, sumIn, sumHit, overallHit, sumOut, sumTot, (totalCostUsd || sumCost).toFixed(6), ((totalCostUsd || sumCost) * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1371
+ return '\uFEFF' + rows.join('\r\n')
1372
+ }
1373
+
1374
+ function generateSessionJson(data) {
1375
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard' } = data || {}
1376
+ return {
1377
+ version: 1,
1378
+ generator: '@goodandready/dsh-cost-meter',
1379
+ exportedAt: new Date().toISOString(),
1380
+ session: {
1381
+ model, provider, currency, fxRate: fx, totalCostUsd, totalCostLocal: totalCostUsd * fx,
1382
+ cacheSavings: { savedUsd: cacheSavingsUsd, savedLocal: cacheSavingsUsd * fx, percent: cacheSavingsPercent },
1383
+ tier, turnsCount: turns.length,
1384
+ },
1385
+ turns: turns.map((t) => ({
1386
+ turn: t.turn, step: t.step ?? 0, timestamp: t.timestamp,
1387
+ tokens: { uncachedInput: Number(t.uncachedInputTokens) || 0, cacheRead: Number(t.cacheReadTokens) || 0, output: Number(t.outputTokens) || 0, total: Number(t.tokens) || 0, cacheHitRate: t.cacheHitRate || 0 },
1388
+ costDeltaUsd: Number(t.costDeltaUsd) || 0, costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
1389
+ })),
1390
+ }
1391
+ }
1392
+
1393
+ function renderSummaryCardToCanvas(canvas, data) {
1394
+ if (!canvas || typeof canvas.getContext !== 'function') return null
1395
+ const width = 1200, height = 680
1396
+ canvas.width = width; canvas.height = height
1397
+ const ctx = canvas.getContext('2d')
1398
+ if (!ctx) return null
1399
+ const { model = 'model', provider = '', currency = '$', fx = 1, costUsd = 0, tokens = {}, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard', turns = [] } = data || {}
1400
+ const totalCost = costUsd * fx
1401
+ const costFmt = totalCost >= 1 ? (currency + totalCost.toFixed(2)) : (totalCost > 0 ? (currency + totalCost.toFixed(4)) : (currency + '0.00'))
1402
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
1403
+ const hit = Number(tokens.cacheReadTokens) || 0
1404
+ const out = Number(tokens.outputTokens) || 0
1405
+ const totTok = uncached + hit + out
1406
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
1407
+ const savedAmt = cacheSavingsUsd * fx
1408
+ const savedFmt = savedAmt >= 1 ? (currency + savedAmt.toFixed(2)) : (currency + savedAmt.toFixed(4))
1409
+
1410
+ const bg = ctx.createLinearGradient(0, 0, width, height)
1411
+ bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1e293b')
1412
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height)
1413
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 3; ctx.strokeRect(20, 20, width - 40, height - 40)
1414
+
1415
+ ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.roundRect(50, 45, 220, 44, 22); ctx.fill()
1416
+ ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.stroke()
1417
+ ctx.fillStyle = '#38bdf8'; ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText('⚡ Cost Meter', 75, 74)
1418
+
1419
+ ctx.fillStyle = '#94a3b8'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'right'
1420
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
1421
+ ctx.textAlign = 'left'
1422
+
1423
+ ctx.fillStyle = '#f8fafc'; ctx.font = 'bold 36px system-ui, sans-serif'
1424
+ ctx.fillText(provider ? (provider + '/' + model) : model, 50, 145)
1425
+
1426
+ ctx.fillStyle = tier.includes('Off-Peak') || tier.includes('低谷') ? '#10b981' : '#f59e0b'
1427
+ ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText(tier, 50, 180)
1428
+
1429
+ const tiles = [
1430
+ { label: 'SESSION SPEND', val: costFmt, color: '#38bdf8' },
1431
+ { label: 'TOTAL TOKENS', val: totTok.toLocaleString(), color: '#f8fafc' },
1432
+ { label: 'CACHE HIT RATE', val: hitRate + '%', color: '#34d399' },
1433
+ { label: 'CACHE SAVED', val: '≈ ' + savedFmt + ' (' + cacheSavingsPercent + '%)', color: '#a78bfa' },
1434
+ ]
1435
+ tiles.forEach((t, i) => {
1436
+ const x = 50 + i * 270, y = 215
1437
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'; ctx.beginPath(); ctx.roundRect(x, y, 250, 110, 12); ctx.fill()
1438
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1439
+ ctx.fillStyle = '#94a3b8'; ctx.font = 'bold 14px system-ui, sans-serif'; ctx.fillText(t.label, x + 16, y + 36)
1440
+ ctx.fillStyle = t.color; ctx.font = 'bold 26px system-ui, sans-serif'; ctx.fillText(t.val, x + 16, y + 80)
1441
+ })
1442
+
1443
+ const chartY = 360, chartH = 190, chartW = width - 100
1444
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'; ctx.beginPath(); ctx.roundRect(50, chartY, chartW, chartH, 12); ctx.fill()
1445
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1446
+ ctx.fillStyle = '#64748b'; ctx.font = 'bold 15px system-ui, sans-serif'; ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
1447
+
1448
+ const activeTurns = turns.slice(-25)
1449
+ if (activeTurns.length > 0) {
1450
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
1451
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
1452
+ const baseY = chartY + chartH - 30
1453
+ activeTurns.forEach((t, i) => {
1454
+ const x = 70 + i * (barW + 8)
1455
+ const tok = Number(t.tokens) || 0
1456
+ const h = Math.max(6, Math.round((tok / maxTok) * 100))
1457
+ ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.roundRect(x, baseY - h, barW, h, 4); ctx.fill()
1458
+ const hitTokens = Number(t.cacheReadTokens) || 0
1459
+ if (hitTokens > 0) {
1460
+ const hitH = Math.round((hitTokens / tok) * h)
1461
+ ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.roundRect(x, baseY - hitH, barW, hitH, 4); ctx.fill()
1462
+ }
1463
+ })
1464
+ }
1465
+
1466
+ ctx.fillStyle = '#64748b'; ctx.font = '16px system-ui, sans-serif'; ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
1467
+ return canvas
1468
+ }
1469
+
1470
+ // --------------------------------------------------- MessageTokenAction (Issue #120)
1471
+
1472
+ function MessageTokenAction(props) {
1473
+ const ctx = props.ctx
1474
+ const locale = useActiveLocale(ctx)
1475
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1476
+ const [open, setOpen] = React.useState(false)
1477
+ const useProjection = props.useProjection || (ctx && ctx.useProjection)
1478
+ const byModel = useProjection ? useProjection('costByModel') : undefined
1479
+
1480
+ const turns = (byModel && Array.isArray(byModel.turns) && byModel.turns.length > 0)
1481
+ ? byModel.turns
1482
+ : []
1483
+
1484
+ // Match turn number if passed by slot or use latest turn
1485
+ const msgTurn = typeof props.turn === 'number'
1486
+ ? props.turn
1487
+ : (props.message && typeof props.message.turn === 'number' ? props.message.turn : null)
1488
+
1489
+ const tr = msgTurn !== null
1490
+ ? (turns.find((x) => x.turn === msgTurn) || turns[turns.length - 1])
1491
+ : (turns.length > 0 ? turns[turns.length - 1] : null)
1492
+
1493
+ if (!tr) return null
1494
+
1495
+ const tok = Number(tr.tokens) || 0
1496
+ const uncached = Number(tr.uncachedInputTokens) || 0
1497
+ const hit = Number(tr.cacheReadTokens) || 0
1498
+ const out = Number(tr.outputTokens) || 0
1499
+ const hitRate = tr.cacheHitRate !== undefined
1500
+ ? tr.cacheHitRate
1501
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1502
+ const durMs = Number(tr.durationMs) || 0
1503
+ const cost = Number(tr.costDeltaUsd) || 0
1504
+
1505
+ const fmtDur = (ms) => {
1506
+ if (!ms || ms <= 0) return '0s'
1507
+ const s = Math.round(ms / 1000)
1508
+ const m = Math.floor(s / 60)
1509
+ const r = s % 60
1510
+ return m > 0 ? (m + 'm ' + r + 's') : (r + 's')
1511
+ }
1512
+
1513
+ return React.createElement(React.Fragment, null,
1514
+ React.createElement('button', {
1515
+ key: 'btn',
1516
+ type: 'button',
1517
+ className: 'dcm-msg-action-btn',
1518
+ title: t('turn.modal_title'),
1519
+ onClick: () => setOpen(true),
1520
+ }, [
1521
+ React.createElement('span', { key: 'icon' }, '⚡'),
1522
+ React.createElement('span', { key: 't' }, tokens(tok)),
1523
+ ]),
1524
+ open ? React.createElement('div', {
1525
+ key: 'modal',
1526
+ className: 'dcm-inspector-modal',
1527
+ onClick: (e) => { if (e.target === e.currentTarget) setOpen(false) },
1528
+ },
1529
+ React.createElement('div', { className: 'dcm-inspector-card' }, [
1530
+ React.createElement('div', { key: 'head', className: 'dcm-inspector-title' }, [
1531
+ React.createElement('span', { key: 't' }, t('turn.modal_title')),
1532
+ React.createElement('button', {
1533
+ key: 'close',
1534
+ type: 'button',
1535
+ className: 'dcm-btn dcm-btn-sm',
1536
+ onClick: () => setOpen(false),
1537
+ }, '✕'),
1538
+ ]),
1539
+ React.createElement('div', { key: 'sub', style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11, marginBottom: 10 } },
1540
+ t('turn.turn_step', { turn: tr.turn, step: tr.step || 0 })
1541
+ ),
1542
+ React.createElement('div', { key: 'grid', className: 'dcm-inspector-grid' }, [
1543
+ React.createElement('div', { key: 's1', className: 'dcm-inspector-stat' }, [
1544
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.tokens_total')),
1545
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, tokens(tok)),
1546
+ ]),
1547
+ React.createElement('div', { key: 's2', className: 'dcm-inspector-stat' }, [
1548
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cache_hit_rate')),
1549
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, hitRate + '%'),
1550
+ ]),
1551
+ React.createElement('div', { key: 's3', className: 'dcm-inspector-stat' }, [
1552
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.duration')),
1553
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, fmtDur(durMs)),
1554
+ ]),
1555
+ React.createElement('div', { key: 's4', className: 'dcm-inspector-stat' }, [
1556
+ React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cost_title')),
1557
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, cost > 0 ? ('$' + cost.toFixed(4)) : '—'),
1558
+ ]),
1559
+ ]),
1560
+ React.createElement('div', { key: 'breakdown', style: { display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11.5, marginTop: 8 } }, [
1561
+ React.createElement('div', { key: 'b1', style: { display: 'flex', justifyContent: 'space-between' } }, [
1562
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.uncached_in')),
1563
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(uncached)),
1564
+ ]),
1565
+ React.createElement('div', { key: 'b2', style: { display: 'flex', justifyContent: 'space-between' } }, [
1566
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.cache_read')),
1567
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(hit)),
1568
+ ]),
1569
+ React.createElement('div', { key: 'b3', style: { display: 'flex', justifyContent: 'space-between' } }, [
1570
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.output')),
1571
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(out)),
1572
+ ]),
1573
+ ]),
1574
+ ])
1575
+ ) : null
1576
+ )
1577
+ }
1578
+
1579
+
1177
1580
  // ------------------------------------------------------------ CostMeter
1178
1581
 
1179
1582
 
@@ -1190,6 +1593,10 @@ window.__ModuleLoader__.load({
1190
1593
  const [now, setNow] = React.useState(() => new Date())
1191
1594
  const [copied, setCopied] = React.useState(false)
1192
1595
  const [showAllModels, setShowAllModels] = React.useState(false)
1596
+ const [showTurnInspector, setShowTurnInspector] = React.useState(false)
1597
+ const [selectedCurrency, setSelectedCurrency] = React.useState(null)
1598
+ const [exportingPng, setExportingPng] = React.useState(false)
1599
+ const lastAlertRef = React.useRef(null)
1193
1600
  const box = React.useRef(null)
1194
1601
 
1195
1602
  React.useEffect(() => { ensureStyles() }, [])
@@ -1280,8 +1687,26 @@ window.__ModuleLoader__.load({
1280
1687
 
1281
1688
  const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1282
1689
  const stored = (settingsSnap && settingsSnap.value) || {}
1283
- const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
1284
- const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
1690
+ const CURRENCY_LIST = [{ symbol: '$', code: 'USD' }, { symbol: '¥', code: 'CNY' }, { symbol: '₽', code: 'RUB' }, { symbol: '€', code: 'EUR' }]
1691
+ const activeCurrency = selectedCurrency || (stored.currency !== undefined ? String(stored.currency) : (state.currency || '$'))
1692
+ const fxRates = state.fxRates || { USD: 1, RUB: 80, CNY: 7.15, EUR: 0.92 }
1693
+ const normCode = normalizeCurrencyCode(activeCurrency)
1694
+ const fx = (selectedCurrency && fxRates[normCode] !== undefined)
1695
+ ? Number(fxRates[normCode])
1696
+ : (stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1))
1697
+ const currency = activeCurrency
1698
+
1699
+ const handleCurrencySelect = (sym) => {
1700
+ setSelectedCurrency(sym)
1701
+ const code = normalizeCurrencyCode(sym)
1702
+ const newFx = fxRates[code] || 1
1703
+ if (scope && typeof scope.set === 'function') {
1704
+ try {
1705
+ scope.set('currency', sym)
1706
+ scope.set('usdRate', newFx)
1707
+ } catch (_) {}
1708
+ }
1709
+ }
1285
1710
  const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
1286
1711
  const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
1287
1712
  const schedules = state.schedules || {}
@@ -1318,6 +1743,42 @@ window.__ModuleLoader__.load({
1318
1743
  const leftMinutes = minutesUntilChange(schedule, minute)
1319
1744
  const left = clock(leftMinutes)
1320
1745
 
1746
+ const alertBeforeSwitch = stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : (state.alertBeforeSwitch !== false)
1747
+ const alertLeadMinutes = Number(stored.alertLeadMinutes || state.alertLeadMinutes || 5)
1748
+ const browserNotifications = Boolean(stored.browserNotifications || state.browserNotifications || false)
1749
+
1750
+ let alertActive = false
1751
+ let alertDirection = null
1752
+ let alertBanner = null
1753
+
1754
+ if (!flat && alertBeforeSwitch && leftMinutes > 0 && leftMinutes <= alertLeadMinutes) {
1755
+ alertActive = true
1756
+ alertDirection = cheapNow ? 'peak' : 'offpeak'
1757
+ const timeStr = left || (leftMinutes + 'm')
1758
+ alertBanner = alertDirection === 'peak'
1759
+ ? t('alert.pre_switch_peak', { time: timeStr })
1760
+ : t('alert.pre_switch_offpeak', { time: timeStr })
1761
+ }
1762
+
1763
+ // Desktop HTML5 notification trigger (Issue #117)
1764
+ React.useEffect(() => {
1765
+ if (!alertActive || !browserNotifications || !alertBanner) return
1766
+ const transKey = Math.floor(minute / 30) + ':' + alertDirection
1767
+ if (lastAlertRef.current === transKey) return
1768
+ lastAlertRef.current = transKey
1769
+ if (typeof Notification !== 'undefined') {
1770
+ if (Notification.permission === 'granted') {
1771
+ try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1772
+ } else if (Notification.permission !== 'denied') {
1773
+ Notification.requestPermission().then((p) => {
1774
+ if (p === 'granted') {
1775
+ try { new Notification('DeepSeek Tariff Alert', { body: alertBanner }) } catch (_) {}
1776
+ }
1777
+ })
1778
+ }
1779
+ }
1780
+ }, [alertActive, browserNotifications, alertBanner, minute, alertDirection])
1781
+
1321
1782
  const perModel = used.map((m) => {
1322
1783
  const key = m.provider + '/' + m.model
1323
1784
  const own = schedules[key] || schedules[m.model]
@@ -1463,7 +1924,7 @@ window.__ModuleLoader__.load({
1463
1924
  return h('div', { ref: box, style: { position: 'relative' } }, [
1464
1925
  h('button', {
1465
1926
  key: 'chip', type: 'button',
1466
- className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
1927
+ className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : '') + (alertActive ? (alertDirection === 'peak' ? ' dcm-chip-alert-peak' : ' dcm-chip-alert-offpeak') : ''),
1467
1928
  onClick: () => setOpen((v) => !v),
1468
1929
  title: (budgetExceeded
1469
1930
  ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
@@ -1479,7 +1940,17 @@ window.__ModuleLoader__.load({
1479
1940
  open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1480
1941
  h('div', { key: 'title', className: 'dcm-panel-title' }, [
1481
1942
  h('span', { key: 'model-name' }, activeModelName || (state.route ? state.route.model : 'model')),
1482
- flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
1943
+ h('div', { key: 'curr-switcher', className: 'dcm-curr-switcher' },
1944
+ CURRENCY_LIST.map((c) =>
1945
+ h('button', {
1946
+ key: c.code,
1947
+ type: 'button',
1948
+ className: 'dcm-curr-btn' + (currency === c.symbol || currency === c.code ? ' dcm-curr-btn-active' : ''),
1949
+ onClick: () => handleCurrencySelect(c.symbol),
1950
+ title: c.code,
1951
+ }, c.symbol)
1952
+ )
1953
+ ),
1483
1954
  ]),
1484
1955
 
1485
1956
  h('div', { key: 'note', className: 'dcm-panel-note' },
@@ -1493,6 +1964,11 @@ window.__ModuleLoader__.load({
1493
1964
  : h('div', { key: 'clock', className: 'dcm-panel-note' },
1494
1965
  t('peak') + ': ' + windowText + ' ' + zl + ' · now ' + nowLocal + ' ' + zl),
1495
1966
 
1967
+ alertBanner ? h('div', {
1968
+ key: 'preswitch-banner',
1969
+ className: 'dcm-alert-banner ' + (alertDirection === 'peak' ? 'dcm-alert-banner-peak' : 'dcm-alert-banner-offpeak'),
1970
+ }, alertBanner) : null,
1971
+
1496
1972
  discountHint ? h('div', { key: 'savings-box', className: 'dcm-tip-box', style: { marginTop: 6 } }, discountHint) : null,
1497
1973
 
1498
1974
  cacheSavedAmount > 0
@@ -1511,6 +1987,37 @@ window.__ModuleLoader__.load({
1511
1987
  t,
1512
1988
  }),
1513
1989
 
1990
+ turns.length > 0 ? [
1991
+ h('div', {
1992
+ key: 'turns-header',
1993
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
1994
+ }, [
1995
+ h('span', { key: 'th-lbl', className: 'dcm-section-lbl' }, t('turn.history_title')),
1996
+ h('button', {
1997
+ key: 'th-btn',
1998
+ type: 'button',
1999
+ className: 'dcm-accordion-btn',
2000
+ onClick: () => setShowTurnInspector((v) => !v),
2001
+ }, showTurnInspector ? t('turn.hide_history') : t('turn.show_history')),
2002
+ ]),
2003
+ showTurnInspector ? h('div', { key: 'turn-list', className: 'dcm-turn-list' },
2004
+ turns.slice(-10).reverse().map((tr, idx) => {
2005
+ const tok = Number(tr.tokens) || 0
2006
+ const hit = Number(tr.cacheReadTokens) || 0
2007
+ const uncached = Number(tr.uncachedInputTokens) || 0
2008
+ const hitRate = tr.cacheHitRate !== undefined
2009
+ ? tr.cacheHitRate
2010
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
2011
+ const cost = Number(tr.costDeltaUsd) || 0
2012
+ return h('div', { key: 'tr-' + idx, className: 'dcm-turn-row' }, [
2013
+ h('span', { key: 'tn', style: { fontWeight: 500 } }, 'T#' + tr.turn + (tr.step ? '.' + tr.step : '')),
2014
+ h('span', { key: 'tok', style: { color: 'var(--dsw-alias-label-secondary)' } }, tokens(tok) + ' tok (' + hitRate + '% hit)'),
2015
+ h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? money(cost * fx, currency) : '—'),
2016
+ ])
2017
+ })
2018
+ ) : null,
2019
+ ] : null,
2020
+
1514
2021
  (() => {
1515
2022
  const proj = calculateBurnRateAndProjection(turns, total / fx)
1516
2023
  const burnRate = proj.burnRatePerHourUsd * fx
@@ -1621,13 +2128,98 @@ window.__ModuleLoader__.load({
1621
2128
  hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
1622
2129
  t('tokens.no_usage_yet')),
1623
2130
 
1624
- h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
2131
+ h('div', { key: 'footer-actions', className: 'dcm-export-group' }, [
1625
2132
  h('button', {
2133
+ key: 'btn-csv',
2134
+ type: 'button',
2135
+ className: 'dcm-btn dcm-btn-sm',
2136
+ title: t('action.export_csv'),
2137
+ onClick: () => {
2138
+ const csvData = generateSessionCsv({
2139
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2140
+ provider: activeProvider || '',
2141
+ currency,
2142
+ fx,
2143
+ turns,
2144
+ totalCostUsd: totalUsd,
2145
+ })
2146
+ const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
2147
+ const link = document.createElement('a')
2148
+ link.href = URL.createObjectURL(blob)
2149
+ link.download = (activeModelName || 'cost-meter') + '-session.csv'
2150
+ link.click()
2151
+ },
2152
+ }, '📥 CSV'),
2153
+
2154
+ h('button', {
2155
+ key: 'btn-json',
2156
+ type: 'button',
2157
+ className: 'dcm-btn dcm-btn-sm',
2158
+ title: t('action.export_json'),
2159
+ onClick: () => {
2160
+ const jsonData = generateSessionJson({
2161
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2162
+ provider: activeProvider || '',
2163
+ currency,
2164
+ fx,
2165
+ turns,
2166
+ totalCostUsd: totalUsd,
2167
+ cacheSavingsUsd: cacheSavedAmount / fx,
2168
+ cacheSavingsPercent: cacheSavedPercent,
2169
+ tier: flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak')),
2170
+ })
2171
+ const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' })
2172
+ const link = document.createElement('a')
2173
+ link.href = URL.createObjectURL(blob)
2174
+ link.download = (activeModelName || 'cost-meter') + '-session.json'
2175
+ link.click()
2176
+ },
2177
+ }, '📋 JSON'),
2178
+
2179
+ h('button', {
2180
+ key: 'btn-png',
2181
+ type: 'button',
2182
+ className: 'dcm-btn dcm-btn-sm',
2183
+ title: t('action.export_png'),
2184
+ disabled: exportingPng,
2185
+ onClick: () => {
2186
+ setExportingPng(true)
2187
+ try {
2188
+ const canvas = document.createElement('canvas')
2189
+ renderSummaryCardToCanvas(canvas, {
2190
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2191
+ provider: activeProvider || '',
2192
+ currency,
2193
+ fx,
2194
+ costUsd: totalUsd,
2195
+ tokens: usage || {},
2196
+ cacheSavingsUsd: cacheSavedAmount / fx,
2197
+ cacheSavingsPercent: cacheSavedPercent,
2198
+ tier: flat ? 'Flat Tariff' : (cheapNow ? ('Off-Peak (' + t('offpeak') + ')') : ('Peak Tariff (' + t('peak') + ')')),
2199
+ turns,
2200
+ })
2201
+ canvas.toBlob((blob) => {
2202
+ setExportingPng(false)
2203
+ if (blob) {
2204
+ const link = document.createElement('a')
2205
+ link.href = URL.createObjectURL(blob)
2206
+ link.download = (activeModelName || 'cost-meter') + '-summary.png'
2207
+ link.click()
2208
+ }
2209
+ }, 'image/png')
2210
+ } catch (_) {
2211
+ setExportingPng(false)
2212
+ }
2213
+ },
2214
+ }, exportingPng ? t('action.exporting_png') : '🖼️ PNG'),
2215
+
2216
+ h('button', {
2217
+ key: 'btn-copy',
1626
2218
  type: 'button',
1627
2219
  className: 'dcm-btn dcm-btn-sm',
1628
2220
  onClick: copyReport,
1629
- }, copied ? t('action.copied') : t('action.copy_report'))
1630
- ),
2221
+ }, copied ? t('action.copied') : t('action.copy_report')),
2222
+ ]),
1631
2223
  ]) : null,
1632
2224
  ])
1633
2225
  }
@@ -1679,6 +2271,14 @@ window.__ModuleLoader__.load({
1679
2271
  ),
1680
2272
  )
1681
2273
 
2274
+ // Message action bar token & cost inspector (Issue #120)
2275
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2276
+ ctx.slots.register(
2277
+ { name: 'conversation.chat.assistant-actions', id: 'dsh-cost-meter-token-inspector', order: 40, inject: () => ({ ctx }) },
2278
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(MessageTokenAction, { ...p, ctx: (p && p.ctx) || ctx })),
2279
+ ),
2280
+ )
2281
+
1682
2282
  // List seat (plugins.item): the seat the Plugins page renders as the plugin's
1683
2283
  // own page with its configuration. The label is a static string on purpose —
1684
2284
  // it is resolved while the page renders, and a locale lookup there would take
@@ -1712,6 +2312,7 @@ window.__ModuleLoader__.load({
1712
2312
  inject: ['slots', 'locale', 'configForms'],
1713
2313
  CostMeterCard,
1714
2314
  CostMeter,
2315
+ MessageTokenAction,
1715
2316
  ErrorBoundary,
1716
2317
  ensureStyles,
1717
2318
  minutesUntilChange,