dsh-tokenlens 1.1.0 → 1.2.1
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/CHANGELOG.md +15 -0
- package/PROTOCOL.md +25 -7
- package/README.en.md +5 -5
- package/README.md +6 -6
- package/client.js +568 -126
- package/index.js +10 -1
- package/lib/provider-store.mjs +7 -0
- package/lib/usage-jsonl.mjs +9 -6
- package/lib/usage-opencode.mjs +10 -4
- package/lib/usage-stats.mjs +108 -25
- package/lib/usage-zcode.mjs +23 -8
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -66,6 +66,8 @@ window.__ModuleLoader__.load({
|
|
|
66
66
|
const PROVIDERS_TTL_MS = 30000
|
|
67
67
|
/** Auto-sync cadence of the provider list while the management page is open (v0.17.0). */
|
|
68
68
|
const MANAGEMENT_SYNC_MS = 15000
|
|
69
|
+
/** Retry delay of a 明细-window save blocked by an in-flight settings request (v1.2.1). */
|
|
70
|
+
const DETAILS_WINDOW_SAVE_RETRY_MS = 200
|
|
69
71
|
/** How many per-provider / per-model stats rows the dialog lists. */
|
|
70
72
|
const MAX_PROVIDER_ROWS = 5
|
|
71
73
|
const MAX_MODEL_ROWS = 3
|
|
@@ -125,14 +127,16 @@ window.__ModuleLoader__.load({
|
|
|
125
127
|
/** Fixed segment palette of the 明细 donut charts (v0.27.0, color-blind friendly order). */
|
|
126
128
|
const DONUT_PALETTE = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#06b6d4', '#f97316']
|
|
127
129
|
/**
|
|
128
|
-
* Host-side display defaults (v0.29.0
|
|
129
|
-
*
|
|
130
|
+
* Host-side display defaults (v0.29.0; the five fields include the v1.2.1
|
|
131
|
+
* 明细 donut window). 「恢复默认设置」 sends exactly these; must stay in sync
|
|
132
|
+
* with DEFAULT_SETTINGS in lib/provider-store.mjs.
|
|
130
133
|
*/
|
|
131
134
|
const DEFAULT_PLUGIN_SETTINGS = {
|
|
132
135
|
labelStyle: 'full',
|
|
133
136
|
defaultView: 'chart',
|
|
134
137
|
defaultScope: 'provider',
|
|
135
138
|
detailsExpanded: 'auto',
|
|
139
|
+
detailsWindow: 'total',
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
const zh = {
|
|
@@ -208,6 +212,12 @@ window.__ModuleLoader__.load({
|
|
|
208
212
|
statsChartPeak: '峰值 {value}({day})',
|
|
209
213
|
statsChartBarTitle: '{date}: {tokens}',
|
|
210
214
|
statsChartReadout: '{date} · {value}({share}%)',
|
|
215
|
+
statsChartBack: '← 返回日趋势',
|
|
216
|
+
statsChartBackHint: '返回每日趋势图',
|
|
217
|
+
statsChartHourTitle: '按小时 · {date}',
|
|
218
|
+
statsChartHourBarTitle: '{date} {hour} · {tokens}',
|
|
219
|
+
statsChartHourEmpty: '该日没有小时级数据',
|
|
220
|
+
statsWindowLabel: '明细统计窗口',
|
|
211
221
|
statsByHarness: '按 Harness',
|
|
212
222
|
statsHarnessUnavailable: '不可用',
|
|
213
223
|
statsHarnessRetryHint: '检查本机 Harness 数据源后点刷新重试',
|
|
@@ -429,6 +439,12 @@ window.__ModuleLoader__.load({
|
|
|
429
439
|
statsChartPeak: 'Peak {value} ({day})',
|
|
430
440
|
statsChartBarTitle: '{date}: {tokens}',
|
|
431
441
|
statsChartReadout: '{date} · {value} ({share}%)',
|
|
442
|
+
statsChartBack: '← Back to daily',
|
|
443
|
+
statsChartBackHint: 'Back to the daily trend chart',
|
|
444
|
+
statsChartHourTitle: 'By hour · {date}',
|
|
445
|
+
statsChartHourBarTitle: '{date} {hour} · {tokens}',
|
|
446
|
+
statsChartHourEmpty: 'No hourly data for this day',
|
|
447
|
+
statsWindowLabel: 'Details window',
|
|
432
448
|
statsByHarness: 'By harness',
|
|
433
449
|
statsHarnessUnavailable: 'Unavailable',
|
|
434
450
|
statsHarnessRetryHint: 'Check the harness data source, then click Refresh to retry',
|
|
@@ -1089,9 +1105,15 @@ window.__ModuleLoader__.load({
|
|
|
1089
1105
|
return raw === 'expanded' || raw === 'collapsed' ? raw : 'auto'
|
|
1090
1106
|
}
|
|
1091
1107
|
|
|
1108
|
+
/** One `detailsWindow` setting: 'today' | 'last7Days' | 'second' | 'total' (junk means 总计). */
|
|
1109
|
+
function normalizeDetailsWindow(raw) {
|
|
1110
|
+
return raw === 'today' || raw === 'last7Days' || raw === 'second' ? raw : 'total'
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1092
1113
|
/**
|
|
1093
|
-
* The
|
|
1094
|
-
*
|
|
1114
|
+
* The five display settings of the `providers` payload (v0.29.0; the 明细
|
|
1115
|
+
* donut window joined them in v1.2.1), every field normalized to a usable
|
|
1116
|
+
* value so old Hosts keep the previous defaults.
|
|
1095
1117
|
*/
|
|
1096
1118
|
function normalizePluginSettings(raw) {
|
|
1097
1119
|
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
@@ -1100,6 +1122,7 @@ window.__ModuleLoader__.load({
|
|
|
1100
1122
|
defaultView: normalizeDisplayView(source.defaultView),
|
|
1101
1123
|
defaultScope: normalizeDisplayScope(source.defaultScope),
|
|
1102
1124
|
detailsExpanded: normalizeDetailsExpanded(source.detailsExpanded),
|
|
1125
|
+
detailsWindow: normalizeDetailsWindow(source.detailsWindow),
|
|
1103
1126
|
}
|
|
1104
1127
|
}
|
|
1105
1128
|
|
|
@@ -1238,6 +1261,17 @@ window.__ModuleLoader__.load({
|
|
|
1238
1261
|
}
|
|
1239
1262
|
}
|
|
1240
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* The local 0..23 hour histogram of one daily entry (v1.2.0), or null when
|
|
1266
|
+
* the wire omits it (old Hosts). Anything but a length-24 array is treated
|
|
1267
|
+
* as unsupported; a non-finite / negative slot degrades to 0 so hostile data
|
|
1268
|
+
* can never poison the chart.
|
|
1269
|
+
*/
|
|
1270
|
+
function normalizeHourHistogram(raw) {
|
|
1271
|
+
if (!Array.isArray(raw) || raw.length !== 24) return null
|
|
1272
|
+
return raw.map((value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0))
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1241
1275
|
/** One provider attribution row (stats v3 aggregates DSH + ZCode per provider). */
|
|
1242
1276
|
function normalizeProviderRow(entry) {
|
|
1243
1277
|
const providerId = stringOrEmpty(entry.providerId)
|
|
@@ -1358,6 +1392,8 @@ window.__ModuleLoader__.load({
|
|
|
1358
1392
|
: []
|
|
1359
1393
|
// v0.24.0: the Host reports a per-day token series (ascending, <=120 days)
|
|
1360
1394
|
// for the chart mode. Every field is normalized defensively.
|
|
1395
|
+
// v1.2.0: each day (and each provider split of that day) may also carry
|
|
1396
|
+
// the local 0..23 hour histogram used by the chart drill-down.
|
|
1361
1397
|
const daily = Array.isArray(raw.daily)
|
|
1362
1398
|
? raw.daily
|
|
1363
1399
|
.filter((entry) => entry !== null && typeof entry === 'object')
|
|
@@ -1369,10 +1405,18 @@ window.__ModuleLoader__.load({
|
|
|
1369
1405
|
const dailyProviders = {}
|
|
1370
1406
|
for (const [providerId, bucketRaw] of Object.entries(providerRaw)) {
|
|
1371
1407
|
if (providerId === '' || bucketRaw === null || typeof bucketRaw !== 'object') continue
|
|
1372
|
-
dailyProviders[providerId] =
|
|
1408
|
+
dailyProviders[providerId] = {
|
|
1409
|
+
...normalizeBucket(bucketRaw),
|
|
1410
|
+
hours: normalizeHourHistogram(bucketRaw.hours),
|
|
1411
|
+
}
|
|
1373
1412
|
}
|
|
1374
1413
|
const day = stringOrEmpty(entry.day).trim()
|
|
1375
|
-
return {
|
|
1414
|
+
return {
|
|
1415
|
+
day,
|
|
1416
|
+
total: normalizeOptionalBucket(entry.total),
|
|
1417
|
+
hours: normalizeHourHistogram(entry.hours),
|
|
1418
|
+
providers: dailyProviders,
|
|
1419
|
+
}
|
|
1376
1420
|
})
|
|
1377
1421
|
.filter((entry) => entry.day !== '')
|
|
1378
1422
|
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0))
|
|
@@ -1706,6 +1750,13 @@ window.__ModuleLoader__.load({
|
|
|
1706
1750
|
// v0.25.0 chart hover: the nearest bar index under the pointer, or null.
|
|
1707
1751
|
// Reset on mouse leave and whenever the scope/view/provider changes.
|
|
1708
1752
|
chartHover: null,
|
|
1753
|
+
// v1.2.0 chart drill-down: the clicked day whose 0..23 hour histogram
|
|
1754
|
+
// is charted instead of the daily trend, or null (daily trend).
|
|
1755
|
+
chartDay: null,
|
|
1756
|
+
// v1.2.0 明细 donut window: 'today' | 'last7Days' | 'second' | 'total'.
|
|
1757
|
+
// v1.2.1: persisted setting (the last choice survives reopening the dialog
|
|
1758
|
+
// and restarting DSH; the startup bootstrap loads it back).
|
|
1759
|
+
detailsWindow: DEFAULT_PLUGIN_SETTINGS.detailsWindow,
|
|
1709
1760
|
// Provider management page (v0.9.0).
|
|
1710
1761
|
view: 'usage', // 'usage' | 'management'
|
|
1711
1762
|
management: defaultManagementState(),
|
|
@@ -1756,7 +1807,10 @@ window.__ModuleLoader__.load({
|
|
|
1756
1807
|
/** Bumped whenever the selected provider changes; stale responses are dropped. */
|
|
1757
1808
|
let usageEpoch = 0
|
|
1758
1809
|
|
|
1759
|
-
/**
|
|
1810
|
+
/**
|
|
1811
|
+
* The five display settings currently in effect (v0.29.0; the 明细 donut
|
|
1812
|
+
* window joined them in v1.2.1).
|
|
1813
|
+
*/
|
|
1760
1814
|
function currentPluginSettings() {
|
|
1761
1815
|
const state = store.get()
|
|
1762
1816
|
return {
|
|
@@ -1764,6 +1818,7 @@ window.__ModuleLoader__.load({
|
|
|
1764
1818
|
defaultView: normalizeDisplayView(state.defaultView),
|
|
1765
1819
|
defaultScope: normalizeDisplayScope(state.defaultScope),
|
|
1766
1820
|
detailsExpanded: normalizeDetailsExpanded(state.detailsExpanded),
|
|
1821
|
+
detailsWindow: normalizeDetailsWindow(state.detailsWindow),
|
|
1767
1822
|
}
|
|
1768
1823
|
}
|
|
1769
1824
|
|
|
@@ -1786,7 +1841,13 @@ window.__ModuleLoader__.load({
|
|
|
1786
1841
|
statsView: settings.defaultView,
|
|
1787
1842
|
statsScope: settings.defaultScope,
|
|
1788
1843
|
showDetails: showDetailsFor(settings.detailsExpanded),
|
|
1844
|
+
// v1.2.1: the 明细 donut window is a persisted setting, so reopening the
|
|
1845
|
+
// dialog or switching provider keeps the last saved choice instead of
|
|
1846
|
+
// always snapping back to 总计 (detailsWindowOf still falls back when the
|
|
1847
|
+
// chosen window is absent from the stats payload).
|
|
1848
|
+
detailsWindow: settings.detailsWindow,
|
|
1789
1849
|
chartHover: null,
|
|
1850
|
+
chartDay: null,
|
|
1790
1851
|
}
|
|
1791
1852
|
}
|
|
1792
1853
|
|
|
@@ -1983,11 +2044,16 @@ window.__ModuleLoader__.load({
|
|
|
1983
2044
|
|
|
1984
2045
|
/**
|
|
1985
2046
|
* Adopt the display settings and wire label cache of a providers payload
|
|
1986
|
-
* (v0.10.0, extended v0.29.0 with the plugin display defaults).
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
2047
|
+
* (v0.10.0, extended v0.29.0 with the plugin display defaults).
|
|
2048
|
+
*
|
|
2049
|
+
* `options.adoptDetailsWindow` is only set by the startup bootstrap: the
|
|
2050
|
+
* persisted 明细 window must be loaded once, but a plain settingsUpdate
|
|
2051
|
+
* answer must never repaint a just-clicked pill (the answer of an earlier
|
|
2052
|
+
* save would otherwise overwrite the last of a fast click series, v1.2.1).
|
|
2053
|
+
* The management list itself is applied by the caller, so a read-only
|
|
2054
|
+
* startup load can reuse this without touching the page state.
|
|
1989
2055
|
*/
|
|
1990
|
-
function adoptProvidersMeta(parsed) {
|
|
2056
|
+
function adoptProvidersMeta(parsed, options = {}) {
|
|
1991
2057
|
if (parsed === null || typeof parsed !== 'object') return
|
|
1992
2058
|
const current = store.get()
|
|
1993
2059
|
const patch = {}
|
|
@@ -1996,6 +2062,9 @@ window.__ModuleLoader__.load({
|
|
|
1996
2062
|
if (current.defaultView !== settings.defaultView) patch.defaultView = settings.defaultView
|
|
1997
2063
|
if (current.defaultScope !== settings.defaultScope) patch.defaultScope = settings.defaultScope
|
|
1998
2064
|
if (current.detailsExpanded !== settings.detailsExpanded) patch.detailsExpanded = settings.detailsExpanded
|
|
2065
|
+
if (options.adoptDetailsWindow === true && current.detailsWindow !== settings.detailsWindow) {
|
|
2066
|
+
patch.detailsWindow = settings.detailsWindow
|
|
2067
|
+
}
|
|
1999
2068
|
if (parsed.labelCatalog !== null && typeof parsed.labelCatalog === 'object') {
|
|
2000
2069
|
const catalog = { ...current.labelCatalog, ...parsed.labelCatalog }
|
|
2001
2070
|
if (Object.keys(catalog).length > 0) patch.labelCatalog = catalog
|
|
@@ -2006,10 +2075,11 @@ window.__ModuleLoader__.load({
|
|
|
2006
2075
|
/**
|
|
2007
2076
|
* Read the providers payload once at controller startup (v0.10.0).
|
|
2008
2077
|
*
|
|
2009
|
-
* Only the display style
|
|
2010
|
-
* honor a persisted 'full' style
|
|
2011
|
-
*
|
|
2012
|
-
* the
|
|
2078
|
+
* Only the display style, the persisted 明细 window and the wire label
|
|
2079
|
+
* cache are adopted: the chip must honor a persisted 'full' style and the
|
|
2080
|
+
* dialog must restore the saved donut window before the 管理 page is ever
|
|
2081
|
+
* opened, while the page keeps its own lazy load. Silent on failure — an
|
|
2082
|
+
* old Host without the action (or an unreachable bridge) keeps the defaults.
|
|
2013
2083
|
*/
|
|
2014
2084
|
async function bootstrapProviders() {
|
|
2015
2085
|
let data
|
|
@@ -2021,7 +2091,7 @@ window.__ModuleLoader__.load({
|
|
|
2021
2091
|
if (data === null || typeof data !== 'object' || data.ok !== true) return { kind: 'envelope' }
|
|
2022
2092
|
const parsed = normalizeProvidersPayload(data)
|
|
2023
2093
|
if (parsed === null) return { kind: 'transport' }
|
|
2024
|
-
adoptProvidersMeta(parsed)
|
|
2094
|
+
adoptProvidersMeta(parsed, { adoptDetailsWindow: true })
|
|
2025
2095
|
return { kind: 'ok' }
|
|
2026
2096
|
}
|
|
2027
2097
|
|
|
@@ -2162,10 +2232,11 @@ window.__ModuleLoader__.load({
|
|
|
2162
2232
|
}
|
|
2163
2233
|
|
|
2164
2234
|
/**
|
|
2165
|
-
* Persist one display-settings change (v0.29.0): the FULL
|
|
2166
|
-
* object is always sent
|
|
2167
|
-
* keeps a single source of truth. The
|
|
2168
|
-
* labels and provider list are adopted.
|
|
2235
|
+
* Persist one display-settings change (v0.29.0): the FULL five-field settings
|
|
2236
|
+
* object is always sent (the v1.2.1 明细 window included), because the Host
|
|
2237
|
+
* accepts any subset but the page keeps a single source of truth. The
|
|
2238
|
+
* answered payload's settings, wire labels and provider list are adopted.
|
|
2239
|
+
* Never throws.
|
|
2169
2240
|
*/
|
|
2170
2241
|
async function requestSettingsUpdate(settings) {
|
|
2171
2242
|
const next = normalizePluginSettings(settings)
|
|
@@ -2214,7 +2285,37 @@ window.__ModuleLoader__.load({
|
|
|
2214
2285
|
return { kind: 'transport' }
|
|
2215
2286
|
}
|
|
2216
2287
|
|
|
2217
|
-
/**
|
|
2288
|
+
/** Pending retry timer of the 明细-window save (a single timer, v1.2.1). */
|
|
2289
|
+
let detailsWindowSaveTimer = null
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* Persist the 明细 donut window choice (v1.2.1).
|
|
2293
|
+
*
|
|
2294
|
+
* The store field is also the persisted setting value, so a pill click saves
|
|
2295
|
+
* the FULL current settings through `settingsUpdate`. When another settings
|
|
2296
|
+
* request is still in flight, one 200ms retry timer keeps the LAST click of a
|
|
2297
|
+
* fast series (the earlier answer must not overwrite it: adoptProvidersMeta
|
|
2298
|
+
* ignores detailsWindow for non-bootstrap answers). The bridge answers
|
|
2299
|
+
* locally within a few milliseconds, so a single retry is enough. The Host's
|
|
2300
|
+
* answer then refreshes the provider list as usual.
|
|
2301
|
+
*/
|
|
2302
|
+
function persistDetailsWindow() {
|
|
2303
|
+
if (detailsWindowSaveTimer !== null) {
|
|
2304
|
+
window.clearTimeout(detailsWindowSaveTimer)
|
|
2305
|
+
detailsWindowSaveTimer = null
|
|
2306
|
+
}
|
|
2307
|
+
if (store.get().management.settingsBusy === true) {
|
|
2308
|
+
detailsWindowSaveTimer = window.setTimeout(() => {
|
|
2309
|
+
detailsWindowSaveTimer = null
|
|
2310
|
+
if (store.get().management.settingsBusy === true) return
|
|
2311
|
+
void requestSettingsUpdate(currentPluginSettings())
|
|
2312
|
+
}, DETAILS_WINDOW_SAVE_RETRY_MS)
|
|
2313
|
+
return
|
|
2314
|
+
}
|
|
2315
|
+
void requestSettingsUpdate(currentPluginSettings())
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** Change one display setting; the other four keep their current values. */
|
|
2218
2319
|
function savePluginSetting(field, value) {
|
|
2219
2320
|
if (store.get().management.settingsBusy === true) return
|
|
2220
2321
|
const next = { ...currentPluginSettings(), [field]: value }
|
|
@@ -2224,6 +2325,9 @@ window.__ModuleLoader__.load({
|
|
|
2224
2325
|
/** 「恢复默认设置」: send the Host-side display defaults as one full payload. */
|
|
2225
2326
|
function resetPluginSettings() {
|
|
2226
2327
|
if (store.get().management.settingsBusy === true) return
|
|
2328
|
+
// v1.2.1: reset the persisted 明细 window locally too — a plain
|
|
2329
|
+
// settingsUpdate answer intentionally does not adopt that field.
|
|
2330
|
+
store.set({ detailsWindow: DEFAULT_PLUGIN_SETTINGS.detailsWindow })
|
|
2227
2331
|
void requestSettingsUpdate({ ...DEFAULT_PLUGIN_SETTINGS })
|
|
2228
2332
|
}
|
|
2229
2333
|
|
|
@@ -2857,6 +2961,19 @@ window.__ModuleLoader__.load({
|
|
|
2857
2961
|
.dsh-tokenlens-chart-summary>span{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
|
2858
2962
|
.dsh-tokenlens-chart-empty{box-sizing:border-box;min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border:1px dashed var(--dsw-alias-border-subtle,rgba(127,127,127,.28));border-radius:8px;padding:10px;text-align:center;min-width:0}
|
|
2859
2963
|
.dsh-tokenlens-chart-empty-hint{opacity:.8;font-size:11px}
|
|
2964
|
+
/* Hourly drill-down of the daily chart (v1.2.0): clickable/focusable day bars,
|
|
2965
|
+
a selected-day ring, and the back + title row above the hour bars. */
|
|
2966
|
+
.dsh-tokenlens-chart-bar-wrap-clickable{cursor:pointer}
|
|
2967
|
+
.dsh-tokenlens-chart-bar-wrap-clickable:focus-visible{outline:2px solid #2e9e5b;outline-offset:1px;border-radius:3px}
|
|
2968
|
+
.dsh-tokenlens-chart-bar-wrap[data-chart-selected=true] .dsh-tokenlens-chart-bar{background:#1d6b3d;box-shadow:0 0 0 1px rgba(64,200,119,.9)}
|
|
2969
|
+
.dsh-tokenlens-chart-drilldown{gap:4px}
|
|
2970
|
+
.dsh-tokenlens-chart-drill-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
2971
|
+
.dsh-tokenlens-chart-back{font:inherit;font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;padding:1px 8px;border:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.24));border-radius:999px;background:0 0;color:var(--dsw-alias-label-tertiary);cursor:pointer;flex:none}
|
|
2972
|
+
.dsh-tokenlens-chart-back:hover{color:var(--dsw-alias-label-secondary)}
|
|
2973
|
+
.dsh-tokenlens-chart-back:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5))}
|
|
2974
|
+
.dsh-tokenlens-chart-drill-title{color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
2975
|
+
/* 明细 donut window switch (v1.2.0): one pill row under the details title. */
|
|
2976
|
+
.dsh-tokenlens-details-window{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
|
|
2860
2977
|
.dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75}
|
|
2861
2978
|
.dsh-tokenlens-cards{gap:6px;flex-wrap:wrap;display:flex}
|
|
2862
2979
|
.dsh-tokenlens-cards-caption{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;margin-bottom:-3px}
|
|
@@ -3555,7 +3672,7 @@ window.__ModuleLoader__.load({
|
|
|
3555
3672
|
'data-scope': scope,
|
|
3556
3673
|
'data-active': effective === scope ? 'true' : 'false',
|
|
3557
3674
|
disabled: stats === null,
|
|
3558
|
-
onClick: () => store.set({ statsScope: scope, chartHover: null }),
|
|
3675
|
+
onClick: () => store.set({ statsScope: scope, chartHover: null, chartDay: null }),
|
|
3559
3676
|
},
|
|
3560
3677
|
label,
|
|
3561
3678
|
)
|
|
@@ -3581,7 +3698,7 @@ window.__ModuleLoader__.load({
|
|
|
3581
3698
|
'data-active': active === view ? 'true' : 'false',
|
|
3582
3699
|
'aria-pressed': active === view ? 'true' : 'false',
|
|
3583
3700
|
onClick: () => {
|
|
3584
|
-
if (active !== view) store.set({ statsView: view, chartHover: null })
|
|
3701
|
+
if (active !== view) store.set({ statsView: view, chartHover: null, chartDay: null })
|
|
3585
3702
|
},
|
|
3586
3703
|
},
|
|
3587
3704
|
Icon(iconName, 12),
|
|
@@ -3601,37 +3718,88 @@ window.__ModuleLoader__.load({
|
|
|
3601
3718
|
}
|
|
3602
3719
|
|
|
3603
3720
|
/**
|
|
3604
|
-
* The daily
|
|
3721
|
+
* The daily provider ids the chart's provider scope matches (v0.24.0).
|
|
3722
|
+
*
|
|
3723
|
+
* Both the stats row id and the session-selected id are matched, in exact and
|
|
3724
|
+
* canonical form, so `deepseek-official` and `deepseek` find the same bucket.
|
|
3725
|
+
*/
|
|
3726
|
+
function chartDailyProviderIds(ctx) {
|
|
3727
|
+
const wanted = []
|
|
3728
|
+
if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
|
|
3729
|
+
wanted.push(ctx.currentRow.providerId)
|
|
3730
|
+
}
|
|
3731
|
+
if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
|
|
3732
|
+
wanted.push(ctx.selectedProviderId)
|
|
3733
|
+
}
|
|
3734
|
+
return { wanted, canonicalWanted: new Set(wanted.map((id) => canonicalProviderId(id))) }
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
/** The daily provider bucket of one day in provider scope, or null. */
|
|
3738
|
+
function chartDailyProviderBucket(ctx, entry) {
|
|
3739
|
+
const { wanted, canonicalWanted } = chartDailyProviderIds(ctx)
|
|
3740
|
+
for (const [key, bucket] of Object.entries(entry.providers)) {
|
|
3741
|
+
if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket ?? null
|
|
3742
|
+
}
|
|
3743
|
+
return null
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
/** The 24-hour histogram of one chart point, or null when unsupported. */
|
|
3747
|
+
function chartHoursOfPoint(point) {
|
|
3748
|
+
return point !== null && point !== undefined && Array.isArray(point.hours) && point.hours.length === 24
|
|
3749
|
+
? point.hours
|
|
3750
|
+
: null
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
/** The `HH:00` label of one local hour of the drill-down chart (v1.2.0). */
|
|
3754
|
+
function chartHourLabel(hour) {
|
|
3755
|
+
return `${String(hour).padStart(2, '0')}:00`
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/** Enter / Space activate a focusable chart bar (v1.2.0). */
|
|
3759
|
+
function isChartActivationKey(event) {
|
|
3760
|
+
const key = typeof event?.key === 'string' ? event.key : ''
|
|
3761
|
+
return key === 'Enter' || key === ' ' || key === 'Spacebar'
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
/**
|
|
3765
|
+
* Toggle the chart drill-down (v1.2.0): clicking a day charts its 0..23 hour
|
|
3766
|
+
* histogram; clicking the SAME day again returns to the daily trend.
|
|
3767
|
+
*/
|
|
3768
|
+
function activateChartDay(day) {
|
|
3769
|
+
if (typeof day !== 'string' || day === '') return
|
|
3770
|
+
const next = store.get().chartDay === day ? null : day
|
|
3771
|
+
store.set({ chartDay: next, chartHover: null })
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
/** Leave the hourly drill-down and chart the daily trend again (v1.2.0). */
|
|
3775
|
+
function clearChartDay() {
|
|
3776
|
+
if (store.get().chartDay !== null) store.set({ chartDay: null, chartHover: null })
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
/**
|
|
3780
|
+
* The daily series of the chart (v0.24.0; v1.2.0 adds the day histograms).
|
|
3605
3781
|
*
|
|
3606
3782
|
* Provider scope reads the selected provider's daily bucket and limits the
|
|
3607
3783
|
* window to the provider's effective day when it is subscription-backed
|
|
3608
3784
|
* (fallback: the last 30 days). The all-harness scope uses the day totals of
|
|
3609
|
-
* the last 30 days.
|
|
3785
|
+
* the last 30 days. Every point also carries its 0..23 hour histogram
|
|
3786
|
+
* (`hours`, or null on old Hosts) so the chart can drill into one day.
|
|
3610
3787
|
*/
|
|
3611
3788
|
function chartSeriesOf(ctx) {
|
|
3612
3789
|
const stats = ctx.stats
|
|
3613
3790
|
if (stats === null) return []
|
|
3614
3791
|
const daily = stats.daily
|
|
3615
3792
|
if (ctx.scope === 'all') {
|
|
3616
|
-
return daily.slice(-30).map((entry) => ({
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
if (ctx.currentRow !== null && typeof ctx.currentRow.providerId === 'string' && ctx.currentRow.providerId !== '') {
|
|
3622
|
-
wanted.push(ctx.currentRow.providerId)
|
|
3623
|
-
}
|
|
3624
|
-
if (typeof ctx.selectedProviderId === 'string' && ctx.selectedProviderId !== '') {
|
|
3625
|
-
wanted.push(ctx.selectedProviderId)
|
|
3626
|
-
}
|
|
3627
|
-
const canonicalWanted = new Set(wanted.map((id) => canonicalProviderId(id)))
|
|
3628
|
-
const valueOf = (entry) => {
|
|
3629
|
-
for (const [key, bucket] of Object.entries(entry.providers)) {
|
|
3630
|
-
if (wanted.includes(key) || canonicalWanted.has(canonicalProviderId(key))) return bucket?.total ?? 0
|
|
3631
|
-
}
|
|
3632
|
-
return 0
|
|
3793
|
+
return daily.slice(-30).map((entry) => ({
|
|
3794
|
+
day: entry.day,
|
|
3795
|
+
total: entry.total?.total ?? 0,
|
|
3796
|
+
hours: entry.hours ?? null,
|
|
3797
|
+
}))
|
|
3633
3798
|
}
|
|
3634
|
-
const series = daily.map((entry) =>
|
|
3799
|
+
const series = daily.map((entry) => {
|
|
3800
|
+
const bucket = chartDailyProviderBucket(ctx, entry)
|
|
3801
|
+
return { day: entry.day, total: bucket?.total ?? 0, hours: bucket?.hours ?? null }
|
|
3802
|
+
})
|
|
3635
3803
|
const effectiveDay = ctx.currentRow !== null ? ctx.currentRow.effectiveDay : ''
|
|
3636
3804
|
if (ctx.subscribed && typeof effectiveDay === 'string' && effectiveDay !== '') {
|
|
3637
3805
|
return series.filter((point) => point.day >= effectiveDay)
|
|
@@ -3710,7 +3878,6 @@ window.__ModuleLoader__.load({
|
|
|
3710
3878
|
if (store.get().chartHover !== next) store.set({ chartHover: next })
|
|
3711
3879
|
}
|
|
3712
3880
|
|
|
3713
|
-
/** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
|
|
3714
3881
|
/** The active today bucket of the current stats scope (v0.27.2). */
|
|
3715
3882
|
function todayBucketOf(ctx) {
|
|
3716
3883
|
return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
|
|
@@ -3727,37 +3894,25 @@ window.__ModuleLoader__.load({
|
|
|
3727
3894
|
return ctx.providerScoped ? ctx.currentRow?.last7Days ?? null : ctx.stats?.last7Days ?? null
|
|
3728
3895
|
}
|
|
3729
3896
|
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
h(
|
|
3742
|
-
'span',
|
|
3743
|
-
{ key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
|
|
3744
|
-
t('statsChartEmptyHint'),
|
|
3745
|
-
),
|
|
3746
|
-
),
|
|
3747
|
-
)
|
|
3748
|
-
}
|
|
3897
|
+
/**
|
|
3898
|
+
* The shared pure-CSS bar plot of the chart modes (v0.25.0; v1.2.0 reuses
|
|
3899
|
+
* it for the hourly drill-down): Y-axis ticks, gridlines, bars, the X-axis
|
|
3900
|
+
* labels, the crosshair and the live pointer readout. Pure DOM, no deps.
|
|
3901
|
+
*
|
|
3902
|
+
* `spec.bar(point, index, meta)` returns the wrapper element of one bar
|
|
3903
|
+
* (`meta.max` / `meta.hovered` drive its height and highlight),
|
|
3904
|
+
* `spec.xLabel(point, index)` returns `{text, attrs}` for the X axis, and
|
|
3905
|
+
* `spec.readout(hovered, share)` returns the live readout text.
|
|
3906
|
+
*/
|
|
3907
|
+
function buildChartPlot(state, points, spec) {
|
|
3749
3908
|
const max = Math.max(...points.map((point) => point.total), 1)
|
|
3750
|
-
|
|
3751
|
-
for (const point of points) {
|
|
3752
|
-
if (point.total > peak.total) peak = point
|
|
3753
|
-
}
|
|
3909
|
+
const total = points.reduce((sum, point) => sum + point.total, 0)
|
|
3754
3910
|
const hoverIndex =
|
|
3755
3911
|
Number.isInteger(state.chartHover) && state.chartHover >= 0 && state.chartHover < points.length
|
|
3756
3912
|
? state.chartHover
|
|
3757
3913
|
: null
|
|
3758
3914
|
const hovered = hoverIndex === null ? null : points[hoverIndex]
|
|
3759
|
-
const hoverShare =
|
|
3760
|
-
hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
|
|
3915
|
+
const hoverShare = hovered === null || total <= 0 ? 0 : Math.round((hovered.total / total) * 1000) / 10
|
|
3761
3916
|
const handleMove = (event) => {
|
|
3762
3917
|
const index = chartIndexFromEvent(event, points.length)
|
|
3763
3918
|
if (index !== null) setChartHover(index)
|
|
@@ -3783,32 +3938,10 @@ window.__ModuleLoader__.load({
|
|
|
3783
3938
|
tick.text,
|
|
3784
3939
|
),
|
|
3785
3940
|
)
|
|
3786
|
-
const bars = points.map((point, index) => {
|
|
3787
|
-
const height = point.total > 0 ? Math.max(2, Math.round((point.total / max) * 1000) / 10) : 0
|
|
3788
|
-
const isHovered = index === hoverIndex
|
|
3789
|
-
return h(
|
|
3790
|
-
'div',
|
|
3791
|
-
{
|
|
3792
|
-
key: point.day,
|
|
3793
|
-
className: 'dsh-tokenlens-chart-bar-wrap',
|
|
3794
|
-
'data-chart-day': point.day,
|
|
3795
|
-
'data-chart-total': String(point.total),
|
|
3796
|
-
'data-chart-hover': isHovered ? 'true' : 'false',
|
|
3797
|
-
title: t('statsChartBarTitle', {
|
|
3798
|
-
date: point.day,
|
|
3799
|
-
tokens: formatTokensPrecise(point.total),
|
|
3800
|
-
}),
|
|
3801
|
-
},
|
|
3802
|
-
h('div', {
|
|
3803
|
-
className: 'dsh-tokenlens-chart-bar',
|
|
3804
|
-
'data-empty': point.total > 0 ? 'false' : 'true',
|
|
3805
|
-
'data-height': String(height),
|
|
3806
|
-
style: { height: `${height}%` },
|
|
3807
|
-
}),
|
|
3808
|
-
)
|
|
3809
|
-
})
|
|
3941
|
+
const bars = points.map((point, index) => spec.bar(point, index, { max, hovered: index === hoverIndex }))
|
|
3810
3942
|
const xLabels = chartXLabelIndices(points.length).map((index) => {
|
|
3811
3943
|
const point = points[index]
|
|
3944
|
+
const label = spec.xLabel(point, index)
|
|
3812
3945
|
const first = index === 0
|
|
3813
3946
|
const last = index === points.length - 1
|
|
3814
3947
|
const style = first
|
|
@@ -3819,12 +3952,12 @@ window.__ModuleLoader__.load({
|
|
|
3819
3952
|
return h(
|
|
3820
3953
|
'span',
|
|
3821
3954
|
{
|
|
3822
|
-
key: `x-${
|
|
3955
|
+
key: `x-${index}`,
|
|
3823
3956
|
className: 'dsh-tokenlens-chart-x-label',
|
|
3824
|
-
|
|
3957
|
+
...(label.attrs === null || label.attrs === undefined ? {} : label.attrs),
|
|
3825
3958
|
style,
|
|
3826
3959
|
},
|
|
3827
|
-
|
|
3960
|
+
label.text,
|
|
3828
3961
|
)
|
|
3829
3962
|
})
|
|
3830
3963
|
const crosshair =
|
|
@@ -3857,37 +3990,249 @@ window.__ModuleLoader__.load({
|
|
|
3857
3990
|
role: 'status',
|
|
3858
3991
|
style: readoutStyle,
|
|
3859
3992
|
},
|
|
3860
|
-
|
|
3861
|
-
date: hovered.day,
|
|
3862
|
-
value: formatTokensPrecise(hovered.total),
|
|
3863
|
-
share: String(hoverShare),
|
|
3864
|
-
}),
|
|
3993
|
+
spec.readout(hovered, hoverShare),
|
|
3865
3994
|
)
|
|
3866
3995
|
return h(
|
|
3867
3996
|
'div',
|
|
3868
|
-
{ key: '
|
|
3997
|
+
{ key: 'body', className: 'dsh-tokenlens-chart-body' },
|
|
3998
|
+
h('div', { key: 'y-axis', className: 'dsh-tokenlens-chart-yaxis', 'aria-hidden': 'true' }, yTicks),
|
|
3999
|
+
h(
|
|
4000
|
+
'div',
|
|
4001
|
+
{
|
|
4002
|
+
key: 'plot',
|
|
4003
|
+
className: 'dsh-tokenlens-chart-plot',
|
|
4004
|
+
'data-chart-plot': 'true',
|
|
4005
|
+
onMouseMove: handleMove,
|
|
4006
|
+
onMouseLeave: handleLeave,
|
|
4007
|
+
onPointerMove: handleMove,
|
|
4008
|
+
onPointerLeave: handleLeave,
|
|
4009
|
+
},
|
|
4010
|
+
h('div', { key: 'grid', className: 'dsh-tokenlens-chart-grid', 'aria-hidden': 'true' }, gridLines),
|
|
4011
|
+
h('div', { key: 'bars', className: 'dsh-tokenlens-chart-bars' }, bars),
|
|
4012
|
+
crosshair,
|
|
4013
|
+
readout,
|
|
4014
|
+
h('div', { key: 'xaxis', className: 'dsh-tokenlens-chart-xaxis', 'aria-hidden': 'true' }, xLabels),
|
|
4015
|
+
),
|
|
4016
|
+
)
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
/**
|
|
4020
|
+
* One bar of the daily trend chart (v0.25.0). A day whose entry carries the
|
|
4021
|
+
* 0..23 hour histogram is ALSO a real button (v1.2.0): focusable, Enter /
|
|
4022
|
+
* Space activate it, `aria-pressed` marks the drilled day. A day without
|
|
4023
|
+
* hours is not drillable and keeps the old non-interactive bar.
|
|
4024
|
+
*/
|
|
4025
|
+
function buildDailyChartBar(state, t, point, meta) {
|
|
4026
|
+
const height = point.total > 0 ? Math.max(2, Math.round((point.total / meta.max) * 1000) / 10) : 0
|
|
4027
|
+
const drillable = chartHoursOfPoint(point) !== null
|
|
4028
|
+
const selected = drillable && state.chartDay === point.day
|
|
4029
|
+
const attrs = {
|
|
4030
|
+
key: point.day,
|
|
4031
|
+
className: drillable
|
|
4032
|
+
? 'dsh-tokenlens-chart-bar-wrap dsh-tokenlens-chart-bar-wrap-clickable'
|
|
4033
|
+
: 'dsh-tokenlens-chart-bar-wrap',
|
|
4034
|
+
'data-chart-day': point.day,
|
|
4035
|
+
'data-chart-total': String(point.total),
|
|
4036
|
+
'data-chart-hover': meta.hovered ? 'true' : 'false',
|
|
4037
|
+
'data-chart-drillable': drillable ? 'true' : 'false',
|
|
4038
|
+
'data-chart-selected': selected ? 'true' : 'false',
|
|
4039
|
+
title: t('statsChartBarTitle', {
|
|
4040
|
+
date: point.day,
|
|
4041
|
+
tokens: formatTokensPrecise(point.total),
|
|
4042
|
+
}),
|
|
4043
|
+
}
|
|
4044
|
+
if (drillable) {
|
|
4045
|
+
attrs.role = 'button'
|
|
4046
|
+
attrs.tabIndex = 0
|
|
4047
|
+
attrs['aria-pressed'] = selected ? 'true' : 'false'
|
|
4048
|
+
attrs['aria-label'] = t('statsChartBarTitle', {
|
|
4049
|
+
date: point.day,
|
|
4050
|
+
tokens: formatTokensPrecise(point.total),
|
|
4051
|
+
})
|
|
4052
|
+
attrs.onClick = () => activateChartDay(point.day)
|
|
4053
|
+
attrs.onKeyDown = (event) => {
|
|
4054
|
+
if (!isChartActivationKey(event)) return
|
|
4055
|
+
if (typeof event?.preventDefault === 'function') event.preventDefault()
|
|
4056
|
+
activateChartDay(point.day)
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
return h(
|
|
4060
|
+
'div',
|
|
4061
|
+
attrs,
|
|
4062
|
+
h('div', {
|
|
4063
|
+
className: 'dsh-tokenlens-chart-bar',
|
|
4064
|
+
'data-empty': point.total > 0 ? 'false' : 'true',
|
|
4065
|
+
'data-height': String(height),
|
|
4066
|
+
style: { height: `${height}%` },
|
|
4067
|
+
}),
|
|
4068
|
+
)
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
/**
|
|
4072
|
+
* The drilled day view (v1.2.0): one day's 00:00–23:00 token bars, with a
|
|
4073
|
+
* back button to the daily trend. It reuses the shared plot (axes, pointer
|
|
4074
|
+
* readout, tooltips) and the existing summary formatters.
|
|
4075
|
+
*/
|
|
4076
|
+
function buildHourChart(state, point, hours, t) {
|
|
4077
|
+
const dayLabel = chartDayLabel(point.day)
|
|
4078
|
+
const hourPoints = hours.map((value, hour) => ({ hour, total: value }))
|
|
4079
|
+
const total = hourPoints.reduce((sum, hourPoint) => sum + hourPoint.total, 0)
|
|
4080
|
+
const head = h(
|
|
4081
|
+
'div',
|
|
4082
|
+
{ key: 'drill-head', className: 'dsh-tokenlens-chart-drill-head', 'data-chart-drill-head': 'true' },
|
|
4083
|
+
h(
|
|
4084
|
+
'button',
|
|
4085
|
+
{
|
|
4086
|
+
key: 'back',
|
|
4087
|
+
type: 'button',
|
|
4088
|
+
className: 'dsh-tokenlens-chart-back',
|
|
4089
|
+
'data-chart-back': point.day,
|
|
4090
|
+
title: t('statsChartBackHint'),
|
|
4091
|
+
'aria-label': t('statsChartBack'),
|
|
4092
|
+
onClick: () => clearChartDay(),
|
|
4093
|
+
},
|
|
4094
|
+
t('statsChartBack'),
|
|
4095
|
+
),
|
|
3869
4096
|
h(
|
|
4097
|
+
'span',
|
|
4098
|
+
{ key: 'title', className: 'dsh-tokenlens-chart-drill-title', 'data-chart-hour-title': point.day },
|
|
4099
|
+
t('statsChartHourTitle', { date: point.day }),
|
|
4100
|
+
),
|
|
4101
|
+
)
|
|
4102
|
+
const containerAttrs = {
|
|
4103
|
+
key: 'chart',
|
|
4104
|
+
className: 'dsh-tokenlens-chart dsh-tokenlens-chart-drilldown',
|
|
4105
|
+
'data-chart': 'true',
|
|
4106
|
+
'data-chart-drilldown': 'true',
|
|
4107
|
+
'data-chart-drill-day': point.day,
|
|
4108
|
+
}
|
|
4109
|
+
if (total <= 0) {
|
|
4110
|
+
// A hosted histogram that is all zeros carries no data: say so instead of
|
|
4111
|
+
// inventing an empty 24-bar chart (old Hosts never get this far: their
|
|
4112
|
+
// bars are not drillable at all).
|
|
4113
|
+
return h(
|
|
3870
4114
|
'div',
|
|
3871
|
-
|
|
3872
|
-
|
|
4115
|
+
containerAttrs,
|
|
4116
|
+
head,
|
|
3873
4117
|
h(
|
|
4118
|
+
'div',
|
|
4119
|
+
{ key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-hour-empty': 'true' },
|
|
4120
|
+
h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartHourEmpty')),
|
|
4121
|
+
),
|
|
4122
|
+
)
|
|
4123
|
+
}
|
|
4124
|
+
let peak = hourPoints[0]
|
|
4125
|
+
for (const hourPoint of hourPoints) {
|
|
4126
|
+
if (hourPoint.total > peak.total) peak = hourPoint
|
|
4127
|
+
}
|
|
4128
|
+
const body = buildChartPlot(state, hourPoints, {
|
|
4129
|
+
bar: (hourPoint, index, meta) => {
|
|
4130
|
+
const height = hourPoint.total > 0 ? Math.max(2, Math.round((hourPoint.total / meta.max) * 1000) / 10) : 0
|
|
4131
|
+
return h(
|
|
3874
4132
|
'div',
|
|
3875
4133
|
{
|
|
3876
|
-
key:
|
|
3877
|
-
className: 'dsh-tokenlens-chart-
|
|
3878
|
-
'data-chart-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
4134
|
+
key: `hour-${hourPoint.hour}`,
|
|
4135
|
+
className: 'dsh-tokenlens-chart-bar-wrap',
|
|
4136
|
+
'data-chart-hour': String(hourPoint.hour),
|
|
4137
|
+
'data-chart-total': String(hourPoint.total),
|
|
4138
|
+
'data-chart-hover': meta.hovered ? 'true' : 'false',
|
|
4139
|
+
title: t('statsChartHourBarTitle', {
|
|
4140
|
+
date: dayLabel,
|
|
4141
|
+
hour: chartHourLabel(hourPoint.hour),
|
|
4142
|
+
tokens: formatTokensPrecise(hourPoint.total),
|
|
4143
|
+
}),
|
|
3883
4144
|
},
|
|
3884
|
-
h('div', {
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
4145
|
+
h('div', {
|
|
4146
|
+
className: 'dsh-tokenlens-chart-bar',
|
|
4147
|
+
'data-empty': hourPoint.total > 0 ? 'false' : 'true',
|
|
4148
|
+
'data-height': String(height),
|
|
4149
|
+
style: { height: `${height}%` },
|
|
4150
|
+
}),
|
|
4151
|
+
)
|
|
4152
|
+
},
|
|
4153
|
+
xLabel: (hourPoint) => ({
|
|
4154
|
+
text: chartHourLabel(hourPoint.hour),
|
|
4155
|
+
attrs: { 'data-chart-axis-hour': String(hourPoint.hour) },
|
|
4156
|
+
}),
|
|
4157
|
+
readout: (hovered, share) =>
|
|
4158
|
+
t('statsChartReadout', {
|
|
4159
|
+
date: `${dayLabel} ${chartHourLabel(hovered.hour)}`,
|
|
4160
|
+
value: formatTokensPrecise(hovered.total),
|
|
4161
|
+
share: String(share),
|
|
4162
|
+
}),
|
|
4163
|
+
})
|
|
4164
|
+
return h(
|
|
4165
|
+
'div',
|
|
4166
|
+
containerAttrs,
|
|
4167
|
+
head,
|
|
4168
|
+
body,
|
|
4169
|
+
h(
|
|
4170
|
+
'div',
|
|
4171
|
+
{ key: 'summary', className: 'dsh-tokenlens-chart-summary' },
|
|
4172
|
+
h(
|
|
4173
|
+
'span',
|
|
4174
|
+
{ key: 'total', 'data-chart-summary': 'hour-total' },
|
|
4175
|
+
t('statsChartTotal', { value: formatTokensPrecise(total) }),
|
|
4176
|
+
),
|
|
4177
|
+
h(
|
|
4178
|
+
'span',
|
|
4179
|
+
{ key: 'peak', 'data-chart-summary': 'hour-peak' },
|
|
4180
|
+
t('statsChartPeak', { value: formatTokensPrecise(peak.total), day: chartHourLabel(peak.hour) }),
|
|
3889
4181
|
),
|
|
3890
4182
|
),
|
|
4183
|
+
)
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
/** The pure-CSS daily bar chart: axes + live pointer readout (v0.25.0; no deps). */
|
|
4187
|
+
function buildDailyChart(state, ctx, t) {
|
|
4188
|
+
const points = chartSeriesOf(ctx)
|
|
4189
|
+
const total = points.reduce((sum, point) => sum + point.total, 0)
|
|
4190
|
+
if (points.length === 0 || total === 0) {
|
|
4191
|
+
return h(
|
|
4192
|
+
'div',
|
|
4193
|
+
{ key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true', 'data-chart-empty-series': 'true' },
|
|
4194
|
+
h(
|
|
4195
|
+
'div',
|
|
4196
|
+
{ key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-chart-empty': 'true' },
|
|
4197
|
+
h('span', { key: 'title', className: 'dsh-tokenlens-hint' }, t('statsChartEmpty')),
|
|
4198
|
+
h(
|
|
4199
|
+
'span',
|
|
4200
|
+
{ key: 'hint', className: 'dsh-tokenlens-hint dsh-tokenlens-chart-empty-hint' },
|
|
4201
|
+
t('statsChartEmptyHint'),
|
|
4202
|
+
),
|
|
4203
|
+
),
|
|
4204
|
+
)
|
|
4205
|
+
}
|
|
4206
|
+
// v1.2.0: a drilled day replaces the daily trend with its 24-hour
|
|
4207
|
+
// histogram. A stale selection (day gone / no hours) silently falls back to
|
|
4208
|
+
// the daily trend instead of charting a fabricated hour series.
|
|
4209
|
+
const drillPoint =
|
|
4210
|
+
typeof state.chartDay === 'string' && state.chartDay !== ''
|
|
4211
|
+
? (points.find((point) => point.day === state.chartDay) ?? null)
|
|
4212
|
+
: null
|
|
4213
|
+
const drillHours = drillPoint === null ? null : chartHoursOfPoint(drillPoint)
|
|
4214
|
+
if (drillPoint !== null && drillHours !== null) return buildHourChart(state, drillPoint, drillHours, t)
|
|
4215
|
+
let peak = points[0]
|
|
4216
|
+
for (const point of points) {
|
|
4217
|
+
if (point.total > peak.total) peak = point
|
|
4218
|
+
}
|
|
4219
|
+
const body = buildChartPlot(state, points, {
|
|
4220
|
+
bar: (point, index, meta) => buildDailyChartBar(state, t, point, meta),
|
|
4221
|
+
xLabel: (point) => ({
|
|
4222
|
+
text: chartDayLabel(point.day),
|
|
4223
|
+
attrs: { 'data-chart-axis-day': point.day },
|
|
4224
|
+
}),
|
|
4225
|
+
readout: (hovered, share) =>
|
|
4226
|
+
t('statsChartReadout', {
|
|
4227
|
+
date: hovered.day,
|
|
4228
|
+
value: formatTokensPrecise(hovered.total),
|
|
4229
|
+
share: String(share),
|
|
4230
|
+
}),
|
|
4231
|
+
})
|
|
4232
|
+
return h(
|
|
4233
|
+
'div',
|
|
4234
|
+
{ key: 'chart', className: 'dsh-tokenlens-chart', 'data-chart': 'true' },
|
|
4235
|
+
body,
|
|
3891
4236
|
h(
|
|
3892
4237
|
'div',
|
|
3893
4238
|
{ key: 'summary', className: 'dsh-tokenlens-chart-summary' },
|
|
@@ -4086,6 +4431,87 @@ window.__ModuleLoader__.load({
|
|
|
4086
4431
|
return t('statsLast30Days')
|
|
4087
4432
|
}
|
|
4088
4433
|
|
|
4434
|
+
/**
|
|
4435
|
+
* The 明细 donut windows in display order (v1.2.0): 今日 → 最近7天 →
|
|
4436
|
+
* 最近X天/最近30天 → 总计. Old Hosts omit the 7-day / second windows, so their
|
|
4437
|
+
* pills are hidden (never disabled ghosts of a window that cannot exist).
|
|
4438
|
+
*/
|
|
4439
|
+
function detailsWindowChoices(ctx, t) {
|
|
4440
|
+
const choices = [{ key: 'today', label: t('statsTodayCol') }]
|
|
4441
|
+
if (ctx.hasWeek) choices.push({ key: 'last7Days', label: t('statsLast7Days') })
|
|
4442
|
+
if (ctx.hasMonth) choices.push({ key: 'second', label: detailsSecondLabel(ctx, t) })
|
|
4443
|
+
choices.push({ key: 'total', label: t('statsAllTimeCol') })
|
|
4444
|
+
return choices
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
/**
|
|
4448
|
+
* The active 明细 donut window (v1.2.1): the stored choice while that window
|
|
4449
|
+
* still exists, else 总计. Since v1.2.1 the choice is also persisted to the
|
|
4450
|
+
* plugin settings (the startup bootstrap loads it back), so reopening the
|
|
4451
|
+
* dialog / restarting DSH restores it.
|
|
4452
|
+
*/
|
|
4453
|
+
function detailsWindowOf(state, ctx) {
|
|
4454
|
+
const key = typeof state.detailsWindow === 'string' ? state.detailsWindow : 'total'
|
|
4455
|
+
if (key === 'today') return 'today'
|
|
4456
|
+
if (key === 'last7Days') return ctx.hasWeek ? 'last7Days' : 'total'
|
|
4457
|
+
if (key === 'second') return ctx.hasMonth ? 'second' : 'total'
|
|
4458
|
+
return 'total'
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
/**
|
|
4462
|
+
* The bucket of one 明细 row for a donut window (v1.2.0). Harness/provider
|
|
4463
|
+
* rows carry the four stats windows directly; model rows use the caller's
|
|
4464
|
+
* second-window accessor (`secondModelOf`) for the effective-window column.
|
|
4465
|
+
*/
|
|
4466
|
+
function detailsWindowBucketOf(windowKey, buckets, secondBucketOf) {
|
|
4467
|
+
if (buckets === null || buckets === undefined) return null
|
|
4468
|
+
if (windowKey === 'today') return buckets.today ?? null
|
|
4469
|
+
if (windowKey === 'last7Days') return buckets.last7Days ?? null
|
|
4470
|
+
if (windowKey === 'second') {
|
|
4471
|
+
return typeof secondBucketOf === 'function' ? secondBucketOf(buckets) : buckets.last30Days ?? null
|
|
4472
|
+
}
|
|
4473
|
+
return buckets.allTime ?? null
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
/** The token total of one 明细 row for a donut window (v1.2.0). */
|
|
4477
|
+
function detailsWindowTotalOf(windowKey, buckets, secondBucketOf) {
|
|
4478
|
+
const bucket = detailsWindowBucketOf(windowKey, buckets, secondBucketOf)
|
|
4479
|
+
return bucket === null ? 0 : bucket.total ?? 0
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
/** The window pill row under the 明细 donut title (v1.2.0; saved since v1.2.1). */
|
|
4483
|
+
function buildDetailsWindowSwitch(state, ctx, t) {
|
|
4484
|
+
const active = detailsWindowOf(state, ctx)
|
|
4485
|
+
const option = (key, label) =>
|
|
4486
|
+
h(
|
|
4487
|
+
'button',
|
|
4488
|
+
{
|
|
4489
|
+
key,
|
|
4490
|
+
type: 'button',
|
|
4491
|
+
className: 'dsh-tokenlens-scope-option',
|
|
4492
|
+
'data-details-window': key,
|
|
4493
|
+
'data-active': active === key ? 'true' : 'false',
|
|
4494
|
+
'aria-pressed': active === key ? 'true' : 'false',
|
|
4495
|
+
onClick: () => {
|
|
4496
|
+
if (active !== key) {
|
|
4497
|
+
store.set({ detailsWindow: key })
|
|
4498
|
+
persistDetailsWindow()
|
|
4499
|
+
}
|
|
4500
|
+
},
|
|
4501
|
+
},
|
|
4502
|
+
label,
|
|
4503
|
+
)
|
|
4504
|
+
return h(
|
|
4505
|
+
'div',
|
|
4506
|
+
{ key: 'details-window', className: 'dsh-tokenlens-details-window', 'data-details-window-switch': 'true' },
|
|
4507
|
+
h(
|
|
4508
|
+
'div',
|
|
4509
|
+
{ key: 'scope', className: 'dsh-tokenlens-scope', role: 'group', 'aria-label': t('statsWindowLabel') },
|
|
4510
|
+
detailsWindowChoices(ctx, t).map((choice) => option(choice.key, choice.label)),
|
|
4511
|
+
),
|
|
4512
|
+
)
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4089
4515
|
/**
|
|
4090
4516
|
* The compact 明细 table columns of one group (v0.30.2). Harness/provider
|
|
4091
4517
|
* rows start at 今日; the all-harness top-model table starts at the
|
|
@@ -4758,10 +5184,18 @@ window.__ModuleLoader__.load({
|
|
|
4758
5184
|
)
|
|
4759
5185
|
}
|
|
4760
5186
|
|
|
4761
|
-
/**
|
|
5187
|
+
/**
|
|
5188
|
+
* The 明细 column in chart mode: donut share charts + ranked legends.
|
|
5189
|
+
*
|
|
5190
|
+
* v1.2.0: a 今日 / 最近7天 / 最近X天 / 总计 window switch under the title picks
|
|
5191
|
+
* which stats window the three donuts aggregate (default 总计). The pills of
|
|
5192
|
+
* windows an old Host does not report are hidden; the state lives in the store
|
|
5193
|
+
* and, since v1.2.1, is persisted to the plugin settings.
|
|
5194
|
+
*/
|
|
4762
5195
|
function buildStatsDetailsCharts(state, ctx, t) {
|
|
4763
|
-
const { stats
|
|
5196
|
+
const { stats } = ctx
|
|
4764
5197
|
if (stats === null) return null
|
|
5198
|
+
const windowKey = detailsWindowOf(state, ctx)
|
|
4765
5199
|
const children = [
|
|
4766
5200
|
h(
|
|
4767
5201
|
'div',
|
|
@@ -4773,17 +5207,18 @@ window.__ModuleLoader__.load({
|
|
|
4773
5207
|
h('span', { key: 'label' }, t('statsDetailsTitle')),
|
|
4774
5208
|
),
|
|
4775
5209
|
),
|
|
5210
|
+
buildDetailsWindowSwitch(state, ctx, t),
|
|
4776
5211
|
]
|
|
5212
|
+
const groups = []
|
|
4777
5213
|
const harnessRows = scopedHarnessRows(ctx).map((row) => ({
|
|
4778
5214
|
label: row.label,
|
|
4779
|
-
value:
|
|
5215
|
+
value: detailsWindowTotalOf(windowKey, row.buckets, ctx.secondBucketOf),
|
|
4780
5216
|
}))
|
|
4781
|
-
const groups = []
|
|
4782
5217
|
const harnessChart = buildBreakdownDonutGroup('harness-chart', t('statsByHarness'), 'list', harnessRows, t)
|
|
4783
5218
|
if (harnessChart !== null) groups.push(harnessChart)
|
|
4784
5219
|
const providerRows = stats.providers.map((provider) => ({
|
|
4785
5220
|
label: provider.label,
|
|
4786
|
-
value: ctx.secondBucketOf
|
|
5221
|
+
value: detailsWindowTotalOf(windowKey, provider, ctx.secondBucketOf),
|
|
4787
5222
|
}))
|
|
4788
5223
|
const providerChart = buildBreakdownDonutGroup('provider-chart', t('statsByProvider'), 'server', providerRows, t)
|
|
4789
5224
|
if (providerChart !== null) groups.push(providerChart)
|
|
@@ -4792,18 +5227,25 @@ window.__ModuleLoader__.load({
|
|
|
4792
5227
|
// models from other providers silently disappear from the donut (v0.27.1).
|
|
4793
5228
|
const modelRows = stats.models.map((model) => ({
|
|
4794
5229
|
label: model.modelId,
|
|
4795
|
-
value: ctx.secondModelOf
|
|
5230
|
+
value: detailsWindowTotalOf(windowKey, model, ctx.secondModelOf),
|
|
4796
5231
|
}))
|
|
4797
5232
|
const modelChart = buildBreakdownDonutGroup('model-chart', t('statsTopModels'), 'cube', modelRows, t)
|
|
4798
5233
|
if (modelChart !== null) groups.push(modelChart)
|
|
4799
5234
|
if (groups.length > 0) {
|
|
4800
5235
|
children.push(h('div', { key: 'charts', className: 'dsh-tokenlens-details-charts-grid' }, groups))
|
|
4801
|
-
}
|
|
4802
|
-
|
|
5236
|
+
} else {
|
|
5237
|
+
// An all-zero window renders no donut at all: keep the readable hint.
|
|
4803
5238
|
children.push(h('div', { key: 'empty', className: 'dsh-tokenlens-hint' }, t('statsEmpty')))
|
|
4804
5239
|
}
|
|
4805
|
-
|
|
4806
|
-
|
|
5240
|
+
return h(
|
|
5241
|
+
'div',
|
|
5242
|
+
{
|
|
5243
|
+
key: 'details',
|
|
5244
|
+
className: 'dsh-tokenlens-section dsh-tokenlens-details',
|
|
5245
|
+
'data-details-window': windowKey,
|
|
5246
|
+
},
|
|
5247
|
+
children,
|
|
5248
|
+
)
|
|
4807
5249
|
}
|
|
4808
5250
|
|
|
4809
5251
|
function buildStatsDetails(state, ctx, t) {
|