@feiyang666/dsh-usage-plugin 1.9.1 → 1.9.2

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
@@ -8,6 +8,21 @@
8
8
 
9
9
  ---
10
10
 
11
+ ## v1.9.2 (2026-08-20)
12
+
13
+ > 修复 issue #4:持久化路径随会话工作区漂移,导致历史用量数据"消失/统计为 0",且 UI 显示路径与实际落盘路径不一致。
14
+
15
+ ### 修复
16
+ - **固定专用数据目录**:持久化根不再跟随 `agent.session.cwd` / `sandboxPolicy.workspaceRoot` 漂移。解析顺序为:环境变量 `DSH_USAGE_DATA_DIR` > 系统应用数据目录 `%LOCALAPPDATA%\dsh-usage-plugin`(仅 Windows;macOS/Linux 下该分支不生效,因 `LOCALAPPDATA`/`APPDATA` 未定义)> 用户主目录下专用文件夹 `~/dsh-usage-data`(Windows 为 `%USERPROFILE%\dsh-usage-data`,macOS/Linux 为 `~/dsh-usage-data`)。该目录独立于桌面端安装目录、`~/.dsh` 主目录与任何会话工作区,删除工作区或卸载 APP 都不会丢失数据。
17
+ - **启动时合并去重**:首次初始化与会话激活时,自动把散落在用户主目录、`.dsh`、各工作区旧路径里的 `usage-records.json` 按 `time` 去重合并进固定根(复用现有 `normalizeRecord`),不再只读当前根的那一份。
18
+ - **UI 路径一致**:统计页顶部显示的「数据持久化」路径恒等于真实落盘路径;写入不再绑定工作区沙箱策略,固定目录可正常写入。
19
+ - **数据迁移**:切换根目录时对旧根记录做真正合并(非复制/分裂),重启后自动恢复全部历史。
20
+
21
+ ### 影响
22
+ - 旧版本落在 `%USERPROFILE%\dsh-usage`、`.dsh\dsh-usage`、各工作区 `dsh-usage` 下的历史记录,会在首次启动本版本时自动合并到固定目录,无需手动迁移。
23
+
24
+ ---
25
+
11
26
  ## v1.9.1 (2026-08-16)
12
27
 
13
28
  - **文档**: README 改为英文优先(`README.md` 英文 + 新增 `README.zh.md` 中文);补充「npm 包名已更换」醒目通知(旧包名 `@feiyang666/deepseekharnessdesktop` → 新包名 `@feiyang666/dsh-usage-plugin`),并移除发布教程等无关内容;修正 tarball 测试命令为新包名文件名。
package/README.md CHANGED
@@ -195,11 +195,19 @@ For manual installs (Method B), do it in reverse: remove the `usage-plugin` row
195
195
 
196
196
  ## Data & locations
197
197
 
198
- - Records: `<session workspace>/dsh-usage/usage-records.json`
199
- - Price config (edited & saved in the panel): `<session workspace>/dsh-usage/pricing.json`
200
- - Default export dir: `<session workspace>/dsh-usage/{csv,json,images}/`
198
+ > **Since v1.9.2**, records are stored in a **fixed, dedicated data directory** (fixes [#4](https://github.com/feiyang-dev/dsh-usage-plugin/issues/4)). The path no longer follows the session workspace / `~/.dsh` / desktop-app install dir, so your history never "disappears" (counted as 0) when the workspace changes, and the path shown in the UI equals the on-disk path.
199
+
200
+ - **Records**: `<data root>/dsh-usage/usage-records.json`
201
+ - Resolution order for the **data root**:
202
+ 1. env var `DSH_USAGE_DATA_DIR` (if set) — overrides everything;
203
+ 2. Windows: `%LOCALAPPDATA%\dsh-usage-plugin` (falls back to `%APPDATA%` if `LOCALAPPDATA` is unset);
204
+ 3. user home dir: `~/dsh-usage-data` (Windows `%USERPROFILE%\dsh-usage-data`, macOS/Linux `~/dsh-usage-data`).
205
+ - Default on Windows: `%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`
206
+ - **Legacy data auto-merge**: on first start, records previously scattered in `%USERPROFILE%\dsh-usage`, `~/.dsh/dsh-usage`, and each workspace's `dsh-usage` (or `.dsh-usage-records.json`) are merged into the fixed root, deduplicated by `time` — no manual migration needed.
207
+ - Price config (edited & saved in the panel): `<data root>/dsh-usage/pricing.json`
208
+ - Default export dir: `<data root>/dsh-usage/{csv,json,images}/`
201
209
  - Custom export dir: set in the panel's "Export target directory" or click "Choose directory…"
202
- - Startup diagnostics (if the plugin fails to activate): `dsh-usage-boot.log` in the session workspace
210
+ - Startup diagnostics (if the plugin fails to activate): `dsh-usage-boot.log` next to the data root
203
211
 
204
212
  ---
205
213
 
@@ -251,6 +259,7 @@ pnpm dsh web
251
259
 
252
260
  ## Acknowledgements
253
261
 
262
+ - **[@mumuer1024](https://github.com/mumuer1024)**: reported and diagnosed the persistence-path drift across workspaces (history "disappearing" / counted as 0) and proposed storing data in a fixed, dedicated directory ([#4](https://github.com/feiyang-dev/dsh-usage-plugin/issues/4)).
254
263
  - **[@liu3734](https://github.com/liu3734)**: reported and diagnosed the Windows-only path handling / spawn issues on macOS (POSIX) and proposed the cross-platform fix ([#1](https://github.com/feiyang-dev/dsh-usage-plugin/issues/1)).
255
264
 
256
265
  ## License
package/README.zh.md CHANGED
@@ -195,11 +195,19 @@ dsh plugin --profile web remove @feiyang666/dsh-usage-plugin
195
195
 
196
196
  ## 数据与位置
197
197
 
198
- - 数据文件:`<会话工作区>/dsh-usage/usage-records.json`
199
- - 价格配置(面板内编辑后保存):`<会话工作区>/dsh-usage/pricing.json`
200
- - 导出目录(默认):`<会话工作区>/dsh-usage/{csv,json,images}/`
198
+ > **自 v1.9.2 起**,记录存储在**固定专用数据目录**中(修复 [#4](https://github.com/feiyang-dev/dsh-usage-plugin/issues/4))。路径不再跟随会话工作区 / `~/.dsh` / 桌面端安装目录漂移,因此切换工作区时历史数据不会再"消失"(被统计为 0),且 UI 中显示的路径与真实落盘路径始终一致。
199
+
200
+ - 数据文件:`<数据根>/dsh-usage/usage-records.json`
201
+ - **数据根**的解析顺序:
202
+ 1. 环境变量 `DSH_USAGE_DATA_DIR`(若已设置)——优先级最高,覆盖其它;
203
+ 2. Windows:`%LOCALAPPDATA%\dsh-usage-plugin`(若 `LOCALAPPDATA` 未设置则回退到 `%APPDATA%`);
204
+ 3. 用户主目录:`~/dsh-usage-data`(Windows 为 `%USERPROFILE%\dsh-usage-data`,macOS/Linux 为 `~/dsh-usage-data`)。
205
+ - Windows 下默认位置:`%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`
206
+ - 旧数据自动合并:首次启动时,落在 `%USERPROFILE%\dsh-usage`、`~/.dsh/dsh-usage` 以及各工作区 `dsh-usage`(或 `.dsh-usage-records.json`)下的旧记录会按 `time` 去重合并进固定数据根,无需手动迁移。
207
+ - 价格配置(面板内编辑后保存):`<数据根>/dsh-usage/pricing.json`
208
+ - 导出目录(默认):`<数据根>/dsh-usage/{csv,json,images}/`
201
209
  - 自定义导出目录:在面板「导出目标目录」里填写或点「选择目录…」
202
- - 启动诊断日志(若插件激活失败):会话工作区下的 `dsh-usage-boot.log`
210
+ - 启动诊断日志(若插件激活失败):数据根旁的 `dsh-usage-boot.log`
203
211
 
204
212
  ---
205
213
 
@@ -251,6 +259,7 @@ pnpm dsh web
251
259
 
252
260
  ## 致谢
253
261
 
262
+ - **[@mumuer1024](https://github.com/mumuer1024)**:报告并定位了持久化路径随会话工作区漂移导致历史数据"消失/统计为 0"的问题,提出把数据写到固定专用目录的方案([#4](https://github.com/feiyang-dev/dsh-usage-plugin/issues/4))。
254
263
  - **[@liu3734](https://github.com/liu3734)**:报告并定位 macOS(POSIX)下路径处理与 spawn 的 Windows 专用问题,提出跨平台修复方案([#1](https://github.com/feiyang-dev/dsh-usage-plugin/issues/1))。
255
264
 
256
265
  ## 许可
package/cordis.patch.yml CHANGED
@@ -1,22 +1,23 @@
1
- # dsh-usage-plugin — bundle patch layer.
2
- #
3
- # This file is the package's `dsh.bundle.patch` layer. When the package is
4
- # installed into a DSH profile (via `dsh plugin --profile <name> add
5
- # @feiyang666/dsh-usage-plugin`, or manually), the loader applies this
6
- # patch over the profile's empty root entry list, mounting the plugin row.
7
- #
8
- # The `inject` list is REQUIRED: it tells the Cordis loader to wait until the
9
- # fs / webServer / subprocess / credentials / sandboxPolicy / agents services
10
- # are ready before activating the plugin. Without it the plugin's `apply` may
11
- # run before those services exist, the `/usage/api` route never registers, and
12
- # the client panel fails with `Unexpected end of JSON input`.
13
- - insert:
14
- - id: usage-plugin
15
- name: '@feiyang666/dsh-usage-plugin'
16
- inject:
17
- - fs
18
- - webServer
19
- - subprocess
20
- - credentials
21
- - sandboxPolicy
22
- - agents
1
+ # dsh-usage-plugin — bundle patch layer.
2
+ #
3
+ # This file is the package's `dsh.bundle.patch` layer. When the package is
4
+ # installed into a DSH profile (via `dsh plugin --profile <name> add
5
+ # @feiyang666/dsh-usage-plugin`, or manually), the loader applies this
6
+ # patch over the profile's empty root entry list, mounting the plugin row.
7
+ #
8
+ # The `inject` list is REQUIRED: it tells the Cordis loader to wait until the
9
+ # fs / webServer / subprocess / credentials / settings / sandboxPolicy / agents
10
+ # services are ready before activating the plugin. Without it the plugin's `apply`
11
+ # may run before those services exist, the `/usage/api` route never registers,
12
+ # and the client panel fails with `Unexpected end of JSON input`.
13
+ - insert:
14
+ - id: usage-plugin
15
+ name: '@feiyang666/dsh-usage-plugin'
16
+ inject:
17
+ - fs
18
+ - webServer
19
+ - subprocess
20
+ - credentials
21
+ - settings
22
+ - sandboxPolicy
23
+ - agents
package/lib/balance.js ADDED
@@ -0,0 +1,205 @@
1
+ const PROVIDERS = {
2
+ deepseek: {
3
+ id: 'deepseek',
4
+ name: 'DeepSeek',
5
+ credentialNames: ['DEEPSEEK_API_KEY'],
6
+ endpoint: 'https://api.deepseek.com/user/balance',
7
+ credentialHint: 'DEEPSEEK_API_KEY(推理 Key)',
8
+ queryMode: 'direct',
9
+ modelProviderAliases: ['deepseek']
10
+ },
11
+ siliconflow: {
12
+ id: 'siliconflow',
13
+ name: 'SiliconFlow',
14
+ credentialNames: [],
15
+ endpoint: 'https://api.siliconflow.cn/v1/user/info',
16
+ credentialHint: '模型设置中名为 siliconflow 的提供商所引用的 API Key',
17
+ queryMode: 'direct',
18
+ modelProviderAliases: ['siliconflow', 'silicon-flow'],
19
+ credentialHelpUrl: 'https://cloud.siliconflow.cn/account/ak'
20
+ },
21
+ digitalocean: {
22
+ id: 'digitalocean',
23
+ name: 'DigitalOcean',
24
+ credentialNames: ['DIGITALOCEAN_TOKEN', 'DIGITALOCEAN_ACCESS_TOKEN'],
25
+ endpoint: 'https://api.digitalocean.com/v2/customers/my/balance',
26
+ credentialHint: 'DIGITALOCEAN_TOKEN(账户级 Personal Access Token,不是 DO AI 推理 Key)',
27
+ queryMode: 'account',
28
+ modelProviderAliases: ['digitalocean', 'digital-ocean'],
29
+ credentialHelpUrl: 'https://cloud.digitalocean.com/account/api/tokens'
30
+ },
31
+ 'amd-gpu-cloud': {
32
+ id: 'amd-gpu-cloud',
33
+ name: 'AMD GPU Cloud',
34
+ credentialNames: [],
35
+ endpoint: '',
36
+ credentialHint: 'AMD GPU Cloud 当前未公开余额查询端点',
37
+ queryMode: 'unsupported',
38
+ modelProviderAliases: ['amd', 'amd-gpu-cloud'],
39
+ credentialHelpUrl: 'https://www.amd.com/en/developer/resources/cloud-access/amd-developer-cloud.html'
40
+ }
41
+ }
42
+
43
+ export const BALANCE_PROVIDERS = Object.freeze(PROVIDERS)
44
+
45
+ export function providerList() {
46
+ return Object.values(PROVIDERS).map((p) => ({
47
+ id: p.id,
48
+ name: p.name,
49
+ credentialHint: p.credentialHint,
50
+ queryMode: p.queryMode,
51
+ credentialHelpUrl: p.credentialHelpUrl || ''
52
+ }))
53
+ }
54
+
55
+ export function getBalanceProvider(id) {
56
+ return PROVIDERS[String(id || 'deepseek').toLowerCase()] || null
57
+ }
58
+
59
+ function normalizedRoute(value) {
60
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '')
61
+ }
62
+
63
+ export function matchesModelProvider(providerId, routeId, displayName) {
64
+ const provider = getBalanceProvider(providerId)
65
+ if (!provider) return false
66
+ const candidates = [routeId, displayName].map(normalizedRoute).filter(Boolean)
67
+ const aliases = (provider.modelProviderAliases || []).map(normalizedRoute)
68
+ return candidates.some((candidate) => aliases.includes(candidate))
69
+ }
70
+
71
+ export function resolveBalanceEndpoint(providerId, modelBaseURL) {
72
+ const provider = getBalanceProvider(providerId)
73
+ if (!provider) return ''
74
+ if (provider.id !== 'siliconflow' || !modelBaseURL) return provider.endpoint
75
+ try {
76
+ const url = new URL(String(modelBaseURL))
77
+ const allowed = url.protocol === 'https:'
78
+ && !url.username
79
+ && !url.password
80
+ && !url.port
81
+ && (url.hostname === 'api.siliconflow.cn' || url.hostname === 'api.siliconflow.com')
82
+ if (allowed) return url.origin + '/v1/user/info'
83
+ } catch (e) {}
84
+ return provider.endpoint
85
+ }
86
+
87
+ function fail(message) {
88
+ return { ok: false, error: message }
89
+ }
90
+
91
+ function json(text) {
92
+ try { return JSON.parse(text) } catch (e) { return null }
93
+ }
94
+
95
+ function value(source, names) {
96
+ if (!source || typeof source !== 'object') return undefined
97
+ for (const name of names) {
98
+ if (source[name] !== undefined && source[name] !== null && source[name] !== '') return source[name]
99
+ }
100
+ return undefined
101
+ }
102
+
103
+ function money(v, fallback = '0') {
104
+ return String(v === undefined || v === null || v === '' ? fallback : v)
105
+ }
106
+
107
+ function result(provider, fields) {
108
+ return {
109
+ ok: true,
110
+ provider: provider.id,
111
+ providerName: provider.name,
112
+ queriedAt: Date.now(),
113
+ ...fields
114
+ }
115
+ }
116
+
117
+ function parseDeepSeek(provider, data) {
118
+ const infos = data && Array.isArray(data.balance_infos) ? data.balance_infos : []
119
+ if (!infos.length) return fail('DeepSeek 响应中没有余额信息')
120
+ const b = infos[0] || {}
121
+ const legacyInfo = {
122
+ currency: String(b.currency || 'CNY'),
123
+ totalBalance: money(b.total_balance),
124
+ grantedBalance: money(b.granted_balance),
125
+ toppedUpBalance: money(b.topped_up_balance)
126
+ }
127
+ return result(provider, {
128
+ currency: legacyInfo.currency,
129
+ isAvailable: data.is_available == null ? null : data.is_available === true,
130
+ totalBalance: legacyInfo.totalBalance,
131
+ infos: [legacyInfo],
132
+ details: [
133
+ { label: '充值余额', value: legacyInfo.toppedUpBalance, hint: '实际充值' },
134
+ { label: '赠送余额', value: legacyInfo.grantedBalance, hint: '平台赠送' }
135
+ ],
136
+ sourceNote: '数据来自 DeepSeek 官方 /user/balance 接口。'
137
+ })
138
+ }
139
+
140
+ function parseSiliconFlow(provider, data) {
141
+ if (data && data.code !== undefined && Number(data.code) !== 20000) {
142
+ return fail('SiliconFlow 接口返回错误:' + String(data.message || data.code))
143
+ }
144
+ const d = data && data.data && typeof data.data === 'object' ? data.data : data
145
+ const total = value(d, ['totalBalance', 'total_balance'])
146
+ const granted = value(d, ['balance', 'grantedBalance', 'granted_balance'])
147
+ const charged = value(d, ['chargeBalance', 'charge_balance', 'toppedUpBalance', 'topped_up_balance'])
148
+ if (total === undefined && granted === undefined && charged === undefined) {
149
+ return fail('SiliconFlow 响应中没有可识别的余额字段')
150
+ }
151
+ const computed = total !== undefined ? total : (Number(granted || 0) + Number(charged || 0))
152
+ const numeric = [computed, granted || 0, charged || 0].map((entry) => Number(entry))
153
+ return result(provider, {
154
+ currency: String(value(d, ['currency']) || 'CNY'),
155
+ isAvailable: null,
156
+ totalBalance: money(computed),
157
+ zeroBalance: numeric.every((entry) => Number.isFinite(entry) && entry === 0),
158
+ details: [
159
+ { label: '充值余额', value: money(charged), hint: 'chargeBalance · 用户充值余额' },
160
+ { label: '赠送/旧免费余额', value: money(granted), hint: 'balance · 旧版赠送余额字段' }
161
+ ],
162
+ fieldDefinitions: [
163
+ { name: 'totalBalance', meaning: '公开 API 返回的总余额,通常为 balance 与 chargeBalance 的合计' },
164
+ { name: 'chargeBalance', meaning: '用户充值形成的余额' },
165
+ { name: 'balance', meaning: '赠送或旧版免费余额字段' }
166
+ ],
167
+ sourceNote: '数据来自 SiliconFlow 官方 /v1/user/info 接口;该接口未公开代金券或历史用量字段,数值可能与控制台可用总额不同。'
168
+ })
169
+ }
170
+
171
+ function parseDigitalOcean(provider, data) {
172
+ const account = value(data, ['account_balance'])
173
+ if (account === undefined) return fail('DigitalOcean 响应中没有 account_balance 字段')
174
+ const accountNumber = Number(account)
175
+ if (!Number.isFinite(accountNumber)) return fail('DigitalOcean account_balance 不是有效数字')
176
+ const credit = accountNumber < 0 ? Math.abs(accountNumber) : 0
177
+ const due = accountNumber > 0 ? accountNumber : 0
178
+ const balanceKind = credit > 0 ? 'credit' : (due > 0 ? 'due' : 'settled')
179
+ return result(provider, {
180
+ currency: 'USD',
181
+ isAvailable: null,
182
+ totalBalance: money(credit > 0 ? credit : due),
183
+ balanceKind,
184
+ balanceLabel: balanceKind === 'credit' ? '可用信用余额' : (balanceKind === 'due' ? '待结算账户余额' : '账户余额'),
185
+ rawAccountBalance: money(account),
186
+ monthToDateUsage: money(value(data, ['month_to_date_usage'])),
187
+ monthToDateBalance: money(value(data, ['month_to_date_balance'])),
188
+ details: [
189
+ { label: '本月至今使用', value: money(value(data, ['month_to_date_usage'])), hint: 'month_to_date_usage · 当前账期使用金额' }
190
+ ],
191
+ generatedAt: String(value(data, ['generated_at']) || ''),
192
+ sourceNote: '数据来自 DigitalOcean 账户级 Billing API;负数账户余额按可用信用额取绝对值展示,DO AI 推理 Key 不能用于此查询。'
193
+ })
194
+ }
195
+
196
+ export function parseBalanceResponse(providerId, text) {
197
+ const provider = getBalanceProvider(providerId)
198
+ if (!provider) return fail('不支持的余额服务商:' + String(providerId || ''))
199
+ const data = json(text)
200
+ if (data === null) return fail('无法解析 ' + provider.name + ' 余额响应')
201
+ if (provider.id === 'deepseek') return parseDeepSeek(provider, data)
202
+ if (provider.id === 'siliconflow') return parseSiliconFlow(provider, data)
203
+ if (provider.id === 'digitalocean') return parseDigitalOcean(provider, data)
204
+ return fail(provider.name + ' 当前没有可用的公开余额查询接口')
205
+ }