@dougen/dsh-deepseek-usage 0.6.0 → 0.7.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/README.md CHANGED
@@ -10,21 +10,23 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
10
10
 
11
11
  - **Balance**: `¥ 110.00` large display (symbol follows account currency: `¥` CNY / `$` USD)
12
12
  - **Unit pricing**: `→|1.5 ⚡0.05 |→4.5` (input / input cache / output, bottom-right of the card; switches with the active model and peak/off-peak window — **updates instantly when you switch models**)
13
- - **Auto price sync**: unit prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card — DeepSeek price changes no longer require a plugin update. The page is parsed **structurally (HTML table cells)**, not by matching surrounding text, so official layout/rewording changes no longer break it; a plain-text strategy remains as a fallback
14
- - **Rename-proof model mapping**: when DeepSeek renames a model (e.g. `deepseek-v4-flash` → `deepseek-flash`) the old id is charged at the current model's price, and the card notes `Priced as deepseek-flash` instead of silently showing an outdated built-in price
15
- - **Visible sync state**: if the official page cannot be fetched/parsed, the card says prices are not synced (hover for the error) instead of quietly showing stale built-in numbers
13
+ - **Editable price table**: prices live in a JSON file (`<DSH_HOME>/dsh-deepseek-usage/pricing.json`), not in code. The plugin reads it at startup, so the card prices correctly **immediately after a restart**, and a successful official sync is written back into it. Hand-edit it for a gateway/manual pricing; changes apply without restarting (`autoUpdate: false` also turns the automatic sync off completely)
14
+ - **Auto price sync**: prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card; the result is merged back into the price table (official entries overwrite same-named ones, your own entries are kept). The page is parsed **structurally (HTML table cells)**, not by matching surrounding text, so official layout/rewording changes no longer break it; a plain-text strategy remains as a fallback
15
+ - **Rename-proof model mapping**: when DeepSeek renames a model (e.g. `deepseek-v4-flash` → `deepseek-flash`) the old id is charged at the current model's price, and the card notes `Priced as deepseek-flash` instead of silently showing an outdated price
16
+ - **Visible sync state**: if the official page cannot be fetched/parsed, the card is blanked and says which prices it is using (hover for the error and the last sync time) instead of quietly showing stale numbers
16
17
  - **Peak indicator**: top-right dot, orange during peak hours (Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time), green otherwise
17
- - **Auto refresh**: every 5 minutes; click the card to refresh instantly
18
+ - **Auto refresh**: every 5 minutes; click the card to refresh instantly — plus a few quick retries while the host is still syncing, so the card converges within seconds
18
19
  - **i18n**: UI copy follows the DSH locale (Chinese / English)
19
20
  - Follows the app light/dark theme
20
21
 
21
22
  - **余额**:`¥ 110.00` 大字显示(符号随账号币种自动切换:人民币 `¥` / 美元 `$`)
22
23
  - **档单价**:`→|1.5 ⚡0.05 |→4.5`(输入 / 输入缓存 / 输出,位于卡片右下角;随当前启用模型与峰谷时段自动切换,**切换模型时立即同步**)
23
- - **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价;官方调价无需更新插件。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
24
- - **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,卡片会注明 `按 deepseek-flash 计价`,不会静默显示内置旧价
25
- - **同步状态可见**:官方页抓取/解析失败时,卡片会提示"价格未同步"(悬停可看具体错误),而不是悄悄显示内置旧价
24
+ - **可编辑的价格表**:价格不再写在代码里,而是存在 JSON 文件 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`。插件启动时先读它,所以**重启后卡片立刻就是正确价格**;官方同步成功后会自动回写到这份表里。手改该文件即可用于中转/手动计价,改完**无需重启**(`autoUpdate: false` 还能彻底关掉自动同步)
25
+ - **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价,并把结果**合并回写**到价格表(官方条目覆盖同名项,你自己加的条目保留)。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
26
+ - **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,卡片会注明 `按 deepseek-flash 计价`,不会静默显示旧价
27
+ - **同步状态可见**:官方页抓取/解析失败时,卡片会置空并说明当前用的是哪份价(悬停可看具体错误与上次同步时间),而不是悄悄显示旧价
26
28
  - **峰谷指示灯**:卡片右上角,高峰(周一至周五 9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色
27
- - **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新
29
+ - **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新;宿主正在后台同步时还会在几秒内快速补几次请求,卡片很快收敛到最新官方价
28
30
  - **多语言**:界面文案跟随 DSH 语言(中文 / English)
29
31
  - 跟随应用明暗主题
30
32
 
@@ -53,48 +55,90 @@ Restart the web app (`dsh web`), open http://127.0.0.1:3080 , and the usage card
53
55
  | Data | Source |
54
56
  |---|---|
55
57
  | Balance | Official DeepSeek API `GET /user/balance` (uses `DEEPSEEK_API_KEY` configured in DSH) |
56
- | Unit pricing / peak window | Auto-synced from the official pricing page (fetched on first daily use and on card click: [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)); parsed from the HTML **table cells** (text fallback available); built-in table (current official snapshot) as offline fallback |
58
+ | Unit pricing | Local price table `<DSH_HOME>/dsh-deepseek-usage/pricing.json` (read at startup; editable). Refreshed from the official page ([api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)) on first daily use or on card click, then written back. Parsed from the HTML **table cells** (text fallback available); the built-in snapshot in code is only the seed for the very first run |
59
+ | Peak window | Built-in official rule: Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time (weekends are always off-peak) |
57
60
 
58
61
  | 数据 | 来源 |
59
62
  |---|---|
60
63
  | 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
61
- | 档单价 / 峰谷时段 | 自动从官方价格页同步(每日首次使用或点击卡片时拉取 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/));解析**官方页 HTML 表格单元格**(另有纯文本兜底策略);内置价格表仅作离线兜底 |
64
+ | 档单价 | 本地价格表 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(启动即读取,可手动编辑);每日首次使用或点击卡片时从官方价格页 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) 同步并**回写**该表。官方页解析**读取 HTML 表格单元格**(另有纯文本兜底策略);代码里的内置快照只在首次运行时用于生成价格表 |
65
+ | 峰谷时段 | 内置官方口径:周一至周五 9:00–12:00、14:00–18:00(北京时间),周末恒为空闲 |
62
66
 
63
67
  Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before use.
64
68
 
65
69
  使用前请确保 DSH 已配置 `DEEPSEEK_API_KEY`(可在 DSH 设置中的模型页面配置)。
66
70
 
71
+ ## Price table / 价格表
72
+
73
+ `<DSH_HOME>/dsh-deepseek-usage/pricing.json` (default `~/.dsh/dsh-deepseek-usage/pricing.json`; override with `DEEPSEEK_USAGE_PRICE_FILE`). Created from the built-in snapshot on first run, then maintained by the plugin:
74
+
75
+ ```jsonc
76
+ {
77
+ "autoUpdate": true, // false = manual mode: never fetch, use this table as-is
78
+ "updatedAt": 1789090582880, // last write (ms or ISO string)
79
+ "updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
80
+ "strategy": "table", // table | flat | seed
81
+ "aliases": { "deepseek-v4-flash": "deepseek-flash" }, // old id → current id
82
+ "models": {
83
+ // input / cacheRead / output are [peak, off-peak] in CNY per 1M tokens
84
+ "deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
85
+ }
86
+ }
87
+ ```
88
+
89
+ - Full-line `//` comments are allowed; a bare `{"model-id": {...}}` map (no `models` wrapper) also works.
90
+ - Every successful official sync **merges** into this file: official entries overwrite same-named ones, your own entries stay.
91
+ - Edits are picked up without restarting the plugin (the file's mtime is checked per request).
92
+ - If the file is corrupt, the plugin keeps the last good prices and **never overwrites** it — fix it and it reloads.
93
+ - The card's peak-dot tooltip shows the file path and the time the displayed prices came from.
94
+
95
+ 价格表文件位于 `<DSH_HOME>/dsh-deepseek-usage/pricing.json`(默认 `~/.dsh/dsh-deepseek-usage/pricing.json`,可用 `DEEPSEEK_USAGE_PRICE_FILE` 覆盖)。首次运行由内置快照生成,之后由插件维护:
96
+
97
+ - 允许**整行** `//` 注释;也接受省略 `models` 外壳的简写形式 `{"deepseek-flash": {...}}`。
98
+ - 每次官方同步成功都会**合并回写**本文件:官方页列出的模型覆盖同名条目,你自己加的条目保留。
99
+ - 手改后无需重启(每次请求会比对文件 mtime),改完点一下卡片即生效。
100
+ - 文件损坏时不会覆盖你的内容,会继续用上一次的价格并写日志;修好后自动重载。
101
+ - 卡片峰谷灯的悬停提示里会显示该文件路径,以及当前这份价是什么时候同步的。
102
+
67
103
  ## FAQ / 常见问题
68
104
 
69
105
  **Balance shows "—"? / 余额显示"—"?**
70
106
 
71
107
  - `DEEPSEEK_API_KEY` is not configured — configure it in DSH settings and restart.
72
- - Network or API error — the error message is shown below the card.
108
+ - Network or API error — the card is blanked and shows only the error text; click it to retry.
73
109
 
74
110
  - 未配置 `DEEPSEEK_API_KEY`:在 DSH 设置中配置后重启。
75
- - 网络或接口异常:错误信息会显示在卡片下方。
111
+ - 网络或接口异常:卡片会置空并只显示错误文字,点击卡片可重试。
76
112
 
77
113
  **Price differs from the official one? / 单价与官方价格不一致?**
78
114
 
79
- - The unit price switches with the **active model** and the **peak/off-peak window**; prices are re-synced from the official pricing page daily (first use) or on card click. If the active model is not on the official page nor in the built-in table, `deepseek-chat` pricing is shown with a notice.
80
- - Hover/text cues at the bottom-left of the card tell you where the number comes from:
81
- - *Prices not synced (built-in table)* — the official page could not be fetched/parsed; click the card to retry (hover the text for the exact error).
115
+ - The unit price switches with the **active model** and the **peak/off-peak window**. Prices come from the local price table (last successful official sync, or your own edits); the plugin re-syncs the official page on first daily use or on card click and writes the result back into that table.
116
+ - If a notice or an error appears, the card is blanked: balance, unit prices and the peak dot are hidden and only the notice text is shown (hover it for the exact error / last sync time). Purely informational notices (priced as a renamed model) are dismissable: **click the card to flip back to the normal balance/price card** — the notice stays reachable from the peak dot's tooltip. Sync failures and API errors always keep the card blanked; there, clicking only refreshes. The text tells you where the number comes from:
117
+ - *Prices not synced (built-in table)* — no usable price table **and** the official page could not be fetched/parsed; click the card to retry (hover for the error).
118
+ - *Sync failed; using last prices (09-11 09:36)* — the price table has the last good official prices; only the refresh failed.
82
119
  - *Priced as `deepseek-flash`* — the active model id was renamed by DeepSeek; the current model's price is used.
83
- - *Priced as built-in `…`* — the model is not listed on the official page; a built-in entry is used.
120
+ - *Priced as built-in `…`* — the model is neither in the official page nor in the price table; the built-in entry is used.
121
+ - **After every `dsh` restart the card used to say "prices not synced (built-in table)"** — that was a race: sync state only lived in memory, so the first request after a restart was always answered from the built-in table. Since 0.7.0 the price table is persisted and read at startup, so restarts show real prices right away.
84
122
 
85
- - 单价按**当前启用模型**与**峰谷时段**自动切换;单价每日(首次使用)或点击卡片时从官方价格页同步。若当前模型不在官方价格页也不在内置表,会按 `deepseek-chat` 档展示并给出提示。
86
- - 卡片左下角的提示文字说明数字来自哪里:
87
- - *价格未同步(用内置表)*:官方页抓取/解析失败,点击卡片可重试(悬停看具体错误)。
123
+ - 单价按**当前启用模型**与**峰谷时段**自动切换;价格取自本地价格表(上次成功同步到的官方价,或你自己改的价)。插件在每日首次使用或点击卡片时重新同步官方页,并把结果回写到该表。
124
+ - 一旦出现提示或错误,卡片会**置空**:不显示余额、档单价与峰谷灯,只显示提示文字(悬停看具体错误与上次同步时间)。其中纯说明性的良性提示(按现名计价)可以**点击卡片切回普通状态**(余额 + 档单价 + 峰谷灯),提示此时保留在峰谷灯的 hover 里。同步失败与接口错误属于异常,卡片始终置空(此时点击只刷新)。文字说明数字来自哪里:
125
+ - *价格未同步(用内置表)*:既没有可用价格表,官方页也抓不到;点击卡片可重试(悬停看具体错误)。
126
+ - *同步失败,仍用上次价格(09-11 09:36)*:价格表里是上次同步到的官方价,只是这次刷新失败了。
88
127
  - *按 `deepseek-flash` 计价*:官方给该模型改名了,已按现名价格计费。
89
- - *按内置 `…` 档计价*:该模型不在官方价格页,用了内置表。
128
+ - *按内置 `…` 档计价*:该模型既不在官方价格页,也不在价格表里,用了内置条目。
129
+ - **以前每次 `dsh` 重启后卡片都会提示"价格未同步(用内置表)"**:那是竞态——同步状态只存在内存里,重启后的第一次请求只能用内置表回答。0.7.0 起价格表落盘并在启动时读取,重启后立即显示真实价格。
90
130
 
91
131
  ## Development / 开发与自检
92
132
 
93
133
  ```sh
94
- npm test # 解析 + 定价单测(node --test)
134
+ npm test # 解析 + 定价 + 价格表 + 卡片布局 + 宿主路由(node --test)
95
135
  npm run check:pricing # 联网抓取官方页并打印解析结果;失败时退出码非 0
96
136
  ```
97
137
 
138
+ 模块划分:`lib/index.js` 宿主路由(同步节流、余额、状态透出)· `lib/pricing.js` 三层价格
139
+ (remote/file/seed)· `lib/pricing-store.js` 价格表读写(可编辑的 pricing.json)·
140
+ `lib/pricing-remote.js` 官方页抓取与解析 · `lib/client.js` 侧边栏卡片。
141
+
98
142
  官方页再次改版时(自检失败 / 卡片提示"价格未同步"):
99
143
 
100
144
  ```sh
@@ -106,11 +150,18 @@ node lib/pricing-remote.js --check --save page.html
106
150
  node lib/pricing-remote.js --check --html page.html --json
107
151
 
108
152
  # 3. 把新页面存为 test/fixtures/official-pricing-zh.html,跑 npm test 防回归
153
+ # (若解析出的模型集合变了,再同步更新 lib/pricing.js 里的内置快照——
154
+ # 它只用于首次运行生成 pricing.json)
109
155
  ```
110
156
 
157
+ > 测试里的"重启"是给模块图打 `?graph=<tag>`(见 `test/host-route.test.mjs`),每个 tag
158
+ > 都拿到全新模块实例;peer 依赖 `@deepseek-ai/dsh-credentials` 由 `test/stubs/` 里的
159
+ > 替身接管,所以本仓库不需要 node_modules 也能跑宿主逻辑。
160
+ >
111
161
  > DSH 沙箱内 `node --test test/` 可能因禁止子进程管道而报 `spawn EPERM`,此时用
112
162
  > `node --test --test-isolation=none "test/*.test.mjs"`(Node ≥ 22.8),或逐个跑
113
- > `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/client-layout.test.mjs`。
163
+ > `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/pricing-store.test.mjs`、
164
+ > `node test/client-layout.test.mjs`、`node test/host-route.test.mjs`。
114
165
 
115
166
  ## Uninstall / 卸载
116
167
 
package/lib/client.js CHANGED
@@ -2,9 +2,13 @@
2
2
  //
3
3
  // 侧边栏底部用量卡片(注册进 `sidebar.footer.action` 槽位,独占一行,位于
4
4
  // Cordis 面板按钮下方)。轮询宿主路由 `/api/deepseek-usage`(见 lib/index.js)
5
- // 每 5 分钟一次,点击卡片任意位置可立即刷新。
5
+ // 每 5 分钟一次,点击卡片任意位置可立即刷新;宿主还在后台同步官方价
6
+ // (pricing.syncing)时按 QUICK_RETRY_MS 阶梯快速补几次请求,卡片几秒内收敛。
6
7
  // 卡片显示:余额(放大显示,符号随币种 ¥/$)+ 当前模型档单价(输入/输入缓存/
7
8
  // 输出,图标 + 数字,位于卡片右下角)+ 峰谷指示灯(右上角)。
9
+ // 价格来源(宿主 pricing.source):remote=本次官方同步 / file=本地价格表
10
+ // (重启后即用它,属正常态)/ seed=内置兜底;峰谷灯 hover 里可看到来源、
11
+ // 上次同步时间与价格表文件路径。
8
12
  // 样式仅使用 `--dsw-*` 主题变量(含兜底值),跟随明暗主题。
9
13
  window.__ModuleLoader__.load({
10
14
  id: "@dougen/dsh-deepseek-usage",
@@ -19,6 +23,12 @@ window.__ModuleLoader__.load({
19
23
  // ---- constants -------------------------------------------------
20
24
  const POLL_MS = 5 * 60 * 1000;
21
25
  const USAGE_PATH = "/api/deepseek-usage";
26
+ // 常态卡片高度:上下 padding 8+8 + 余额行 lineHeight 28 + 边框 2 = 46。
27
+ // 异常态把卡片置空时用它兜住高度,侧边栏高度不跳动。
28
+ const CARD_MIN_HEIGHT = 46;
29
+ // 宿主正在后台同步官方价格(pricing.syncing)或本地接口刚重启时的快速重试节奏:
30
+ // 否则卡片要等满 5 分钟轮询才会从"本地价格表"变成最新官方价。
31
+ const QUICK_RETRY_MS = [4000, 8000, 15000, 30000];
22
32
 
23
33
  // ---- i18n(DSH locale 服务;zh 为兜底)----
24
34
  const NS = "dsh-deepseek-usage";
@@ -29,9 +39,12 @@ window.__ModuleLoader__.load({
29
39
  "peak.peak": "高峰时段",
30
40
  "peak.off": "空闲时段",
31
41
  "price.stale": "价格未同步(用内置表)",
32
- "price.lastGood": "同步失败,仍用上次价格",
42
+ "price.lastGood": "同步失败,仍用上次价格({at})",
33
43
  "price.alias": "按 {model} 计价",
34
44
  "price.builtin": "按内置 {model} 档计价",
45
+ "price.file": "本地价格表({at} 同步)",
46
+ "price.manual": "本地价格表(自动同步已关闭)",
47
+ "price.filePath": "价格表:{path}",
35
48
  };
36
49
  const EN = {
37
50
  "price.input": "Input",
@@ -40,9 +53,12 @@ window.__ModuleLoader__.load({
40
53
  "peak.peak": "Peak hours",
41
54
  "peak.off": "Off-peak hours",
42
55
  "price.stale": "Prices not synced (built-in table)",
43
- "price.lastGood": "Sync failed; using last prices",
56
+ "price.lastGood": "Sync failed; using last prices ({at})",
44
57
  "price.alias": "Priced as {model}",
45
58
  "price.builtin": "Priced as built-in {model}",
59
+ "price.file": "Local price table (synced {at})",
60
+ "price.manual": "Local price table (auto-sync off)",
61
+ "price.filePath": "Price table: {path}",
46
62
  };
47
63
 
48
64
  // ---- helpers ---------------------------------------------------
@@ -58,6 +74,15 @@ window.__ModuleLoader__.load({
58
74
  return String(parseFloat(n.toFixed(2)));
59
75
  }
60
76
 
77
+ /** 时间戳(ms)→ `MM-DD HH:mm`(本地时区);无效值返回空串。 */
78
+ function fmtWhen(value) {
79
+ const n = Number(value);
80
+ if (!Number.isFinite(n) || n <= 0) return "";
81
+ const d = new Date(n);
82
+ const p = (v) => String(v).padStart(2, "0");
83
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
84
+ }
85
+
61
86
  // 主题变量(带兜底值)
62
87
  const C = {
63
88
  bg: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.12))",
@@ -100,7 +125,13 @@ window.__ModuleLoader__.load({
100
125
  const wide = !!props.wide;
101
126
  const [data, setData] = useState(null);
102
127
  const [error, setError] = useState("");
128
+ // 纯良性提示(模型改名 / 官方页没列该模型)时卡片默认显示提示,点击可切回普通
129
+ // 状态(余额 + 单价 + 峰谷灯),再点切回提示。异常不受它影响,始终置空。
130
+ const [showNormal, setShowNormal] = useState(false);
103
131
  const mounted = useRef(true);
132
+ // 快速重试:阶梯下标 + 定时器(宿主正在后台同步时,几秒后自己再看一眼)。
133
+ const retryStep = useRef(0);
134
+ const retryTimer = useRef(null);
104
135
  const locale = ctx && ctx.locale;
105
136
 
106
137
  // 订阅语言变化(DSH locale 服务;不可用时回退中文)。
@@ -121,6 +152,20 @@ window.__ModuleLoader__.load({
121
152
  return ZH[key] || key;
122
153
  };
123
154
 
155
+ // 价格还没到位时按 QUICK_RETRY_MS 阶梯补一次请求(封顶后回到 5 分钟轮询):
156
+ // 覆盖两种情况——宿主正在后台同步官方价(pricing.syncing)、本地接口刚重启/报错。
157
+ const scheduleRetry = () => {
158
+ if (!mounted.current) return;
159
+ const delay = QUICK_RETRY_MS[retryStep.current];
160
+ if (delay === undefined) return;
161
+ retryStep.current += 1;
162
+ if (retryTimer.current !== null) clearTimeout(retryTimer.current);
163
+ retryTimer.current = setTimeout(() => {
164
+ retryTimer.current = null;
165
+ load();
166
+ }, delay);
167
+ };
168
+
124
169
  const load = useCallback(async (force) => {
125
170
  try {
126
171
  const res = await fetch(USAGE_PATH + "?lang=" + lang + (force ? "&refresh=1" : ""), { cache: "no-store" });
@@ -134,9 +179,15 @@ window.__ModuleLoader__.load({
134
179
  if (!mounted.current) return;
135
180
  setData(body);
136
181
  setError("");
182
+ // 宿主还在拉官方价 → 价格只是"本地价格表"的历史值,几秒后再确认一次;
183
+ // 已经拿到官方价(或手动模式/同步失败)就不再补请求。
184
+ const pricing = body.pricing && typeof body.pricing === "object" ? body.pricing : null;
185
+ if (pricing && pricing.mode !== "manual" && pricing.syncing === true) scheduleRetry();
186
+ else retryStep.current = 0;
137
187
  } catch (e) {
138
188
  if (!mounted.current) return;
139
189
  setError(e instanceof Error ? e.message : String(e));
190
+ scheduleRetry();
140
191
  }
141
192
  }, [lang]);
142
193
 
@@ -158,6 +209,10 @@ window.__ModuleLoader__.load({
158
209
  return () => {
159
210
  mounted.current = false;
160
211
  clearInterval(timer);
212
+ if (retryTimer.current !== null) {
213
+ clearTimeout(retryTimer.current);
214
+ retryTimer.current = null;
215
+ }
161
216
  try {
162
217
  if (typeof off === "function") off();
163
218
  } catch (e) { /* 忽略退订错误 */ }
@@ -169,22 +224,49 @@ window.__ModuleLoader__.load({
169
224
  const pricing = data && data.pricing ? data.pricing : null;
170
225
  const symbol = balance && balance.currency === "USD" ? "$" : "¥";
171
226
  const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
172
- // 价格来源提示:模型改名按现名计价 / 价格取自内置表(同步失败或该模型不在官方页)
173
- // 时明示,避免"官方已调价但卡片静静显示内置旧价"。
227
+ // 价格来源提示:模型改名按现名计价 / 价格来自本地价格表或内置表时明示,避免
228
+ // "官方已调价但卡片静静显示旧价"。
229
+ // friendly=true 表示只是"价格从哪来"的良性说明(可点击切回普通状态);
230
+ // friendly=false 表示同步失败/旧价兜底,属于异常,必须一直可见。
231
+ //
232
+ // 0.7.0 起价格有三层(宿主 pricing.source):
233
+ // remote = 本次已从官方页同步到;
234
+ // file = 本地价格表(上次同步到的官方价 / 用户手改价)→ 属于正常态,不置空卡片,
235
+ // 只有"这次同步确实失败"才提示,并写明这份价是什么时候的;
236
+ // seed = 内置兜底(本地价格表缺失/损坏且官方页也拿不到)。
237
+ // 另外宿主正在后台同步(syncing)时不提示:这是正常过程,不是"未同步"。
174
238
  const hints = [];
239
+ const addHint = (text, friendly) => hints.push({ text, friendly });
240
+ const at = fmtWhen(pricing && (pricing.layerAt || pricing.syncedAt));
241
+ const withAt = (key) => t(key).replace("{at}", at || "—");
175
242
  if (pricing && typeof pricing === "object") {
176
243
  const used = pricing.used ? String(pricing.used) : "";
177
- if (pricing.via === "remote-alias" || pricing.via === "remote-family") {
178
- hints.push(t("price.alias").replace("{model}", used));
179
- } else if (pricing.source === "seed" && pricing.via !== "fallback") {
180
- hints.push(pricing.syncOk === false
181
- ? t("price.stale")
182
- : t("price.builtin").replace("{model}", used));
244
+ const via = String(pricing.via || "");
245
+ if (via === "remote-alias" || via === "remote-family" || via === "file-alias" || via === "file-family") {
246
+ addHint(t("price.alias").replace("{model}", used), true);
247
+ } else if (pricing.syncing === true) {
248
+ // 后台正在拉官方页:此刻显示的是本地价格表里的价,属于正常过程,不提示。
249
+ } else if (pricing.source === "file") {
250
+ if (pricing.mode !== "manual" && pricing.syncOk === false) addHint(withAt("price.lastGood"), false);
251
+ } else if (pricing.source === "seed" && via !== "fallback") {
252
+ if (pricing.mode === "manual") {
253
+ // 手动模式下不联网,模型不在表里只是"用了内置条目",不是同步失败
254
+ addHint(t("price.builtin").replace("{model}", used), true);
255
+ } else if (pricing.syncOk === false) {
256
+ addHint(t("price.stale"), false);
257
+ } else {
258
+ addHint(t("price.builtin").replace("{model}", used), true);
259
+ }
183
260
  } else if (pricing.syncOk === false && pricing.syncedAt) {
184
261
  // 本次刷新失败,但仍在用上次成功同步到的官方价格
185
- hints.push(t("price.lastGood"));
262
+ addHint(withAt("price.lastGood"), false);
186
263
  }
187
264
  }
265
+ const hintText = hints.map((x) => x.text).join(" · ");
266
+ // blocking:同步失败/接口错误这类异常,卡片始终置空、点击只刷新;
267
+ // blanked:异常,或"纯良性提示且用户还没点开普通状态"。
268
+ const blocking = errs.length > 0 || hints.some((x) => !x.friendly);
269
+ const blanked = blocking || (hints.length > 0 && !showNormal);
188
270
  const hintTitle = pricing && typeof pricing.syncError === "string" ? pricing.syncError : "";
189
271
  const dot = {
190
272
  width: 8,
@@ -195,9 +277,30 @@ window.__ModuleLoader__.load({
195
277
  boxShadow: "0 0 4px rgba(0,0,0,.3)",
196
278
  };
197
279
  // 指示灯 hover 提示只显示当前处于高峰/空闲时段,不拼时段明细(太长);
198
- // 价格未同步/按现名计价等提示附在末尾(窄卡片下唯一可见的提示位置)。
199
- const peakTitle = (data && data.peak ? t("peak.peak") : t("peak.off"))
200
- + (hints.length ? " · " + hints.join(" · ") : "");
280
+ // 价格来源提示、价格表位置附在末尾——普通状态(良性提示被点开后)它是提示
281
+ // 唯一可见处,也是用户想知道"价从哪来、去哪儿改价"时的入口。
282
+ const fileInfo = pricing && pricing.file && typeof pricing.file === "object" ? pricing.file : null;
283
+ const sourceNote = !pricing
284
+ ? ""
285
+ : pricing.mode === "manual"
286
+ ? t("price.manual")
287
+ : pricing.source === "file"
288
+ ? t("price.file").replace("{at}", at || "—")
289
+ : "";
290
+ const pathNote = fileInfo && typeof fileInfo.path === "string" && fileInfo.path !== ""
291
+ ? t("price.filePath").replace("{path}", fileInfo.path)
292
+ : "";
293
+ const peakTitle = [
294
+ data && data.peak ? t("peak.peak") : t("peak.off"),
295
+ hintText,
296
+ sourceNote,
297
+ pathNote,
298
+ ].filter((x) => x !== "").join(" · ");
299
+ // 点击卡片:刷新数据;若是纯良性提示,再在「提示态 / 普通态」之间切换。
300
+ const onCardClick = () => {
301
+ if (hints.length > 0 && !blocking) setShowNormal((v) => !v);
302
+ load(true);
303
+ };
201
304
 
202
305
  if (!wide) {
203
306
  return h("div", {
@@ -224,6 +327,50 @@ window.__ModuleLoader__.load({
224
327
  h("span", { style: dot, title: peakTitle }));
225
328
  }
226
329
 
330
+ // A2/A3(价格来源提示 / 错误)出现时,卡片直接置空:不画余额、档单价与峰谷灯,
331
+ // 只在空白卡片上显示提示文字——提示不再和余额数字、单价行抢同一块版面,从根本
332
+ // 上消除遮挡。minHeight 兜住常态卡片高度(CARD_MIN_HEIGHT),侧边栏不跳动;
333
+ // 文字多行时卡片自然变高。提示/错误字号取 12px(与卡片正文同级;原来 10px 可读性
334
+ // 差),字号变大后靠换行留在卡片内,不再用 nowrap + 省略号截断。纯良性提示可由
335
+ // 点击切回普通状态(见 blanked)。
336
+ // 窄卡片(!wide)没有文字空间,维持原样(提示在峰谷灯 hover 里)。
337
+ if (blanked) {
338
+ return h("div", {
339
+ style: {
340
+ boxSizing: "border-box",
341
+ flex: "1 1 100%",
342
+ minWidth: 0,
343
+ minHeight: CARD_MIN_HEIGHT,
344
+ padding: "8px 10px",
345
+ borderRadius: 10,
346
+ border: "1px solid " + C.border,
347
+ background: C.bg,
348
+ color: C.label,
349
+ cursor: "pointer",
350
+ userSelect: "none",
351
+ display: "flex",
352
+ flexDirection: "column",
353
+ justifyContent: "center",
354
+ gap: 3,
355
+ marginTop: 4,
356
+ fontSize: "12px",
357
+ lineHeight: "15px",
358
+ },
359
+ onClick: onCardClick,
360
+ },
361
+ hints.length
362
+ ? h("div", {
363
+ style: { color: C.sub, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
364
+ title: hintTitle || hintText,
365
+ }, hintText)
366
+ : null,
367
+ errs.length
368
+ ? h("div", {
369
+ style: { color: C.err, fontSize: 12, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
370
+ }, errs.join(" | "))
371
+ : null);
372
+ }
373
+
227
374
  const Price = (icon, label, value) =>
228
375
  h("span", { style: { display: "inline-flex", alignItems: "center", gap: 3, minWidth: 0, color: C.sub, whiteSpace: "nowrap" } },
229
376
  h("span", { style: { display: "inline-flex", alignItems: "center" }, title: label }, icon),
@@ -249,7 +396,7 @@ window.__ModuleLoader__.load({
249
396
  fontSize: "12px",
250
397
  lineHeight: "15px",
251
398
  },
252
- onClick: () => load(true),
399
+ onClick: onCardClick,
253
400
  },
254
401
  // 第一行:余额(24px,符号与数字 3px 间距)
255
402
  h("div", { style: { display: "flex", alignItems: "baseline", minWidth: 0, paddingRight: 14, gap: 6 } },
@@ -259,25 +406,17 @@ window.__ModuleLoader__.load({
259
406
  style: { position: "absolute", top: 8, right: 9, ...dot },
260
407
  title: peakTitle,
261
408
  }),
262
- // 底部行:左侧价格来源提示(有则显示,过长省略、hover 看详情)+ 右下角当前档单价
263
- // 注意:容器用 flex-end 让单价恒靠右;提示靠 marginRight:auto 顶到左侧,
264
- // 不能用 space-between——没有提示时单子元素会被推到最左边。
409
+ // 底部行:只剩当前档单价,恒靠右下角。
410
+ // 用 flex-end 而不是 space-between:容器只有一个子元素时 space-between 会把它
411
+ // 推到最左侧(0.6.0 的回归点)。提示文字不在这张卡片上重复显示:异常态由空白
412
+ // 卡片承担,普通态只剩峰谷灯 hover(peakTitle)。
265
413
  h("span", {
266
414
  style: { position: "absolute", left: 9, right: 9, bottom: 4, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 },
267
415
  },
268
- hints.length
269
- ? h("span", {
270
- style: { color: C.sub, fontSize: 10, minWidth: 0, marginRight: "auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
271
- title: hintTitle || hints.join(" · "),
272
- }, hints.join(" · "))
273
- : null,
274
416
  h("span", { style: { display: "inline-flex", alignItems: "center", gap: 5, flex: "none" } },
275
417
  Price(h(IconIn, null), t("price.input"), tier && tier.input),
276
418
  Price(h(IconCache, null), t("price.cache"), tier && tier.cacheRead),
277
- Price(h(IconOut, null), t("price.output"), tier && tier.output))),
278
- errs.length
279
- ? h("div", { style: { color: C.err, fontSize: 10, whiteSpace: "pre-wrap" } }, errs.join(" | "))
280
- : null);
419
+ Price(h(IconOut, null), t("price.output"), tier && tier.output))));
281
420
  }
282
421
 
283
422
  // ---- client plugin body -----------------------------------------
package/lib/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * ok: true,
11
11
  * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
12
12
  * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
13
- * pricing: { source, via, requested, used, syncOk, syncedAt, ... }, // 价格来源/同步状态
13
+ * pricing: { source, via, requested, used, syncOk, syncing, syncedAt, layerAt,
14
+ * mode, file: { path, updatedAt, ... } }, // 价格来源/同步状态
14
15
  * peak, peakHours, // 峰谷时段(北京时间)
15
16
  * updatedAt, errors
16
17
  * }
@@ -18,16 +19,23 @@
18
19
  * `lang` 参数(zh 默认)控制错误消息与峰谷文案的语言;UI 其余文案由
19
20
  * client 端(DSH locale 服务)本地化。
20
21
  *
21
- * 单价数据源(0.5.0 起):
22
+ * 单价数据源(0.7.0 起,三层:remote → file → seed):
23
+ * - 本地价格配置表(lib/pricing-store.js):`<DSH_HOME>/dsh-deepseek-usage/pricing.json`
24
+ * (可用 DEEPSEEK_USAGE_PRICE_FILE 覆盖)。插件启动时先读它并注入 pricing.setFilePrices(),
25
+ * 所以**重启后卡片立刻就有价格**(上次同步到的官方价 / 用户手改的价),不会再退回内置表。
26
+ * 首次运行会把内置快照写进该文件,方便用户直接编辑;文件被手改后无需重启,下次请求
27
+ * (点击卡片即可)会按 mtime 自动重载。
22
28
  * - 默认从 DeepSeek 官方"模型 & 价格"页自动同步(lib/pricing-remote.js):
23
29
  * 每天首次成功同步一次,失败按 10 分钟退避重试;请求带 refresh=1(点击卡片)
24
- * 时立即重拉并等待结果,便于官方刚调价后手动跟新。
30
+ * 时立即重拉并等待结果,便于官方刚调价后手动跟新。同步成功后**合并回写**本地价格表
31
+ * (官方页列出的模型覆盖同名条目,用户自加条目保留),下次重启即可直接用。
25
32
  * - 解析优先走"结构化表格"策略(对官方改版排版不敏感),失败再退回文本策略;
26
- * 两者都失败才使用内置种子表(lib/pricing.js)。
27
- * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或内置表,并通过
33
+ * 两者都失败才使用上一层的价格(本地表 / 内置种子表 lib/pricing.js)。
34
+ * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或本地/内置表,并通过
28
35
  * pricing.syncOk / pricing.source 让界面提示"当前价格未同步",不静默展示旧价。
29
- * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES 映射到现名计价。
30
- * 峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
36
+ * - 本地价格表里 autoUpdate=false 即"手动模式":完全不联网,档单价完全以该表为准。
37
+ * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES / 价格表 aliases 映射到
38
+ * 现名计价。峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
31
39
  *
32
40
  * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
33
41
  * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
@@ -41,11 +49,16 @@ import {
41
49
  DEFAULT_MODEL,
42
50
  PEAK_HOURS_TEXT,
43
51
  PEAK_HOURS_TEXT_EN,
52
+ filePriceMeta,
44
53
  isPeak,
45
54
  remotePriceMeta,
55
+ seedAliases,
56
+ seedPrices,
57
+ setFilePrices,
46
58
  setRemotePrices,
47
59
  unitAt,
48
60
  } from "./pricing.js";
61
+ import { loadPriceTable, priceTableStat, savePriceTable } from "./pricing-store.js";
49
62
  import { fetchOfficialPrices } from "./pricing-remote.js";
50
63
 
51
64
  const name = "dsh-deepseek-usage";
@@ -95,6 +108,130 @@ let lastForcedAt = 0; // 上次手动重拉时刻
95
108
  /** 同步状态(透出到界面:同步失败时让用户看到"当前是内置/旧价",而不是静默展示)。 */
96
109
  let remoteStatus = { ok: false, at: 0, error: null, models: 0, strategy: null };
97
110
 
111
+ // ---- 本地价格配置表状态(进程内镜像;文件才是持久层,见 lib/pricing-store.js) ----
112
+ /** 本地价格表当前状态:路径 / 上次写入时间 / mtime(探测手改)/ 模型数 / 是否自动同步。 */
113
+ let fileState = { path: "", exists: false, updatedAt: null, updatedFrom: null, mtimeMs: 0, models: 0, autoUpdate: true };
114
+
115
+ /** 把一份读取结果注入 pricing 层,并刷新进程内镜像。 */
116
+ function applyPriceFile(loaded, mtimeMs) {
117
+ const table = loaded.table;
118
+ setFilePrices(
119
+ table.models,
120
+ {
121
+ path: loaded.path,
122
+ updatedAt: table.updatedAt,
123
+ source: table.updatedFrom,
124
+ strategy: table.strategy,
125
+ autoUpdate: table.autoUpdate,
126
+ version: table.version,
127
+ },
128
+ table.aliases,
129
+ );
130
+ fileState = {
131
+ path: loaded.path,
132
+ exists: true,
133
+ updatedAt: table.updatedAt,
134
+ updatedFrom: table.updatedFrom,
135
+ mtimeMs: mtimeMs ?? priceTableStat({ path: loaded.path }).mtimeMs,
136
+ models: Object.keys(table.models).length,
137
+ autoUpdate: table.autoUpdate !== false,
138
+ };
139
+ return fileState;
140
+ }
141
+
142
+ /**
143
+ * 启动时载入本地价格配置表:有 → 直接注入(重启后卡片立刻有价);没有 → 用内置快照
144
+ * 生成一份,让用户有个可直接编辑的起点;损坏 → 只记日志、不覆盖文件(避免毁掉用户
145
+ * 正在编辑的内容),内存里仍用内置快照兜底。
146
+ */
147
+ function initPriceFile(ctx) {
148
+ const loaded = loadPriceTable();
149
+ fileState.path = loaded.path;
150
+ for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
151
+
152
+ if (loaded.table) {
153
+ applyPriceFile(loaded);
154
+ if (loaded.table.autoUpdate === false) {
155
+ ctx.logger.info(`dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,手动模式:不再联网同步)`);
156
+ } else {
157
+ ctx.logger.info(
158
+ `dsh-deepseek-usage: 已载入本地价格表 ${loaded.path}(${fileState.models} 个模型,上次写入 ${fileState.updatedAt ? new Date(fileState.updatedAt).toISOString() : "未知"})`,
159
+ );
160
+ }
161
+ return;
162
+ }
163
+
164
+ const seed = { models: seedPrices(), aliases: seedAliases() };
165
+ if (loaded.exists) {
166
+ ctx.logger.warn(`dsh-deepseek-usage: 本地价格表无法解析(${loaded.error}),本次先用内置快照,文件未被覆盖:${loaded.path}`);
167
+ setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
168
+ fileState = {
169
+ ...fileState,
170
+ exists: true,
171
+ updatedFrom: "built-in",
172
+ models: Object.keys(seed.models).length,
173
+ autoUpdate: true,
174
+ mtimeMs: priceTableStat({ path: loaded.path }).mtimeMs, // 用户改好文件后 mtime 变化即可自动重载
175
+ };
176
+ return;
177
+ }
178
+
179
+ try {
180
+ const written = savePriceTable({
181
+ models: seed.models,
182
+ aliases: seed.aliases,
183
+ updatedAt: Date.now(),
184
+ updatedFrom: "built-in",
185
+ strategy: "seed",
186
+ autoUpdate: true,
187
+ });
188
+ applyPriceFile({ path: written.path, table: written.doc });
189
+ ctx.logger.info(`dsh-deepseek-usage: 未找到本地价格表,已用内置快照生成 ${written.path}(可手动编辑,autoUpdate=false 可关闭自动同步)`);
190
+ } catch (error) {
191
+ ctx.logger.warn(`dsh-deepseek-usage: 无法写入本地价格表 ${loaded.path}(${error instanceof Error ? error.message : String(error)}),本次用内置快照`);
192
+ setFilePrices(seed.models, { path: loaded.path, updatedAt: null, source: "built-in", strategy: "seed", autoUpdate: true }, seed.aliases);
193
+ fileState = { ...fileState, updatedFrom: "built-in", models: Object.keys(seed.models).length };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * 手改价格表后免重启生效:每次请求先比 mtime,变了就重载(一次 stat,成本可忽略)。
199
+ * 只重载"能解析"的文件;解析失败保持现状并记日志。
200
+ */
201
+ function reloadPriceFileIfChanged(ctx) {
202
+ if (!fileState.path) return;
203
+ const stat = priceTableStat({ path: fileState.path });
204
+ if (!stat.exists || stat.mtimeMs === fileState.mtimeMs) return;
205
+ const loaded = loadPriceTable({ path: fileState.path });
206
+ if (!loaded.table) {
207
+ fileState.mtimeMs = stat.mtimeMs; // 别每请求都重读坏文件
208
+ ctx.logger.warn(`dsh-deepseek-usage: 本地价格表改动后无法解析,继续用上一次的价格:${loaded.error}`);
209
+ return;
210
+ }
211
+ applyPriceFile(loaded, stat.mtimeMs);
212
+ for (const warning of loaded.warnings ?? []) ctx.logger.warn(`dsh-deepseek-usage: ${warning}`);
213
+ ctx.logger.info(`dsh-deepseek-usage: 本地价格表已重新载入 ${fileState.path}(${fileState.models} 个模型)`);
214
+ }
215
+
216
+ /**
217
+ * 把官方同步结果合并回写本地价格表(官方页列出的模型覆盖同名条目,用户自加条目保留),
218
+ * 并同步刷新内存里的 file 层——这样"这次同步失败"的后续请求也还能用上最新一份官方价。
219
+ */
220
+ function persistOfficialPrices(ctx, models, meta) {
221
+ try {
222
+ const written = savePriceTable({
223
+ models,
224
+ updatedAt: meta.fetchedAt || Date.now(),
225
+ updatedFrom: meta.source,
226
+ strategy: meta.strategy,
227
+ });
228
+ applyPriceFile({ path: written.path, table: written.doc }, priceTableStat({ path: written.path }).mtimeMs);
229
+ ctx.logger.info(`dsh-deepseek-usage: 官方价格已回写本地价格表 ${written.path}(共 ${fileState.models} 个模型)`);
230
+ } catch (error) {
231
+ ctx.logger.warn(`dsh-deepseek-usage: 官方价格回写本地价格表失败(本次仍按内存里的官方价显示):${error instanceof Error ? error.message : String(error)}`);
232
+ }
233
+ }
234
+
98
235
  /** 北京时间日期键(yyyy-mm-dd)。 */
99
236
  function beijingDayKey(ms) {
100
237
  const d = new Date(ms + SHANGHAI_OFFSET_MS);
@@ -106,10 +243,13 @@ function beijingDayKey(ms) {
106
243
  /**
107
244
  * 保证官方价格已同步:每天首次自动拉取一次,失败则按 RETRY_GAP_MS 退避重试;
108
245
  * force(点击卡片 refresh=1)时立即重拉并等待。
109
- * 拉取成功 → pricing.setRemotePrices() 注入;失败 → 记日志并记录状态,
110
- * 继续用内置/上次数据,同时把失败状态透出到界面。
246
+ * 拉取成功 → pricing.setRemotePrices() 注入 + 合并回写本地价格表;失败 → 记日志并记录
247
+ * 状态,继续用本地表/上次数据,同时把失败状态透出到界面。
248
+ * 注意:非强制(自动)路径不在请求内等待拉取结果,界面用 pricing.syncing 表示"正在同步",
249
+ * 免得把一次正常的后台刷新误报成"未同步"。
111
250
  */
112
251
  async function refreshRemote(ctx, force) {
252
+ if (!fileState.autoUpdate) return; // 手动模式:完全不联网
113
253
  const now = Date.now();
114
254
  if (!force) {
115
255
  if (remoteDay === beijingDayKey(now)) return; // 今天已成功同步过
@@ -137,10 +277,11 @@ async function refreshRemote(ctx, force) {
137
277
  ctx.logger.info(
138
278
  `dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,策略 ${meta.strategy},来源 ${meta.source})`,
139
279
  );
280
+ persistOfficialPrices(ctx, models, meta);
140
281
  } catch (error) {
141
282
  const message = error instanceof Error ? error.message : String(error);
142
283
  remoteStatus = { ...remoteStatus, ok: false, error: message };
143
- ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用内置/上次价格: ${message}`);
284
+ ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用本地价格表/上次价格: ${message}`);
144
285
  } finally {
145
286
  remoteInFlight = null;
146
287
  }
@@ -202,6 +343,13 @@ function resolveQuery(req) {
202
343
  }
203
344
 
204
345
  function apply(ctx) {
346
+ // ---- 本地价格配置表:启动即载入(重启后卡片立刻有价,不再退回内置表)----
347
+ try {
348
+ initPriceFile(ctx);
349
+ } catch (error) {
350
+ ctx.logger.warn(`dsh-deepseek-usage: 载入本地价格表失败,改用内置快照:${error instanceof Error ? error.message : String(error)}`);
351
+ }
352
+
205
353
  ctx.effect(
206
354
  () => ctx.webServer.register({
207
355
  kind: "exact",
@@ -216,7 +364,14 @@ function apply(ctx) {
216
364
  const { lang, refresh } = resolveQuery(req);
217
365
  const msg = MESSAGES[lang] || MESSAGES.zh;
218
366
 
219
- // ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待)----
367
+ // ---- 手改价格表免重启生效(mtime 变了才重读)----
368
+ try {
369
+ reloadPriceFileIfChanged(ctx);
370
+ } catch (error) {
371
+ ctx.logger.warn(`dsh-deepseek-usage: 重载本地价格表失败:${error instanceof Error ? error.message : String(error)}`);
372
+ }
373
+
374
+ // ---- 官方价格自动同步(每日首次后台拉取;点击卡片强制并等待;手动模式跳过)----
220
375
  await refreshRemote(ctx, refresh);
221
376
 
222
377
  // ---- 当前模型当前档单价(展示用)----
@@ -276,19 +431,32 @@ function apply(ctx) {
276
431
  model: unit.model,
277
432
  known: unit.known,
278
433
  },
279
- // 价格来源与同步状态:让"官方页改版/网络失败 → 正在用内置旧价"可见,
434
+ // 价格来源与同步状态:让"官方页改版/网络失败 → 正在用旧价"可见,
280
435
  // 而不是静默按过期价格展示。
281
436
  pricing: {
282
- source: unit.source, // remote=官方页同步 / seed=内置兜底
283
- via: unit.via, // remote | remote-alias | remote-family | alias | seed | fallback
437
+ source: unit.source, // remote=本次官方页同步 / file=本地价格表 / seed=内置兜底
438
+ via: unit.via, // remote* | file* | alias | seed | fallback
284
439
  requested: unit.requested, // 当前启用的模型 id
285
440
  used: unit.model, // 实际取价使用的模型 id
441
+ mode: fileState.autoUpdate ? "auto" : "manual", // manual=价格表 autoUpdate:false,不联网
442
+ syncing: remoteInFlight !== null, // 后台正在拉官方页(别误报成"未同步")
286
443
  syncOk: remoteStatus.ok,
287
444
  syncedAt: remoteStatus.at || null,
445
+ layerAt: unit.layerAt, // 当前这份单价所属层的时间戳(remote/file)
288
446
  strategy: remoteStatus.strategy, // table=结构化表格 / flat=文本兜底
289
447
  modelCount: remoteStatus.models,
290
448
  syncError: remoteStatus.error,
291
449
  remoteMeta: remotePriceMeta(),
450
+ // 本地价格配置表(用户可编辑;路径透出给界面,方便知道去哪儿改价)
451
+ file: fileState.path
452
+ ? {
453
+ path: fileState.path,
454
+ updatedAt: fileState.updatedAt,
455
+ updatedFrom: fileState.updatedFrom,
456
+ autoUpdate: fileState.autoUpdate,
457
+ models: fileState.models,
458
+ }
459
+ : null,
292
460
  },
293
461
  updatedAt: now,
294
462
  errors,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-deepseek-usage — 本地价格配置表(磁盘文件,lib/pricing.js 的数据源之一)。
3
+ *
4
+ * 为什么有这一层:价格同步状态原来只活在进程内存里,dsh 一重启就没了,于是重启后
5
+ * 的第一张卡片只能用代码内置的价格表(表现为"价格未同步(用内置表)")。现在把
6
+ * "模型 → 档单价"从代码里抽出来存成配置文件:
7
+ *
8
+ * 启动 → 读本文件(上次同步到的官方价 / 用户手改的价)→ 卡片立刻有价
9
+ * 每天/点击 → 拉官方价格页 → 合并回写本文件(官方条目覆盖同名项,用户自加条目保留)
10
+ *
11
+ * 路径解析优先级:
12
+ * 1. 环境变量 DEEPSEEK_USAGE_PRICE_FILE(显式指定,测试/多实例用)
13
+ * 2. <DSH_HOME|~/.dsh>/dsh-deepseek-usage/pricing.json
14
+ *
15
+ * 文件格式(JSON;允许**整行** `//` 注释,方便手改):
16
+ * {
17
+ * "version": 1,
18
+ * "autoUpdate": true, // false = 手动模式:不再联网同步,直接用本表
19
+ * "updatedAt": 1789090582880, // 上次写入时间(ms;也接受 ISO 字符串)
20
+ * "updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
21
+ * "strategy": "table", // 官方页解析策略(table=结构化表格 / flat=文本兜底 / seed=内置快照)
22
+ * "aliases": { "deepseek-v4-flash": "deepseek-flash" },
23
+ * "models": {
24
+ * "deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
25
+ * }
26
+ * }
27
+ * 单价单位:元 / 百万 tokens;input / cacheRead / output 均为 [高峰, 空闲] 两档
28
+ * (cacheWrite 目前官方不计费,恒 0)。也接受"整个文件就是模型映射"的简写形式:
29
+ * { "deepseek-flash": { "input": [2, 1], ... } }
30
+ *
31
+ * 写入策略:读-改-写 + 原子替换(先写同目录 .tmp 再 rename),避免半截文件。
32
+ * 本文件不做任何网络访问,也不含运行时状态(解析 → 返回,交给调用方注入)。
33
+ */
34
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+
38
+ /** 价格表文件格式版本(结构不兼容时递增)。 */
39
+ export const PRICE_FILE_VERSION = 1;
40
+ /** 价格表路径覆盖(便于测试与多实例)。 */
41
+ const FILE_ENV = "DEEPSEEK_USAGE_PRICE_FILE";
42
+ /** DSH 家目录覆盖(与 dsh-home-paths 的约定一致)。 */
43
+ const HOME_ENV = "DSH_HOME";
44
+ /** 插件在 DSH 家目录下的子目录 / 文件名。 */
45
+ const DIR_NAME = "dsh-deepseek-usage";
46
+ const FILE_NAME = "pricing.json";
47
+
48
+ /** 写进文件的格式说明(JSON 无注释,只能占一个字段)。 */
49
+ const FILE_COMMENT =
50
+ "dsh-deepseek-usage 价格表:input/cacheRead/output 为 [高峰, 空闲] 两档单价,单位 元/百万 tokens;"
51
+ + "autoUpdate=true 时插件每天首次使用(或点击卡片)会拉取官方价格页并回写本文件——"
52
+ + "官方页列出的模型覆盖同名条目,你自己加的条目保留;autoUpdate=false 表示不再联网同步,完全以本表为准。"
53
+ + "允许整行 // 注释。";
54
+
55
+ /** 当前生效的价格表路径(env > DSH_HOME/dsh-deepseek-usage/pricing.json > ~/.dsh/...)。 */
56
+ export function defaultPriceFilePath() {
57
+ const fromEnv = process.env[FILE_ENV];
58
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
59
+ const home = process.env[HOME_ENV];
60
+ const base = typeof home === "string" && home.trim() !== "" ? home.trim() : join(homedir(), ".dsh");
61
+ return join(base, DIR_NAME, FILE_NAME);
62
+ }
63
+
64
+ /** 只剥掉"整行 //"注释:URL 里的 `//` 在行中间,不会被误伤。 */
65
+ function stripLineComments(text) {
66
+ return text
67
+ .split(/\r?\n/)
68
+ .map((line) => (/^\s*\/\//.test(line) ? "" : line))
69
+ .join("\n");
70
+ }
71
+
72
+ /** [高峰, 空闲] 二元组校验。 */
73
+ const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
74
+
75
+ /** 时间容忍数字(ms)/ ISO 字符串。 */
76
+ function toMs(value) {
77
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
78
+ if (typeof value === "string") {
79
+ const n = Number(value);
80
+ if (Number.isFinite(n) && n > 0) return n;
81
+ const t = Date.parse(value);
82
+ if (Number.isFinite(t)) return t;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** 清洗模型映射:非法条目丢弃并记入 dropped(缺失的档位回退到 input 档)。 */
88
+ function cleanModels(raw) {
89
+ const models = {};
90
+ const dropped = [];
91
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { models, dropped };
92
+ for (const [id, entry] of Object.entries(raw)) {
93
+ if (typeof id !== "string" || id === "" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
94
+ dropped.push(String(id));
95
+ continue;
96
+ }
97
+ const input = okPair(entry.input) ? entry.input.slice() : null;
98
+ if (!input) {
99
+ dropped.push(id);
100
+ continue;
101
+ }
102
+ const cacheWrite = Number(entry.cacheWrite);
103
+ models[id] = {
104
+ input,
105
+ cacheRead: okPair(entry.cacheRead) ? entry.cacheRead.slice() : input.slice(),
106
+ cacheWrite: Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : 0,
107
+ output: okPair(entry.output) ? entry.output.slice() : input.slice(),
108
+ };
109
+ }
110
+ return { models, dropped };
111
+ }
112
+
113
+ /** 清洗别名映射(旧名 → 现名)。 */
114
+ function cleanAliases(raw) {
115
+ const aliases = {};
116
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return aliases;
117
+ for (const [from, to] of Object.entries(raw)) {
118
+ if (typeof from === "string" && from !== "" && typeof to === "string" && to !== "") aliases[from] = to;
119
+ }
120
+ return aliases;
121
+ }
122
+
123
+ /** 按键名排序(让文件 diff 稳定、便于人读)。 */
124
+ function sorted(obj) {
125
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
126
+ }
127
+
128
+ /**
129
+ * 解析价格表 JSON 文本。
130
+ * @throws 根节点/模型条目完全不合法时抛错(调用方据此判定"文件损坏")。
131
+ * @returns {{ doc: { version, autoUpdate, updatedAt, updatedFrom, strategy, aliases, models }, dropped: string[] }}
132
+ */
133
+ export function parsePriceTable(text) {
134
+ const raw = JSON.parse(stripLineComments(String(text)));
135
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("价格表根节点必须是对象");
136
+
137
+ // 显式结构(带 models/autoUpdate/... 字段)与简写结构(整个文件就是模型映射)都支持。
138
+ const explicit = ["models", "aliases", "autoUpdate", "updatedAt", "updatedFrom", "strategy", "version"].some(
139
+ (k) => raw[k] !== undefined,
140
+ );
141
+ const { models, dropped } = cleanModels(explicit ? raw.models : raw);
142
+ if (Object.keys(models).length === 0) {
143
+ throw new Error("价格表里没有可用模型条目(每项至少要有 input: [高峰, 空闲])");
144
+ }
145
+ return {
146
+ doc: {
147
+ version: Number.isFinite(Number(raw.version)) ? Number(raw.version) : PRICE_FILE_VERSION,
148
+ autoUpdate: raw.autoUpdate !== false,
149
+ updatedAt: toMs(raw.updatedAt),
150
+ updatedFrom: typeof raw.updatedFrom === "string" && raw.updatedFrom !== "" ? raw.updatedFrom : null,
151
+ strategy: typeof raw.strategy === "string" && raw.strategy !== "" ? raw.strategy : null,
152
+ aliases: explicit ? sorted(cleanAliases(raw.aliases)) : {},
153
+ models: sorted(models),
154
+ },
155
+ dropped,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * 读取价格表文件。
161
+ * @returns {{ path: string, exists: boolean, error: string|null, table: object|null, warnings: string[] }}
162
+ * exists=false 表示文件不存在(首次运行,error 为 null);table=null 且 error 非空表示文件损坏。
163
+ */
164
+ export function loadPriceTable(options = {}) {
165
+ const path = options.path ?? defaultPriceFilePath();
166
+ let text;
167
+ try {
168
+ text = readFileSync(path, "utf8");
169
+ } catch (error) {
170
+ const code = error && typeof error === "object" ? error.code : void 0;
171
+ return {
172
+ path,
173
+ exists: false,
174
+ error: code === "ENOENT" ? null : error instanceof Error ? error.message : String(error),
175
+ table: null,
176
+ warnings: [],
177
+ };
178
+ }
179
+ try {
180
+ const { doc, dropped } = parsePriceTable(text);
181
+ return {
182
+ path,
183
+ exists: true,
184
+ error: null,
185
+ table: doc,
186
+ warnings: dropped.length > 0 ? [`忽略 ${dropped.length} 个无法解析的模型条目:${dropped.join(", ")}`] : [],
187
+ };
188
+ } catch (error) {
189
+ return {
190
+ path,
191
+ exists: true,
192
+ error: error instanceof Error ? error.message : String(error),
193
+ table: null,
194
+ warnings: [],
195
+ };
196
+ }
197
+ }
198
+
199
+ /** 文件状态(用于"手改价格表后免重启生效"的变更探测)。 */
200
+ export function priceTableStat(options = {}) {
201
+ const path = options.path ?? defaultPriceFilePath();
202
+ try {
203
+ const st = statSync(path);
204
+ return { path, exists: true, mtimeMs: st.mtimeMs, size: st.size };
205
+ } catch {
206
+ return { path, exists: false, mtimeMs: 0, size: 0 };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 合并写回价格表:patch.models / patch.aliases 覆盖同名项,其余条目(含用户自加模型)
212
+ * 保留;未给出的字段沿用磁盘上的旧值。写盘为"临时文件 + rename"原子替换。
213
+ * @returns {{ path: string, doc: object }}
214
+ */
215
+ export function savePriceTable(patch = {}, options = {}) {
216
+ const path = options.path ?? defaultPriceFilePath();
217
+ const base = loadPriceTable({ path }).table ?? {
218
+ version: PRICE_FILE_VERSION,
219
+ autoUpdate: true,
220
+ updatedAt: null,
221
+ updatedFrom: null,
222
+ strategy: null,
223
+ aliases: {},
224
+ models: {},
225
+ };
226
+ const { models } = cleanModels({ ...base.models, ...(patch.models ?? {}) });
227
+ const aliases = cleanAliases({ ...base.aliases, ...(patch.aliases ?? {}) });
228
+ const doc = {
229
+ $comment: FILE_COMMENT,
230
+ version: PRICE_FILE_VERSION,
231
+ autoUpdate: patch.autoUpdate ?? base.autoUpdate ?? true,
232
+ updatedAt: patch.updatedAt ?? Date.now(),
233
+ updatedFrom: patch.updatedFrom ?? base.updatedFrom ?? null,
234
+ strategy: patch.strategy ?? base.strategy ?? null,
235
+ aliases: sorted(aliases),
236
+ models: sorted(models),
237
+ };
238
+
239
+ mkdirSync(dirname(path), { recursive: true });
240
+ const tmp = `${path}.${process.pid}.tmp`;
241
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
242
+ try {
243
+ renameSync(tmp, path);
244
+ } catch (error) {
245
+ try {
246
+ rmSync(tmp, { force: true });
247
+ } catch {
248
+ /* 清理失败不影响报错 */
249
+ }
250
+ throw error;
251
+ }
252
+ return { path, doc };
253
+ }
package/lib/pricing.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * dsh-deepseek-usage — 定价表(元 / 百万 tokens)。
3
3
  *
4
- * 价格来源(0.5.0 起):
5
- * - 首选:DeepSeek 官方"模型 & 价格"页(lib/pricing-remote.js 抓取,每日首次
6
- * 访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块,作为 LIVE
7
- * 层按模型覆盖内置种子——官方调价只需页面更新,无需再改本文件。
8
- * - 兜底:下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
9
- * (2026-09-10,与当时官方价格页一致),仅当官方页抓取/解析失败时生效。
4
+ * 价格来源优先级(0.7.0 起,三层):
5
+ * - remote(进程内最新):DeepSeek 官方"模型 & 价格"页(lib/pricing-remote.js 抓取,
6
+ * 每日首次访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块——官方调价
7
+ * 只需页面更新,无需改本文件。
8
+ * - file(本地价格配置表):lib/pricing-store.js 维护的 pricing.json,内容是"上次同步到
9
+ * 的官方价 + 用户手改的价",经 setFilePrices() 注入。进程重启后 remote 层为空,卡片
10
+ * 直接用它,因此不会再退回内置表(也就不会再出现"价格未同步(用内置表)")。
11
+ * - seed(内置兜底):下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
12
+ * (2026-09-10,与当时官方价格页一致)。首次运行会拿它生成初始 pricing.json;
13
+ * 只有"本地价格表缺失/损坏且官方页也拿不到"时才会真正被展示。
10
14
  *
11
15
  * 模型改名(0.6.0 起):官方会把模型改名并把旧名保留为别名(如
12
16
  * deepseek-v4-flash / deepseek-v4-flash-vision-exp → deepseek-flash)。旧名若
@@ -32,7 +36,8 @@ export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
32
36
  /**
33
37
  * 内置种子表:input(缓存未命中)/ cacheRead(缓存命中)/ output 均为
34
38
  * [高峰, 空闲] 二元组;cacheWrite 为常量 0。
35
- * 仅作离线兜底,正常情况会被官方价格页同步结果覆盖。
39
+ * 只用于两处:首次运行生成 pricing.json(lib/pricing-store.js),以及"价格表缺失/
40
+ * 损坏且官方页也拿不到"时的最后兜底;正常运行时都会被 file / remote 层覆盖。
36
41
  */
37
42
  const PRICE_TABLE = {
38
43
  'deepseek-flash': { input: [2, 1], cacheRead: [0.04, 0.02], cacheWrite: 0, output: [8, 4] },
@@ -55,6 +60,12 @@ export const MODEL_ALIASES = {
55
60
  let remoteTable = null;
56
61
  /** 远端同步元信息(解析策略 / 抓取时间 / 来源),供界面展示同步状态。 */
57
62
  let remoteMeta = null;
63
+ /** 本地价格配置表(setFilePrices 注入;重启后由它接管,见 lib/pricing-store.js)。 */
64
+ let fileTable = null;
65
+ /** 本地价格表元信息(路径 / 上次写入时间 / 来源)。 */
66
+ let fileMeta = null;
67
+ /** 本地价格表里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并使用。 */
68
+ let fileAliases = {};
58
69
 
59
70
  /** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
60
71
  export const DEFAULT_MODEL = 'deepseek-flash';
@@ -84,15 +95,11 @@ export function isPeak(ms) {
84
95
  return PEAK_WINDOWS.some(([start, end]) => h >= start && h < end);
85
96
  }
86
97
 
87
- /** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回种子。 */
88
- export function setRemotePrices(table, meta) {
89
- if (!table || typeof table !== 'object') {
90
- remoteTable = null;
91
- remoteMeta = null;
92
- return;
93
- }
94
- const clean = {};
98
+ /** 清洗一张价格表(形状校验 + 档位回退),供 remote / file 两层共用。 */
99
+ function cleanTable(table) {
100
+ if (!table || typeof table !== 'object') return null;
95
101
  const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
102
+ const clean = {};
96
103
  for (const [id, e] of Object.entries(table)) {
97
104
  if (typeof id !== 'string' || id === '' || !e || typeof e !== 'object') continue;
98
105
  const input = okPair(e.input) ? e.input : null;
@@ -104,7 +111,12 @@ export function setRemotePrices(table, meta) {
104
111
  output: okPair(e.output) ? e.output : input,
105
112
  };
106
113
  }
107
- remoteTable = Object.keys(clean).length > 0 ? clean : null;
114
+ return Object.keys(clean).length > 0 ? clean : null;
115
+ }
116
+
117
+ /** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回下一层。 */
118
+ export function setRemotePrices(table, meta) {
119
+ remoteTable = cleanTable(table);
108
120
  remoteMeta = remoteTable && meta && typeof meta === 'object' ? { ...meta } : null;
109
121
  }
110
122
 
@@ -118,6 +130,38 @@ export function hasRemotePrices() {
118
130
  return remoteTable !== null;
119
131
  }
120
132
 
133
+ /**
134
+ * 注入本地价格配置表(lib/pricing-store.js 的读取结果)。
135
+ * @param table 模型映射(models 字段)
136
+ * @param meta 元信息(path / updatedAt / source / strategy / autoUpdate)
137
+ * @param aliases 文件里的模型别名(旧名 → 现名),与内置 MODEL_ALIASES 合并
138
+ */
139
+ export function setFilePrices(table, meta, aliases) {
140
+ fileTable = cleanTable(table);
141
+ fileMeta = fileTable && meta && typeof meta === 'object' ? { ...meta } : null;
142
+ fileAliases = aliases && typeof aliases === 'object' && !Array.isArray(aliases) ? { ...aliases } : {};
143
+ }
144
+
145
+ /** 本地价格表元信息(未载入时为 null)。 */
146
+ export function filePriceMeta() {
147
+ return fileMeta ? { ...fileMeta } : null;
148
+ }
149
+
150
+ /** 本地价格表是否可用。 */
151
+ export function hasFilePrices() {
152
+ return fileTable !== null;
153
+ }
154
+
155
+ /** 内置种子价格表快照(深拷贝;用于首次运行生成 pricing.json)。 */
156
+ export function seedPrices() {
157
+ return JSON.parse(JSON.stringify(PRICE_TABLE));
158
+ }
159
+
160
+ /** 内置模型改名映射快照(用于首次运行生成 pricing.json)。 */
161
+ export function seedAliases() {
162
+ return { ...MODEL_ALIASES };
163
+ }
164
+
121
165
  /** 模型家族名(用于官方改名后的兜底匹配)。 */
122
166
  function familyOf(id) {
123
167
  const m = /(flash|pro|chat|reasoner)/.exec(id);
@@ -126,41 +170,51 @@ function familyOf(id) {
126
170
 
127
171
  const has = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
128
172
 
129
- /** 在远端表里找模型:精确 → 别名 → 同家族唯一匹配。 */
130
- function remoteLookup(id) {
131
- if (!remoteTable || typeof id !== 'string' || id === '') return null;
132
- if (has(remoteTable, id)) return { id, via: 'remote' };
133
- const alias = MODEL_ALIASES[id];
134
- if (alias && has(remoteTable, alias)) return { id: alias, via: 'remote-alias' };
173
+ /** 别名(旧名 → 现名):本地价格表的 aliases 覆盖内置 MODEL_ALIASES。 */
174
+ function aliasOf(id) {
175
+ if (typeof id !== 'string' || id === '') return undefined;
176
+ return has(fileAliases, id) ? fileAliases[id] : MODEL_ALIASES[id];
177
+ }
178
+
179
+ /** 在某一层价格表里找模型:精确 → 别名 → 同家族唯一匹配。 */
180
+ function layerLookup(table, tag, id) {
181
+ if (!table || typeof id !== 'string' || id === '') return null;
182
+ if (has(table, id)) return { id, via: tag };
183
+ const alias = aliasOf(id);
184
+ if (alias && has(table, alias)) return { id: alias, via: `${tag}-alias` };
135
185
  const fam = familyOf(id);
136
186
  if (fam) {
137
- const hits = Object.keys(remoteTable).filter((k) => familyOf(k) === fam);
187
+ const hits = Object.keys(table).filter((k) => familyOf(k) === fam);
138
188
  // 仅在家族唯一时匹配,避免把 flash 档算成 pro 档。
139
- if (hits.length === 1) return { id: hits[0], via: 'remote-family' };
189
+ if (hits.length === 1) return { id: hits[0], via: `${tag}-family` };
140
190
  }
141
191
  return null;
142
192
  }
143
193
 
144
194
  /**
145
- * 解析模型的计价条目。
146
- * @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'alias'|'seed'|'fallback', known: boolean }}
195
+ * 解析模型的计价条目(优先级:remote → file → seed → chat 兜底)。
196
+ * @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'file'|'file-alias'|'file-family'|'alias'|'seed'|'fallback', source: 'remote'|'file'|'seed', known: boolean }}
147
197
  */
148
198
  function lookup(model) {
149
- const hit = remoteLookup(model);
150
- if (hit) return { price: remoteTable[hit.id], id: hit.id, via: hit.via, known: true };
199
+ const remote = layerLookup(remoteTable, 'remote', model);
200
+ if (remote) return { price: remoteTable[remote.id], id: remote.id, via: remote.via, source: 'remote', known: true };
201
+
202
+ // 进程重启后 remote 层为空 → 先用本地价格表(上次同步到的官方价 / 用户手改价)。
203
+ const file = layerLookup(fileTable, 'file', model);
204
+ if (file) return { price: fileTable[file.id], id: file.id, via: file.via, source: 'file', known: true };
151
205
 
152
- // 远端不可用(尚未同步/同步失败)或该模型确实不在官方页 → 内置表兜底。
153
- const alias = typeof model === 'string' ? MODEL_ALIASES[model] : undefined;
206
+ // 本地表也没有(首次运行、文件损坏、或模型确实不在表里)→ 内置种子兜底。
207
+ const alias = aliasOf(model);
154
208
  if (alias && has(PRICE_TABLE, alias)) {
155
- return { price: PRICE_TABLE[alias], id: alias, via: 'alias', known: true };
209
+ return { price: PRICE_TABLE[alias], id: alias, via: 'alias', source: 'seed', known: true };
156
210
  }
157
211
  if (has(PRICE_TABLE, model)) {
158
- return { price: PRICE_TABLE[model], id: model, via: 'seed', known: true };
212
+ return { price: PRICE_TABLE[model], id: model, via: 'seed', source: 'seed', known: true };
159
213
  }
160
- return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', known: false };
214
+ return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', source: 'seed', known: false };
161
215
  }
162
216
 
163
- /** 模型是否能解析出价格(远端精确/别名/家族,或内置表/别名)。 */
217
+ /** 模型是否能解析出价格(远端精确/别名/家族,或本地表/内置表)。 */
164
218
  export function isKnownModel(model) {
165
219
  return lookup(model).known;
166
220
  }
@@ -168,7 +222,9 @@ export function isKnownModel(model) {
168
222
  /**
169
223
  * 某模型在某一时刻的单位价格(元/百万 tokens)。
170
224
  * 未知模型回退 chat 档;known=false 供调用方提示,避免静默算错。
171
- * via 说明价格来源,供界面区分"官方页同步"与"内置兜底"。
225
+ * via 说明取价路径,source 说明价格来自哪一层(remote 官方页本次同步 / file 本地价格表 /
226
+ * seed 内置兜底);layerAt 是该层数据的时间戳(remote.fetchedAt / file.updatedAt),
227
+ * 供界面说明"这份价是什么时候同步的"。
172
228
  */
173
229
  export function unitAt(model, ms) {
174
230
  const found = lookup(model);
@@ -183,6 +239,12 @@ export function unitAt(model, ms) {
183
239
  requested: typeof model === 'string' ? model : '',
184
240
  known: found.known,
185
241
  via: found.via,
186
- source: found.via.startsWith('remote') ? 'remote' : 'seed',
242
+ source: found.source,
243
+ layerAt:
244
+ found.source === 'remote'
245
+ ? (remoteMeta && Number.isFinite(remoteMeta.fetchedAt) ? remoteMeta.fetchedAt : null)
246
+ : found.source === 'file'
247
+ ? (fileMeta && Number.isFinite(fileMeta.updatedAt) ? fileMeta.updatedAt : null)
248
+ : null,
187
249
  };
188
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougen/dsh-deepseek-usage",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DeepSeek 余额侧边栏插件:余额(官方 API)、当前档单价与峰谷时段指示灯,支持中/英文界面。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",