dsh-token-gauge 0.1.7 → 0.1.9

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 +71 -44
  2. package/package.json +84 -84
  3. package/src/client.ts +341 -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,8 +944,41 @@ var gaugeRef = (el) => {
943
944
  mountGauge(el);
944
945
  }
945
946
  };
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
+ }
967
+ });
968
+ }
946
969
  function GaugeTab() {
947
- return import_react.default.createElement("div", { ref: gaugeRef, className: "tg-tab-root" });
970
+ return import_react.default.createElement("div", {
971
+ ref: gaugeRef,
972
+ className: "tg-panel-root",
973
+ style: {
974
+ width: "100%",
975
+ height: "100%",
976
+ boxSizing: "border-box",
977
+ overflow: "hidden",
978
+ background: "var(--dsw-alias-bg-base)",
979
+ color: "var(--dsw-alias-label-primary)"
980
+ }
981
+ });
948
982
  }
949
983
  var GaugeIcon = (size) => {
950
984
  const s = typeof size === "number" && size > 0 ? size : 16;
@@ -972,61 +1006,54 @@ var GaugeIcon = (size) => {
972
1006
  };
973
1007
  var TAB_ORDER = 90;
974
1008
  var name = PLUGIN_ID;
1009
+ var inject = ["slots"];
975
1010
  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;
1011
+ let unregisterTab = null;
1012
+ let overlayDispose = void 0;
1013
+ let registeredViaBetter = false;
1014
+ let fallbackTimer = void 0;
1015
+ const tryMountOverlay = (reason) => {
1016
+ if (registeredViaBetter || overlayDispose !== void 0) return;
1017
+ console.warn(`[dsh-token-gauge] ${reason}\uFF0C\u4EEA\u8868\u76D8\u6539\u4E3A\u53F3\u4E0B\u89D2\u6D6E\u5C42\u663E\u793A\uFF08\u4ECD\u53EF\u7528\uFF09\u3002`);
981
1018
  try {
982
- unregister = sidebar.registerTab({
983
- id: PLUGIN_ID,
984
- title: "Token \u4EEA\u8868\u76D8",
985
- single: true,
986
- order: TAB_ORDER,
987
- icon: GaugeIcon,
988
- component: GaugeTab
989
- });
1019
+ overlayDispose = ctx.slots.register(
1020
+ { name: "shell.overlay", id: PLUGIN_ID, order: 100, label: "Token \u4EEA\u8868\u76D8" },
1021
+ () => import_react.default.createElement(GaugePanel)
1022
+ );
990
1023
  } catch (e) {
991
- registered = false;
1024
+ console.error("[dsh-token-gauge] \u6D6E\u5C42\u515C\u5E95\u4E5F\u5931\u8D25", e);
992
1025
  }
993
1026
  };
994
- let disposeInject = void 0;
995
- try {
996
- disposeInject = ctx.inject(["betterSidebar"], (sidebarCtx) => {
997
- register(sidebarCtx && sidebarCtx.betterSidebar);
1027
+ const disposeSidebar = ctx.inject(["betterSidebar"], (sidebarCtx) => {
1028
+ const sidebar = sidebarCtx.betterSidebar;
1029
+ if (!sidebar || typeof sidebar.registerTab !== "function") return;
1030
+ unregisterTab = sidebar.registerTab({
1031
+ id: PLUGIN_ID,
1032
+ title: "Token \u4EEA\u8868\u76D8",
1033
+ single: true,
1034
+ order: TAB_ORDER,
1035
+ icon: GaugeIcon,
1036
+ component: () => import_react.default.createElement(GaugeTab)
998
1037
  });
999
- } catch (e) {
1000
- }
1001
- const tryRead = () => {
1002
- if (registered) return;
1038
+ registeredViaBetter = true;
1039
+ if (typeof fallbackTimer !== "undefined") clearTimeout(fallbackTimer);
1003
1040
  try {
1004
- const s = typeof ctx.get === "function" ? ctx.get("betterSidebar") : ctx.betterSidebar;
1005
- register(s);
1041
+ if (typeof sidebar.openTab === "function") sidebar.openTab({ type: PLUGIN_ID, single: true });
1006
1042
  } catch (e) {
1007
1043
  }
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);
1016
- }
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
- );
1044
+ return () => {
1045
+ if (unregisterTab) unregisterTab();
1046
+ };
1047
+ });
1048
+ fallbackTimer = setTimeout(() => {
1049
+ if (!registeredViaBetter) {
1050
+ tryMountOverlay("\u672A\u53D6\u5F97 dsh-better-sidebar \u670D\u52A1\uFF08\u8BF7\u786E\u8BA4\u5176\u5DF2\u5B89\u88C5\u5E76\u542F\u7528\uFF09");
1023
1051
  }
1024
1052
  }, 3e4);
1025
1053
  return () => {
1026
- if (timer !== void 0) clearInterval(timer);
1027
- clearTimeout(stop);
1028
- if (typeof unregister === "function") unregister();
1029
- if (typeof disposeInject === "function") disposeInject();
1054
+ if (typeof fallbackTimer !== "undefined") clearTimeout(fallbackTimer);
1055
+ if (typeof disposeSidebar === "function") disposeSidebar();
1056
+ if (typeof overlayDispose === "function") overlayDispose();
1030
1057
  };
1031
1058
  }
1032
1059
  ; return module.exports; } });
package/package.json CHANGED
@@ -1,84 +1,84 @@
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.9",
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
+ }
package/src/client.ts CHANGED
@@ -1,334 +1,341 @@
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
+ // 优先把仪表盘注册到 dsh-better-sidebar 的右侧工作台 Tab(停靠侧边栏);若 better-sidebar
8
+ // 未就绪,回退到官方 shell.overlay 浮层(双模式,保证仪表盘永远可见)。
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
+ // 浮层面板组件:固定在右下角、占视口高度 1/3(方案 A:shell.overlay 插槽)。
202
+ // 仍是零 hook、ref 回调挂原生 DOM,规避双 React 实例;外层用内联定位样式,
203
+ // 不被 mountGauge 内的 className 覆盖(style 优先级高于 class)。
204
+ function GaugePanel(): React.ReactElement {
205
+ return React.createElement('div', {
206
+ ref: gaugeRef,
207
+ className: 'tg-panel-root',
208
+ style: {
209
+ position: 'fixed',
210
+ right: '0',
211
+ bottom: '0',
212
+ width: '420px',
213
+ height: '33vh',
214
+ zIndex: 50,
215
+ pointerEvents: 'auto',
216
+ boxSizing: 'border-box',
217
+ overflow: 'hidden',
218
+ borderTop: '1px solid var(--dsw-alias-hairline)',
219
+ borderLeft: '1px solid var(--dsw-alias-hairline)',
220
+ background: 'var(--dsw-alias-bg-base)',
221
+ color: 'var(--dsw-alias-label-primary)',
222
+ boxShadow: '0 0 24px rgba(0,0,0,0.28)',
223
+ } as any,
224
+ })
225
+ }
226
+
227
+ // 停靠在 better-sidebar 工作台 Tab 内的面板:填满容器(非固定定位),同样零 hook、ref 挂原生 DOM。
228
+ function GaugeTab(): React.ReactElement {
229
+ return React.createElement('div', {
230
+ ref: gaugeRef,
231
+ className: 'tg-panel-root',
232
+ style: {
233
+ width: '100%',
234
+ height: '100%',
235
+ boxSizing: 'border-box',
236
+ overflow: 'hidden',
237
+ background: 'var(--dsw-alias-bg-base)',
238
+ color: 'var(--dsw-alias-label-primary)',
239
+ } as any,
240
+ })
241
+ }
242
+
243
+ // 侧边栏图标:better-sidebar 以 `icon(size)` 形式调用(见其内置 tab:
244
+ // `icon: (size) => jsx(Icon, { size })`),故这里接收数字尺寸。用 currentColor
245
+ // 着色以自适应明暗主题,纯 SVG 零依赖。
246
+ const GaugeIcon = (size?: number): any => {
247
+ const s = typeof size === 'number' && size > 0 ? size : 16
248
+ const svgProps: any = {
249
+ width: s,
250
+ height: s,
251
+ viewBox: '0 0 16 16',
252
+ fill: 'none',
253
+ xmlns: 'http://www.w3.org/2000/svg',
254
+ 'aria-hidden': 'true',
255
+ }
256
+ // 仪表盘弧 + 指针
257
+ const arc = React.createElement('path', {
258
+ d: 'M2.6 11.6a5.6 5.6 0 1 1 10.8 0',
259
+ stroke: 'currentColor',
260
+ strokeWidth: 1.5,
261
+ strokeLinecap: 'round',
262
+ })
263
+ const needle = React.createElement('path', {
264
+ d: 'M8 11.6 10.4 7.2',
265
+ stroke: 'currentColor',
266
+ strokeWidth: 1.5,
267
+ strokeLinecap: 'round',
268
+ })
269
+ return React.createElement('svg', svgProps, arc, needle)
270
+ }
271
+
272
+ // 在「+ 新建 Tab」菜单里的排序位。内置 tab 用 20/91/110 等,order 缺省为 100
273
+ // 会排到菜单最底部不易发现,这里显式给一个偏前的值。
274
+ const TAB_ORDER = 90
275
+
276
+ export const name = PLUGIN_ID
277
+
278
+ // slots 是 layout 提供的框架级服务(root 早期即就绪),用于浮层兜底,可安全声明顶层 inject。
279
+ // 注意:betterSidebar 是兄弟 bundle 提供的服务,不用顶层 inject(会触发 pending),改用
280
+ // apply 内的 ctx.inject 软注入回调。
281
+ export const inject = ['slots']
282
+
283
+ export function apply(ctx: any): (() => void) | void {
284
+ let unregisterTab: (() => void) | null = null
285
+ let overlayDispose: unknown = void 0
286
+ let registeredViaBetter = false
287
+ let fallbackTimer: any = void 0
288
+
289
+ // 浮层兜底:把仪表盘挂到官方 shell.overlay(全程右下角 33vh),保证即使 better-sidebar
290
+ // 缺失/未启用,仪表盘也始终可见。
291
+ const tryMountOverlay = (reason: string): void => {
292
+ if (registeredViaBetter || overlayDispose !== void 0) return
293
+ console.warn(`[dsh-token-gauge] ${reason},仪表盘改为右下角浮层显示(仍可用)。`)
294
+ try {
295
+ overlayDispose = ctx.slots.register(
296
+ { name: 'shell.overlay', id: PLUGIN_ID, order: 100, label: 'Token 仪表盘' },
297
+ () => React.createElement(GaugePanel),
298
+ )
299
+ } catch (e) {
300
+ console.error('[dsh-token-gauge] 浮层兜底也失败', e)
301
+ }
302
+ }
303
+
304
+ // 主路径:注册到 better-sidebar 右侧工作台 Tab(停靠侧边栏,带图标、靠前排序)
305
+ const disposeSidebar = ctx.inject(['betterSidebar'], (sidebarCtx: any) => {
306
+ const sidebar = sidebarCtx.betterSidebar
307
+ if (!sidebar || typeof sidebar.registerTab !== 'function') return
308
+ unregisterTab = sidebar.registerTab({
309
+ id: PLUGIN_ID,
310
+ title: 'Token 仪表盘',
311
+ single: true,
312
+ order: TAB_ORDER,
313
+ icon: GaugeIcon,
314
+ component: () => React.createElement(GaugeTab),
315
+ })
316
+ registeredViaBetter = true
317
+ if (typeof fallbackTimer !== 'undefined') clearTimeout(fallbackTimer)
318
+ // 会话就绪时尝试自动打开 Tab(未就绪会静默 no-op,用户也可从「+ 新建 Tab」菜单手动打开)
319
+ try {
320
+ if (typeof sidebar.openTab === 'function') sidebar.openTab({ type: PLUGIN_ID, single: true })
321
+ } catch (e) {
322
+ /* ignore */
323
+ }
324
+ return () => {
325
+ if (unregisterTab) unregisterTab()
326
+ }
327
+ })
328
+
329
+ // 兜底:30s 内 better-sidebar 仍未就绪 → 浮层
330
+ fallbackTimer = setTimeout(() => {
331
+ if (!registeredViaBetter) {
332
+ tryMountOverlay('未取得 dsh-better-sidebar 服务(请确认其已安装并启用)')
333
+ }
334
+ }, 30000)
335
+
336
+ return () => {
337
+ if (typeof fallbackTimer !== 'undefined') clearTimeout(fallbackTimer)
338
+ if (typeof disposeSidebar === 'function') (disposeSidebar as () => void)()
339
+ if (typeof overlayDispose === 'function') (overlayDispose as () => void)()
340
+ }
341
+ }