@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/alerts.js +100 -0
- package/lib/client.js +608 -7
- package/lib/commands.js +260 -0
- package/lib/export.js +378 -0
- package/lib/index.js +29 -22
- package/lib/projection.js +19 -0
- package/lib/routes.js +5 -0
- package/package.json +1 -1
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
|
|
1284
|
-
const
|
|
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
|
-
|
|
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',
|
|
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,
|