dsh-token-gauge 0.1.0

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/index.js ADDED
@@ -0,0 +1,234 @@
1
+ // src/host.ts
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ var PLUGIN_ID = "dsh-token-gauge";
6
+ var DATA_DIR = join(homedir(), ".dsh-token-gauge");
7
+ var STATE_FILE = join(DATA_DIR, "state.json");
8
+ var DEFAULT_PRICING = {
9
+ "deepseek-chat": { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
10
+ "deepseek-v3": { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
11
+ "deepseek-reasoner": { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
12
+ "deepseek-r1": { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
13
+ "deepseek-v4-flash": { inputHit: 0.5, inputMiss: 2, output: 2, cacheWrite: 2 },
14
+ "deepseek-v4-pro": { inputHit: 1, inputMiss: 4, output: 8, cacheWrite: 4 },
15
+ "deepseek-coder": { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
16
+ __default__: { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 }
17
+ };
18
+ function emptyEntry() {
19
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cost: 0, calls: 0 };
20
+ }
21
+ function addEntry(t, s) {
22
+ t.input += s.input;
23
+ t.cacheRead += s.cacheRead;
24
+ t.cacheWrite += s.cacheWrite;
25
+ t.output += s.output;
26
+ t.cost += s.cost;
27
+ t.calls += s.calls;
28
+ }
29
+ function mergedPricing(settings) {
30
+ return { ...DEFAULT_PRICING, ...settings.pricing || {} };
31
+ }
32
+ function costFor(modelKey, b, pricing) {
33
+ const p = pricing[modelKey] || pricing["__default__"] || DEFAULT_PRICING["__default__"];
34
+ return (b.input * p.inputMiss + b.cacheRead * p.inputHit + b.cacheWrite * p.cacheWrite + b.output * p.output) / 1e6;
35
+ }
36
+ function todayKey(d = /* @__PURE__ */ new Date()) {
37
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
38
+ }
39
+ function monthKey(d = /* @__PURE__ */ new Date()) {
40
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
41
+ }
42
+ function weekKey(d = /* @__PURE__ */ new Date()) {
43
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
44
+ const dayNum = (date.getUTCDay() + 6) % 7;
45
+ date.setUTCDate(date.getUTCDate() - dayNum + 3);
46
+ const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
47
+ const week = 1 + Math.round(
48
+ ((date.getTime() - firstThursday.getTime()) / 864e5 - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
49
+ );
50
+ return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
51
+ }
52
+ function defaultSettings() {
53
+ return {
54
+ style: "ev",
55
+ // 默认风格:现代电车科技
56
+ palette: "neon",
57
+ // 默认配色:霓虹蓝
58
+ lang: "zh",
59
+ metric: "global",
60
+ display: {
61
+ usage: true,
62
+ cost: true,
63
+ io: true,
64
+ cacheMiss: true
65
+ },
66
+ limits: { global: null, daily: null, weekly: null, monthly: null },
67
+ pricing: {}
68
+ };
69
+ }
70
+ function defaultState() {
71
+ return {
72
+ global: emptyEntry(),
73
+ daily: {},
74
+ weekly: {},
75
+ monthly: {},
76
+ sessions: {},
77
+ models: {},
78
+ activeSession: "",
79
+ settings: defaultSettings()
80
+ };
81
+ }
82
+ function loadState() {
83
+ try {
84
+ if (existsSync(STATE_FILE)) {
85
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf8"));
86
+ const base = defaultState();
87
+ const merged = {
88
+ ...base,
89
+ ...raw,
90
+ settings: { ...base.settings, ...raw.settings || {} }
91
+ };
92
+ merged.settings.display = { ...base.settings.display, ...raw.settings?.display || {} };
93
+ merged.settings.limits = { ...base.settings.limits, ...raw.settings?.limits || {} };
94
+ merged.settings.pricing = raw.settings?.pricing || {};
95
+ merged.daily = raw.daily || {};
96
+ merged.weekly = raw.weekly || {};
97
+ merged.monthly = raw.monthly || {};
98
+ merged.sessions = raw.sessions || {};
99
+ merged.models = raw.models || {};
100
+ merged.global = { ...emptyEntry(), ...raw.global || {} };
101
+ return merged;
102
+ }
103
+ } catch (e) {
104
+ console.warn(`[${PLUGIN_ID}] load state failed, use default:`, e);
105
+ }
106
+ return defaultState();
107
+ }
108
+ function saveState(state) {
109
+ try {
110
+ mkdirSync(DATA_DIR, { recursive: true });
111
+ writeFileSync(STATE_FILE, JSON.stringify(state));
112
+ } catch (e) {
113
+ console.warn(`[${PLUGIN_ID}] save state failed:`, e);
114
+ }
115
+ }
116
+ function extractUsage(data) {
117
+ if (!data) return null;
118
+ const u = data.usage ?? data;
119
+ const input = u.input_tokens ?? u.prompt_tokens ?? u.uncachedInput ?? u.input ?? u.total_input_tokens ?? 0;
120
+ const cacheRead = u.cache_read_tokens ?? u.cacheRead ?? u.prompt_cache_hit_tokens ?? u.cache_read ?? 0;
121
+ const cacheWrite = u.cache_write_tokens ?? u.cacheWrite ?? u.cache_write ?? 0;
122
+ const output = u.output_tokens ?? u.completion_tokens ?? u.output ?? u.total_output_tokens ?? 0;
123
+ if (!input && !cacheRead && !cacheWrite && !output) return null;
124
+ const model = data.model ?? u.model ?? "unknown";
125
+ const session = data.session ?? u.session ?? data.sessionId ?? u.sessionId ?? "";
126
+ const id = data.id ?? u.id ?? data.seq ?? u.seq;
127
+ return {
128
+ buckets: { input: Number(input) || 0, cacheRead: Number(cacheRead) || 0, cacheWrite: Number(cacheWrite) || 0, output: Number(output) || 0 },
129
+ model,
130
+ session,
131
+ id
132
+ };
133
+ }
134
+ var name = PLUGIN_ID;
135
+ var inject = ["events"];
136
+ function apply(ctx) {
137
+ const state = loadState();
138
+ let saveTimer = null;
139
+ const scheduleSave = () => {
140
+ if (saveTimer) return;
141
+ saveTimer = setTimeout(() => {
142
+ saveTimer = null;
143
+ saveState(state);
144
+ }, 400);
145
+ };
146
+ const pushState = () => {
147
+ ctx.events.emit(`${PLUGIN_ID}:state`, state);
148
+ };
149
+ const countedIds = /* @__PURE__ */ new Set();
150
+ const accumulate = (data) => {
151
+ const ex = extractUsage(data);
152
+ if (!ex) return;
153
+ if (ex.id) {
154
+ if (countedIds.has(ex.id)) return;
155
+ countedIds.add(ex.id);
156
+ }
157
+ const modelKey = ex.model || "unknown";
158
+ if (ex.session) state.activeSession = ex.session;
159
+ const pricing = mergedPricing(state.settings);
160
+ const cost = costFor(modelKey, ex.buckets, pricing);
161
+ const e = { ...ex.buckets, cost, calls: 1 };
162
+ addEntry(state.global, e);
163
+ const dk = todayKey();
164
+ state.daily[dk] = state.daily[dk] || emptyEntry();
165
+ addEntry(state.daily[dk], e);
166
+ const wk = weekKey();
167
+ state.weekly[wk] = state.weekly[wk] || emptyEntry();
168
+ addEntry(state.weekly[wk], e);
169
+ const mk = monthKey();
170
+ state.monthly[mk] = state.monthly[mk] || emptyEntry();
171
+ addEntry(state.monthly[mk], e);
172
+ if (ex.session) {
173
+ state.sessions[ex.session] = state.sessions[ex.session] || emptyEntry();
174
+ addEntry(state.sessions[ex.session], e);
175
+ }
176
+ state.models[modelKey] = state.models[modelKey] || emptyEntry();
177
+ addEntry(state.models[modelKey], e);
178
+ scheduleSave();
179
+ pushState();
180
+ };
181
+ ctx.effect(() => {
182
+ const onUsage = (data) => accumulate(data);
183
+ ctx.events.on("assistant/message.usage", onUsage);
184
+ ctx.events.on("usage", onUsage);
185
+ ctx.events.on("assistant/message", (data) => {
186
+ if (data && (data.usage || data.data && data.data.usage)) onUsage(data);
187
+ });
188
+ return () => {
189
+ ctx.events.off("assistant/message.usage", onUsage);
190
+ ctx.events.off("usage", onUsage);
191
+ };
192
+ });
193
+ ctx.effect(() => {
194
+ const onCmd = (msg) => {
195
+ if (!msg || typeof msg.type !== "string") return;
196
+ switch (msg.type) {
197
+ case "getState":
198
+ pushState();
199
+ break;
200
+ case "setSettings":
201
+ state.settings = { ...state.settings, ...msg.payload || {} };
202
+ scheduleSave();
203
+ pushState();
204
+ break;
205
+ case "resetSettings":
206
+ state.settings = defaultSettings();
207
+ scheduleSave();
208
+ pushState();
209
+ break;
210
+ case "clearUsage":
211
+ state.global = emptyEntry();
212
+ state.daily = {};
213
+ state.weekly = {};
214
+ state.monthly = {};
215
+ state.sessions = {};
216
+ state.models = {};
217
+ countedIds.clear();
218
+ scheduleSave();
219
+ pushState();
220
+ break;
221
+ default:
222
+ break;
223
+ }
224
+ };
225
+ ctx.events.on(`${PLUGIN_ID}:cmd`, onCmd);
226
+ return () => ctx.events.off(`${PLUGIN_ID}:cmd`, onCmd);
227
+ });
228
+ pushState();
229
+ }
230
+ export {
231
+ apply,
232
+ inject,
233
+ name
234
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dsh-token-gauge",
3
+ "version": "0.1.0",
4
+ "description": "DSH Web 侧边栏机车风格 Token 用量与费用仪表盘插件(多风格/多配色/限额/中英文/报表导出)",
5
+ "displayName": "Token 机车仪表盘 / Token Gauge Dashboard",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "dsh": {
9
+ "host": "./lib/index.js",
10
+ "client": "./lib/client.js"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "src",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.en.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "node build.mjs",
22
+ "prepack": "node build.mjs"
23
+ },
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "deepseek",
28
+ "deepseek-harness",
29
+ "token-usage",
30
+ "token-cost",
31
+ "dashboard",
32
+ "gauge",
33
+ "sidebar"
34
+ ],
35
+ "author": "liumiaozhi",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/liumiaozhi/dsh-token-gauge.git"
40
+ },
41
+ "homepage": "https://github.com/liumiaozhi/dsh-token-gauge#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/liumiaozhi/dsh-token-gauge/issues"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "devDependencies": {
49
+ "@deepseek-ai/cordis": "*",
50
+ "esbuild": "^0.21.5",
51
+ "typescript": "^5.4.0"
52
+ },
53
+ "publishConfig": {
54
+ "registry": "https://registry.npmjs.org",
55
+ "access": "public"
56
+ }
57
+ }
package/src/client.ts ADDED
@@ -0,0 +1,160 @@
1
+ // ============================================================================
2
+ // DSH Client 半边:注册侧边栏 Tab,渲染机车风格仪表盘 + 设置弹窗
3
+ // 运行环境:浏览器。纯原生 DOM/SVG,无外部运行时依赖(React 等由 DSH 平台提供,本插件不依赖)。
4
+ // ============================================================================
5
+ import type { Context } from '@deepseek-ai/cordis/client'
6
+ import { getStyle, applyPalette, buildCss } from './styles'
7
+ import { createGauge } from './gauge'
8
+ import { t } from './i18n'
9
+ import { downloadReport } from './export'
10
+ import { createSettingsModal } from './settings'
11
+
12
+ const PLUGIN_ID = 'dsh-token-gauge'
13
+
14
+ function totalOf(b: any): number {
15
+ return (b?.input || 0) + (b?.cacheRead || 0) + (b?.cacheWrite || 0) + (b?.output || 0)
16
+ }
17
+ function todayKey(d = new Date()): string {
18
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
19
+ }
20
+ function monthKey(d = new Date()): string {
21
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
22
+ }
23
+ function weekKey(d = new Date()): string {
24
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
25
+ const dayNum = (date.getUTCDay() + 6) % 7
26
+ date.setUTCDate(date.getUTCDate() - dayNum + 3)
27
+ const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
28
+ const week =
29
+ 1 +
30
+ Math.round(
31
+ ((date.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
32
+ )
33
+ return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`
34
+ }
35
+
36
+ function fallbackState(): any {
37
+ return {
38
+ global: {},
39
+ daily: {},
40
+ weekly: {},
41
+ monthly: {},
42
+ sessions: {},
43
+ models: {},
44
+ activeSession: '',
45
+ settings: {
46
+ style: 'ev',
47
+ palette: 'neon',
48
+ lang: 'zh',
49
+ metric: 'global',
50
+ display: { usage: true, cost: true, io: true, cacheMiss: true },
51
+ limits: { global: null, daily: null, weekly: null, monthly: null },
52
+ pricing: {},
53
+ },
54
+ }
55
+ }
56
+
57
+ export const name = PLUGIN_ID
58
+ export const inject = ['webview', 'events']
59
+ export function apply(ctx: Context): void {
60
+ // 注入一次全局样式
61
+ if (!document.getElementById('tg-gauge-style')) {
62
+ const st = document.createElement('style')
63
+ st.id = 'tg-gauge-style'
64
+ st.textContent = buildCss()
65
+ document.head.appendChild(st)
66
+ }
67
+
68
+ let state: any = null
69
+ const tr = (k: string) => t((state?.settings?.lang as 'zh' | 'en') || 'zh', k)
70
+
71
+ const root = document.createElement('div')
72
+ root.className = 'tg-root tg-style-ev'
73
+
74
+ // 头部
75
+ const head = document.createElement('div')
76
+ head.className = 'tg-head'
77
+ const titleEl = document.createElement('div')
78
+ titleEl.className = 'tg-title'
79
+ titleEl.textContent = 'Token 仪表盘'
80
+ const gear = document.createElement('div')
81
+ gear.className = 'tg-gear'
82
+ gear.textContent = '⚙'
83
+ gear.title = '设置'
84
+ head.append(titleEl, gear)
85
+ root.appendChild(head)
86
+
87
+ // 空数据提示
88
+ const empty = document.createElement('div')
89
+ empty.className = 'tg-empty'
90
+ empty.style.display = 'none'
91
+ root.appendChild(empty)
92
+
93
+ // 仪表盘
94
+ const gauge = createGauge()
95
+ root.appendChild(gauge.el)
96
+
97
+ // 设置弹窗
98
+ const modal = createSettingsModal({
99
+ getLang: () => (state?.settings?.lang as 'zh' | 'en') || 'zh',
100
+ onSettings: (patch) => ctx.events.emit(`${PLUGIN_ID}:cmd`, { type: 'setSettings', payload: patch }),
101
+ onExport: (format) => {
102
+ if (state) downloadReport(state, (state.settings.lang as 'zh' | 'en') || 'zh', format)
103
+ },
104
+ onClear: () => ctx.events.emit(`${PLUGIN_ID}:cmd`, { type: 'clearUsage' }),
105
+ onReset: () => ctx.events.emit(`${PLUGIN_ID}:cmd`, { type: 'resetSettings' }),
106
+ })
107
+ root.appendChild(modal.el)
108
+
109
+ gear.addEventListener('click', () => modal.open(state || fallbackState()))
110
+
111
+ // 计算当前指标的量程/费用
112
+ const computeMetric = (s: any): { value: number; limit: number | null; cost: number } => {
113
+ const metric = s.settings.metric
114
+ let entry: any = s.global
115
+ let limit: number | null = s.settings.limits.global
116
+ if (metric === 'session') {
117
+ entry = s.sessions[s.activeSession] || {}
118
+ limit = s.settings.limits.daily
119
+ } else if (metric === 'today' || metric === 'daily') {
120
+ entry = s.daily[todayKey()] || {}
121
+ limit = s.settings.limits.daily
122
+ } else if (metric === 'weekly') {
123
+ entry = s.weekly[weekKey()] || {}
124
+ limit = s.settings.limits.weekly
125
+ } else if (metric === 'monthly') {
126
+ entry = s.monthly[monthKey()] || {}
127
+ limit = s.settings.limits.monthly
128
+ }
129
+ return { value: totalOf(entry), limit, cost: entry.cost || 0 }
130
+ }
131
+
132
+ const applyState = (s: any): void => {
133
+ state = s
134
+ root.className = 'tg-root ' + getStyle(s.settings.style).className
135
+ applyPalette(root, s.settings.palette)
136
+ titleEl.textContent = tr('title')
137
+ const m = computeMetric(s)
138
+ gauge.update({ value: m.value, limit: m.limit, cost: m.cost, state: s, display: s.settings.display, t: tr, lang: s.settings.lang })
139
+ const hasData = totalOf(s.global) > 0
140
+ empty.style.display = hasData ? 'none' : ''
141
+ empty.textContent = tr('noData')
142
+ modal.sync(s)
143
+ }
144
+
145
+ ctx.effect(() => {
146
+ ctx.events.on(`${PLUGIN_ID}:state`, applyState)
147
+ // 请求初始状态
148
+ ctx.events.emit(`${PLUGIN_ID}:cmd`, { type: 'getState' })
149
+ return () => ctx.events.off(`${PLUGIN_ID}:state`, applyState)
150
+ })
151
+
152
+ // 注册侧边栏 Tab
153
+ ctx.webview.registerSidebarTab({
154
+ id: PLUGIN_ID,
155
+ label: 'Token 仪表盘',
156
+ render(container: HTMLElement) {
157
+ container.appendChild(root)
158
+ },
159
+ })
160
+ }
package/src/export.ts ADDED
@@ -0,0 +1,81 @@
1
+ // 用量报表导出(浏览器端):CSV / JSON 生成与下载
2
+ import { t } from './i18n'
3
+
4
+ interface BucketLike {
5
+ input?: number
6
+ cacheRead?: number
7
+ cacheWrite?: number
8
+ output?: number
9
+ cost?: number
10
+ calls?: number
11
+ }
12
+
13
+ function total(b: BucketLike): number {
14
+ return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
15
+ }
16
+
17
+ function num(v: number): string {
18
+ return String(Math.round(v || 0))
19
+ }
20
+
21
+ function rowOf(key: string, b: BucketLike): string[] {
22
+ return [key, num(b.input), num(b.cacheRead), num(b.cacheWrite), num(b.output), num(total(b)), (b.cost || 0).toFixed(4), num(b.calls)]
23
+ }
24
+
25
+ export function buildCsv(state: any, lang: 'zh' | 'en'): string {
26
+ const L = (k: string) => t(lang, k)
27
+ const lines: string[] = []
28
+ lines.push(`# ${L('title')} - ${new Date().toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US')}`)
29
+ lines.push('')
30
+
31
+ lines.push(`# ${L('global')}`)
32
+ lines.push(['', L('inputTokens'), L('cacheHit'), L('cacheWrite'), L('outputTokens'), L('totalTokens'), L('totalCost') + '(¥)', L('calls')].join(','))
33
+ lines.push(rowOf(L('global'), state.global || {}).join(','))
34
+ lines.push('')
35
+
36
+ lines.push(`# ${L('model')}`)
37
+ lines.push(['', L('inputTokens'), L('cacheHit'), L('cacheWrite'), L('outputTokens'), L('totalTokens'), L('totalCost') + '(¥)', L('calls')].join(','))
38
+ for (const [k, v] of Object.entries<any>(state.models || {})) lines.push(rowOf(k, v).join(','))
39
+
40
+ for (const period of ['daily', 'weekly', 'monthly', 'sessions'] as const) {
41
+ const map = (state as any)[period] || {}
42
+ const title = period === 'daily' ? L('metricDaily') : period === 'weekly' ? L('metricWeekly') : period === 'monthly' ? L('metricMonthly') : L('sessions')
43
+ lines.push('')
44
+ lines.push(`# ${title}`)
45
+ lines.push(['', L('inputTokens'), L('cacheHit'), L('cacheWrite'), L('outputTokens'), L('totalTokens'), L('totalCost') + '(¥)', L('calls')].join(','))
46
+ for (const [k, v] of Object.entries<any>(map)) lines.push(rowOf(k, v).join(','))
47
+ }
48
+ return lines.join('\n')
49
+ }
50
+
51
+ export function buildJson(state: any): string {
52
+ return JSON.stringify(
53
+ {
54
+ generatedAt: new Date().toISOString(),
55
+ global: state.global || {},
56
+ models: state.models || {},
57
+ daily: state.daily || {},
58
+ weekly: state.weekly || {},
59
+ monthly: state.monthly || {},
60
+ sessions: state.sessions || {},
61
+ settings: state.settings || {},
62
+ },
63
+ null,
64
+ 2,
65
+ )
66
+ }
67
+
68
+ export function downloadReport(state: any, lang: 'zh' | 'en', format: 'csv' | 'json'): void {
69
+ const content = format === 'csv' ? buildCsv(state, lang) : buildJson(state)
70
+ const mime = format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json'
71
+ const filename = `token-gauge-${new Date().toISOString().slice(0, 10)}.${format}`
72
+ const blob = new Blob([content], { type: mime })
73
+ const url = URL.createObjectURL(blob)
74
+ const a = document.createElement('a')
75
+ a.href = url
76
+ a.download = filename
77
+ document.body.appendChild(a)
78
+ a.click()
79
+ document.body.removeChild(a)
80
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
81
+ }
package/src/gauge.ts ADDED
@@ -0,0 +1,163 @@
1
+ // SVG 弧形仪表盘:指针随用量/限额平滑转动,下方显示用量与费用数字
2
+ import { formatInt, formatMoney, formatCompact, clamp, autoMax } from './util'
3
+
4
+ const SVGNS = 'http://www.w3.org/2000/svg'
5
+ const CX = 100
6
+ const CY = 100
7
+ const R = 78
8
+ const START = 150 // 起始角(屏幕坐标,y 向下)
9
+ const END = 390 // 终止角(=30°+360),扫角 240°
10
+ const SWEEP = END - START
11
+
12
+ interface DisplayFlags {
13
+ usage: boolean
14
+ cost: boolean
15
+ io: boolean
16
+ cacheMiss: boolean
17
+ }
18
+
19
+ export interface GaugePayload {
20
+ value: number
21
+ limit: number | null
22
+ cost: number
23
+ state: any
24
+ display: DisplayFlags
25
+ t: (k: string) => string
26
+ lang: 'zh' | 'en'
27
+ }
28
+
29
+ function polar(angleDeg: number, r: number): [number, number] {
30
+ const a = (angleDeg * Math.PI) / 180
31
+ return [CX + r * Math.cos(a), CY + r * Math.sin(a)]
32
+ }
33
+
34
+ function arcPath(r: number, a0: number, a1: number): string {
35
+ const [sx, sy] = polar(a0, r)
36
+ const [ex, ey] = polar(a1, r)
37
+ const large = a1 - a0 > 180 ? 1 : 0
38
+ return `M ${sx.toFixed(2)} ${sy.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${ex.toFixed(2)} ${ey.toFixed(2)}`
39
+ }
40
+
41
+ function svg(tag: string, attrs: Record<string, string>): SVGElement {
42
+ const el = document.createElementNS(SVGNS, tag) as SVGElement
43
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v)
44
+ return el
45
+ }
46
+
47
+ function totalOf(b: any): number {
48
+ return (b?.input || 0) + (b?.cacheRead || 0) + (b?.cacheWrite || 0) + (b?.output || 0)
49
+ }
50
+
51
+ export interface GaugeHandle {
52
+ el: HTMLElement
53
+ update: (p: GaugePayload) => void
54
+ }
55
+
56
+ export function createGauge(): GaugeHandle {
57
+ const wrap = document.createElement('div')
58
+ wrap.className = 'tg-gauge-wrap'
59
+
60
+ const s = svg('svg', { class: 'tg-gauge', viewBox: '0 0 200 200', role: 'img' }) as SVGSVGElement
61
+
62
+ // 背景轨道
63
+ s.appendChild(svg('path', { class: 'tg-track', d: arcPath(R, START, END) }))
64
+ // 危险区(顶部 15%,透明度随接近限额连续渐变)
65
+ const dangerEl = svg('path', { class: 'tg-danger', d: arcPath(R, START + SWEEP * 0.85, END) })
66
+ s.appendChild(dangerEl)
67
+
68
+ // 刻度 + 刻度标签
69
+ const labelEls: { el: SVGElement; frac: number }[] = []
70
+ for (let i = 0; i <= 10; i++) {
71
+ const frac = i / 10
72
+ const ang = START + SWEEP * frac
73
+ const major = i % 5 === 0
74
+ const rOut = R + (major ? 9 : 5)
75
+ const rIn = R - 2
76
+ const [x1, y1] = polar(ang, rOut)
77
+ const [x2, y2] = polar(ang, rIn)
78
+ s.appendChild(svg('line', { class: 'tg-tick', x1: x1.toFixed(2), y1: y1.toFixed(2), x2: x2.toFixed(2), y2: y2.toFixed(2), 'stroke-width': major ? '2.5' : '1.5' }))
79
+ if (major) {
80
+ const [lx, ly] = polar(ang, R - 18)
81
+ const txt = svg('text', { class: 'tg-ticklabel', x: lx.toFixed(2), y: (ly + 3).toFixed(2), 'text-anchor': 'middle' })
82
+ txt.textContent = String(Math.round(frac * 100))
83
+ s.appendChild(txt)
84
+ labelEls.push({ el: txt, frac })
85
+ }
86
+ }
87
+
88
+ // 指针
89
+ const needle = svg('g', { class: 'tg-needle' })
90
+ const tipY = CY - (R - 14)
91
+ const poly = svg('polygon', {
92
+ points: `${CX},${tipY} ${CX - 4},${CY + 6} ${CX},${CY + 14} ${CX + 4},${CY + 6}`,
93
+ })
94
+ needle.appendChild(poly)
95
+ s.appendChild(needle)
96
+
97
+ // 中心轴
98
+ s.appendChild(svg('circle', { class: 'tg-hub', cx: String(CX), cy: String(CY), r: '6' }))
99
+
100
+ wrap.appendChild(s as unknown as Node)
101
+
102
+ // 数字读数(费用在上、用量在下,间距缩小)
103
+ const readout = document.createElement('div')
104
+ readout.className = 'tg-readout'
105
+ const costEl = document.createElement('div')
106
+ costEl.className = 'tg-cost'
107
+ const usageEl = document.createElement('div')
108
+ usageEl.className = 'tg-usage'
109
+ const cards = document.createElement('div')
110
+ cards.className = 'tg-cards'
111
+ readout.append(costEl, usageEl, cards)
112
+ wrap.appendChild(readout)
113
+
114
+ const update = (p: GaugePayload) => {
115
+ const max = p.limit && p.limit > 0 ? p.limit : autoMax(p.value)
116
+ const ratio = max > 0 ? p.value / max : 0
117
+ const visual = clamp(ratio, 0, 1)
118
+ const needleAngle = START + visual * SWEEP
119
+ const deg = needleAngle - 270
120
+ ;(needle as SVGElement & { style: CSSStyleDeclaration }).style.transform = `rotate(${deg.toFixed(2)}deg)`
121
+
122
+ // 刻度标签按当前量程刷新
123
+ for (const { el, frac } of labelEls) el.textContent = formatCompact(max * frac)
124
+
125
+ // 危险/警告态
126
+ const over = ratio >= 1
127
+ const proximity = clamp((ratio - 0.7) / 0.3, 0, 1)
128
+ dangerEl.style.opacity = (proximity * 0.95).toFixed(3)
129
+ needle.setAttribute('class', over ? 'tg-needle tg-over' : 'tg-needle')
130
+
131
+ // 读数(费用在上、用量单位 k tokens)
132
+ if (p.display.cost) {
133
+ costEl.style.display = ''
134
+ costEl.textContent = formatMoney(p.cost)
135
+ } else costEl.style.display = 'none'
136
+ if (p.display.usage) {
137
+ usageEl.style.display = ''
138
+ usageEl.innerHTML = `${(p.value / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 })}<small>k tokens</small>`
139
+ } else usageEl.style.display = 'none'
140
+
141
+ // 副卡片(4 格:输入 / 输出 / 缓存命中 / 缓存未命中)
142
+ cards.innerHTML = ''
143
+ const addCard = (k: string, v: string) => {
144
+ const c = document.createElement('div')
145
+ c.className = 'tg-card'
146
+ c.innerHTML = `<div class="k">${k}</div><div class="v">${v}</div>`
147
+ cards.appendChild(c)
148
+ }
149
+ if (p.display.io) {
150
+ addCard(p.t('input'), formatInt(p.state?.global?.input || 0))
151
+ addCard(p.t('output'), formatInt(p.state?.global?.output || 0))
152
+ }
153
+ if (p.display.cacheMiss) {
154
+ const hit = p.state?.global?.cacheRead || 0
155
+ const input = p.state?.global?.input || 0
156
+ const miss = Math.max(0, input - hit)
157
+ addCard(p.t('cacheHit'), formatInt(hit))
158
+ addCard(p.t('cacheMiss'), formatInt(miss))
159
+ }
160
+ }
161
+
162
+ return { el: wrap, update }
163
+ }