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/LICENSE +21 -0
- package/README.en.md +54 -0
- package/README.md +98 -0
- package/cordis.patch.yml +9 -0
- package/lib/LICENSE +21 -0
- package/lib/client.js +886 -0
- package/lib/index.js +234 -0
- package/package.json +57 -0
- package/src/client.ts +160 -0
- package/src/export.ts +81 -0
- package/src/gauge.ts +163 -0
- package/src/host.ts +339 -0
- package/src/i18n.ts +59 -0
- package/src/settings.ts +270 -0
- package/src/styles.ts +313 -0
- package/src/util.ts +31 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// DSH Host 半边:Token 用量监听、累计、计费与持久化
|
|
3
|
+
// 运行环境:Node(宿主进程)。只做数据,不直接触碰 DOM。
|
|
4
|
+
// 与 Client 半边通过 DSH 内部事件总线通信:
|
|
5
|
+
// Host -> Client : ctx.events.emit('token-gauge:state', state)
|
|
6
|
+
// Client -> Host : ctx.events.emit('token-gauge:cmd', { type, payload })
|
|
7
|
+
// ============================================================================
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const PLUGIN_ID = 'dsh-token-gauge'
|
|
14
|
+
const DATA_DIR = join(homedir(), '.dsh-token-gauge')
|
|
15
|
+
const STATE_FILE = join(DATA_DIR, 'state.json')
|
|
16
|
+
|
|
17
|
+
// ---- 类型定义 --------------------------------------------------------------
|
|
18
|
+
interface Pricing {
|
|
19
|
+
inputHit: number // 缓存命中输入(¥/百万 token)
|
|
20
|
+
inputMiss: number // 缓存未命中输入
|
|
21
|
+
output: number // 输出
|
|
22
|
+
cacheWrite: number // 缓存写入
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Buckets {
|
|
26
|
+
input: number // 未命中缓存的输入 token
|
|
27
|
+
cacheRead: number // 缓存命中输入 token
|
|
28
|
+
cacheWrite: number // 缓存写入 token
|
|
29
|
+
output: number // 输出 token
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Entry extends Buckets {
|
|
33
|
+
cost: number
|
|
34
|
+
calls: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Metric = 'global' | 'session' | 'today' | 'daily' | 'weekly' | 'monthly'
|
|
38
|
+
|
|
39
|
+
interface DisplayFlags {
|
|
40
|
+
usage: boolean
|
|
41
|
+
cost: boolean
|
|
42
|
+
io: boolean
|
|
43
|
+
cacheMiss: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Limits {
|
|
47
|
+
global: number | null
|
|
48
|
+
daily: number | null
|
|
49
|
+
weekly: number | null
|
|
50
|
+
monthly: number | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Settings {
|
|
54
|
+
style: string
|
|
55
|
+
palette: string
|
|
56
|
+
lang: 'zh' | 'en'
|
|
57
|
+
metric: Metric
|
|
58
|
+
display: DisplayFlags
|
|
59
|
+
limits: Limits
|
|
60
|
+
pricing: Record<string, Pricing>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface State {
|
|
64
|
+
global: Entry
|
|
65
|
+
daily: Record<string, Entry>
|
|
66
|
+
weekly: Record<string, Entry>
|
|
67
|
+
monthly: Record<string, Entry>
|
|
68
|
+
sessions: Record<string, Entry>
|
|
69
|
+
models: Record<string, Entry>
|
|
70
|
+
activeSession: string
|
|
71
|
+
settings: Settings
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- DeepSeek 官方 CNY 定价预设(¥/百万 token),可被设置覆盖 -----------------
|
|
75
|
+
const DEFAULT_PRICING: Record<string, Pricing> = {
|
|
76
|
+
'deepseek-chat': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
|
|
77
|
+
'deepseek-v3': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
|
|
78
|
+
'deepseek-reasoner': { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
|
|
79
|
+
'deepseek-r1': { inputHit: 1, inputMiss: 4, output: 16, cacheWrite: 4 },
|
|
80
|
+
'deepseek-v4-flash': { inputHit: 0.5, inputMiss: 2, output: 2, cacheWrite: 2 },
|
|
81
|
+
'deepseek-v4-pro': { inputHit: 1, inputMiss: 4, output: 8, cacheWrite: 4 },
|
|
82
|
+
'deepseek-coder': { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
|
|
83
|
+
__default__: { inputHit: 0.5, inputMiss: 2, output: 8, cacheWrite: 2 },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- 工具函数 --------------------------------------------------------------
|
|
87
|
+
function emptyEntry(): Entry {
|
|
88
|
+
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cost: 0, calls: 0 }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function addEntry(t: Entry, s: Entry): void {
|
|
92
|
+
t.input += s.input
|
|
93
|
+
t.cacheRead += s.cacheRead
|
|
94
|
+
t.cacheWrite += s.cacheWrite
|
|
95
|
+
t.output += s.output
|
|
96
|
+
t.cost += s.cost
|
|
97
|
+
t.calls += s.calls
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function totalTokens(e: Buckets): number {
|
|
101
|
+
return e.input + e.cacheRead + e.cacheWrite + e.output
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mergedPricing(settings: Settings): Record<string, Pricing> {
|
|
105
|
+
return { ...DEFAULT_PRICING, ...(settings.pricing || {}) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function costFor(modelKey: string, b: Buckets, pricing: Record<string, Pricing>): number {
|
|
109
|
+
const p = pricing[modelKey] || pricing['__default__'] || DEFAULT_PRICING['__default__']
|
|
110
|
+
return (
|
|
111
|
+
(b.input * p.inputMiss +
|
|
112
|
+
b.cacheRead * p.inputHit +
|
|
113
|
+
b.cacheWrite * p.cacheWrite +
|
|
114
|
+
b.output * p.output) /
|
|
115
|
+
1_000_000
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function todayKey(d = new Date()): string {
|
|
120
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function monthKey(d = new Date()): string {
|
|
124
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function weekKey(d = new Date()): string {
|
|
128
|
+
// ISO 周序号
|
|
129
|
+
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
|
130
|
+
const dayNum = (date.getUTCDay() + 6) % 7
|
|
131
|
+
date.setUTCDate(date.getUTCDate() - dayNum + 3)
|
|
132
|
+
const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
|
|
133
|
+
const week =
|
|
134
|
+
1 +
|
|
135
|
+
Math.round(
|
|
136
|
+
((date.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
|
|
137
|
+
)
|
|
138
|
+
return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultSettings(): Settings {
|
|
142
|
+
return {
|
|
143
|
+
style: 'ev', // 默认风格:现代电车科技
|
|
144
|
+
palette: 'neon', // 默认配色:霓虹蓝
|
|
145
|
+
lang: 'zh',
|
|
146
|
+
metric: 'global',
|
|
147
|
+
display: {
|
|
148
|
+
usage: true,
|
|
149
|
+
cost: true,
|
|
150
|
+
io: true,
|
|
151
|
+
cacheMiss: true,
|
|
152
|
+
},
|
|
153
|
+
limits: { global: null, daily: null, weekly: null, monthly: null },
|
|
154
|
+
pricing: {},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function defaultState(): State {
|
|
159
|
+
return {
|
|
160
|
+
global: emptyEntry(),
|
|
161
|
+
daily: {},
|
|
162
|
+
weekly: {},
|
|
163
|
+
monthly: {},
|
|
164
|
+
sessions: {},
|
|
165
|
+
models: {},
|
|
166
|
+
activeSession: '',
|
|
167
|
+
settings: defaultSettings(),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadState(): State {
|
|
172
|
+
try {
|
|
173
|
+
if (existsSync(STATE_FILE)) {
|
|
174
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as Partial<State>
|
|
175
|
+
const base = defaultState()
|
|
176
|
+
const merged: State = {
|
|
177
|
+
...base,
|
|
178
|
+
...raw,
|
|
179
|
+
settings: { ...base.settings, ...(raw.settings || {}) },
|
|
180
|
+
}
|
|
181
|
+
// 补全嵌套字段,避免旧版本缺字段
|
|
182
|
+
merged.settings.display = { ...base.settings.display, ...(raw.settings?.display || {}) }
|
|
183
|
+
merged.settings.limits = { ...base.settings.limits, ...(raw.settings?.limits || {}) }
|
|
184
|
+
merged.settings.pricing = raw.settings?.pricing || {}
|
|
185
|
+
merged.daily = raw.daily || {}
|
|
186
|
+
merged.weekly = raw.weekly || {}
|
|
187
|
+
merged.monthly = raw.monthly || {}
|
|
188
|
+
merged.sessions = raw.sessions || {}
|
|
189
|
+
merged.models = raw.models || {}
|
|
190
|
+
merged.global = { ...emptyEntry(), ...(raw.global || {}) }
|
|
191
|
+
return merged
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.warn(`[${PLUGIN_ID}] load state failed, use default:`, e)
|
|
195
|
+
}
|
|
196
|
+
return defaultState()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saveState(state: State): void {
|
|
200
|
+
try {
|
|
201
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
202
|
+
writeFileSync(STATE_FILE, JSON.stringify(state))
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.warn(`[${PLUGIN_ID}] save state failed:`, e)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 从事件载荷中尽量稳妥地抽取用量桶 + 元数据
|
|
209
|
+
function extractUsage(data: any): { buckets: Buckets; model: string; session: string; id?: string } | null {
|
|
210
|
+
if (!data) return null
|
|
211
|
+
const u = data.usage ?? data
|
|
212
|
+
const input =
|
|
213
|
+
u.input_tokens ?? u.prompt_tokens ?? u.uncachedInput ?? u.input ?? u.total_input_tokens ?? 0
|
|
214
|
+
const cacheRead =
|
|
215
|
+
u.cache_read_tokens ?? u.cacheRead ?? u.prompt_cache_hit_tokens ?? u.cache_read ?? 0
|
|
216
|
+
const cacheWrite = u.cache_write_tokens ?? u.cacheWrite ?? u.cache_write ?? 0
|
|
217
|
+
const output =
|
|
218
|
+
u.output_tokens ?? u.completion_tokens ?? u.output ?? u.total_output_tokens ?? 0
|
|
219
|
+
if (!input && !cacheRead && !cacheWrite && !output) return null
|
|
220
|
+
const model: string = data.model ?? u.model ?? 'unknown'
|
|
221
|
+
const session: string = data.session ?? u.session ?? data.sessionId ?? u.sessionId ?? ''
|
|
222
|
+
const id: string | undefined = data.id ?? u.id ?? data.seq ?? u.seq
|
|
223
|
+
return {
|
|
224
|
+
buckets: { input: Number(input) || 0, cacheRead: Number(cacheRead) || 0, cacheWrite: Number(cacheWrite) || 0, output: Number(output) || 0 },
|
|
225
|
+
model,
|
|
226
|
+
session,
|
|
227
|
+
id,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- 插件入口 --------------------------------------------------------------
|
|
232
|
+
export const name = PLUGIN_ID
|
|
233
|
+
export const inject = ['events']
|
|
234
|
+
export function apply(ctx: Context): void {
|
|
235
|
+
const state = loadState()
|
|
236
|
+
let saveTimer: NodeJS.Timeout | null = null
|
|
237
|
+
|
|
238
|
+
const scheduleSave = (): void => {
|
|
239
|
+
if (saveTimer) return
|
|
240
|
+
saveTimer = setTimeout(() => {
|
|
241
|
+
saveTimer = null
|
|
242
|
+
saveState(state)
|
|
243
|
+
}, 400)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const pushState = (): void => {
|
|
247
|
+
ctx.events.emit(`${PLUGIN_ID}:state`, state)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 累计一次用量
|
|
251
|
+
const countedIds = new Set<string>()
|
|
252
|
+
const accumulate = (data: any): void => {
|
|
253
|
+
const ex = extractUsage(data)
|
|
254
|
+
if (!ex) return
|
|
255
|
+
if (ex.id) {
|
|
256
|
+
if (countedIds.has(ex.id)) return
|
|
257
|
+
countedIds.add(ex.id)
|
|
258
|
+
}
|
|
259
|
+
const modelKey = ex.model || 'unknown'
|
|
260
|
+
if (ex.session) state.activeSession = ex.session
|
|
261
|
+
const pricing = mergedPricing(state.settings)
|
|
262
|
+
const cost = costFor(modelKey, ex.buckets, pricing)
|
|
263
|
+
const e: Entry = { ...ex.buckets, cost, calls: 1 }
|
|
264
|
+
|
|
265
|
+
addEntry(state.global, e)
|
|
266
|
+
const dk = todayKey()
|
|
267
|
+
state.daily[dk] = state.daily[dk] || emptyEntry()
|
|
268
|
+
addEntry(state.daily[dk], e)
|
|
269
|
+
const wk = weekKey()
|
|
270
|
+
state.weekly[wk] = state.weekly[wk] || emptyEntry()
|
|
271
|
+
addEntry(state.weekly[wk], e)
|
|
272
|
+
const mk = monthKey()
|
|
273
|
+
state.monthly[mk] = state.monthly[mk] || emptyEntry()
|
|
274
|
+
addEntry(state.monthly[mk], e)
|
|
275
|
+
if (ex.session) {
|
|
276
|
+
state.sessions[ex.session] = state.sessions[ex.session] || emptyEntry()
|
|
277
|
+
addEntry(state.sessions[ex.session], e)
|
|
278
|
+
}
|
|
279
|
+
state.models[modelKey] = state.models[modelKey] || emptyEntry()
|
|
280
|
+
addEntry(state.models[modelKey], e)
|
|
281
|
+
|
|
282
|
+
scheduleSave()
|
|
283
|
+
pushState()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 监听用量事件(兼容多种事件名/字段形态)
|
|
287
|
+
ctx.effect(() => {
|
|
288
|
+
const onUsage = (data: any) => accumulate(data)
|
|
289
|
+
ctx.events.on('assistant/message.usage', onUsage)
|
|
290
|
+
ctx.events.on('usage', onUsage)
|
|
291
|
+
ctx.events.on('assistant/message', (data: any) => {
|
|
292
|
+
if (data && (data.usage || (data.data && data.data.usage))) onUsage(data)
|
|
293
|
+
})
|
|
294
|
+
return () => {
|
|
295
|
+
ctx.events.off('assistant/message.usage', onUsage)
|
|
296
|
+
ctx.events.off('usage', onUsage)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// 响应 Client 指令
|
|
301
|
+
ctx.effect(() => {
|
|
302
|
+
const onCmd = (msg: { type: string; payload?: any }) => {
|
|
303
|
+
if (!msg || typeof msg.type !== 'string') return
|
|
304
|
+
switch (msg.type) {
|
|
305
|
+
case 'getState':
|
|
306
|
+
pushState()
|
|
307
|
+
break
|
|
308
|
+
case 'setSettings':
|
|
309
|
+
state.settings = { ...state.settings, ...(msg.payload || {}) }
|
|
310
|
+
scheduleSave()
|
|
311
|
+
pushState()
|
|
312
|
+
break
|
|
313
|
+
case 'resetSettings':
|
|
314
|
+
state.settings = defaultSettings()
|
|
315
|
+
scheduleSave()
|
|
316
|
+
pushState()
|
|
317
|
+
break
|
|
318
|
+
case 'clearUsage':
|
|
319
|
+
state.global = emptyEntry()
|
|
320
|
+
state.daily = {}
|
|
321
|
+
state.weekly = {}
|
|
322
|
+
state.monthly = {}
|
|
323
|
+
state.sessions = {}
|
|
324
|
+
state.models = {}
|
|
325
|
+
countedIds.clear()
|
|
326
|
+
scheduleSave()
|
|
327
|
+
pushState()
|
|
328
|
+
break
|
|
329
|
+
default:
|
|
330
|
+
break
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
ctx.events.on(`${PLUGIN_ID}:cmd`, onCmd)
|
|
334
|
+
return () => ctx.events.off(`${PLUGIN_ID}:cmd`, onCmd)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// 初始推送
|
|
338
|
+
pushState()
|
|
339
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// 中英文文案
|
|
2
|
+
type Dict = Record<string, { zh: string; en: string }>
|
|
3
|
+
|
|
4
|
+
const I18N: Dict = {
|
|
5
|
+
title: { zh: 'Token 仪表盘', en: 'Token Gauge' },
|
|
6
|
+
settings: { zh: '设置', en: 'Settings' },
|
|
7
|
+
style: { zh: '风格', en: 'Style' },
|
|
8
|
+
palette: { zh: '配色', en: 'Palette' },
|
|
9
|
+
display: { zh: '显示内容', en: 'Display' },
|
|
10
|
+
metric: { zh: '用量口径', en: 'Metric' },
|
|
11
|
+
limit: { zh: '限额', en: 'Limit' },
|
|
12
|
+
limitGlobal: { zh: '全局限额', en: 'Global limit' },
|
|
13
|
+
limitDaily: { zh: '日限额', en: 'Daily limit' },
|
|
14
|
+
limitWeekly: { zh: '周限额', en: 'Weekly limit' },
|
|
15
|
+
limitMonthly: { zh: '月限额', en: 'Monthly limit' },
|
|
16
|
+
usage: { zh: '用量', en: 'Usage' },
|
|
17
|
+
cost: { zh: '费用', en: 'Cost' },
|
|
18
|
+
input: { zh: '输入', en: 'Input' },
|
|
19
|
+
output: { zh: '输出', en: 'Output' },
|
|
20
|
+
cacheMiss: { zh: '缓存未命中', en: 'Cache miss' },
|
|
21
|
+
lang: { zh: '语言', en: 'Language' },
|
|
22
|
+
export: { zh: '导出报表', en: 'Export report' },
|
|
23
|
+
exportCsv: { zh: 'CSV', en: 'CSV' },
|
|
24
|
+
exportJson: { zh: 'JSON', en: 'JSON' },
|
|
25
|
+
close: { zh: '关闭', en: 'Close' },
|
|
26
|
+
save: { zh: '保存', en: 'Save' },
|
|
27
|
+
reset: { zh: '恢复默认', en: 'Reset' },
|
|
28
|
+
clear: { zh: '清空用量', en: 'Clear usage' },
|
|
29
|
+
advanced: { zh: '高级·定价(¥/百万token)', en: 'Advanced · Pricing (¥/M tokens)' },
|
|
30
|
+
overLimit: { zh: '已超出限额!', en: 'Over limit!' },
|
|
31
|
+
noData: { zh: '暂无用量数据,开始对话后此处会实时更新。', en: 'No usage yet. Start a chat to see live updates.' },
|
|
32
|
+
autoScale: { zh: '自动量程', en: 'auto' },
|
|
33
|
+
metricGlobal: { zh: '全局累计', en: 'Global' },
|
|
34
|
+
metricSession: { zh: '当前会话', en: 'Session' },
|
|
35
|
+
metricToday: { zh: '今日', en: 'Today' },
|
|
36
|
+
metricDaily: { zh: '每日', en: 'Daily' },
|
|
37
|
+
metricWeekly: { zh: '每周', en: 'Weekly' },
|
|
38
|
+
metricMonthly: { zh: '每月', en: 'Monthly' },
|
|
39
|
+
inputTokens: { zh: '输入 Token', en: 'Input tokens' },
|
|
40
|
+
outputTokens: { zh: '输出 Token', en: 'Output tokens' },
|
|
41
|
+
cacheHit: { zh: '缓存命中', en: 'Cache hit' },
|
|
42
|
+
cacheWrite: { zh: '缓存写入', en: 'Cache write' },
|
|
43
|
+
model: { zh: '模型', en: 'Model' },
|
|
44
|
+
period: { zh: '周期', en: 'Period' },
|
|
45
|
+
totalCost: { zh: '总费用', en: 'Total cost' },
|
|
46
|
+
totalTokens: { zh: '总 Token', en: 'Total tokens' },
|
|
47
|
+
calls: { zh: '调用次数', en: 'Calls' },
|
|
48
|
+
hintLimit: { zh: '留空表示不限(仪表盘自动量程)。', en: 'Leave empty for no limit (auto scale).' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function t(lang: 'zh' | 'en', key: string): string {
|
|
52
|
+
const e = I18N[key]
|
|
53
|
+
if (!e) return key
|
|
54
|
+
return e[lang] ?? e.zh
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function metricLabel(lang: 'zh' | 'en', metric: string): string {
|
|
58
|
+
return t(lang, 'metric' + metric.charAt(0).toUpperCase() + metric.slice(1))
|
|
59
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// 侧边栏内设置弹窗:风格 / 配色 / 显示内容 / 用量口径 / 限额 / 定价 / 语言 / 导出
|
|
2
|
+
import { STYLES, PALETTES } from './styles'
|
|
3
|
+
import { t, metricLabel } from './i18n'
|
|
4
|
+
|
|
5
|
+
type Lang = 'zh' | 'en'
|
|
6
|
+
interface DisplayFlags {
|
|
7
|
+
usage: boolean
|
|
8
|
+
cost: boolean
|
|
9
|
+
io: boolean
|
|
10
|
+
cacheMiss: boolean
|
|
11
|
+
}
|
|
12
|
+
interface Settings {
|
|
13
|
+
style: string
|
|
14
|
+
palette: string
|
|
15
|
+
lang: Lang
|
|
16
|
+
metric: string
|
|
17
|
+
display: DisplayFlags
|
|
18
|
+
limits: { global: number | null; daily: number | null; weekly: number | null; monthly: number | null }
|
|
19
|
+
pricing: Record<string, any>
|
|
20
|
+
}
|
|
21
|
+
interface State {
|
|
22
|
+
settings: Settings
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SettingsModalOpts {
|
|
26
|
+
onSettings: (patch: Partial<Settings>) => void
|
|
27
|
+
onExport: (format: 'csv' | 'json') => void
|
|
28
|
+
onClear: () => void
|
|
29
|
+
onReset: () => void
|
|
30
|
+
getLang: () => Lang
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SettingsModal {
|
|
34
|
+
el: HTMLElement
|
|
35
|
+
open: (state: State) => void
|
|
36
|
+
close: () => void
|
|
37
|
+
sync: (state: State) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function chip(label: string, on: boolean, onClick: () => void): HTMLElement {
|
|
41
|
+
const c = document.createElement('div')
|
|
42
|
+
c.className = 'tg-chip' + (on ? ' on' : '')
|
|
43
|
+
c.textContent = label
|
|
44
|
+
c.addEventListener('click', onClick)
|
|
45
|
+
return c
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createSettingsModal(opts: SettingsModalOpts): SettingsModal {
|
|
49
|
+
const mask = document.createElement('div')
|
|
50
|
+
mask.className = 'tg-modal-mask'
|
|
51
|
+
mask.style.display = 'none'
|
|
52
|
+
const box = document.createElement('div')
|
|
53
|
+
box.className = 'tg-modal'
|
|
54
|
+
mask.appendChild(box)
|
|
55
|
+
|
|
56
|
+
let openState: State | null = null
|
|
57
|
+
let isOpen = false
|
|
58
|
+
|
|
59
|
+
const render = (state: State) => {
|
|
60
|
+
const lang = opts.getLang()
|
|
61
|
+
const s = state.settings
|
|
62
|
+
box.innerHTML = ''
|
|
63
|
+
const title = document.createElement('h3')
|
|
64
|
+
title.textContent = t(lang, 'settings')
|
|
65
|
+
box.appendChild(title)
|
|
66
|
+
|
|
67
|
+
// 语言
|
|
68
|
+
const langField = document.createElement('div')
|
|
69
|
+
langField.className = 'tg-field'
|
|
70
|
+
langField.innerHTML = `<label>${t(lang, 'lang')}</label>`
|
|
71
|
+
const langRow = document.createElement('div')
|
|
72
|
+
langRow.className = 'tg-row'
|
|
73
|
+
langRow.append(
|
|
74
|
+
chip('中文', s.lang === 'zh', () => opts.onSettings({ lang: 'zh' })),
|
|
75
|
+
chip('English', s.lang === 'en', () => opts.onSettings({ lang: 'en' })),
|
|
76
|
+
)
|
|
77
|
+
langField.appendChild(langRow)
|
|
78
|
+
box.appendChild(langField)
|
|
79
|
+
|
|
80
|
+
// 风格
|
|
81
|
+
const styleField = document.createElement('div')
|
|
82
|
+
styleField.className = 'tg-field'
|
|
83
|
+
styleField.innerHTML = `<label>${t(lang, 'style')}</label>`
|
|
84
|
+
const styleRow = document.createElement('div')
|
|
85
|
+
styleRow.className = 'tg-row'
|
|
86
|
+
for (const st of STYLES) {
|
|
87
|
+
styleRow.appendChild(
|
|
88
|
+
chip(lang === 'zh' ? st.labelZh : st.labelEn, s.style === st.id, () => opts.onSettings({ style: st.id })),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
styleField.appendChild(styleRow)
|
|
92
|
+
box.appendChild(styleField)
|
|
93
|
+
|
|
94
|
+
// 配色
|
|
95
|
+
const palField = document.createElement('div')
|
|
96
|
+
palField.className = 'tg-field'
|
|
97
|
+
palField.innerHTML = `<label>${t(lang, 'palette')}</label>`
|
|
98
|
+
const palRow = document.createElement('div')
|
|
99
|
+
palRow.className = 'tg-row'
|
|
100
|
+
for (const p of PALETTES) {
|
|
101
|
+
palRow.appendChild(
|
|
102
|
+
chip(lang === 'zh' ? p.labelZh : p.labelEn, s.palette === p.id, () => opts.onSettings({ palette: p.id })),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
palField.appendChild(palRow)
|
|
106
|
+
box.appendChild(palField)
|
|
107
|
+
|
|
108
|
+
// 用量口径
|
|
109
|
+
const metricField = document.createElement('div')
|
|
110
|
+
metricField.className = 'tg-field'
|
|
111
|
+
metricField.innerHTML = `<label>${t(lang, 'metric')}</label>`
|
|
112
|
+
const metricRow = document.createElement('div')
|
|
113
|
+
metricRow.className = 'tg-row'
|
|
114
|
+
for (const m of ['global', 'session', 'today', 'daily', 'weekly', 'monthly']) {
|
|
115
|
+
metricRow.appendChild(chip(metricLabel(lang, m), s.metric === m, () => opts.onSettings({ metric: m })))
|
|
116
|
+
}
|
|
117
|
+
metricField.appendChild(metricRow)
|
|
118
|
+
box.appendChild(metricField)
|
|
119
|
+
|
|
120
|
+
// 显示内容
|
|
121
|
+
const dispField = document.createElement('div')
|
|
122
|
+
dispField.className = 'tg-field'
|
|
123
|
+
dispField.innerHTML = `<label>${t(lang, 'display')}</label>`
|
|
124
|
+
const dispKeys: { key: keyof DisplayFlags; label: string }[] = [
|
|
125
|
+
{ key: 'usage', label: t(lang, 'usage') },
|
|
126
|
+
{ key: 'cost', label: t(lang, 'cost') },
|
|
127
|
+
{ key: 'io', label: t(lang, 'input') + '/' + t(lang, 'output') },
|
|
128
|
+
{ key: 'cacheMiss', label: t(lang, 'cacheMiss') },
|
|
129
|
+
]
|
|
130
|
+
for (const d of dispKeys) {
|
|
131
|
+
const row = document.createElement('label')
|
|
132
|
+
row.className = 'tg-toggle'
|
|
133
|
+
const cb = document.createElement('input')
|
|
134
|
+
cb.type = 'checkbox'
|
|
135
|
+
cb.checked = !!s.display[d.key]
|
|
136
|
+
cb.addEventListener('change', () => {
|
|
137
|
+
const next = { ...s.display, [d.key]: cb.checked }
|
|
138
|
+
opts.onSettings({ display: next })
|
|
139
|
+
})
|
|
140
|
+
row.append(cb, document.createTextNode(d.label))
|
|
141
|
+
dispField.appendChild(row)
|
|
142
|
+
}
|
|
143
|
+
box.appendChild(dispField)
|
|
144
|
+
|
|
145
|
+
// 限额
|
|
146
|
+
const limitField = document.createElement('div')
|
|
147
|
+
limitField.className = 'tg-field'
|
|
148
|
+
limitField.innerHTML = `<label>${t(lang, 'limit')}</label>`
|
|
149
|
+
const limitDefs: { key: keyof Settings['limits']; label: string }[] = [
|
|
150
|
+
{ key: 'global', label: t(lang, 'limitGlobal') },
|
|
151
|
+
{ key: 'daily', label: t(lang, 'limitDaily') },
|
|
152
|
+
{ key: 'weekly', label: t(lang, 'limitWeekly') },
|
|
153
|
+
{ key: 'monthly', label: t(lang, 'limitMonthly') },
|
|
154
|
+
]
|
|
155
|
+
for (const ld of limitDefs) {
|
|
156
|
+
const wrap = document.createElement('div')
|
|
157
|
+
const lab = document.createElement('label')
|
|
158
|
+
lab.className = 'tg-toggle'
|
|
159
|
+
lab.textContent = ld.label
|
|
160
|
+
const inp = document.createElement('input')
|
|
161
|
+
inp.className = 'tg-input'
|
|
162
|
+
inp.type = 'number'
|
|
163
|
+
inp.min = '0'
|
|
164
|
+
inp.placeholder = '∞'
|
|
165
|
+
inp.value = s.limits[ld.key] == null ? '' : String(s.limits[ld.key])
|
|
166
|
+
inp.addEventListener('change', () => {
|
|
167
|
+
const v = inp.value.trim()
|
|
168
|
+
const val = v === '' ? null : Math.max(0, Number(v))
|
|
169
|
+
if (v !== '' && Number.isNaN(Number(v))) return
|
|
170
|
+
opts.onSettings({ limits: { ...s.limits, [ld.key]: val } })
|
|
171
|
+
})
|
|
172
|
+
wrap.append(lab, inp)
|
|
173
|
+
limitField.appendChild(wrap)
|
|
174
|
+
}
|
|
175
|
+
const hint = document.createElement('div')
|
|
176
|
+
hint.className = 'tg-hint'
|
|
177
|
+
hint.textContent = t(lang, 'hintLimit')
|
|
178
|
+
limitField.appendChild(hint)
|
|
179
|
+
box.appendChild(limitField)
|
|
180
|
+
|
|
181
|
+
// 高级定价
|
|
182
|
+
const advField = document.createElement('div')
|
|
183
|
+
advField.className = 'tg-field'
|
|
184
|
+
advField.innerHTML = `<label>${t(lang, 'advanced')}</label>`
|
|
185
|
+
const ta = document.createElement('textarea')
|
|
186
|
+
ta.className = 'tg-input'
|
|
187
|
+
ta.rows = 5
|
|
188
|
+
ta.style.fontFamily = 'monospace'
|
|
189
|
+
ta.value = JSON.stringify(s.pricing || {}, null, 2)
|
|
190
|
+
advField.appendChild(ta)
|
|
191
|
+
const advErr = document.createElement('div')
|
|
192
|
+
advErr.className = 'tg-hint'
|
|
193
|
+
advErr.style.color = 'var(--c-danger)'
|
|
194
|
+
const savePrice = document.createElement('button')
|
|
195
|
+
savePrice.className = 'tg-btn ghost'
|
|
196
|
+
savePrice.style.marginTop = '6px'
|
|
197
|
+
savePrice.textContent = t(lang, 'save')
|
|
198
|
+
savePrice.addEventListener('click', () => {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(ta.value || '{}')
|
|
201
|
+
opts.onSettings({ pricing: parsed })
|
|
202
|
+
advErr.textContent = ''
|
|
203
|
+
} catch (e) {
|
|
204
|
+
advErr.textContent = String(e)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
advField.append(savePrice, advErr)
|
|
208
|
+
box.appendChild(advField)
|
|
209
|
+
|
|
210
|
+
// 导出
|
|
211
|
+
const expField = document.createElement('div')
|
|
212
|
+
expField.className = 'tg-field'
|
|
213
|
+
expField.innerHTML = `<label>${t(lang, 'export')}</label>`
|
|
214
|
+
const expRow = document.createElement('div')
|
|
215
|
+
expRow.className = 'tg-actions'
|
|
216
|
+
const csvBtn = document.createElement('button')
|
|
217
|
+
csvBtn.className = 'tg-btn ghost'
|
|
218
|
+
csvBtn.textContent = 'CSV'
|
|
219
|
+
csvBtn.addEventListener('click', () => opts.onExport('csv'))
|
|
220
|
+
const jsonBtn = document.createElement('button')
|
|
221
|
+
jsonBtn.className = 'tg-btn ghost'
|
|
222
|
+
jsonBtn.textContent = 'JSON'
|
|
223
|
+
jsonBtn.addEventListener('click', () => opts.onExport('json'))
|
|
224
|
+
expRow.append(csvBtn, jsonBtn)
|
|
225
|
+
expField.appendChild(expRow)
|
|
226
|
+
box.appendChild(expField)
|
|
227
|
+
|
|
228
|
+
// 操作
|
|
229
|
+
const act = document.createElement('div')
|
|
230
|
+
act.className = 'tg-actions'
|
|
231
|
+
const clearBtn = document.createElement('button')
|
|
232
|
+
clearBtn.className = 'tg-btn ghost'
|
|
233
|
+
clearBtn.textContent = t(lang, 'clear')
|
|
234
|
+
clearBtn.addEventListener('click', () => opts.onClear())
|
|
235
|
+
const resetBtn = document.createElement('button')
|
|
236
|
+
resetBtn.className = 'tg-btn ghost'
|
|
237
|
+
resetBtn.textContent = t(lang, 'reset')
|
|
238
|
+
resetBtn.addEventListener('click', () => opts.onReset())
|
|
239
|
+
const closeBtn = document.createElement('button')
|
|
240
|
+
closeBtn.className = 'tg-btn'
|
|
241
|
+
closeBtn.textContent = t(lang, 'close')
|
|
242
|
+
closeBtn.addEventListener('click', () => exports.close())
|
|
243
|
+
act.append(clearBtn, resetBtn, closeBtn)
|
|
244
|
+
box.appendChild(act)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const exports: SettingsModal = {
|
|
248
|
+
el: mask,
|
|
249
|
+
open: (state: State) => {
|
|
250
|
+
openState = state
|
|
251
|
+
isOpen = true
|
|
252
|
+
render(state)
|
|
253
|
+
mask.style.display = 'flex'
|
|
254
|
+
},
|
|
255
|
+
close: () => {
|
|
256
|
+
isOpen = false
|
|
257
|
+
mask.style.display = 'none'
|
|
258
|
+
},
|
|
259
|
+
sync: (state: State) => {
|
|
260
|
+
openState = state
|
|
261
|
+
if (isOpen) render(state)
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
mask.addEventListener('click', (e) => {
|
|
266
|
+
if (e.target === mask) exports.close()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return exports
|
|
270
|
+
}
|