@ponydev/dsh-model-usage 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 — 2026-10-20
4
+
5
+ ### 新增
6
+ - **检查更新**:设置 → 模型用量 标题右侧显示当前版本 `vX.Y.Z`,点击「检查更新」查询 npm registry 最新版本;有新版本时显示橙点与更新提示
7
+ - 弹窗展示 运行版本 / 最新版本 / 一键复制手工更新命令(`dsh plugin --profile web add <pkg>@<ver> --registry=...`)
8
+ - RPC `update-check`(10 分钟缓存;registry 固定 npmjs,不返回凭据)
9
+
10
+ ### 说明
11
+ - 自动更新需 DSH profile 包管理器介入,本插件只提供「检查 + 命令复制」,执行后重启 DSH Web 生效
12
+
3
13
  ## 1.1.0 — 2026-10-20
4
14
 
5
15
  ### 新增
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - 官方 Logo:DeepSeek / GLM(Z.ai) / Kimi / OpenRouter / MiniMax / OpenCode / OpenAI / Anthropic / Gemini / xAI / Qwen / Mistral / Groq / GitHub Copilot 等 25+
10
10
  - 设置 → 模型用量:KPI、趋势图、供应商/模型聚合、请求日志、CSV 导出
11
11
  - 设置 → 模型:页面底部显示各供应商额度
12
+ - 版本检查:设置 → 模型用量 右上角「检查更新」(对比 npm 最新版,可复制更新命令)
12
13
  - 只用**真实数据**:额度来自服务商接口,用量来自本机会话日志(逐 token 一致),不做金额估算
13
14
 
14
15
  ![侧边栏额度卡片](https://raw.githubusercontent.com/PonyDevAI/dsh-model-usage/main/docs/screenshots/01-sidebar.png)
package/lib/client.js CHANGED
@@ -44,6 +44,10 @@ window.__ModuleLoader__.load({
44
44
  shortAccount: '账号', remainCompact: '剩 {n}%', remainPrefix: '剩 ', remainSuffix: '', statsError: '统计加载失败', retry: '重试', emptyData: '暂无数据', emptyUsage: '今天还没有调用记录',
45
45
  kpiRequests: '模型调用', kpiTokens: '消耗 Tokens', kpiCacheHit: '缓存命中率',
46
46
  limitMcp: '每月 MCP 工具', limitMonthly: '每月限额', remainCalls: '剩余 {n}/{m} 次',
47
+ versionLabel: '版本', currentVersion: '运行版本', latestVersion: '最新版本', checkUpdate: '检查更新', checkingUpdate: '检查中…', upToDate: '已是最新版本',
48
+ updateAvailableHint: '发现新版本 {v}', updateFailed: '检查失败:{msg}',
49
+ manualUpdateTitle: '手工更新', manualUpdateNote: '插件由 DSH profile 管理:在终端执行以下命令,然后重启 DSH Web。',
50
+ copyCommand: '复制命令', copied: '已复制',
47
51
  groupCurrent: '当前会话在用', groupSub: '订阅窗口', groupApi: 'API 余额', groupWallet: '账号钱包', groupOther: '其他',
48
52
  trendTitle: '使用趋势',
49
53
  thProvider: '供应商', thModel: '模型', thRequests: '请求数', thTokens: 'Tokens',
@@ -80,6 +84,10 @@ window.__ModuleLoader__.load({
80
84
  shortAccount: 'Account', remainCompact: '{n}% left', remainPrefix: '', remainSuffix: ' left', statsError: 'Failed to load stats', retry: 'Retry', emptyData: 'No data', emptyUsage: 'No calls today yet',
81
85
  kpiRequests: 'Model calls', kpiTokens: 'Tokens', kpiCacheHit: 'Cache hit rate',
82
86
  limitMcp: 'MCP tools / month', limitMonthly: 'Monthly', remainCalls: '{n}/{m} calls left',
87
+ versionLabel: 'Version', currentVersion: 'Running', latestVersion: 'Latest', checkUpdate: 'Check for updates', checkingUpdate: 'Checking…', upToDate: 'Up to date',
88
+ updateAvailableHint: 'New version {v}', updateFailed: 'Check failed: {msg}',
89
+ manualUpdateTitle: 'Manual update', manualUpdateNote: 'Plugins are managed by the DSH profile: run this in a terminal, then restart DSH Web.',
90
+ copyCommand: 'Copy command', copied: 'Copied',
83
91
  groupCurrent: 'In use this session', groupSub: 'Subscription windows', groupApi: 'API balance', groupWallet: 'Account wallet', groupOther: 'Other',
84
92
  trendTitle: 'Usage trend',
85
93
  thProvider: 'Provider', thModel: 'Model', thRequests: 'Requests', thTokens: 'Tokens',
@@ -335,6 +343,7 @@ window.__ModuleLoader__.load({
335
343
  sessionProvider: (sessionId) => unwrap('session-provider', { sessionId }),
336
344
  stats: (opts) => unwrap('stats', opts || {}),
337
345
  config: () => unwrap('config', {}),
346
+ updateCheck: (force) => unwrap('update-check', { force: !!force }),
338
347
  }
339
348
  }
340
349
 
@@ -1111,6 +1120,77 @@ window.__ModuleLoader__.load({
1111
1120
  summaryOf({ providers: [p] }, tr))))))
1112
1121
  }
1113
1122
 
1123
+ // ---------------------------------------------------------------- 版本 / 检查更新
1124
+ function VersionBadge({ api, tr }) {
1125
+ const [info, setInfo] = useState(null)
1126
+ const [busy, setBusy] = useState(false)
1127
+ const [open, setOpen] = useState(false)
1128
+ const [copied, setCopied] = useState(false)
1129
+ useEffect(() => {
1130
+ let alive = true
1131
+ api.updateCheck(false).then((v) => { if (alive) setInfo(v) }).catch(() => {})
1132
+ return () => { alive = false }
1133
+ }, [])
1134
+ const check = async () => {
1135
+ setBusy(true)
1136
+ try {
1137
+ setInfo(await api.updateCheck(true))
1138
+ } catch (e) {
1139
+ setInfo({ current: '—', latest: null, error: String((e && e.message) || e), updateAvailable: false })
1140
+ } finally {
1141
+ setBusy(false)
1142
+ setOpen(true)
1143
+ }
1144
+ }
1145
+ const copy = () => {
1146
+ try {
1147
+ navigator.clipboard.writeText((info && info.command) || '').then(() => {
1148
+ setCopied(true)
1149
+ setTimeout(() => setCopied(false), 1500)
1150
+ })
1151
+ } catch {}
1152
+ }
1153
+ const stat = (label, value) => h('div', { className: 'dmu-mstat' },
1154
+ h('div', { className: 'k' }, label),
1155
+ h('div', { className: 'v' }, value))
1156
+ const cmdStyle = {
1157
+ flex: 1, fontSize: 11, fontFamily: 'var(--dsh-font-mono,monospace)',
1158
+ background: 'var(--dsw-alias-bg-base,#f2f4f7)', padding: '8px 10px',
1159
+ borderRadius: 8, wordBreak: 'break-all', whiteSpace: 'pre-wrap', margin: 0,
1160
+ }
1161
+ const dotColor = info && info.updateAvailable ? 'var(--dsw-alias-state-warn-primary,#f79009)' : 'var(--dsw-alias-label-tertiary,#98a2b3)'
1162
+ return h('span', { className: 'dmu-row', style: { gap: 8 } },
1163
+ info && info.updateAvailable ? h('span', { style: { width: 6, height: 6, borderRadius: 999, background: dotColor } }) : null,
1164
+ h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#98a2b3)' } },
1165
+ 'v' + ((info && info.current) || '—')),
1166
+ h(P.Button, { variant: 'ghost', size: 'sm', onClick: check, disabled: busy },
1167
+ busy ? tr('checkingUpdate') : tr('checkUpdate')),
1168
+ open && info && h(P.Modal, {
1169
+ open: true,
1170
+ onClose: () => setOpen(false),
1171
+ title: tr('checkUpdate'),
1172
+ closeLabel: tr('close'),
1173
+ footer: h('div', { className: 'dmu-row', style: { justifyContent: 'flex-end' } },
1174
+ h(P.Button, { variant: 'primary', size: 'sm', onClick: () => setOpen(false) }, tr('close'))),
1175
+ },
1176
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1177
+ h('div', { className: 'dmu-row', style: { gap: 28 } },
1178
+ stat(tr('currentVersion'), info.current ? 'v' + info.current : '—'),
1179
+ stat(tr('latestVersion'), info.latest ? 'v' + info.latest : '—')),
1180
+ info.error
1181
+ ? h('div', { className: 'dmu-warnline', style: { margin: 0 } }, tr('updateFailed', { msg: info.error }))
1182
+ : info.updateAvailable
1183
+ ? h('div', { className: 'dmu-warnline', style: { margin: 0 } }, tr('updateAvailableHint', { v: 'v' + info.latest }))
1184
+ : h('div', { className: 'dmu-mutedline', style: { margin: 0 } }, tr('upToDate')),
1185
+ h('div', null,
1186
+ h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#98a2b3)', marginBottom: 4 } }, tr('manualUpdateTitle')),
1187
+ h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary,#637182)', marginBottom: 6 } }, tr('manualUpdateNote')),
1188
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'flex-start' } },
1189
+ h('code', { style: cmdStyle }, info.command || ''),
1190
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: copy },
1191
+ copied ? tr('copied') : tr('copyCommand')))))))
1192
+ }
1193
+
1114
1194
  // ---------------------------------------------------------------- 入口 2:设置页
1115
1195
  function SettingsEntry(props) {
1116
1196
  const { api } = props
@@ -1122,7 +1202,9 @@ window.__ModuleLoader__.load({
1122
1202
  { value: 'log', label: tr('tabLog'), id: 'dmu-tab-log', panelId: 'dmu-panel-log' },
1123
1203
  ]
1124
1204
  return h('div', { className: 'dmu-settings' },
1125
- h('h2', null, tr('nav')),
1205
+ h('div', { className: 'dmu-row', style: { gap: 10, marginBottom: 10 } },
1206
+ h('h2', { style: { margin: 0 } }, tr('nav')),
1207
+ h('span', { style: { marginLeft: 'auto' } }, h(VersionBadge, { api, tr }))),
1126
1208
  h(P.SegmentedTabs, {
1127
1209
  items: tabs,
1128
1210
  value: tab,
package/lib/index.js CHANGED
@@ -5,14 +5,67 @@
5
5
  // (不走 connection.rpc.handle:其内部对 connection fiber 的 webServer 访问
6
6
  // 在 web 组合下会抛 without inject —— 参见 dsh-api-gateway 的同类做法)
7
7
  // - 凭据只在 Host 进程内存中出现;TTL 缓存 + 单飞 + 指数退避
8
- import { adapterFor, safeMsg } from './adapters.js'
8
+ import { adapterFor, fetchT, safeMsg } from './adapters.js'
9
9
  import { createLedger } from './ledger.js'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { readFile } from 'node:fs/promises'
13
+ import { readFileSync } from 'node:fs'
13
14
  import { homedir } from 'node:os'
14
15
 
15
- const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logos')
16
+ const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
17
+ const ASSETS_DIR = join(PKG_DIR, 'assets', 'logos')
18
+ const REGISTRY = 'https://registry.npmjs.org/'
19
+ const PKG_NAME = '@ponydev/dsh-model-usage'
20
+
21
+ /** 自身版本(读 package.json;失败返回 '0.0.0') */
22
+ let SELF_VERSION = '0.0.0'
23
+ try {
24
+ SELF_VERSION = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')).version || '0.0.0'
25
+ } catch {}
26
+
27
+ /** 检查 npm 最新版本(10 分钟缓存;force 跳过缓存) */
28
+ let updateCache = { at: 0, data: null }
29
+ async function checkUpdate(force) {
30
+ const now = Date.now()
31
+ if (!force && updateCache.data && now - updateCache.at < 10 * 60 * 1000) return updateCache.data
32
+ const url = REGISTRY + PKG_NAME.replace('/', '%2F') + '/latest'
33
+ let latest = null
34
+ let error = null
35
+ try {
36
+ const res = await fetchT(url, { headers: { accept: 'application/json' } }, 8000)
37
+ if (res.ok) {
38
+ const body = await res.json()
39
+ latest = typeof body.version === 'string' ? body.version : null
40
+ if (!latest) error = 'registry 未返回 version'
41
+ } else {
42
+ error = 'HTTP ' + res.status
43
+ }
44
+ } catch (e) {
45
+ error = safeMsg(e)
46
+ }
47
+ const data = {
48
+ current: SELF_VERSION,
49
+ latest,
50
+ updateAvailable: !!(latest && latest !== SELF_VERSION && cmpVersion(latest, SELF_VERSION) > 0),
51
+ registry: REGISTRY,
52
+ name: PKG_NAME,
53
+ command: 'dsh plugin --profile web add ' + PKG_NAME + (latest ? '@' + latest : '') + ' --registry=' + REGISTRY,
54
+ error,
55
+ checkedAt: now,
56
+ }
57
+ if (latest) updateCache = { at: now, data }
58
+ return data
59
+ }
60
+ function cmpVersion(a, b) {
61
+ const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0)
62
+ const pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0)
63
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
64
+ const x = pa[i] || 0, y = pb[i] || 0
65
+ if (x !== y) return x > y ? 1 : -1
66
+ }
67
+ return 0
68
+ }
16
69
 
17
70
  /** 用户自定义 logo 目录($DSH_HOME/model-usage/logos) */
18
71
  function userLogosDir() {
@@ -176,6 +229,8 @@ export function apply(ctx, rawConfig) {
176
229
  return ok(publicConfig())
177
230
  case 'stats':
178
231
  return ok(ledger.stats({ days: payload && payload.days }))
232
+ case 'update-check':
233
+ return ok(await checkUpdate(!!(payload && payload.force)))
179
234
  default:
180
235
  return fail('model-usage/unknown-endpoint', 'unknown endpoint: ' + String(endpoint))
181
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-model-usage",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "DSH Web 插件:实时显示模型供应商额度与用量(DeepSeek / GLM Coding Plan / OpenRouter / Kimi Coding Plan + 自定义适配器)",
5
5
  "keywords": [
6
6
  "dsh",