@feiyang666/dsh-usage-plugin 1.9.3 → 1.10.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 +35 -0
- package/README.md +44 -4
- package/README.zh.md +45 -5
- package/lib/client.js +15 -7
- package/lib/index.js +86 -85
- package/package.json +1 -1
- package/scripts/release.mjs +94 -0
- package/scripts/upload-asset.mjs +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,14 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## 开源贡献基础:合并 PR #6(@Martin-soaring-dev,2026-08-20)
|
|
12
|
+
|
|
13
|
+
> 本期(以及后续 v1.9.2 / v1.9.3)建立在社区贡献分支 **`Martin-soaring-dev/prepare_for_contribution`** 之上。该分支由用户上传、经你合并(GitHub Pull Request #6,合并 commit `12a52be`),是插件转向对外开源贡献的形态基础。
|
|
14
|
+
|
|
15
|
+
### 该分支带来的主要改动
|
|
16
|
+
- **多服务商余额查询**(`lib/balance.js` 新增 +205 行,新增余额适配器 `BalanceProviderAdapter`):将原先仅 DeepSeek 的 `/user/balance` 查询扩展为可对接多个服务商的余额查询框架,新增 DeepSeek / SiliconFlow / DigitalOcean / AMD GPU Cloud 等 Provider 适配器。
|
|
17
|
+
- **按服务商展示余额与价格**(`lib/client.js` +258 行):客户端新增「剩余余额查询」Tab 的多服务商 UI,可分别展示各服务商的「总余额 / 充值 / 赠送」并切换查看。
|
|
18
|
+
- **Provider 化计费**(`lib/index.js` +399 行重构):注入 `settings` 服务用于服务商查找,调用计费改为 provider-aware,支持按不同服务商分别统计与计价。
|
|
19
|
+
- **插件装配与测试**:新增余额适配器单测(`test/balance.test.js`,+130 行)、`scripts/wire.js` 手动装配兜底、`cordis.patch.yml` 插件注册条目。
|
|
20
|
+
|
|
21
|
+
### 致谢
|
|
22
|
+
- **[@Martin-soaring-dev](https://github.com/Martin-soaring-dev)**:提交并筹备了上述开源贡献分支(PR #6),是后续公开发布版本的功能与结构基础。
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## v1.10.0 (2026-08-23)
|
|
27
|
+
|
|
28
|
+
- **新增 DeepSeek 官方模型 `deepseek-v4-flash-vision-exp`(DeepSeek-V4-Flash-Vision-Exp)**:官方价格表、概览「按模型」表、按 API 服务商 × 模型明细、用量日历、缓存命中列表与 PNG 报告均自动按该模型计价;「价格表」页新增该模型一行,`modelKey` 识别优先匹配 `vision`(避免被误归入 `deepseek-v4-flash`)。
|
|
29
|
+
- **价格**(元 / 百万 tokens,与官方最新公布一致):空闲时段 缓存命中 0.05 / 输入未命中 1.5 / 输出 4.5;高峰时段 缓存命中 0.10 / 输入未命中 3.0 / 输出 9.0(与 `deepseek-v4-flash` 同价)。
|
|
30
|
+
- **核对确认**:`deepseek-v4-flash` / `deepseek-v4-pro` 的峰谷价与官方最新公布完全一致,无价格变动;发送给该模型的图片按其尺寸换算成 token,与文本 token 一并计费。
|
|
31
|
+
- **周末统一按空闲(低谷)价计费(2026-08-23 00:00 起)**:新规则生效后,周末(周六、周日)全天不再区分峰谷时段,统一按空闲价计费;工作日仍按北京时间 9:00–12:00、14:00–18:00 高峰分段。新规则生效前的调用仍按原规则结算。插件的高峰时段判定、面板「当前时段」徽标、概览 / 日历 / 缓存列表的高峰与空闲分列统计、PNG 报告均按新规则自动判断。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## v1.9.4 (2026-08-20)
|
|
36
|
+
|
|
37
|
+
- **持久化不再落到工作目录(修复「数据又进了工作区」的反馈)**:之前插件的落盘走的是模型侧沙箱化的 `fs` 服务,在 `workspace-write` 模式下会被限制只能写工作区;当系统/用户目录因沙箱不可写时,数据会兜底写进 `<工作区>/dsh-usage`,于是切换工作区又出现「数据没了」的现象(即 issue #4 的本质)。
|
|
38
|
+
- **改动**:持久化改为由插件**宿主进程自身的 `node:fs`** 直接完成,完全绕过 `workspace-write` 沙箱约束。数据根解析优先级变为 `DSH_USAGE_DATA_DIR` > `%LOCALAPPDATA%\dsh-usage-plugin`(或 `%APPDATA%`)> `~/dsh-usage-data`,**工作区降级为绝对最后的兜底**,正常桌面环境下数据稳定落在 AppData / 用户主目录,与当前工作区无关,切换工作区不再丢失历史。导出、价格表、`.keep` 等写操作同样改为宿主进程 `node:fs`,不再依赖受沙箱子进程。
|
|
39
|
+
- **数据迁移**:原有 4558 条历史记录位于 `<工作区>/dsh-usage/usage-records.json`,首次以新版本启动时会被自动去重合并进新的固定数据根(`%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`),无需手动处理。
|
|
40
|
+
|
|
11
41
|
## v1.9.3 (2026-08-20)
|
|
12
42
|
|
|
13
43
|
- **概览支持日期筛选**:概览 Tab 顶部新增筛选栏,可按「今天 / 近 7 天 / 近 30 天 / 全部」快速切换,或自定义起止日期区间;所有聚合统计(按模型表、服务商 × 模型明细、总计)均按所选范围实时计算,并展示当前范围的记录数与总消耗。
|
|
44
|
+
- **文档**:新增「如何更新插件」章节——桌面端 / 命令行 / 手动三种更新方式、版本锁定、已装版本校验,并提示**勿手改 `node_modules` 下文件**(每次更新会被 npm 覆盖)。
|
|
14
45
|
|
|
15
46
|
## v1.9.2 (2026-08-20)
|
|
16
47
|
|
|
17
48
|
> 修复 issue #4:持久化路径随会话工作区漂移,导致历史用量数据"消失/统计为 0",且 UI 显示路径与实际落盘路径不一致。
|
|
18
49
|
|
|
50
|
+
### 背景(症状与根因)
|
|
51
|
+
- **症状**:切换会话工作区、或用不同工作区重开桌面端后,统计页的「历史用量 / 总消耗」变成 0、仿佛数据丢失;同时统计页顶部显示的「数据持久化」路径,与磁盘上真实写入的文件路径对不上。
|
|
52
|
+
- **根因**:旧版持久化根按 `agent.session.cwd` / `sandboxPolicy.workspaceRoot` 推导——无会话启动时常解析到用户主目录,插件激活后又切到当前工作区,且不同根之间不会互相合并,于是历史被「切走」到另一个目录而读不到;写盘还会受工作区沙箱策略约束。
|
|
53
|
+
|
|
19
54
|
### 修复
|
|
20
55
|
- **固定专用数据目录**:持久化根不再跟随 `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 都不会丢失数据。
|
|
21
56
|
- **启动时合并去重**:首次初始化与会话激活时,自动把散落在用户主目录、`.dsh`、各工作区旧路径里的 `usage-records.json` 按 `time` 去重合并进固定根(复用现有 `normalizeRecord`),不再只读当前根的那一份。
|
package/README.md
CHANGED
|
@@ -32,10 +32,10 @@ dsh-usage-plugin is a **usage & cost tracker** plugin in the DeepSeek Harness ec
|
|
|
32
32
|
|
|
33
33
|
> Supports **Windows / macOS / Linux**: paths are handled per platform (`node:path`), and the folder picker / "reveal in file manager" use each OS's native mechanism (macOS: `osascript` / `open`; Linux: `zenity` / `xdg-open`). Balance query and export do not depend on Windows-only commands.
|
|
34
34
|
|
|
35
|
-
- **Usage & Cost**: records each model call's token usage and cache hits (input miss / cache hit / cache write / output / reasoning / finish reason), and computes cost using DeepSeek's peak/valley or base pricing (peak hours are automatically priced by Beijing time 09:00–12:00 and 14:00–18:00). Model names come from the actual request parameters, so non-DeepSeek models are shown truthfully instead of "unknown model"; models without an official price are counted as 0. The overview shows a by-model table plus a by-API-provider × model drill-down (each provider grouped with every model's calls and peak/off-peak cost split) and a grand total row. The overview also supports **date filtering** (Today / Last 7 days / Last 30 days / All, plus a custom start–end range), so the aggregate stats can be scoped to any single day or date range.
|
|
35
|
+
- **Usage & Cost**: records each model call's token usage and cache hits (input miss / cache hit / cache write / output / reasoning / finish reason), and computes cost using DeepSeek's peak/valley or base pricing (peak hours on weekdays are automatically priced by Beijing time 09:00–12:00 and 14:00–18:00; since 2026-08-23 weekends are billed entirely at the off-peak rate). Model names come from the actual request parameters, so non-DeepSeek models are shown truthfully instead of "unknown model"; models without an official price are counted as 0. The overview shows a by-model table plus a by-API-provider × model drill-down (each provider grouped with every model's calls and peak/off-peak cost split) and a grand total row. The overview also supports **date filtering** (Today / Last 7 days / Last 30 days / All, plus a custom start–end range), so the aggregate stats can be scoped to any single day or date range.
|
|
36
36
|
- **Usage Calendar**: a monthly daily-usage heatmap (colored by cost or call count), hover for details including the peak/off-peak cost split, click a day for its call list and peak/off-peak totals, plus a per-day statistics table with peak cost / off-peak cost / total columns and monthly rollups.
|
|
37
37
|
- **Cache Hit List**: newest-first, fully scrollable, with quick filters (Today / 7 days / 30 days / All) and custom date ranges; the summary line and footer total split peak vs off-peak consumption with a grand cost total. The list is paginated (100 rows per page), so it stays smooth even with large data volumes.
|
|
38
|
-
- **Price Table**: the official DeepSeek API price table — base and peak/valley unit prices shown side by side (peak vs off-peak), editable in-panel and persisted to `pricing.json`, with a reset-to-default option.
|
|
38
|
+
- **Price Table**: the official DeepSeek API price table (covering `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` / `deepseek-v4-pro`) — base and peak/valley unit prices shown side by side (peak vs off-peak), editable in-panel and persisted to `pricing.json`, with a reset-to-default option.
|
|
39
39
|
- **Balance Query**: queries your DeepSeek account balance using the configured `DEEPSEEK_API_KEY`.
|
|
40
40
|
- **Export**: CSV / JSON / **PNG long image** (newest-first, up to the latest 2000 records, warns if exceeded; the PNG report includes peak/off-peak cost columns), to any directory (native picker), auto-opens the folder after export.
|
|
41
41
|
- **Import**: merge-imports JSON / CSV files, deduplicated by time.
|
|
@@ -193,15 +193,55 @@ For manual installs (Method B), do it in reverse: remove the `usage-plugin` row
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## How to update
|
|
197
|
+
|
|
198
|
+
Releasing happens on npm, so updating just means pulling the latest published package. Your usage history is **safe** — since v1.9.2 it lives in a fixed dedicated directory (not in any profile / workspace), so an update never wipes it.
|
|
199
|
+
|
|
200
|
+
### Desktop app
|
|
201
|
+
Open **"Install Plugins"** → find **Usage & Cost Tracker** → click **Update** (or **Re-install**) → **"Restart Service Now"**. If there is no Update button, just remove then re-add it.
|
|
202
|
+
|
|
203
|
+
### Command line (Method A)
|
|
204
|
+
Re-running `add` is idempotent and pulls the newest version:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
dsh plugin --profile web add @feiyang666/dsh-usage-plugin
|
|
208
|
+
dsh web # restart
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Pin a specific version:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
dsh plugin --profile web add @feiyang666/dsh-usage-plugin@1.9.3
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Manual install (Method B)
|
|
218
|
+
In the profile dir:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
cd ~/.dsh/profiles/web
|
|
222
|
+
pnpm update @feiyang666/dsh-usage-plugin # or: npm update @feiyang666/dsh-usage-plugin
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Verify the installed version
|
|
226
|
+
```bash
|
|
227
|
+
npm ls @feiyang666/dsh-usage-plugin --prefix ~/.dsh/profiles/web
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
> ⚠️ **Do not hand-edit files under `~/.dsh/profiles/web/node_modules/@feiyang666/dsh-usage-plugin/`** (e.g. `lib/index.js` / `lib/client.js`). Every update re-extracts the package from npm and overwrites those files, so local edits are silently lost. To change behavior, fork the repo and publish your own version, or contribute upstream.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
196
234
|
## Data & locations
|
|
197
235
|
|
|
198
236
|
> **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
237
|
|
|
200
238
|
- **Records**: `<data root>/dsh-usage/usage-records.json`
|
|
201
|
-
- Resolution order for the **data root
|
|
239
|
+
- Resolution order for the **data root** (data always lands in the **first writable** dir of this list, **never in the workspace** unless all of the below are unwritable):
|
|
202
240
|
1. env var `DSH_USAGE_DATA_DIR` (if set) — overrides everything;
|
|
203
241
|
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`)
|
|
242
|
+
3. user home dir: `~/dsh-usage-data` (Windows `%USERPROFILE%\dsh-usage-data`, macOS/Linux `~/dsh-usage-data`);
|
|
243
|
+
4. **fallback (rare)**: current workspace `<workspace>/dsh-usage` — only used when all system/user dirs above are unwritable, and the panel will show a "persistence disabled" warning.
|
|
244
|
+
- **Writes bypass the model sandbox**: persistence is done by the host plugin process's own filesystem, not subject to the `workspace-write` sandbox, so the fixed directory is always writable and data is not lost when switching workspaces.
|
|
205
245
|
- Default on Windows: `%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`
|
|
206
246
|
- **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
247
|
- Price config (edited & saved in the panel): `<data root>/dsh-usage/pricing.json`
|
package/README.zh.md
CHANGED
|
@@ -32,14 +32,14 @@ dsh-usage-plugin 是 DeepSeek Harness 生态的**用量与消耗统计插件**
|
|
|
32
32
|
|
|
33
33
|
> 支持 **Windows / macOS / Linux**:路径按当前平台处理(`node:path`),目录选择与「打开所在目录」均调用系统原生方式(macOS 用 `osascript` / `open`,Linux 用 `zenity` / `xdg-open`),余额查询与导出不依赖 Windows 专用命令。
|
|
34
34
|
|
|
35
|
-
- **用量与消耗**:记录每次模型调用的 token 用量与缓存命中(输入·未命中 / 缓存命中 / 缓存写入 / 输出 / 推理 / 结束原因),按 DeepSeek
|
|
35
|
+
- **用量与消耗**:记录每次模型调用的 token 用量与缓存命中(输入·未命中 / 缓存命中 / 缓存写入 / 输出 / 推理 / 结束原因),按 DeepSeek 峰谷/基础价格计算消耗(工作日高峰时段按北京时间 9:00–12:00、14:00–18:00 计价;自 2026-08-23 起周末全天按空闲价计费)。模型名以请求参数为准如实显示(非 DeepSeek 模型不再显示为「未知模型」,无官方价格的模型消耗按 0 统计)。概览含「按模型」表与「按 API 服务商 × 模型」明细表(每个服务商一组,组内列出各模型的调用与高峰/空闲分列消耗),底部有总费用合计。概览还支持**日期筛选**(今天 / 近 7 天 / 近 30 天 / 全部,外加自定义起止区间),可将聚合统计限定到任意一天或日期范围。
|
|
36
36
|
- **用量日历**:按月查看每日用量热力图(按消耗或调用数着色),悬停查看详情(含高峰 / 空闲消耗拆分)、点击某天查看当日调用明细与高峰/空闲消耗统计,附本月每日统计表(高峰消耗 / 空闲消耗 / 总消耗分列)与月度汇总。
|
|
37
37
|
- **缓存命中列表**:最新记录排在最前,支持 今天 / 近7天 / 近30天 / 全部 快捷筛选与自定义起止日期区间,汇总行与表尾合计区分高峰消耗 / 空闲消耗 / 总费用合计;列表分页渲染(每页 100 条),记录量大也不卡顿。
|
|
38
|
-
- **价格表**:**DeepSeek 官方 API
|
|
38
|
+
- **价格表**:**DeepSeek 官方 API 价格表**(覆盖官方模型 `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` / `deepseek-v4-pro`),展示基础价与峰谷价(高峰/空闲)单价表,高峰价与空闲价分列展示,支持在面板内直接编辑价格并持久化(数据目录 `pricing.json`),也可一键恢复默认。
|
|
39
39
|
- **剩余余额查询**:用当前配置的 `DEEPSEEK_API_KEY` 查询 DeepSeek 账户余额。
|
|
40
40
|
- **导出**:CSV / JSON / **PNG 长图**(按最新在前展示,最多含最近 2000 条,超出会提示;PNG 报告含高峰 / 空闲消耗分列统计),可导出到任意目录(原生目录选择器),导出后自动打开所在目录。
|
|
41
41
|
- **导入**:选择文件(JSON / CSV)合并导入,按时间去重。
|
|
42
|
-
-
|
|
42
|
+
- **持久化**:记录实时落盘到**固定专用数据目录**(如 `%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`,详见下文「数据目录」),与当前工作区无关,重启自动恢复(上限 100000 条,尽量多存)。
|
|
43
43
|
- **界面适配**:面板字号跟随应用「显示大小」设置自动缩放(em 相对字号),面板宽度以视口封顶、宽表格在容器内横向滑动(max-content + overflow-x),任何窗口大小下所有列与合计都完整可见,不会裁掉右侧内容。
|
|
44
44
|
|
|
45
45
|
---
|
|
@@ -193,15 +193,55 @@ dsh plugin --profile web remove @feiyang666/dsh-usage-plugin
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## 如何更新插件
|
|
197
|
+
|
|
198
|
+
发布在 npm 上,因此更新本质就是把已发布的包拉到最新——你的用量历史**不会丢**(自 v1.9.2 起数据存放在固定的专用目录,不位于任何 profile / 工作区,更新不会清掉它)。
|
|
199
|
+
|
|
200
|
+
### 桌面端
|
|
201
|
+
打开 **「安装插件」** → 找到 **Usage & Cost Tracker** → 点 **更新**(或 **重新安装**)→ **「立即重启服务」**。若没有更新按钮,先卸载再重新安装即可。
|
|
202
|
+
|
|
203
|
+
### 命令行(方式 A)
|
|
204
|
+
重新执行 `add` 是幂等的,会拉取最新版本:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
dsh plugin --profile web add @feiyang666/dsh-usage-plugin
|
|
208
|
+
dsh web # 重启
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
锁定某个版本:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
dsh plugin --profile web add @feiyang666/dsh-usage-plugin@1.9.3
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 手动安装(方式 B)
|
|
218
|
+
在 profile 目录下:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
cd ~/.dsh/profiles/web
|
|
222
|
+
pnpm update @feiyang666/dsh-usage-plugin # 或:npm update @feiyang666/dsh-usage-plugin
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 查看已装版本
|
|
226
|
+
```bash
|
|
227
|
+
npm ls @feiyang666/dsh-usage-plugin --prefix ~/.dsh/profiles/web
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
> ⚠️ **不要手动修改 `~/.dsh/profiles/web/node_modules/@feiyang666/dsh-usage-plugin/` 下的文件**(如 `lib/index.js` / `lib/client.js`)。每次更新都会从 npm 重新解包并覆盖这些文件,本地改动会被静默丢弃。如需改行为,请 fork 仓库自行发布版本或向上游提贡献。
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
196
234
|
## 数据与位置
|
|
197
235
|
|
|
198
236
|
> **自 v1.9.2 起**,记录存储在**固定专用数据目录**中(修复 [#4](https://github.com/feiyang-dev/dsh-usage-plugin/issues/4))。路径不再跟随会话工作区 / `~/.dsh` / 桌面端安装目录漂移,因此切换工作区时历史数据不会再"消失"(被统计为 0),且 UI 中显示的路径与真实落盘路径始终一致。
|
|
199
237
|
|
|
200
238
|
- 数据文件:`<数据根>/dsh-usage/usage-records.json`
|
|
201
|
-
-
|
|
239
|
+
- **数据根**的解析顺序(数据始终落在该顺序里**第一个可写**的目录,**不会放进工作区**,除非以下全部不可写):
|
|
202
240
|
1. 环境变量 `DSH_USAGE_DATA_DIR`(若已设置)——优先级最高,覆盖其它;
|
|
203
241
|
2. Windows:`%LOCALAPPDATA%\dsh-usage-plugin`(若 `LOCALAPPDATA` 未设置则回退到 `%APPDATA%`);
|
|
204
|
-
3. 用户主目录:`~/dsh-usage-data`(Windows 为 `%USERPROFILE%\dsh-usage-data`,macOS/Linux 为 `~/dsh-usage-data
|
|
242
|
+
3. 用户主目录:`~/dsh-usage-data`(Windows 为 `%USERPROFILE%\dsh-usage-data`,macOS/Linux 为 `~/dsh-usage-data`);
|
|
243
|
+
4. **兜底(极少见)**:当前工作区 `<工作区>/dsh-usage` —— 仅当上述系统/用户目录都不可写时才使用,并会在面板顶部提示「持久化未启用」。
|
|
244
|
+
- **写入不经过模型沙箱**:持久化由插件宿主进程自身的文件系统直接完成,不受 `workspace-write` 沙箱约束,因此固定目录一定能正常写入,数据也不因工作区切换而丢失。
|
|
205
245
|
- Windows 下默认位置:`%LOCALAPPDATA%\dsh-usage-plugin\dsh-usage\usage-records.json`
|
|
206
246
|
- 旧数据自动合并:首次启动时,落在 `%USERPROFILE%\dsh-usage`、`~/.dsh/dsh-usage` 以及各工作区 `dsh-usage`(或 `.dsh-usage-records.json`)下的旧记录会按 `time` 去重合并进固定数据根,无需手动迁移。
|
|
207
247
|
- 价格配置(面板内编辑后保存):`<数据根>/dsh-usage/pricing.json`
|
package/lib/client.js
CHANGED
|
@@ -87,7 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
var fmtTime = function (ts) { var d = new Date(ts + 8 * 3600 * 1000); return pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate()) + " " + pad2(d.getUTCHours()) + ":" + pad2(d.getUTCMinutes()) + ":" + pad2(d.getUTCSeconds()); };
|
|
88
88
|
var fmtMoney = function (n) { if (!n) return "¥0.0000"; if (n < 0.0001) return "¥" + n.toExponential(2); if (n < 1) return "¥" + n.toFixed(4); return "¥" + n.toFixed(2); };
|
|
89
89
|
var fmtPrice = function (n) { if (n == null || isNaN(n)) return "—"; return String(parseFloat(Number(n).toFixed(4))); };
|
|
90
|
-
var modelLabel = function (mk) { if (mk === "deepseek-v4-flash") return "deepseek-v4-flash"; if (mk === "deepseek-v4-pro") return "deepseek-v4-pro"; return "未知模型"; };
|
|
90
|
+
var modelLabel = function (mk) { if (mk === "deepseek-v4-flash") return "deepseek-v4-flash"; if (mk === "deepseek-v4-flash-vision-exp") return "deepseek-v4-flash-vision-exp"; if (mk === "deepseek-v4-pro") return "deepseek-v4-pro"; return "未知模型"; };
|
|
91
91
|
// 模型显示名:以请求参数里的真实模型名为准(非 DeepSeek 模型也如实显示),
|
|
92
92
|
// 空模型名时退回已知档位名 / provider / 未知。
|
|
93
93
|
var modelName = function (r) {
|
|
@@ -118,7 +118,15 @@ window.__ModuleLoader__.load({
|
|
|
118
118
|
};
|
|
119
119
|
var finishLabel = function (f) { if (f === "stop") return "完成"; if (f === "tool-calls") return "工具调用"; if (f === "max-tokens") return "超长"; if (f === "error") return "错误"; return f || "—"; };
|
|
120
120
|
var pct = function (a, b) { return b > 0 ? (a / b * 100).toFixed(1) + "%" : "—"; };
|
|
121
|
-
|
|
121
|
+
// 周末统一按空闲价规则生效时间:北京时间 2026-08-23 00:00(周日)。此前(含周末)仍按原峰谷分段。
|
|
122
|
+
var WEEKEND_FLAT_AT = Date.parse("2026-08-23T00:00:00+08:00");
|
|
123
|
+
var isPeakNow = function (ts) {
|
|
124
|
+
var d = new Date(ts + 8 * 3600 * 1000);
|
|
125
|
+
var wd = d.getUTCDay(); // 0=周日 1=周一 … 6=周六
|
|
126
|
+
if (ts >= WEEKEND_FLAT_AT && (wd === 0 || wd === 6)) return false;
|
|
127
|
+
var t = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
128
|
+
return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60);
|
|
129
|
+
};
|
|
122
130
|
// 计费档位选择:auto 用 autoCost(按生效日期自动切换),base 用 baseCost,其余用 peakValleyCost
|
|
123
131
|
// 计费档位显示文案
|
|
124
132
|
var regimeLabel = function (regime) {
|
|
@@ -175,7 +183,7 @@ window.__ModuleLoader__.load({
|
|
|
175
183
|
var api = function (payload) {
|
|
176
184
|
return fetch("/usage/api", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then(function (r) { return r.json(); });
|
|
177
185
|
};
|
|
178
|
-
var PRICE_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"];
|
|
186
|
+
var PRICE_MODELS = ["deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro"];
|
|
179
187
|
|
|
180
188
|
// ── Card ──
|
|
181
189
|
function Card(props) {
|
|
@@ -567,7 +575,7 @@ window.__ModuleLoader__.load({
|
|
|
567
575
|
el(Card, { label: "输入 · 未命中", value: fmtInt(totalMiss), hint: "token" }),
|
|
568
576
|
el(Card, { label: "输入 · 缓存命中", value: fmtInt(totalHit), hint: "命中率 " + hitRate }),
|
|
569
577
|
el(Card, { label: "输出", value: fmtInt(totalOut), hint: "token" }),
|
|
570
|
-
el(Card, { label: "高峰消耗", value: fmtMoney(peakTotal), hint: "
|
|
578
|
+
el(Card, { label: "高峰消耗", value: fmtMoney(peakTotal), hint: "工作日高峰 9:00–12:00、14:00–18:00 · 周末全天空闲价" }),
|
|
571
579
|
el(Card, { label: "空闲消耗", value: fmtMoney(offTotal), hint: "其余空闲时段" }),
|
|
572
580
|
el(Card, { label: "总消耗", value: fmtMoney(totalCost), hint: "高峰 + 空闲 · " + regimeLabel(regime) })
|
|
573
581
|
),
|
|
@@ -755,7 +763,7 @@ window.__ModuleLoader__.load({
|
|
|
755
763
|
el(Card, { label: "输入 · 未命中", value: fmtInt(monthMiss), hint: "token" }),
|
|
756
764
|
el(Card, { label: "输入 · 缓存命中", value: fmtInt(monthHit), hint: "token" }),
|
|
757
765
|
el(Card, { label: "输出", value: fmtInt(monthOut), hint: "token" }),
|
|
758
|
-
el(Card, { label: "高峰消耗", value: fmtMoney(monthPeak), hint: "
|
|
766
|
+
el(Card, { label: "高峰消耗", value: fmtMoney(monthPeak), hint: "工作日高峰 9:00–12:00、14:00–18:00 · 周末全天空闲价" }),
|
|
759
767
|
el(Card, { label: "空闲消耗", value: fmtMoney(monthOff), hint: "其余空闲时段" }),
|
|
760
768
|
el(Card, { label: "本月消耗", value: fmtMoney(monthCost), hint: "高峰 + 空闲 · " + costHint(regime) })
|
|
761
769
|
),
|
|
@@ -1018,7 +1026,7 @@ window.__ModuleLoader__.load({
|
|
|
1018
1026
|
el("div", { style: st.note, marginTop: 6 }, effectiveIn()),
|
|
1019
1027
|
regime === "auto"
|
|
1020
1028
|
? el("div", { style: st.note, marginTop: 6 },
|
|
1021
|
-
"
|
|
1029
|
+
"自动模式:按调用时间自动选择计费档位——新价格表生效前的调用按基础价(旧价格表),生效后的调用按峰谷价(工作日高峰时段 9:00–12:00、14:00–18:00 用高峰价,其余时间——含周末全天——用空闲价)。当前显示:",
|
|
1022
1030
|
el("strong", null, showRegime(regime) === "base" ? "基础价表(旧价格表,新价格生效前)" : "峰谷价表(新价格表)"),
|
|
1023
1031
|
"。价格按官方公布固定,不可编辑。"
|
|
1024
1032
|
)
|
|
@@ -1043,7 +1051,7 @@ window.__ModuleLoader__.load({
|
|
|
1043
1051
|
)
|
|
1044
1052
|
),
|
|
1045
1053
|
el("div", { style: st.note, marginTop: 8 },
|
|
1046
|
-
"单位:元 / 百万 tokens
|
|
1054
|
+
"单位:元 / 百万 tokens。工作日高峰时段(北京时间 9:00–12:00、14:00–18:00)用高峰价,其余时间用空闲价(空闲价 = 高峰价的一半);自 2026-08-23 起周末(周六、周日)全天按空闲价计费,新规则生效前仍按原规则。概览、用量日历、缓存命中列表中的消耗均已按高峰 / 空闲分列统计。"
|
|
1047
1055
|
),
|
|
1048
1056
|
el("div", { style: st.sec, marginTop: 18 }, "第三方平台价格与覆盖状态(2026-08-19 核验)"),
|
|
1049
1057
|
el("div", { style: st.note, marginTop: 6 }, "只把能与记录中的 provider/model 可靠匹配的价格用于自动计费;美元价格先按 USD 计算,再乘 USD/CNY 汇率统一折算成人民币。"),
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import os from 'node:os'
|
|
19
|
+
import { promises as fsp, existsSync } from 'node:fs'
|
|
19
20
|
import {
|
|
20
21
|
getBalanceProvider,
|
|
21
22
|
matchesModelProvider,
|
|
@@ -48,6 +49,7 @@ export default {
|
|
|
48
49
|
const PRICING = {
|
|
49
50
|
base: {
|
|
50
51
|
'deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
52
|
+
'deepseek-v4-flash-vision-exp': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
51
53
|
'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
|
|
52
54
|
},
|
|
53
55
|
peakValley: {
|
|
@@ -55,6 +57,10 @@ export default {
|
|
|
55
57
|
offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
|
|
56
58
|
peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
|
|
57
59
|
},
|
|
60
|
+
'deepseek-v4-flash-vision-exp': {
|
|
61
|
+
offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
|
|
62
|
+
peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
|
|
63
|
+
},
|
|
58
64
|
'deepseek-v4-pro': {
|
|
59
65
|
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
60
66
|
peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
|
|
@@ -62,7 +68,7 @@ export default {
|
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
|
|
65
|
-
const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
|
|
71
|
+
const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro']
|
|
66
72
|
const SILICONFLOW_PRICING = {
|
|
67
73
|
'deepseek-ai/deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
68
74
|
'deepseek-ai/deepseek-v4-pro': { cacheHit: 1.0, cacheMiss: 12.0, output: 24.0 },
|
|
@@ -79,16 +85,24 @@ export default {
|
|
|
79
85
|
// 新价格表(峰谷价)生效时间:北京时间 2026-08-17 00:00。
|
|
80
86
|
// 在此之前的调用按旧价格表(基础价 base)计费;之后按新价格表(峰谷价)计费。
|
|
81
87
|
const EFFECTIVE_AT = Date.parse('2026-08-17T00:00:00+08:00')
|
|
88
|
+
// 周末统一按低谷(空闲)价计费规则生效时间:北京时间 2026-08-23 00:00(周日)。
|
|
89
|
+
// 生效前(含周末)仍按原峰谷分段计费;生效后周末(周六、周日)全天统一按空闲价,工作日仍按峰谷分段。
|
|
90
|
+
const WEEKEND_FLAT_AT = Date.parse('2026-08-23T00:00:00+08:00')
|
|
82
91
|
|
|
83
92
|
function modelKey(model) {
|
|
84
93
|
const m = String(model || '').toLowerCase()
|
|
94
|
+
if (m.indexOf('vision') >= 0) return 'deepseek-v4-flash-vision-exp'
|
|
85
95
|
if (m.indexOf('flash') >= 0) return 'deepseek-v4-flash'
|
|
86
96
|
if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
|
|
87
97
|
return 'unknown'
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
// 高峰时段判定:新规则(2026-08-23 起)周末(周六、周日)全天不再区分峰谷,统一按空闲价;
|
|
101
|
+
// 工作日仍按北京时间 9:00–12:00、14:00–18:00 为高峰;新规则生效前的调用仍按原规则判定。
|
|
90
102
|
function isPeak(ts) {
|
|
91
103
|
const d = new Date(ts + 8 * 3600 * 1000)
|
|
104
|
+
const wd = d.getUTCDay() // 0=周日 1=周一 … 6=周六
|
|
105
|
+
if (ts >= WEEKEND_FLAT_AT && (wd === 0 || wd === 6)) return false
|
|
92
106
|
const t = d.getUTCHours() * 60 + d.getUTCMinutes()
|
|
93
107
|
return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
|
|
94
108
|
}
|
|
@@ -167,6 +181,14 @@ export default {
|
|
|
167
181
|
return IS_WIN ? s.replace(/\//g, '\\') : s
|
|
168
182
|
}
|
|
169
183
|
const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
|
|
184
|
+
// 插件自身持久化统一走宿主进程的 node:fs(不经 ctx.fs 沙箱),
|
|
185
|
+
// 因此可稳定写入工作区之外的系统目录,切换工作区也不会丢失历史。
|
|
186
|
+
const nfsWriteText = async (filePath, content) => {
|
|
187
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
|
188
|
+
await fsp.writeFile(filePath, content, 'utf8')
|
|
189
|
+
}
|
|
190
|
+
const nfsReadText = async (filePath) => fsp.readFile(filePath, 'utf8')
|
|
191
|
+
const nfsExists = (filePath) => { try { return existsSync(filePath) } catch (e) { return false } }
|
|
170
192
|
const stamp = () => {
|
|
171
193
|
const d = new Date()
|
|
172
194
|
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
|
@@ -265,31 +287,21 @@ export default {
|
|
|
265
287
|
}
|
|
266
288
|
|
|
267
289
|
function persistNow() {
|
|
268
|
-
if (!
|
|
290
|
+
if (!dataPath || !persistOk) return Promise.resolve()
|
|
269
291
|
const text = JSON.stringify(records)
|
|
270
|
-
|
|
271
|
-
writeChain = writeChain.then(() =>
|
|
272
|
-
fs.resolve(dataPath).then((target) =>
|
|
273
|
-
fs.writeText(target, text, undefined, undefined, policy || undefined)
|
|
274
|
-
)
|
|
275
|
-
).catch(() => {})
|
|
292
|
+
writeChain = writeChain.then(() => nfsWriteText(dataPath, text)).catch(() => {})
|
|
276
293
|
return writeChain
|
|
277
294
|
}
|
|
278
295
|
|
|
279
296
|
function persistPricing() {
|
|
280
|
-
if (!
|
|
281
|
-
|
|
282
|
-
const policy = undefined
|
|
283
|
-
return fs.resolve(pricingPath)
|
|
284
|
-
.then((target) => fs.writeText(target, text, undefined, undefined, policy || undefined))
|
|
285
|
-
.catch(() => {})
|
|
297
|
+
if (!pricingPath || !persistOk) return Promise.resolve()
|
|
298
|
+
return nfsWriteText(pricingPath, JSON.stringify(PRICING)).catch(() => {})
|
|
286
299
|
}
|
|
287
300
|
|
|
288
|
-
async function loadPricing(
|
|
289
|
-
if (!
|
|
301
|
+
async function loadPricing() {
|
|
302
|
+
if (!pricingPath) return
|
|
290
303
|
try {
|
|
291
|
-
const
|
|
292
|
-
const data = JSON.parse(await fs.readText(target))
|
|
304
|
+
const data = JSON.parse(await nfsReadText(pricingPath))
|
|
293
305
|
if (!data || typeof data !== 'object') return
|
|
294
306
|
for (const regime of ['base', 'peakValley']) {
|
|
295
307
|
const src = data[regime]
|
|
@@ -328,31 +340,33 @@ export default {
|
|
|
328
340
|
}
|
|
329
341
|
}
|
|
330
342
|
|
|
331
|
-
async function tryInitWithRoot(candidate
|
|
343
|
+
async function tryInitWithRoot(candidate) {
|
|
332
344
|
const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
|
|
333
345
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
if (nfsExists(tryPath)) {
|
|
347
|
+
const arr = JSON.parse(await nfsReadText(tryPath))
|
|
348
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
349
|
+
const existing = {}
|
|
350
|
+
for (let i = 0; i < records.length; i++) existing[records[i].time] = true
|
|
351
|
+
for (let i = 0; i < arr.length; i++) {
|
|
352
|
+
const rec = normalizeRecord(arr[i])
|
|
353
|
+
if (!rec || existing[rec.time]) continue
|
|
354
|
+
existing[rec.time] = true
|
|
355
|
+
records.push(rec)
|
|
356
|
+
}
|
|
357
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
358
|
+
records.sort((a, b) => a.time - b.time)
|
|
344
359
|
}
|
|
345
|
-
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
346
|
-
records.sort((a, b) => a.time - b.time)
|
|
347
360
|
}
|
|
348
361
|
} catch (e) {}
|
|
349
362
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
363
|
+
// 真实试写以确认该目录可写;用宿主进程的 node:fs,不受 workspace-write
|
|
364
|
+
// 沙箱约束,因此活动数据根会落在工作区之外的系统/用户目录。
|
|
365
|
+
await nfsWriteText(tryPath, JSON.stringify(records))
|
|
352
366
|
root = normPath(candidate)
|
|
353
367
|
dataPath = tryPath
|
|
354
368
|
pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
|
|
355
|
-
await loadPricing(
|
|
369
|
+
await loadPricing()
|
|
356
370
|
persistOk = true
|
|
357
371
|
persistError = ''
|
|
358
372
|
return { ok: true }
|
|
@@ -362,7 +376,6 @@ export default {
|
|
|
362
376
|
}
|
|
363
377
|
|
|
364
378
|
async function migrateLegacy(candidates) {
|
|
365
|
-
if (!fs) return
|
|
366
379
|
const paths = []
|
|
367
380
|
for (const c of candidates) {
|
|
368
381
|
paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
|
|
@@ -370,7 +383,8 @@ export default {
|
|
|
370
383
|
}
|
|
371
384
|
for (const p of paths) {
|
|
372
385
|
try {
|
|
373
|
-
|
|
386
|
+
if (!nfsExists(p)) continue
|
|
387
|
+
const arr = JSON.parse(await nfsReadText(p))
|
|
374
388
|
if (Array.isArray(arr)) {
|
|
375
389
|
const existing = {}
|
|
376
390
|
for (let j = 0; j < records.length; j++) existing[records[j].time] = true
|
|
@@ -387,17 +401,27 @@ export default {
|
|
|
387
401
|
}
|
|
388
402
|
}
|
|
389
403
|
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// 系统应用数据目录(
|
|
404
|
+
// 固定、专用的数据目录:不随工作区漂移,也绝不放进工作目录。
|
|
405
|
+
// 优先级:环境变量 DSH_USAGE_DATA_DIR >
|
|
406
|
+
// 系统应用数据目录(LOCALAPPDATA 或 APPDATA 下的 dsh-usage-plugin) >
|
|
393
407
|
// 用户主目录下的专用文件夹 dsh-usage-data。
|
|
408
|
+
// 这些目录由宿主进程 node:fs 直接写入,不受 workspace-write 沙箱约束,
|
|
409
|
+
// 因此数据稳定地落在工作区之外——切换工作区也不会丢失历史。
|
|
410
|
+
// 仅在以上目录全部不可写时,才兜底写到工作区(极少见,且会提示用户)。
|
|
394
411
|
function resolveDataRoot() {
|
|
395
412
|
const out = []
|
|
396
413
|
const env = ((typeof process !== 'undefined' && process.env && process.env.DSH_USAGE_DATA_DIR) || '').trim()
|
|
397
414
|
if (env) out.push(normPath(env))
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
415
|
+
const localApp = (typeof process !== 'undefined' && process.env) ? (process.env.LOCALAPPDATA || '') : ''
|
|
416
|
+
if (localApp) out.push(joinPath(normPath(localApp), 'dsh-usage-plugin'))
|
|
417
|
+
const roamingApp = (typeof process !== 'undefined' && process.env) ? (process.env.APPDATA || '') : ''
|
|
418
|
+
if (roamingApp) out.push(joinPath(normPath(roamingApp), 'dsh-usage-plugin'))
|
|
400
419
|
out.push(joinPath(normPath(os.homedir()), 'dsh-usage-data'))
|
|
420
|
+
// 兜底(极少见):以上均不可写时落到工作区,保证不丢数据并提示用户。
|
|
421
|
+
try {
|
|
422
|
+
const sp = ctx.get('sandboxPolicy')
|
|
423
|
+
if (sp && sp.workspaceRoot) out.push(joinPath(normPath(String(sp.workspaceRoot)), 'dsh-usage'))
|
|
424
|
+
} catch (e) {}
|
|
401
425
|
return out
|
|
402
426
|
}
|
|
403
427
|
|
|
@@ -419,17 +443,16 @@ export default {
|
|
|
419
443
|
// 会话激活后不再切换根目录(避免路径漂移/历史被拆散);仅把工作区里
|
|
420
444
|
// 可能遗留的旧记录并入固定的数据根。
|
|
421
445
|
async function ensureSessionRoot() {
|
|
422
|
-
if (!
|
|
446
|
+
if (!persistOk) return
|
|
423
447
|
await migrateLegacy(knownLegacyRoots())
|
|
424
448
|
persistNow()
|
|
425
449
|
}
|
|
426
450
|
|
|
427
451
|
async function initPersistence() {
|
|
428
|
-
if (!fs) { persistError = '文件服务不可用'; return }
|
|
429
452
|
const candidates = resolveDataRoot()
|
|
430
453
|
let lastError = ''
|
|
431
454
|
for (const c of candidates) {
|
|
432
|
-
const r = await tryInitWithRoot(c
|
|
455
|
+
const r = await tryInitWithRoot(c)
|
|
433
456
|
if (r.ok) {
|
|
434
457
|
// 首次初始化时把散落在 主目录/.dsh/各工作区 的历史记录合并进来
|
|
435
458
|
await migrateLegacy(knownLegacyRoots())
|
|
@@ -511,11 +534,10 @@ export default {
|
|
|
511
534
|
|
|
512
535
|
// ── balance / network helpers ──────────────────────────────────────────
|
|
513
536
|
async function safeCwd() {
|
|
514
|
-
if (root
|
|
537
|
+
if (root) {
|
|
515
538
|
try {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (info) return root
|
|
539
|
+
await fsp.stat(root)
|
|
540
|
+
return root
|
|
519
541
|
} catch (e) {}
|
|
520
542
|
}
|
|
521
543
|
return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
|
|
@@ -861,41 +883,23 @@ export default {
|
|
|
861
883
|
}
|
|
862
884
|
|
|
863
885
|
async function writePngFile(base64, outPath) {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
' const buf=Buffer.from(d,"base64");',
|
|
870
|
-
' fs.mkdirSync(require("path").dirname(process.env.PNG_PATH),{recursive:true});',
|
|
871
|
-
' fs.writeFileSync(process.env.PNG_PATH,buf);',
|
|
872
|
-
' process.stdout.write(JSON.stringify({ok:true,bytes:buf.length}));',
|
|
873
|
-
'});'
|
|
874
|
-
].join('\n')
|
|
875
|
-
return spawnNode(script, base64, { PNG_PATH: outPath })
|
|
886
|
+
try {
|
|
887
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
888
|
+
await fsp.writeFile(outPath, Buffer.from(base64, 'base64'))
|
|
889
|
+
return { ok: true }
|
|
890
|
+
} catch (e) { return { ok: false, error: msg(e) } }
|
|
876
891
|
}
|
|
877
892
|
|
|
878
893
|
async function writeTextFileViaNode(content, outPath) {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
' fs.mkdirSync(require("path").dirname(process.env.OUT_PATH),{recursive:true});',
|
|
885
|
-
' fs.writeFileSync(process.env.OUT_PATH, Buffer.from(d,"utf8"));',
|
|
886
|
-
' process.stdout.write(JSON.stringify({ok:true}));',
|
|
887
|
-
'});'
|
|
888
|
-
].join('\n')
|
|
889
|
-
return spawnNode(script, content, { OUT_PATH: outPath })
|
|
894
|
+
try {
|
|
895
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
896
|
+
await fsp.writeFile(outPath, Buffer.from(content, 'utf8'))
|
|
897
|
+
return { ok: true }
|
|
898
|
+
} catch (e) { return { ok: false, error: msg(e) } }
|
|
890
899
|
}
|
|
891
900
|
|
|
892
901
|
async function mkdirViaNode(dir) {
|
|
893
|
-
|
|
894
|
-
'const fs=require("fs");',
|
|
895
|
-
'fs.mkdirSync(process.env.MKDIR_PATH,{recursive:true});',
|
|
896
|
-
'process.stdout.write(JSON.stringify({ok:true}));'
|
|
897
|
-
].join('\n')
|
|
898
|
-
return spawnNode(script, null, { MKDIR_PATH: dir })
|
|
902
|
+
try { await fsp.mkdir(dir, { recursive: true }); return { ok: true } } catch (e) { return { ok: false, error: msg(e) } }
|
|
899
903
|
}
|
|
900
904
|
|
|
901
905
|
async function pickDirectory() {
|
|
@@ -949,10 +953,8 @@ export default {
|
|
|
949
953
|
const d = dirs()
|
|
950
954
|
target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
|
|
951
955
|
target = normPath(target)
|
|
952
|
-
const policy = sessionPolicy()
|
|
953
956
|
try {
|
|
954
|
-
|
|
955
|
-
await fs.writeText(t, '', undefined, undefined, policy || undefined)
|
|
957
|
+
await nfsWriteText(joinPath(target, '.keep'), '')
|
|
956
958
|
} catch (e) {}
|
|
957
959
|
} else {
|
|
958
960
|
target = normPath(dirArg)
|
|
@@ -1044,7 +1046,7 @@ export default {
|
|
|
1044
1046
|
case 'pickDir':
|
|
1045
1047
|
return pickDirectory()
|
|
1046
1048
|
case 'export': {
|
|
1047
|
-
if (!root) return fail('
|
|
1049
|
+
if (!root) return fail('未找到数据根目录')
|
|
1048
1050
|
const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
|
|
1049
1051
|
const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
|
|
1050
1052
|
const content = kind === 'json'
|
|
@@ -1059,9 +1061,8 @@ export default {
|
|
|
1059
1061
|
}
|
|
1060
1062
|
const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
|
|
1061
1063
|
try {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
return { ok: true, path: normPath(fs.processPath ? fs.processPath(target) : outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
|
|
1064
|
+
await nfsWriteText(outPath, content)
|
|
1065
|
+
return { ok: true, path: normPath(outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
|
|
1065
1066
|
} catch (e) { return fail(msg(e)) }
|
|
1066
1067
|
}
|
|
1067
1068
|
case 'exportPng': {
|
|
@@ -1069,7 +1070,7 @@ export default {
|
|
|
1069
1070
|
if (!dataUrl) return fail('缺少图片数据')
|
|
1070
1071
|
const idx = dataUrl.indexOf('base64,')
|
|
1071
1072
|
const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
|
|
1072
|
-
if (!root) return fail('
|
|
1073
|
+
if (!root) return fail('未找到数据根目录')
|
|
1073
1074
|
const name = 'dsh-usage-report-' + stamp() + '.png'
|
|
1074
1075
|
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
1075
1076
|
const outPath = normPath(joinPath(dirArg || dirs().images, name))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feiyang666/dsh-usage-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "DeepSeek Harness usage & cost tracker plugin: per-call token/cache-hit stats, peak/off-peak billing, DeepSeek balance query, CSV/JSON/PNG export with custom destination, and persistent local storage. Ships a host half plus a web client half in one npm package; installs into a DSH profile as a dsh.bundle with one command (dsh plugin --profile web add @feiyang666/dsh-usage-plugin).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const REPO = 'feiyang-dev/dsh-usage-plugin'
|
|
7
|
+
const TAG = 'v1.10.0'
|
|
8
|
+
|
|
9
|
+
function getCredential() {
|
|
10
|
+
const input = 'protocol=https\nhost=github.com\n\n'
|
|
11
|
+
const res = spawnSync('git', ['credential', 'fill'], { input, encoding: 'utf8' })
|
|
12
|
+
const cred = {}
|
|
13
|
+
for (const l of (res.stdout || '').split('\n')) {
|
|
14
|
+
const idx = l.indexOf('=')
|
|
15
|
+
if (idx > 0) cred[l.slice(0, idx).trim()] = l.slice(idx + 1).trim()
|
|
16
|
+
}
|
|
17
|
+
return cred
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function api(method, apiPath, body, contentType = 'application/json', host = 'api.github.com') {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const cred = getCredential()
|
|
23
|
+
const isBuffer = Buffer.isBuffer(body)
|
|
24
|
+
const req = https.request({
|
|
25
|
+
host,
|
|
26
|
+
path: apiPath,
|
|
27
|
+
method,
|
|
28
|
+
headers: {
|
|
29
|
+
'Authorization': `Bearer ${cred.password || ''}`,
|
|
30
|
+
'User-Agent': 'dsh-usage-plugin-publish',
|
|
31
|
+
'Accept': 'application/vnd.github+json',
|
|
32
|
+
'Content-Type': contentType,
|
|
33
|
+
...(body != null ? { 'Content-Length': isBuffer ? body.length : Buffer.byteLength(body) } : {}),
|
|
34
|
+
},
|
|
35
|
+
rejectUnauthorized: false,
|
|
36
|
+
}, (res) => {
|
|
37
|
+
let data = ''
|
|
38
|
+
res.on('data', (d) => data += d)
|
|
39
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
|
40
|
+
})
|
|
41
|
+
req.on('error', (e) => resolve({ status: 0, body: e.message }))
|
|
42
|
+
if (body != null) req.write(body)
|
|
43
|
+
req.end()
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractChangelog(tag) {
|
|
48
|
+
const text = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8')
|
|
49
|
+
const lines = text.split('\n')
|
|
50
|
+
const re = new RegExp('^## ' + tag.replace(/\./g, '\\.') + '\\b')
|
|
51
|
+
const start = lines.findIndex((l) => re.test(l))
|
|
52
|
+
if (start < 0) return ''
|
|
53
|
+
let end = lines.length
|
|
54
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
55
|
+
if (/^## v\d/.test(lines[i])) { end = i; break }
|
|
56
|
+
}
|
|
57
|
+
return lines.slice(start, end).join('\n').trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const body = extractChangelog(TAG)
|
|
61
|
+
if (!body) { console.error('changelog section not found for', TAG); process.exit(1) }
|
|
62
|
+
|
|
63
|
+
console.log('Creating release', TAG, 'for', REPO)
|
|
64
|
+
const created = await api('POST', `/repos/${REPO}/releases`, JSON.stringify({
|
|
65
|
+
tag_name: TAG,
|
|
66
|
+
name: TAG,
|
|
67
|
+
body,
|
|
68
|
+
draft: false,
|
|
69
|
+
prerelease: false,
|
|
70
|
+
}))
|
|
71
|
+
console.log('create status:', created.status)
|
|
72
|
+
if (created.status !== 201) {
|
|
73
|
+
console.error('create failed:', created.body.slice(0, 600))
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
const release = JSON.parse(created.body)
|
|
77
|
+
console.log('release id:', release.id, 'url:', release.html_url)
|
|
78
|
+
|
|
79
|
+
// 打包并上传 tarball 作为附件
|
|
80
|
+
const pack = spawnSync('npm', ['pack'], { encoding: 'utf8' })
|
|
81
|
+
const tgzName = (pack.stdout || '').trim().split('\n').pop()
|
|
82
|
+
if (tgzName && fs.existsSync(tgzName)) {
|
|
83
|
+
const buf = fs.readFileSync(tgzName)
|
|
84
|
+
const assetPath = `/repos/${REPO}/releases/${release.id}/assets?name=${encodeURIComponent(tgzName)}`
|
|
85
|
+
console.log('uploading asset', tgzName, buf.length)
|
|
86
|
+
const asset = await api('POST', assetPath, buf, 'application/octet-stream', 'uploads.github.com')
|
|
87
|
+
console.log('asset status:', asset.status)
|
|
88
|
+
if (asset.status === 201) console.log('asset url:', JSON.parse(asset.body).browser_download_url)
|
|
89
|
+
else console.error('asset body:', asset.body.slice(0, 400))
|
|
90
|
+
try { fs.unlinkSync(tgzName) } catch (e) {}
|
|
91
|
+
} else {
|
|
92
|
+
console.log('no tarball produced; skip asset upload')
|
|
93
|
+
}
|
|
94
|
+
console.log('DONE')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const REPO = 'feiyang-dev/dsh-usage-plugin'
|
|
6
|
+
const RELEASE_ID = 373691852
|
|
7
|
+
|
|
8
|
+
function getCredential() {
|
|
9
|
+
const input = 'protocol=https\nhost=github.com\n\n'
|
|
10
|
+
const res = spawnSync('git', ['credential', 'fill'], { input, encoding: 'utf8' })
|
|
11
|
+
const cred = {}
|
|
12
|
+
for (const l of (res.stdout || '').split('\n')) {
|
|
13
|
+
const idx = l.indexOf('=')
|
|
14
|
+
if (idx > 0) cred[l.slice(0, idx).trim()] = l.slice(idx + 1).trim()
|
|
15
|
+
}
|
|
16
|
+
return cred
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function api(method, apiPath, body, contentType, host = 'api.github.com') {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const cred = getCredential()
|
|
22
|
+
const isBuffer = Buffer.isBuffer(body)
|
|
23
|
+
const req = https.request({
|
|
24
|
+
host, path: apiPath, method,
|
|
25
|
+
headers: {
|
|
26
|
+
'Authorization': `Bearer ${cred.password || ''}`,
|
|
27
|
+
'User-Agent': 'dsh-usage-plugin-publish',
|
|
28
|
+
'Accept': 'application/vnd.github+json',
|
|
29
|
+
'Content-Type': contentType,
|
|
30
|
+
...(body != null ? { 'Content-Length': isBuffer ? body.length : Buffer.byteLength(body) } : {}),
|
|
31
|
+
},
|
|
32
|
+
rejectUnauthorized: false,
|
|
33
|
+
}, (res) => { let d = ''; res.on('data', (c) => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })) })
|
|
34
|
+
req.on('error', (e) => resolve({ status: 0, body: e.message }))
|
|
35
|
+
if (body != null) req.write(body)
|
|
36
|
+
req.end()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tgz = fs.readdirSync('.').find((f) => f.endsWith('.tgz'))
|
|
41
|
+
if (!tgz) { console.error('no tarball found'); process.exit(1) }
|
|
42
|
+
const buf = fs.readFileSync(tgz)
|
|
43
|
+
const assetPath = `/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encodeURIComponent(tgz)}`
|
|
44
|
+
console.log('uploading', tgz, buf.length)
|
|
45
|
+
const asset = await api('POST', assetPath, buf, 'application/octet-stream', 'uploads.github.com')
|
|
46
|
+
console.log('asset status:', asset.status)
|
|
47
|
+
if (asset.status === 201) console.log('asset url:', JSON.parse(asset.body).browser_download_url)
|
|
48
|
+
else console.error('asset body:', asset.body.slice(0, 400))
|
|
49
|
+
try { fs.unlinkSync(tgz) } catch (e) {}
|
|
50
|
+
console.log('DONE')
|