dsh-token-gauge 0.1.7 → 0.1.8

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.
Files changed (3) hide show
  1. package/lib/client.mjs +42 -75
  2. package/package.json +80 -84
  3. package/src/client.ts +302 -334
package/lib/client.mjs CHANGED
@@ -32,6 +32,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
32
32
  var client_exports = {};
33
33
  __export(client_exports, {
34
34
  apply: () => apply,
35
+ inject: () => inject,
35
36
  name: () => name
36
37
  });
37
38
  module.exports = __toCommonJS(client_exports);
@@ -943,90 +944,56 @@ var gaugeRef = (el) => {
943
944
  mountGauge(el);
944
945
  }
945
946
  };
946
- function GaugeTab() {
947
- return import_react.default.createElement("div", { ref: gaugeRef, className: "tg-tab-root" });
948
- }
949
- var GaugeIcon = (size) => {
950
- const s = typeof size === "number" && size > 0 ? size : 16;
951
- const svgProps = {
952
- width: s,
953
- height: s,
954
- viewBox: "0 0 16 16",
955
- fill: "none",
956
- xmlns: "http://www.w3.org/2000/svg",
957
- "aria-hidden": "true"
958
- };
959
- const arc = import_react.default.createElement("path", {
960
- d: "M2.6 11.6a5.6 5.6 0 1 1 10.8 0",
961
- stroke: "currentColor",
962
- strokeWidth: 1.5,
963
- strokeLinecap: "round"
964
- });
965
- const needle = import_react.default.createElement("path", {
966
- d: "M8 11.6 10.4 7.2",
967
- stroke: "currentColor",
968
- strokeWidth: 1.5,
969
- strokeLinecap: "round"
947
+ function GaugePanel() {
948
+ return import_react.default.createElement("div", {
949
+ ref: gaugeRef,
950
+ className: "tg-panel-root",
951
+ style: {
952
+ position: "fixed",
953
+ right: "0",
954
+ bottom: "0",
955
+ width: "420px",
956
+ height: "33vh",
957
+ zIndex: 50,
958
+ pointerEvents: "auto",
959
+ boxSizing: "border-box",
960
+ overflow: "hidden",
961
+ borderTop: "1px solid var(--dsw-alias-hairline)",
962
+ borderLeft: "1px solid var(--dsw-alias-hairline)",
963
+ background: "var(--dsw-alias-bg-base)",
964
+ color: "var(--dsw-alias-label-primary)",
965
+ boxShadow: "0 0 24px rgba(0,0,0,0.28)"
966
+ }
970
967
  });
971
- return import_react.default.createElement("svg", svgProps, arc, needle);
972
- };
973
- var TAB_ORDER = 90;
968
+ }
974
969
  var name = PLUGIN_ID;
970
+ var inject = ["slots"];
975
971
  function apply(ctx) {
976
- let registered = false;
977
- let unregister = void 0;
978
- const register = (sidebar) => {
979
- if (registered || !sidebar || typeof sidebar.registerTab !== "function") return;
980
- registered = true;
981
- try {
982
- unregister = sidebar.registerTab({
972
+ let disposeRegistration = void 0;
973
+ const doRegister = () => {
974
+ disposeRegistration = ctx.slots.register(
975
+ {
976
+ name: "shell.overlay",
983
977
  id: PLUGIN_ID,
984
- title: "Token \u4EEA\u8868\u76D8",
985
- single: true,
986
- order: TAB_ORDER,
987
- icon: GaugeIcon,
988
- component: GaugeTab
989
- });
990
- } catch (e) {
991
- registered = false;
992
- }
978
+ order: 100,
979
+ label: "Token \u4EEA\u8868\u76D8"
980
+ },
981
+ () => import_react.default.createElement(GaugePanel)
982
+ );
993
983
  };
994
- let disposeInject = void 0;
984
+ let stop = void 0;
995
985
  try {
996
- disposeInject = ctx.inject(["betterSidebar"], (sidebarCtx) => {
997
- register(sidebarCtx && sidebarCtx.betterSidebar);
998
- });
999
- } catch (e) {
1000
- }
1001
- const tryRead = () => {
1002
- if (registered) return;
1003
- try {
1004
- const s = typeof ctx.get === "function" ? ctx.get("betterSidebar") : ctx.betterSidebar;
1005
- register(s);
1006
- } catch (e) {
986
+ if (typeof ctx.slots.inject === "function") {
987
+ stop = ctx.slots.inject("shell.overlay", doRegister);
988
+ } else {
989
+ doRegister();
1007
990
  }
1008
- };
1009
- tryRead();
1010
- let timer = void 0;
1011
- if (!registered) {
1012
- timer = setInterval(() => {
1013
- tryRead();
1014
- if (registered && timer !== void 0) clearInterval(timer);
1015
- }, 500);
991
+ } catch (e) {
992
+ console.error("[dsh-token-gauge] \u6CE8\u518C shell.overlay \u5931\u8D25", e);
1016
993
  }
1017
- const stop = setTimeout(() => {
1018
- if (timer !== void 0) clearInterval(timer);
1019
- if (!registered) {
1020
- console.error(
1021
- "[dsh-token-gauge] \u672A\u53D6\u5F97 dsh-better-sidebar \u670D\u52A1\uFF0C\u4FA7\u8FB9\u680F Tab \u672A\u6CE8\u518C\u3002\n \u8BF7\u628A\u5B83\u88C5\u6210 profile \u7684\u300C\u76F4\u63A5\u4F9D\u8D56\u300D\u5E76\u6302\u8F7D\uFF1A\n dsh plugin --profile web add dsh-better-sidebar\n \u7136\u540E\u5B8C\u6574\u91CD\u542F dsh web\u3002\n \u6CE8\u610F\uFF1A\u4EC5\u4F5C\u4E3A\u672C\u63D2\u4EF6\u7684\u4F20\u9012\u4F9D\u8D56\u5B58\u5728\u4E8E node_modules \u662F\u4E0D\u591F\u7684\u2014\u2014DSH \u4E0D\u4F1A\u628A\n \u4F20\u9012\u4F9D\u8D56\u6302\u6210 bundle \u5C42\uFF0C\u90A3\u79CD\u60C5\u51B5\u4E0B\u672C\u63D2\u4EF6\u4F1A\u9759\u9ED8\u5730\u6CA1\u6709 Tab\u3002"
1022
- );
1023
- }
1024
- }, 3e4);
1025
994
  return () => {
1026
- if (timer !== void 0) clearInterval(timer);
1027
- clearTimeout(stop);
1028
- if (typeof unregister === "function") unregister();
1029
- if (typeof disposeInject === "function") disposeInject();
995
+ if (typeof disposeRegistration === "function") disposeRegistration();
996
+ if (typeof stop === "function") stop();
1030
997
  };
1031
998
  }
1032
999
  ; return module.exports; } });
package/package.json CHANGED
@@ -1,84 +1,80 @@
1
- {
2
- "name": "dsh-token-gauge",
3
- "version": "0.1.7",
4
- "description": "DSH 机车风格 Token 用量/费用仪表盘侧边栏插件(稳定版 0.1.1-rc.x 兼容)",
5
- "type": "module",
6
- "main": "lib/index.mjs",
7
- "module": "lib/index.mjs",
8
- "exports": {
9
- ".": "./lib/index.mjs",
10
- "./client": "./lib/client.mjs",
11
- "./package.json": "./package.json"
12
- },
13
- "files": [
14
- "lib/index.mjs",
15
- "lib/client.mjs",
16
- "lib/LICENSE",
17
- "src",
18
- "preview",
19
- "README.md",
20
- "README.en.md",
21
- "LICENSE",
22
- "cordis.patch.yml",
23
- "build.mjs",
24
- "tsconfig.json"
25
- ],
26
- "author": "WorkBuddy User",
27
- "license": "MIT",
28
- "engines": {
29
- "node": ">=20"
30
- },
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/liumiaozhi/dsh-token-gauge.git"
34
- },
35
- "homepage": "https://github.com/liumiaozhi/dsh-token-gauge#readme",
36
- "bugs": {
37
- "url": "https://github.com/liumiaozhi/dsh-token-gauge/issues"
38
- },
39
- "publishConfig": {
40
- "registry": "https://registry.npmjs.org",
41
- "access": "public"
42
- },
43
- "dsh": {
44
- "engines": {
45
- "dsh": ">=0.1.1-rc.1"
46
- },
47
- "bundle": {
48
- "patch": "./cordis.patch.yml"
49
- },
50
- "compatibility": {
51
- "dshReleases": {
52
- "0.1.1-rc.1": "compatible",
53
- "0.1.1-rc.2": "compatible"
54
- }
55
- }
56
- },
57
- "scripts": {
58
- "build": "node build.mjs",
59
- "prepack": "node build.mjs"
60
- },
61
- "peerDependencies": {
62
- "@deepseek-ai/cordis": "*",
63
- "@deepseek-ai/dsh-host-webserver": "*",
64
- "dsh-better-sidebar": ">=0.15.2"
65
- },
66
- "peerDependenciesMeta": {
67
- "@deepseek-ai/cordis": {
68
- "optional": true
69
- },
70
- "@deepseek-ai/dsh-host-webserver": {
71
- "optional": true
72
- },
73
- "dsh-better-sidebar": {
74
- "optional": true
75
- }
76
- },
77
- "devDependencies": {
78
- "@deepseek-ai/cordis": "*",
79
- "@types/react": "^18.2.0",
80
- "esbuild": "^0.21.5",
81
- "react": "^18.2.0",
82
- "typescript": "^5.4.0"
83
- }
84
- }
1
+ {
2
+ "name": "dsh-token-gauge",
3
+ "version": "0.1.8",
4
+ "description": "DSH 机车风格 Token 用量/费用仪表盘侧边栏插件(稳定版 0.1.1-rc.x 兼容)",
5
+ "type": "module",
6
+ "main": "lib/index.mjs",
7
+ "module": "lib/index.mjs",
8
+ "exports": {
9
+ ".": "./lib/index.mjs",
10
+ "./client": "./lib/client.mjs",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.mjs",
15
+ "lib/client.mjs",
16
+ "lib/LICENSE",
17
+ "src",
18
+ "preview",
19
+ "README.md",
20
+ "README.en.md",
21
+ "LICENSE",
22
+ "cordis.patch.yml",
23
+ "build.mjs",
24
+ "tsconfig.json"
25
+ ],
26
+ "author": "WorkBuddy User",
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/liumiaozhi/dsh-token-gauge.git"
34
+ },
35
+ "homepage": "https://github.com/liumiaozhi/dsh-token-gauge#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/liumiaozhi/dsh-token-gauge/issues"
38
+ },
39
+ "publishConfig": {
40
+ "registry": "https://registry.npmjs.org",
41
+ "access": "public"
42
+ },
43
+ "dsh": {
44
+ "engines": {
45
+ "dsh": ">=0.1.1-rc.1"
46
+ },
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "compatibility": {
51
+ "dshReleases": {
52
+ "0.1.1-rc.1": "compatible",
53
+ "0.1.1-rc.2": "compatible"
54
+ }
55
+ }
56
+ },
57
+ "scripts": {
58
+ "build": "node build.mjs",
59
+ "prepack": "node build.mjs"
60
+ },
61
+ "peerDependencies": {
62
+ "@deepseek-ai/cordis": "*",
63
+ "@deepseek-ai/dsh-host-webserver": "*"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@deepseek-ai/cordis": {
67
+ "optional": true
68
+ },
69
+ "@deepseek-ai/dsh-host-webserver": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@deepseek-ai/cordis": "*",
75
+ "@types/react": "^18.2.0",
76
+ "esbuild": "^0.21.5",
77
+ "react": "^18.2.0",
78
+ "typescript": "^5.4.0"
79
+ }
80
+ }
package/src/client.ts CHANGED
@@ -1,334 +1,302 @@
1
- // ============================================================================
2
- // DSH Client 半边:注册侧边栏 Tab,渲染机车风格仪表盘 + 设置弹窗
3
- // 运行环境:浏览器(web 平台,与 webServer 同域)。纯原生 DOM/SVG 仪表盘,
4
- // 通过同域 HTTP 与 Host 半边通信:
5
- // GET /plugins/dsh-token-gauge/state -> 完整 State(JSON)
6
- // POST /plugins/dsh-token-gauge/cmd -> {type,payload} -> 更新后 State
7
- // 侧边栏 Tab 通过 dsh-better-sidebar 的 ctx.betterSidebar.registerTab 注册
8
- // (稳定版 0.1.1-rc.x 唯一的外部 Tab 入口)。
9
- // 为避免与运行时 React 产生双实例 hook 冲突,本组件不调用任何 hook,
10
- // 仅在 ref 回调里挂载一次原生 DOM 仪表盘,并自行轮询刷新。
11
- // ============================================================================
12
- import React from 'react'
13
- import { getStyle, applyPalette, buildCss } from './styles'
14
- import { createGauge } from './gauge'
15
- import { t } from './i18n'
16
- import { downloadReport } from './export'
17
- import { createSettingsModal } from './settings'
18
-
19
- const PLUGIN_ID = 'dsh-token-gauge'
20
- const ROUTE_STATE = '/plugins/dsh-token-gauge/state'
21
- const ROUTE_CMD = '/plugins/dsh-token-gauge/cmd'
22
-
23
- function totalOf(b: any): number {
24
- return (b?.input || 0) + (b?.cacheRead || 0) + (b?.cacheWrite || 0) + (b?.output || 0)
25
- }
26
- function todayKey(d = new Date()): string {
27
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
28
- }
29
- function monthKey(d = new Date()): string {
30
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
31
- }
32
- function weekKey(d = new Date()): string {
33
- const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
34
- const dayNum = (date.getUTCDay() + 6) % 7
35
- date.setUTCDate(date.getUTCDate() - dayNum + 3)
36
- const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
37
- const week =
38
- 1 +
39
- Math.round(
40
- ((date.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
41
- )
42
- return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`
43
- }
44
-
45
- function fallbackState(): any {
46
- return {
47
- global: {},
48
- daily: {},
49
- weekly: {},
50
- monthly: {},
51
- sessions: {},
52
- models: {},
53
- activeSession: '',
54
- settings: {
55
- style: 'ev',
56
- palette: 'neon',
57
- lang: 'zh',
58
- metric: 'global',
59
- display: { usage: true, cost: true, io: true, cacheMiss: true },
60
- limits: { global: null, daily: null, weekly: null, monthly: null },
61
- pricing: {},
62
- },
63
- }
64
- }
65
-
66
- // 计算当前指标的量程/费用
67
- function computeMetric(s: any): { value: number; limit: number | null; cost: number } {
68
- const metric = s.settings.metric
69
- let entry: any = s.global
70
- let limit: number | null = s.settings.limits.global
71
- if (metric === 'session') {
72
- entry = s.sessions[s.activeSession] || {}
73
- limit = s.settings.limits.daily
74
- } else if (metric === 'today' || metric === 'daily') {
75
- entry = s.daily[todayKey()] || {}
76
- limit = s.settings.limits.daily
77
- } else if (metric === 'weekly') {
78
- entry = s.weekly[weekKey()] || {}
79
- limit = s.settings.limits.weekly
80
- } else if (metric === 'monthly') {
81
- entry = s.monthly[monthKey()] || {}
82
- limit = s.settings.limits.monthly
83
- }
84
- return { value: totalOf(entry), limit, cost: entry.cost || 0 }
85
- }
86
-
87
- // 把原生 DOM 仪表盘挂载进容器,并启动轮询
88
- function mountGauge(container: HTMLElement): void {
89
- // 注入一次全局样式
90
- if (!document.getElementById('tg-gauge-style')) {
91
- const st = document.createElement('style')
92
- st.id = 'tg-gauge-style'
93
- st.textContent = buildCss()
94
- document.head.appendChild(st)
95
- }
96
-
97
- let state: any = null
98
- const tr = (k: string) => t((state?.settings?.lang as 'zh' | 'en') || 'zh', k)
99
-
100
- const root = container
101
- root.className = 'tg-root tg-style-ev'
102
-
103
- // 头部
104
- const head = document.createElement('div')
105
- head.className = 'tg-head'
106
- const titleEl = document.createElement('div')
107
- titleEl.className = 'tg-title'
108
- titleEl.textContent = 'Token 仪表盘'
109
- const gear = document.createElement('div')
110
- gear.className = 'tg-gear'
111
- gear.textContent = '⚙'
112
- gear.title = '设置'
113
- head.append(titleEl, gear)
114
- root.appendChild(head)
115
-
116
- // 空数据提示
117
- const empty = document.createElement('div')
118
- empty.className = 'tg-empty'
119
- empty.style.display = 'none'
120
- root.appendChild(empty)
121
-
122
- // 仪表盘
123
- const gauge = createGauge()
124
- root.appendChild(gauge.el)
125
-
126
- // 设置弹窗
127
- const modal = createSettingsModal({
128
- getLang: () => (state?.settings?.lang as 'zh' | 'en') || 'zh',
129
- onSettings: (patch) => sendCmd('setSettings', patch),
130
- onExport: (format) => {
131
- if (state) downloadReport(state, (state.settings.lang as 'zh' | 'en') || 'zh', format)
132
- },
133
- onClear: () => sendCmd('clearUsage', undefined),
134
- onReset: () => sendCmd('resetSettings', undefined),
135
- })
136
- root.appendChild(modal.el)
137
-
138
- gear.addEventListener('click', () => modal.open(state || fallbackState()))
139
-
140
- const applyState = (s: any): void => {
141
- state = s
142
- root.className = 'tg-root ' + getStyle(s.settings.style).className
143
- applyPalette(root, s.settings.palette)
144
- titleEl.textContent = tr('title')
145
- const m = computeMetric(s)
146
- gauge.update({ value: m.value, limit: m.limit, cost: m.cost, state: s, display: s.settings.display, t: tr, lang: s.settings.lang })
147
- const hasData = totalOf(s.global) > 0
148
- empty.style.display = hasData ? 'none' : ''
149
- empty.textContent = tr('noData')
150
- modal.sync(s)
151
- }
152
-
153
- // 拉取最新状态
154
- async function poll(): Promise<void> {
155
- try {
156
- const r = await fetch(ROUTE_STATE, { headers: { Accept: 'application/json' } })
157
- if (r.ok) {
158
- const s = await r.json()
159
- applyState(s)
160
- }
161
- } catch (e) {
162
- // 网络抖动忽略,下一轮重试
163
- }
164
- }
165
-
166
- // 发送指令并刷新
167
- async function sendCmd(type: string, payload: any): Promise<void> {
168
- try {
169
- await fetch(ROUTE_CMD, {
170
- method: 'POST',
171
- headers: { 'Content-Type': 'application/json' },
172
- body: JSON.stringify({ type, payload }),
173
- })
174
- } catch (e) {
175
- console.warn(`[${PLUGIN_ID}] cmd ${type} failed`, e)
176
- }
177
- await poll()
178
- }
179
-
180
- // 立即拉一次,并定时轮询
181
- void poll()
182
- const timer = window.setInterval(() => void poll(), 2000)
183
- // 容器被移除时停止轮询
184
- const mo = new MutationObserver(() => {
185
- if (!document.body.contains(container)) {
186
- window.clearInterval(timer)
187
- mo.disconnect()
188
- }
189
- })
190
- mo.observe(document.body, { childList: true, subtree: true })
191
- }
192
-
193
- // 稳定的 ref 回调:只在首次挂载时构建一次仪表盘
194
- const gaugeRef = (el: HTMLElement | null): void => {
195
- if (el && !el.dataset.tgMounted) {
196
- el.dataset.tgMounted = '1'
197
- mountGauge(el)
198
- }
199
- }
200
-
201
- // 侧边栏 Tab 组件(不调用任何 hook,规避双 React 实例冲突)
202
- function GaugeTab(): React.ReactElement {
203
- return React.createElement('div', { ref: gaugeRef, className: 'tg-tab-root' })
204
- }
205
-
206
- // 侧边栏图标:better-sidebar 以 `icon(size)` 形式调用(见其内置 tab:
207
- // `icon: (size) => jsx(Icon, { size })`),故这里接收数字尺寸。用 currentColor
208
- // 着色以自适应明暗主题,纯 SVG 零依赖。
209
- const GaugeIcon = (size?: number): any => {
210
- const s = typeof size === 'number' && size > 0 ? size : 16
211
- const svgProps: any = {
212
- width: s,
213
- height: s,
214
- viewBox: '0 0 16 16',
215
- fill: 'none',
216
- xmlns: 'http://www.w3.org/2000/svg',
217
- 'aria-hidden': 'true',
218
- }
219
- // 仪表盘弧 + 指针
220
- const arc = React.createElement('path', {
221
- d: 'M2.6 11.6a5.6 5.6 0 1 1 10.8 0',
222
- stroke: 'currentColor',
223
- strokeWidth: 1.5,
224
- strokeLinecap: 'round',
225
- })
226
- const needle = React.createElement('path', {
227
- d: 'M8 11.6 10.4 7.2',
228
- stroke: 'currentColor',
229
- strokeWidth: 1.5,
230
- strokeLinecap: 'round',
231
- })
232
- return React.createElement('svg', svgProps, arc, needle)
233
- }
234
-
235
- // 在「+ 新建 Tab」菜单里的排序位。内置 tab 用 20/91/110 等,order 缺省为 100
236
- // 会排到菜单最底部不易发现,这里显式给一个偏前的值。
237
- const TAB_ORDER = 90
238
-
239
- export const name = PLUGIN_ID
240
-
241
- // 兄弟 bundle 提供的服务(betterSidebar)在本机这个 cordis 里两种读法都不行,必须用
242
- // ctx.inject 回调模式。原因(已实测坐实,见本文件末尾注释与 dsh-plugin-guide 官方
243
- // postmortem):
244
- // 1) 插件级 `export const inject = ['betterSidebar']`
245
- // -> cordis 把整插件 defer,单趟 boot 的 assertEntriesActivated 检查点在
246
- // loader.await() 后跑,此时兄弟 bundle 的 provide 还没落到本 fiber,
247
- // 直接判 "pending (waiting for service: betterSidebar)"(0.1.3 实测)。
248
- // 2) apply 顶层 `ctx.get('betterSidebar')` 或 `ctx.betterSidebar`
249
- // -> 0811 cordis 是「严格注入」:未声明 inject 直接读服务属性,走 ancestor-only
250
- // fiber 遍历到 root 找不到就抛
251
- // "cannot get property betterSidebar without inject"(0.1.4 实测)。
252
- // 正确姿势:插件级不声明任何 inject(插件立即激活,不 pending),改用
253
- // ctx.inject(['betterSidebar'], cb) —— 回调拿到的子上下文里 betterSidebar 是已注入的
254
- // 合法属性,读它不触发守卫;且注册被延迟到服务就绪那一刻(兄弟 bundle 提供得早或晚
255
- // 都能注册上)。生态插件 @linxin666/dsh-client-ui-aionui-panel 用的正是这个模式。
256
- export function apply(ctx: any): (() => void) | void {
257
- let registered = false
258
- let unregister: unknown = void 0
259
-
260
- // 注册一次即止(registerTab 对重复 id 会抛 "already registered")
261
- const register = (sidebar: any): void => {
262
- if (registered || !sidebar || typeof sidebar.registerTab !== 'function') return
263
- registered = true
264
- try {
265
- unregister = sidebar.registerTab({
266
- id: PLUGIN_ID,
267
- title: 'Token 仪表盘',
268
- single: true,
269
- order: TAB_ORDER,
270
- icon: GaugeIcon,
271
- component: GaugeTab,
272
- })
273
- } catch (e) {
274
- registered = false // 失败则允许后续重试
275
- }
276
- }
277
-
278
- // 主路径:ctx.inject 回调——服务就绪时触发,回调内读 sidebarCtx.betterSidebar 合法
279
- let disposeInject: unknown = void 0
280
- try {
281
- disposeInject = ctx.inject(['betterSidebar'], (sidebarCtx: any) => {
282
- register(sidebarCtx && sidebarCtx.betterSidebar)
283
- })
284
- } catch (e) {
285
- /* 运行时无 ctx.inject:交由下面的轮询兜底 */
286
- }
287
-
288
- // 兜底:轮询读取。0811 严格注入下未声明 inject 时 ctx.get('betterSidebar')
289
- // 会「抛异常」(而非返回 undefined),所以必须用 try/catch 包住读取。
290
- const tryRead = (): void => {
291
- if (registered) return
292
- try {
293
- const s = typeof ctx.get === 'function' ? ctx.get('betterSidebar') : ctx.betterSidebar
294
- register(s)
295
- } catch (e) {
296
- /* 服务此刻不可达,下一轮再试 */
297
- }
298
- }
299
- tryRead()
300
-
301
- let timer: unknown = void 0
302
- if (!registered) {
303
- timer = setInterval(() => {
304
- tryRead()
305
- if (registered && timer !== void 0) clearInterval(timer as number)
306
- }, 500)
307
- }
308
- const stop = setTimeout(() => {
309
- if (timer !== void 0) clearInterval(timer as number)
310
- if (!registered) {
311
- // 服务始终不可达 ≈ dsh-better-sidebar 没被挂成 profile 层。
312
- // 关键陷阱:它若只是本插件的「传递依赖」,只会落进 node_modules 而不会被
313
- // 加入 dsh.profile.bundles —— 加载树里没有条目,服务根本不会被 provide,
314
- // 本插件于是「已挂载但无 Tab」且全程零报错(真·静默失败)。故这里必须
315
- // 打出可操作的指引,而不是默默放弃。
316
- console.error(
317
- '[dsh-token-gauge] 未取得 dsh-better-sidebar 服务,侧边栏 Tab 未注册。\n' +
318
- ' 请把它装成 profile 的「直接依赖」并挂载:\n' +
319
- ' dsh plugin --profile web add dsh-better-sidebar\n' +
320
- ' 然后完整重启 dsh web。\n' +
321
- ' 注意:仅作为本插件的传递依赖存在于 node_modules 是不够的——DSH 不会把\n' +
322
- ' 传递依赖挂成 bundle 层,那种情况下本插件会静默地没有 Tab。',
323
- )
324
- }
325
- }, 30000)
326
-
327
- // 插件级 disposer
328
- return () => {
329
- if (timer !== void 0) clearInterval(timer as number)
330
- clearTimeout(stop)
331
- if (typeof unregister === 'function') (unregister as () => void)()
332
- if (typeof disposeInject === 'function') (disposeInject as () => void)()
333
- }
334
- }
1
+ // ============================================================================
2
+ // DSH Client 半边:注册侧边栏 Tab,渲染机车风格仪表盘 + 设置弹窗
3
+ // 运行环境:浏览器(web 平台,与 webServer 同域)。纯原生 DOM/SVG 仪表盘,
4
+ // 通过同域 HTTP 与 Host 半边通信:
5
+ // GET /plugins/dsh-token-gauge/state -> 完整 State(JSON)
6
+ // POST /plugins/dsh-token-gauge/cmd -> {type,payload} -> 更新后 State
7
+ // 通过官方 shell.overlay 插槽独立挂载浮层面板(方案 A),不再依赖 dsh-better-sidebar。
8
+ // 为避免与运行时 React 产生双实例 hook 冲突,本组件不调用任何 hook,
9
+ // 仅在 ref 回调里挂载一次原生 DOM 仪表盘,并自行轮询刷新。
10
+ // ============================================================================
11
+ import React from 'react'
12
+ import { getStyle, applyPalette, buildCss } from './styles'
13
+ import { createGauge } from './gauge'
14
+ import { t } from './i18n'
15
+ import { downloadReport } from './export'
16
+ import { createSettingsModal } from './settings'
17
+
18
+ const PLUGIN_ID = 'dsh-token-gauge'
19
+ const ROUTE_STATE = '/plugins/dsh-token-gauge/state'
20
+ const ROUTE_CMD = '/plugins/dsh-token-gauge/cmd'
21
+
22
+ function totalOf(b: any): number {
23
+ return (b?.input || 0) + (b?.cacheRead || 0) + (b?.cacheWrite || 0) + (b?.output || 0)
24
+ }
25
+ function todayKey(d = new Date()): string {
26
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
27
+ }
28
+ function monthKey(d = new Date()): string {
29
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
30
+ }
31
+ function weekKey(d = new Date()): string {
32
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
33
+ const dayNum = (date.getUTCDay() + 6) % 7
34
+ date.setUTCDate(date.getUTCDate() - dayNum + 3)
35
+ const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
36
+ const week =
37
+ 1 +
38
+ Math.round(
39
+ ((date.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
40
+ )
41
+ return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`
42
+ }
43
+
44
+ function fallbackState(): any {
45
+ return {
46
+ global: {},
47
+ daily: {},
48
+ weekly: {},
49
+ monthly: {},
50
+ sessions: {},
51
+ models: {},
52
+ activeSession: '',
53
+ settings: {
54
+ style: 'ev',
55
+ palette: 'neon',
56
+ lang: 'zh',
57
+ metric: 'global',
58
+ display: { usage: true, cost: true, io: true, cacheMiss: true },
59
+ limits: { global: null, daily: null, weekly: null, monthly: null },
60
+ pricing: {},
61
+ },
62
+ }
63
+ }
64
+
65
+ // 计算当前指标的量程/费用
66
+ function computeMetric(s: any): { value: number; limit: number | null; cost: number } {
67
+ const metric = s.settings.metric
68
+ let entry: any = s.global
69
+ let limit: number | null = s.settings.limits.global
70
+ if (metric === 'session') {
71
+ entry = s.sessions[s.activeSession] || {}
72
+ limit = s.settings.limits.daily
73
+ } else if (metric === 'today' || metric === 'daily') {
74
+ entry = s.daily[todayKey()] || {}
75
+ limit = s.settings.limits.daily
76
+ } else if (metric === 'weekly') {
77
+ entry = s.weekly[weekKey()] || {}
78
+ limit = s.settings.limits.weekly
79
+ } else if (metric === 'monthly') {
80
+ entry = s.monthly[monthKey()] || {}
81
+ limit = s.settings.limits.monthly
82
+ }
83
+ return { value: totalOf(entry), limit, cost: entry.cost || 0 }
84
+ }
85
+
86
+ // 把原生 DOM 仪表盘挂载进容器,并启动轮询
87
+ function mountGauge(container: HTMLElement): void {
88
+ // 注入一次全局样式
89
+ if (!document.getElementById('tg-gauge-style')) {
90
+ const st = document.createElement('style')
91
+ st.id = 'tg-gauge-style'
92
+ st.textContent = buildCss()
93
+ document.head.appendChild(st)
94
+ }
95
+
96
+ let state: any = null
97
+ const tr = (k: string) => t((state?.settings?.lang as 'zh' | 'en') || 'zh', k)
98
+
99
+ const root = container
100
+ root.className = 'tg-root tg-style-ev'
101
+
102
+ // 头部
103
+ const head = document.createElement('div')
104
+ head.className = 'tg-head'
105
+ const titleEl = document.createElement('div')
106
+ titleEl.className = 'tg-title'
107
+ titleEl.textContent = 'Token 仪表盘'
108
+ const gear = document.createElement('div')
109
+ gear.className = 'tg-gear'
110
+ gear.textContent = '⚙'
111
+ gear.title = '设置'
112
+ head.append(titleEl, gear)
113
+ root.appendChild(head)
114
+
115
+ // 空数据提示
116
+ const empty = document.createElement('div')
117
+ empty.className = 'tg-empty'
118
+ empty.style.display = 'none'
119
+ root.appendChild(empty)
120
+
121
+ // 仪表盘
122
+ const gauge = createGauge()
123
+ root.appendChild(gauge.el)
124
+
125
+ // 设置弹窗
126
+ const modal = createSettingsModal({
127
+ getLang: () => (state?.settings?.lang as 'zh' | 'en') || 'zh',
128
+ onSettings: (patch) => sendCmd('setSettings', patch),
129
+ onExport: (format) => {
130
+ if (state) downloadReport(state, (state.settings.lang as 'zh' | 'en') || 'zh', format)
131
+ },
132
+ onClear: () => sendCmd('clearUsage', undefined),
133
+ onReset: () => sendCmd('resetSettings', undefined),
134
+ })
135
+ root.appendChild(modal.el)
136
+
137
+ gear.addEventListener('click', () => modal.open(state || fallbackState()))
138
+
139
+ const applyState = (s: any): void => {
140
+ state = s
141
+ root.className = 'tg-root ' + getStyle(s.settings.style).className
142
+ applyPalette(root, s.settings.palette)
143
+ titleEl.textContent = tr('title')
144
+ const m = computeMetric(s)
145
+ gauge.update({ value: m.value, limit: m.limit, cost: m.cost, state: s, display: s.settings.display, t: tr, lang: s.settings.lang })
146
+ const hasData = totalOf(s.global) > 0
147
+ empty.style.display = hasData ? 'none' : ''
148
+ empty.textContent = tr('noData')
149
+ modal.sync(s)
150
+ }
151
+
152
+ // 拉取最新状态
153
+ async function poll(): Promise<void> {
154
+ try {
155
+ const r = await fetch(ROUTE_STATE, { headers: { Accept: 'application/json' } })
156
+ if (r.ok) {
157
+ const s = await r.json()
158
+ applyState(s)
159
+ }
160
+ } catch (e) {
161
+ // 网络抖动忽略,下一轮重试
162
+ }
163
+ }
164
+
165
+ // 发送指令并刷新
166
+ async function sendCmd(type: string, payload: any): Promise<void> {
167
+ try {
168
+ await fetch(ROUTE_CMD, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ type, payload }),
172
+ })
173
+ } catch (e) {
174
+ console.warn(`[${PLUGIN_ID}] cmd ${type} failed`, e)
175
+ }
176
+ await poll()
177
+ }
178
+
179
+ // 立即拉一次,并定时轮询
180
+ void poll()
181
+ const timer = window.setInterval(() => void poll(), 2000)
182
+ // 容器被移除时停止轮询
183
+ const mo = new MutationObserver(() => {
184
+ if (!document.body.contains(container)) {
185
+ window.clearInterval(timer)
186
+ mo.disconnect()
187
+ }
188
+ })
189
+ mo.observe(document.body, { childList: true, subtree: true })
190
+ }
191
+
192
+ // 稳定的 ref 回调:只在首次挂载时构建一次仪表盘
193
+ const gaugeRef = (el: HTMLElement | null): void => {
194
+ if (el && !el.dataset.tgMounted) {
195
+ el.dataset.tgMounted = '1'
196
+ mountGauge(el)
197
+ }
198
+ }
199
+
200
+ // 浮层面板组件:固定在右下角、占视口高度 1/3(方案 A:shell.overlay 插槽)。
201
+ // 仍是零 hook、ref 回调挂原生 DOM,规避双 React 实例;外层用内联定位样式,
202
+ // 不被 mountGauge 内的 className 覆盖(style 优先级高于 class)。
203
+ function GaugePanel(): React.ReactElement {
204
+ return React.createElement('div', {
205
+ ref: gaugeRef,
206
+ className: 'tg-panel-root',
207
+ style: {
208
+ position: 'fixed',
209
+ right: '0',
210
+ bottom: '0',
211
+ width: '420px',
212
+ height: '33vh',
213
+ zIndex: 50,
214
+ pointerEvents: 'auto',
215
+ boxSizing: 'border-box',
216
+ overflow: 'hidden',
217
+ borderTop: '1px solid var(--dsw-alias-hairline)',
218
+ borderLeft: '1px solid var(--dsw-alias-hairline)',
219
+ background: 'var(--dsw-alias-bg-base)',
220
+ color: 'var(--dsw-alias-label-primary)',
221
+ boxShadow: '0 0 24px rgba(0,0,0,0.28)',
222
+ } as any,
223
+ })
224
+ }
225
+
226
+ // 侧边栏图标:better-sidebar 以 `icon(size)` 形式调用(见其内置 tab:
227
+ // `icon: (size) => jsx(Icon, { size })`),故这里接收数字尺寸。用 currentColor
228
+ // 着色以自适应明暗主题,纯 SVG 零依赖。
229
+ const GaugeIcon = (size?: number): any => {
230
+ const s = typeof size === 'number' && size > 0 ? size : 16
231
+ const svgProps: any = {
232
+ width: s,
233
+ height: s,
234
+ viewBox: '0 0 16 16',
235
+ fill: 'none',
236
+ xmlns: 'http://www.w3.org/2000/svg',
237
+ 'aria-hidden': 'true',
238
+ }
239
+ // 仪表盘弧 + 指针
240
+ const arc = React.createElement('path', {
241
+ d: 'M2.6 11.6a5.6 5.6 0 1 1 10.8 0',
242
+ stroke: 'currentColor',
243
+ strokeWidth: 1.5,
244
+ strokeLinecap: 'round',
245
+ })
246
+ const needle = React.createElement('path', {
247
+ d: 'M8 11.6 10.4 7.2',
248
+ stroke: 'currentColor',
249
+ strokeWidth: 1.5,
250
+ strokeLinecap: 'round',
251
+ })
252
+ return React.createElement('svg', svgProps, arc, needle)
253
+ }
254
+
255
+ // 在「+ 新建 Tab」菜单里的排序位。内置 tab 用 20/91/110 等,order 缺省为 100
256
+ // 会排到菜单最底部不易发现,这里显式给一个偏前的值。
257
+ const TAB_ORDER = 90
258
+
259
+ export const name = PLUGIN_ID
260
+
261
+ // 方案 A:通过官方 shell.overlay 插槽(list 型、additive、全框架级浮层)独立挂载仪表盘,
262
+ // 不再依赖第三方插件 dsh-better-sidebar。slots 是 layout 提供的框架级服务(root 早期即
263
+ // 就绪),故插件级 `inject: ['slots']` 不会触发此前 betterSidebar 的 pending / without-inject
264
+ // 问题——这正是脱离 better-sidebar 的核心收益。
265
+ export const inject = ['slots']
266
+
267
+ export function apply(ctx: any): (() => void) | void {
268
+ let disposeRegistration: unknown = void 0
269
+
270
+ const doRegister = (): void => {
271
+ // shell.overlay 是 click-through 层,entry 需自行 opt-in pointer events(已在
272
+ // GaugePanel 外层设 pointerEvents:'auto')。第二参是返回 React.Element 的工厂函数
273
+ //(照抄 dsh-cordis-client-runner 官方示例)。
274
+ disposeRegistration = ctx.slots.register(
275
+ {
276
+ name: 'shell.overlay',
277
+ id: PLUGIN_ID,
278
+ order: 100,
279
+ label: 'Token 仪表盘',
280
+ },
281
+ () => React.createElement(GaugePanel),
282
+ )
283
+ }
284
+
285
+ let stop: unknown = void 0
286
+ try {
287
+ // slots.inject(seat, cb):等 shell.overlay 这个 seat 就绪再注册(root 已声明,
288
+ // 通常同步触发)。某些环境下若无 inject 方法,则直接 register 兜底。
289
+ if (typeof ctx.slots.inject === 'function') {
290
+ stop = ctx.slots.inject('shell.overlay', doRegister)
291
+ } else {
292
+ doRegister()
293
+ }
294
+ } catch (e) {
295
+ console.error('[dsh-token-gauge] 注册 shell.overlay 失败', e)
296
+ }
297
+
298
+ return () => {
299
+ if (typeof disposeRegistration === 'function') (disposeRegistration as () => void)()
300
+ if (typeof stop === 'function') (stop as () => void)()
301
+ }
302
+ }