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/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
|
+
}
|