deepseek-harness-wallet 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.3.1 - 2026-08-23
6
+
7
+ - 修复 Windows DPAPI 账户密钥在重启后无法解密的问题,并在加密文件损坏或密钥不可用时锁定写入,避免覆盖原数据。 / Fixed Windows DPAPI account keys failing to decrypt after restart and locked writes when encrypted data cannot be recovered, preventing accidental overwrite.
8
+ - 修复 Electron/Tauri 同步通知句柄的点击/关闭回调断链,并让所有充值入口统一经过桌面端外链适配与首次域名确认。 / Fixed callback wiring for synchronous Electron/Tauri notification handles and routed every recharge entry through the desktop external-link adapter and first-use domain confirmation.
9
+ - 修复官方价格页改用“周一至周五”表述后同步被误判为结构变化的问题,并把视觉模型的计费生效时间校正为 2026-08-21 上线日。 / Accepted the official pricing page's current weekday-only wording and corrected vision-model billing to its 2026-08-21 launch date.
10
+ - 将周五 18:00 至周一 09:00 作为连续低谷周期:周五提示“周末全天低谷”,周六/周日分别显示当天全天低谷,周一 09:00 前显示剩余多久进入高峰;跨午夜不重复提醒。 / Treated Friday 18:00 through Monday 09:00 as one continuous off-peak period: Friday previews the all-weekend rule, Saturday/Sunday name the current day, Monday shows time remaining to peak, and midnight boundaries do not repeat alerts.
11
+ - 旧客户端在周末收到空峰值窗口,避免未强刷的 Edge 标签页继续误报高峰;账户文件现在自动保留加密 `.bak`,主文件缺失时可自动恢复。当前 91/91 通过。 / Weekend snapshots give stale clients no peak windows so an old Edge tab cannot keep falsely reporting peak; encrypted account files now keep a `.bak` fallback that restores a missing primary file. 91/91 tests pass.
12
+
13
+ ## 0.3.0 - 2026-08-23
14
+
15
+ - 新增 `deepseek-v4-flash-vision-exp` 视觉模型计费,沿用 V4 Flash 的峰谷价格;图片 token 由 Harness 上报后与文本 token 一起计入。 / Added billing for `deepseek-v4-flash-vision-exp` using V4 Flash peak/off-peak rates; image tokens reported by Harness are counted with text tokens.
16
+ - 新增官方价格页自动检查:成功解析并完整校验后才应用新规则,网络失败或页面结构变化时保留内置规则并显示状态。 / Added official pricing-page sync: new rules apply only after complete validation; network failures or schema changes keep the built-in policy and expose the status.
17
+ - 新增 Harness 健康检查:显示宿主/插件版本、兼容性、价格同步状态与无敏感信息的诊断复制。 / Added Harness health checks for host/plugin versions, compatibility, pricing-sync status, and safe diagnostic copying.
18
+ - 多账户 API Key 改为加密存储:Windows 使用当前用户 DPAPI,其他系统使用 AES-GCM 本地密钥文件;兼容旧版明文账户文件并在下次保存时迁移。 / Encrypted multi-account API keys at rest with Windows DPAPI or an AES-GCM local key file; legacy plaintext account files migrate on the next save.
19
+
5
20
  ## 0.2.6 - 2026-08-23
6
21
 
7
22
  - 跟随 DeepSeek 官方规则更新峰谷计费:自 2026-08-23 北京时间 00:00 起,周六、周日全天按低谷价计费;工作日高峰仍为 09:00–12:00、14:00–18:00。 / Updated peak/off-peak billing to match DeepSeek's official rule: from 2026-08-23 00:00 Beijing, Saturday and Sunday are all-day off-peak; weekday peak windows remain 09:00–12:00 and 14:00–18:00.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/deepseek-harness-wallet?label=npm&color=5965d8)](https://www.npmjs.com/package/deepseek-harness-wallet)
4
4
  [![GitHub release](https://img.shields.io/github/v/release/feibi-mochi/deepseek-harness-control-center?label=release&color=5965d8)](https://github.com/feibi-mochi/deepseek-harness-control-center/releases)
5
5
  [![CI](https://github.com/feibi-mochi/deepseek-harness-control-center/actions/workflows/validate.yml/badge.svg)](https://github.com/feibi-mochi/deepseek-harness-control-center/actions/workflows/validate.yml)
6
- [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-0.1.0--rc.8-4aa3ff)](https://github.com/deepseek-ai/DeepSeek-Harness)
6
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-0.1.1--rc.2-4aa3ff)](https://github.com/deepseek-ai/DeepSeek-Harness)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-3b7a57)](./LICENSE)
8
8
 
9
9
  **DeepSeek Harness monitoring, alerts, recharge, and session control center.**
@@ -14,6 +14,8 @@
14
14
 
15
15
  > A local-first companion that keeps account status, per-conversation usage, completion reminders, official recharge, flexible layout, and host-gated session controls beside the DSH composer.
16
16
 
17
+ > **Versions:** GitHub `main` is v0.3.1 source; npm and the latest Release remain v0.3.0. Use the GitHub install only for the current source build.
18
+
17
19
  > If DeepSeek Harness Control Center helps you, please consider leaving a ⭐ Star. Thank you!
18
20
 
19
21
  ## What it does
@@ -23,7 +25,9 @@
23
25
  ```
24
26
 
25
27
  - **Official DeepSeek** — live balance (60s global refresh with fast boot retries), an estimated current-session cost (not an official bill) locked to the price active for each usage event, including the 2026-08-17 peak/off-peak rollout, and token breakdown.
26
- - **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash` and `v4-pro`, indicating real-time pricing windows (peak vs. 50% discount off-peak), countdown to the next switch, and optional desktop switch notifications. Weekdays use 09:00–12:00 and 14:00–18:00 Beijing time; Saturday and Sunday are all-day off-peak from 2026-08-23. Other models keep their flat rates.
28
+ - **Vision model accounting** — `deepseek-v4-flash-vision-exp` is priced like V4 Flash; image tokens reported by the Harness are included with text tokens.
29
+ - **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash`, `v4-pro`, and `v4-flash-vision-exp`. Weekday peak windows are 09:00–12:00 and 14:00–18:00 Beijing time. After Friday 18:00 the card previews “weekend all-day off-peak”; Saturday and Sunday name the current all-day off-peak rule; Monday before 09:00 shows the time remaining to enter peak. Optional notifications treat Friday 18:00 through Monday 09:00 as one continuous off-peak period.
30
+ - **Official pricing sync** — periodically checks the official DeepSeek pricing page and applies only a fully validated table. Network failures retain the last validated rule (or the built-in rule before the first successful sync); an unrecognized page structure is marked for review instead of silently changing billing.
27
31
  - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
28
32
  - **Provider classification** — observed wrapper routes appear in the settings page; opted-in routes join the official token/cost bucket and are priced with the official table.
29
33
  - **Click the chip** to open the detail panel: correctly formatted per-currency balances, cost and token splits, a freely editable low-balance threshold for the active account and currency (two decimals, persisted per account; alerts never mix currencies), manual refresh, and a jump to the official recharge page (first click shows the domain for confirmation — anti-phishing).
@@ -32,7 +36,7 @@
32
36
  - **Completion reminders** — optionally notify when a conversation finishes, with persistent or timed modes, queueing and deduplication for simultaneous completions, cross-tab coordination, and an in-page fallback when system notifications are unavailable.
33
37
  - **Optional permanent deletion** — when the DSH host advertises a real deletion capability, an opt-in setting enables a confirmed permanent-delete action in the session menu; unsupported hosts keep the control disabled.
34
38
  - **Low-balance alert** — below the threshold the chip turns red with a breathing animation and fires one desktop notification; it resets automatically once the balance recovers.
35
- - **Theme-native UI** — built entirely on `--dsw-alias-*` theme variables, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
39
+ - **Theme-native UI** — uses DSH `--dsw-alias-*` variables with safe fallback colors, so light and dark themes both render correctly; the panel closes when you click outside and flips open-direction near screen edges.
36
40
  - **Clear current-session wallet data** — one button clears only the open conversation's token/cost records; it does not delete the conversation, and every other conversation is untouched.
37
41
 
38
42
  ## Multi-account
@@ -40,7 +44,7 @@
40
44
  - Open the wallet panel → **Account Management(账户管理)** to add accounts (name + API key), switch the active one, or remove them.
41
45
  - The first account added becomes the active account automatically and is synced into the credentials seam.
42
46
  - Switching prompts a confirmation because it changes **LLM billing** for subsequent requests: the switch writes the account key into the credentials seam (`credentials.set('DEEPSEEK_API_KEY', ...)`), and since the llm-deepseek provider route resolves that reference per request, the very next LLM call is billed with the new account — no restart needed.
43
- - Account keys are stored in plaintext in `$DSH_HOME/storages/accounts.json`; the UI only shows masked keys. Treat the DSH data directory as a local credential store and protect access to it. Balance lookups prefer the active account's key and fall back to the credentials seam when no account is active.
47
+ - Account keys are encrypted at rest in `$DSH_HOME/storages/accounts.json`: Windows uses the current user's DPAPI; other platforms use an owner-only AES-GCM key file. The UI only shows masked keys. Balance lookups prefer the active account's key and fall back to the credentials seam when no account is active.
44
48
  - Session usage estimates follow the active account's currency: USD-settled accounts show `本约 $x`, converted from the CNY price table at the vendor's long-standing list ratio (not a live FX rate); CNY accounts show `本场 ¥x`. These are local estimates, not an official invoice.
45
49
  - If `DEEPSEEK_API_KEY` is supplied by the launching environment, switching is refused with a clear error (the credentials provider rejects shadowed writes) — unset it in your shell to enable switching.
46
50
 
@@ -65,13 +69,13 @@ Details: [compatibility](#browser-desktop-and-os-compatibility) · [data and tru
65
69
 
66
70
  ## Install
67
71
 
68
- From npm:
72
+ From npm (stable v0.3.0):
69
73
 
70
74
  ```sh
71
75
  dsh plugin --profile web add deepseek-harness-wallet
72
76
  ```
73
77
 
74
- or from GitHub directly:
78
+ or from GitHub `main` (current v0.3.1 source):
75
79
 
76
80
  ```sh
77
81
  dsh plugin --profile web add github:feibi-mochi/deepseek-harness-control-center
@@ -81,8 +85,8 @@ Restart `dsh web`, then hard-refresh the page.
81
85
 
82
86
  ## Quick use
83
87
 
84
- 1. Click the wallet or peak/off-peak card to open its control panel.
85
- 2. Choose horizontal or vertical layout, then use the scale control between 100% and 120%.
88
+ 1. Click the wallet or peak/off-peak card to open its control panel; open the Harness settings card for health and compatibility checks.
89
+ 2. The peak card supports horizontal/vertical layout and 100%–120% scaling. The wallet chip uses a separate scale: 100%–105% in the composer and up to 125% when docked or floating.
86
90
  3. Turn the official recharge button off when you need a smaller card; official and third-party rows can also be shown independently.
87
91
  4. Drag the card to any open area. If it is hard to find after a layout change, use **Reset/Dock(归位/停靠)** in the panel to return it to the sidebar.
88
92
  5. The card follows the host light/dark theme. A hard refresh after upgrading makes sure the new client bundle is loaded.
@@ -139,7 +143,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
139
143
  | --- | --- |
140
144
  | Token accounting | Listens to the `llm/stream` event and buckets per session and provider: `deepseek-official` plus explicitly opted-in wrapper routes use the official bucket; other providers stay third-party; each usage event also locks its contemporaneous official price, so multiple sessions and pricing windows never mix. |
141
145
  | Balance | The wallet plugin itself sends the active key directly only to the official `/user/balance` endpoint. When multi-account switching is enabled, the selected key is also written into the DSH credentials seam; DSH may then use it for subsequent model requests. |
142
- | Accounts | Keys live in `$DSH_HOME/storages/accounts.json` in plaintext, matching the harness's own credential storage. The UI only shows masked keys, and switching writes the chosen key into the credentials seam for subsequent LLM billing. Protect the DSH data directory as you would any local credential store. |
146
+ | Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`, with an encrypted `accounts.json.bak` fallback that restores a missing primary file. Windows uses current-user DPAPI, so copies normally remain tied to the same Windows user. Other platforms use an owner-only AES-GCM key file; move or back up `accounts.json`, `.bak`, and `.key` together. The UI only shows masked keys, and switching writes the chosen key into the credentials seam for subsequent LLM billing. |
143
147
  | Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
144
148
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
145
149
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
@@ -155,15 +159,16 @@ CNY per 1M tokens, curated from official announcements (cache writes are not bil
155
159
  - Since 2026-08-17 00:00 Beijing — peak/off-peak pricing for the v4 models (peak windows Beijing 09:00–12:00 / 14:00–18:00; off-peak is half the peak rate):
156
160
  - v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
157
161
  - v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
162
+ - Since 2026-08-21 — v4-flash-vision-exp launched with the V4 Flash peak/off-peak table: cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9.
158
163
  - Since 2026-08-23 00:00 Beijing — Saturday and Sunday are no longer split into peak/off-peak windows; weekend calls use the off-peak rates all day. Weekday peak windows remain 09:00–12:00 and 14:00–18:00.
159
164
 
160
- deepseek-chat and deepseek-reasoner keep their flat rates. Each usage event is priced when it arrives; upgrading from 0.1.2 migrates legacy counters once using the then-current rate. Costs are estimates; the API-returned balance is authoritative.
165
+ Historical deepseek-chat and deepseek-reasoner records retain their original flat-rate table; this is not a claim that those legacy model names remain currently available. Each usage event is priced when it arrives; upgrading from 0.1.2 migrates legacy counters once using the then-current rate. Costs are estimates; the API-returned balance is authoritative.
161
166
 
162
167
  ## Roadmap
163
168
 
164
- - [ ] Third-party price tables (cost per token)
165
- - [ ] Balance history chart
166
- - [ ] Balance-API adapters for other providers (e.g. Zhipu)
169
+ - [ ] 365-day Token heatmap and rebuildable local usage ledger
170
+ - [ ] Z.ai Coding Plan monitoring, built on a generic provider-adapter contract
171
+ - [ ] Additional provider price/balance adapters only after real-account validation
167
172
 
168
173
  ## License
169
174
 
package/index.js CHANGED
@@ -16,8 +16,9 @@
16
16
  * seam when no account is active;
17
17
  * - the /api/wallet routes the browser chip polls.
18
18
  */
19
- import { randomUUID } from 'node:crypto'
20
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
19
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto'
20
+ import { spawnSync } from 'node:child_process'
21
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
21
22
  import { homedir } from 'node:os'
22
23
  import { dirname, join } from 'node:path'
23
24
 
@@ -26,10 +27,18 @@ export const inject = ['webServer']
26
27
 
27
28
  const OFFICIAL_PROVIDER = 'deepseek-official'
28
29
  const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
30
+ const PLUGIN_VERSION = '0.3.1'
31
+ const PRICING_SOURCE_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/'
32
+ const PRICING_SYNC_INTERVAL_MS = 6 * 60 * 60_000
33
+ const PRICING_SYNC_TIMEOUT_MS = 8_000
34
+ const MIN_HOST_VERSION = '0.1.0-rc.8'
29
35
  const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
30
36
  const STORE_PATH = join(DSH_HOME, 'storages', 'wallet.json')
31
37
  const ACCOUNTS_PATH = join(DSH_HOME, 'storages', 'accounts.json')
32
- const ACCOUNTS_VERSION = 1
38
+ const ACCOUNTS_KEY_PATH = ACCOUNTS_PATH + '.key'
39
+ const ACCOUNTS_BACKUP_PATH = ACCOUNTS_PATH + '.bak'
40
+ const ACCOUNTS_VERSION = 2
41
+ const ACCOUNTS_CRYPTO_VERSION = 1
33
42
  const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
34
43
  const DEFAULT_THRESHOLD = 5
35
44
  const BALANCE_REFRESH_MS = 60_000
@@ -39,10 +48,12 @@ const STORE_VERSION = 2
39
48
  // From 2026-08-23 00:00 Beijing, Saturday and Sunday are off-peak all day.
40
49
  // Exposed to clients via snapshot.pricingWindows so the ring clock renders
41
50
  // from the active policy instead of hard-coding hours in the bundle.
42
- const PEAK_WINDOWS = [{ startHour: 9, endHour: 12 }, { startHour: 14, endHour: 18 }]
51
+ const DEFAULT_PEAK_WINDOWS = [{ startHour: 9, endHour: 12 }, { startHour: 14, endHour: 18 }]
43
52
  const OFF_PEAK_RATE = 0.5
44
53
  const BEIJING_OFFSET_MS = 8 * 3600_000
45
54
  const WEEKEND_OFF_PEAK_SINCE = Date.UTC(2026, 7, 22, 16)
55
+ let peakWindows = DEFAULT_PEAK_WINDOWS.map((window) => ({ ...window }))
56
+ let weekendOffPeakSince = WEEKEND_OFF_PEAK_SINCE
46
57
 
47
58
  function isBeijingWeekend(atMs) {
48
59
  if (!Number.isFinite(atMs)) return false
@@ -52,9 +63,25 @@ function isBeijingWeekend(atMs) {
52
63
  }
53
64
 
54
65
  export function isBeijingPeak(atMs) {
55
- if (atMs >= WEEKEND_OFF_PEAK_SINCE && isBeijingWeekend(atMs)) return false
66
+ if (atMs >= weekendOffPeakSince && isBeijingWeekend(atMs)) return false
56
67
  const hours = ((atMs + BEIJING_OFFSET_MS) % 86_400_000) / 3_600_000
57
- return (hours >= 9 && hours < 12) || (hours >= 14 && hours < 18)
68
+ return peakWindows.some((window) => hours >= window.startHour && hours < window.endHour)
69
+ }
70
+
71
+ export function pricingWindowSnapshot(atMs = Date.now()) {
72
+ const weekendOffPeak = atMs >= weekendOffPeakSince && isBeijingWeekend(atMs)
73
+ return {
74
+ timezone: 'Asia/Shanghai',
75
+ offsetMinutes: 480,
76
+ // Current clients use weekendOffPeak for a full green ring. Empty windows
77
+ // also make stale pre-weekend clients fail neutral instead of falsely
78
+ // claiming Sunday is a peak period until the browser is hard-refreshed.
79
+ windows: weekendOffPeak ? [] : peakWindows.map((window) => ({ ...window })),
80
+ offPeakRate: OFF_PEAK_RATE,
81
+ isPeak: isBeijingPeak(atMs),
82
+ weekendOffPeak,
83
+ weekendOffPeakSince,
84
+ }
58
85
  }
59
86
 
60
87
  // Pricing timeline (CNY per 1M tokens; cacheWrite is not billed by DeepSeek).
@@ -83,11 +110,258 @@ export const PRICE_POLICIES = [
83
110
  'deepseek-v4-pro': { cacheHit: [0.15, 0.3], input: [4.5, 9], output: [13.5, 27] },
84
111
  },
85
112
  },
113
+ {
114
+ // Vision Exp was released on 2026-08-21 and uses the V4 Flash table.
115
+ since: Date.UTC(2026, 7, 20, 16),
116
+ peakOffPeak: true,
117
+ models: {
118
+ 'deepseek-v4-flash-vision-exp': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
119
+ },
120
+ },
86
121
  ]
87
122
 
123
+ let activePricePolicies = PRICE_POLICIES
124
+ let pricingSync = {
125
+ status: 'built-in',
126
+ source: PRICING_SOURCE_URL,
127
+ checkedAt: 0,
128
+ etag: null,
129
+ lastModified: null,
130
+ ruleVersion: 'built-in-2026-08-23',
131
+ modelCount: 3,
132
+ message: '尚未检查官方价格页',
133
+ }
134
+
135
+ function decodeHtmlText(value) {
136
+ return String(value)
137
+ .replace(/<br\s*\/?>/gi, ' ')
138
+ .replace(/<[^>]*>/g, ' ')
139
+ .replace(/&amp;/gi, '&')
140
+ .replace(/&nbsp;/gi, ' ')
141
+ .replace(/&#39;/g, "'")
142
+ .replace(/&quot;/gi, '"')
143
+ .replace(/\s+/g, ' ')
144
+ .trim()
145
+ }
146
+
147
+ function numericCells(row, count) {
148
+ const values = row.slice(Math.max(0, row.length - count)).map((cell) => {
149
+ const match = decodeHtmlText(cell).match(/\d+(?:\.\d+)?/)
150
+ return match === null ? null : Number(match[0])
151
+ })
152
+ return values.length === count && values.every((value) => Number.isFinite(value)) ? values : null
153
+ }
154
+
155
+ /**
156
+ * Parse the stable, server-rendered pricing table from DeepSeek's official
157
+ * documentation. A failed parse is deliberately treated as review-required;
158
+ * the wallet never silently bills from a partially understood page.
159
+ */
160
+ export function parseOfficialPricingHtml(html) {
161
+ if (typeof html !== 'string' || html.length < 500) throw new Error('official pricing page is empty')
162
+ const rows = [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((match) =>
163
+ [...match[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((cell) => decodeHtmlText(cell[1])),
164
+ ).filter((row) => row.length > 0)
165
+ const header = rows.find((row) => row.some((cell) => cell === 'deepseek-v4-flash'))
166
+ const models = header ? header.filter((cell) => /^deepseek-v4-[a-z0-9-]+$/i.test(cell)) : []
167
+ const requiredModels = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp']
168
+ if (models.length < requiredModels.length || requiredModels.some((model) => !models.includes(model))) {
169
+ throw new Error('official pricing model table changed')
170
+ }
171
+
172
+ function findPair(label) {
173
+ const index = rows.findIndex((row) => row.some((cell) => cell.includes(label)) && row.some((cell) => cell.includes('空闲时段')))
174
+ if (index < 0 || !rows[index + 1]?.some((cell) => cell.includes('高峰时段'))) throw new Error('official pricing rate rows changed')
175
+ const offPeak = numericCells(rows[index], models.length)
176
+ const peak = numericCells(rows[index + 1], models.length)
177
+ if (offPeak === null || peak === null) throw new Error('official pricing values changed')
178
+ return { offPeak, peak }
179
+ }
180
+
181
+ const cacheHit = findPair('缓存命中')
182
+ const input = findPair('缓存未命中')
183
+ const output = findPair('百万tokens输出')
184
+ const modelsWithRates = {}
185
+ for (const model of requiredModels) {
186
+ const index = models.indexOf(model)
187
+ modelsWithRates[model] = {
188
+ cacheHit: [cacheHit.offPeak[index], cacheHit.peak[index]],
189
+ input: [input.offPeak[index], input.peak[index]],
190
+ output: [output.offPeak[index], output.peak[index]],
191
+ }
192
+ }
193
+ const text = decodeHtmlText(html)
194
+ const windowMatch = text.match(/高峰时段为北京时间(?:周一至周五)?\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00、\s*(\d{1,2}):00\s*-\s*(\d{1,2}):00/)
195
+ if (windowMatch === null) throw new Error('official peak window changed')
196
+ const parsedWindows = [
197
+ { startHour: Number(windowMatch[1]), endHour: Number(windowMatch[2]) },
198
+ { startHour: Number(windowMatch[3]), endHour: Number(windowMatch[4]) },
199
+ ]
200
+ const weekdayOnly = /高峰时段为北京时间周一至周五/.test(text)
201
+ const weekendMatch = text.match(/北京时间\s*(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日[^。]*?00:00起[^。]*?周末[^。]*?低谷/)
202
+ if (!weekdayOnly && weekendMatch === null) throw new Error('official weekend rule changed')
203
+ const weekendSince = weekdayOnly
204
+ ? WEEKEND_OFF_PEAK_SINCE
205
+ : Date.UTC(
206
+ Number(weekendMatch[1]), Number(weekendMatch[2]) - 1, Number(weekendMatch[3]), 0, 0, 0, 0,
207
+ ) - BEIJING_OFFSET_MS
208
+ return {
209
+ models: modelsWithRates,
210
+ peakWindows: parsedWindows,
211
+ weekendOffPeakSince: weekendSince,
212
+ ruleVersion: 'official-' + createHash('sha256').update(JSON.stringify({ modelsWithRates, parsedWindows, weekendSince })).digest('hex').slice(0, 12),
213
+ }
214
+ }
215
+
216
+ function applyOfficialPricing(parsed, response) {
217
+ activePricePolicies = PRICE_POLICIES.map((policy) => {
218
+ const models = {}
219
+ for (const model of Object.keys(policy.models)) {
220
+ models[model] = parsed.models[model] || policy.models[model]
221
+ }
222
+ return { ...policy, models }
223
+ })
224
+ peakWindows = parsed.peakWindows.map((window) => ({ ...window }))
225
+ weekendOffPeakSince = parsed.weekendOffPeakSince
226
+ pricingSync = {
227
+ status: 'synced',
228
+ source: PRICING_SOURCE_URL,
229
+ checkedAt: Date.now(),
230
+ etag: response?.headers?.get?.('etag') ?? pricingSync.etag,
231
+ lastModified: response?.headers?.get?.('last-modified') ?? pricingSync.lastModified,
232
+ ruleVersion: parsed.ruleVersion,
233
+ modelCount: Object.keys(parsed.models).length,
234
+ message: '已同步官方价格页',
235
+ }
236
+ }
237
+
238
+ async function syncOfficialPricing(logger) {
239
+ if (typeof fetch !== 'function') return
240
+ const controller = new AbortController()
241
+ const timeout = setTimeout(() => controller.abort(), PRICING_SYNC_TIMEOUT_MS)
242
+ let stage = 'network'
243
+ try {
244
+ const headers = {}
245
+ if (pricingSync.etag) headers['if-none-match'] = pricingSync.etag
246
+ if (pricingSync.lastModified) headers['if-modified-since'] = pricingSync.lastModified
247
+ const response = await fetch(PRICING_SOURCE_URL, { headers, signal: controller.signal })
248
+ if (response.status === 304) {
249
+ pricingSync = { ...pricingSync, status: 'synced', checkedAt: Date.now(), message: '官方价格页未变化' }
250
+ return
251
+ }
252
+ if (!response.ok) throw new Error('official pricing page returned ' + response.status)
253
+ stage = 'parse'
254
+ const parsed = parseOfficialPricingHtml(await response.text())
255
+ applyOfficialPricing(parsed, response)
256
+ } catch (error) {
257
+ const hasValidatedOfficialRule = typeof pricingSync.ruleVersion === 'string' && pricingSync.ruleVersion.startsWith('official-')
258
+ pricingSync = {
259
+ ...pricingSync,
260
+ status: stage === 'parse' ? 'review-required' : 'offline',
261
+ checkedAt: Date.now(),
262
+ message: stage === 'parse'
263
+ ? '官方价格页结构有变化,需要复核'
264
+ : hasValidatedOfficialRule ? '官方价格页暂时不可用,沿用上次已验证规则' : '官方价格页暂时不可用,使用内置规则',
265
+ }
266
+ if (logger && typeof logger.warn === 'function') logger.warn('dsh-wallet: official pricing sync unavailable')
267
+ } finally {
268
+ clearTimeout(timeout)
269
+ }
270
+ }
271
+
272
+ export function pricingSnapshot() {
273
+ return {
274
+ status: pricingSync.status,
275
+ source: pricingSync.source,
276
+ checkedAt: pricingSync.checkedAt,
277
+ ruleVersion: pricingSync.ruleVersion,
278
+ message: pricingSync.message,
279
+ modelCount: pricingSync.modelCount,
280
+ peakWindows: peakWindows.map((window) => ({ ...window })),
281
+ weekendOffPeakSince,
282
+ }
283
+ }
284
+
285
+ function readPackageManifest(path) {
286
+ try {
287
+ const manifest = JSON.parse(readFileSync(path, 'utf8'))
288
+ return manifest !== null && typeof manifest === 'object' ? manifest : null
289
+ } catch {
290
+ return null
291
+ }
292
+ }
293
+
294
+ function detectHostManifest() {
295
+ const starts = [
296
+ typeof process.cwd === 'function' ? process.cwd() : null,
297
+ typeof process.argv?.[1] === 'string' ? dirname(process.argv[1]) : null,
298
+ ].filter(Boolean)
299
+ const seen = new Set()
300
+ for (const start of starts) {
301
+ let current = start
302
+ for (let depth = 0; depth < 10 && current !== dirname(current); depth += 1) {
303
+ if (seen.has(current)) break
304
+ seen.add(current)
305
+ const manifest = readPackageManifest(join(current, 'package.json'))
306
+ if (manifest && ['@deepseek-ai/dsh-root', '@deepseek-ai/dsh'].includes(manifest.name)) {
307
+ return { name: manifest.name, version: typeof manifest.version === 'string' ? manifest.version : null }
308
+ }
309
+ current = dirname(current)
310
+ }
311
+ }
312
+ return { name: null, version: null }
313
+ }
314
+
315
+ function compareVersions(left, right) {
316
+ const parse = (value) => {
317
+ const match = typeof value === 'string' && value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
318
+ if (match === null) return null
319
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre: match[4] || '' }
320
+ }
321
+ const a = parse(left)
322
+ const b = parse(right)
323
+ if (a === null || b === null) return null
324
+ for (const key of ['major', 'minor', 'patch']) {
325
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
326
+ }
327
+ if (a.pre === b.pre) return 0
328
+ if (a.pre === '') return 1
329
+ if (b.pre === '') return -1
330
+ return a.pre.localeCompare(b.pre, 'en', { numeric: true })
331
+ }
332
+
333
+ const HOST_MANIFEST = detectHostManifest()
334
+
335
+ export function hostHealthSnapshot() {
336
+ const hostVersion = HOST_MANIFEST.version
337
+ const comparison = compareVersions(hostVersion, MIN_HOST_VERSION)
338
+ const compatibility = comparison === null
339
+ ? { status: 'unknown', minimumVersion: MIN_HOST_VERSION, message: '无法读取 Harness 版本' }
340
+ : comparison >= 0
341
+ ? { status: 'compatible', minimumVersion: MIN_HOST_VERSION, message: '满足插件最低版本要求' }
342
+ : { status: 'upgrade-recommended', minimumVersion: MIN_HOST_VERSION, message: 'Harness 版本低于插件建议版本' }
343
+ return {
344
+ name: HOST_MANIFEST.name,
345
+ version: hostVersion,
346
+ detected: hostVersion !== null,
347
+ compatibility,
348
+ }
349
+ }
350
+
351
+ export function healthSnapshot() {
352
+ return {
353
+ ok: true,
354
+ plugin: { name: 'deepseek-harness-wallet', version: PLUGIN_VERSION },
355
+ host: hostHealthSnapshot(),
356
+ pricing: pricingSnapshot(),
357
+ accounts: accountStorageSnapshot(),
358
+ runtime: { node: process.version, platform: process.platform },
359
+ }
360
+ }
361
+
88
362
  export function ratesFor(model, atMs) {
89
363
  let entry
90
- for (const policy of PRICE_POLICIES) {
364
+ for (const policy of activePricePolicies) {
91
365
  // Own-property check: a model named `__proto__`/`toString` would otherwise
92
366
  // resolve to an Object.prototype member and produce NaN costs.
93
367
  if (atMs >= policy.since && Object.hasOwn(policy.models, model)) entry = policy
@@ -289,10 +563,173 @@ function scheduleSave(logger) {
289
563
 
290
564
  // ---------------------------------------------------------------------------
291
565
  // Multi-account store (accounts.json): list, add, remove, hot-switch.
292
- // Keys are stored plaintext next to .credentials.yaml, matching the harness's
293
- // own credential storage; the API surface only ever exposes masked keys.
566
+ // Keys remain available in memory for the active LLM/balance request, but are
567
+ // encrypted at rest. Windows uses the current user's DPAPI; other platforms
568
+ // use an owner-only AES-GCM key file as a best-effort local fallback.
294
569
  // ---------------------------------------------------------------------------
295
570
 
571
+ const WINDOWS_DPAPI_PROTECT = [
572
+ '$ErrorActionPreference = "Stop"',
573
+ 'Add-Type -AssemblyName System.Security',
574
+ '$encoded = [Console]::In.ReadToEnd().Trim()',
575
+ '$bytes = [Convert]::FromBase64String($encoded)',
576
+ '$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
577
+ '[Console]::Out.Write([Convert]::ToBase64String($protected))',
578
+ ].join(';')
579
+ const WINDOWS_DPAPI_UNPROTECT = [
580
+ '$ErrorActionPreference = "Stop"',
581
+ 'Add-Type -AssemblyName System.Security',
582
+ '$encoded = [Console]::In.ReadToEnd().Trim()',
583
+ '$protected = [Convert]::FromBase64String($encoded)',
584
+ '$bytes = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
585
+ '[Console]::Out.Write([Convert]::ToBase64String($bytes))',
586
+ ].join(';')
587
+ let fallbackEncryptionKey = null
588
+ let accountStorageScheme = process.platform === 'win32' ? 'windows-dpapi' : 'aes-gcm-file-key'
589
+ let accountStorageError = null
590
+ let accountStorageLocked = false
591
+ let accountStorageRecovered = false
592
+
593
+ function runWindowsDpapi(operation, value) {
594
+ if (process.platform !== 'win32') return null
595
+ // Protect expects base64-encoded UTF-8 plaintext. Unprotect expects the
596
+ // DPAPI ciphertext's own base64 representation, so encoding it a second
597
+ // time makes every persisted account undecryptable after restart.
598
+ const input = operation === 'protect'
599
+ ? Buffer.from(value, 'utf8').toString('base64')
600
+ : value
601
+ const result = spawnSync(
602
+ 'powershell.exe',
603
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', operation === 'protect' ? WINDOWS_DPAPI_PROTECT : WINDOWS_DPAPI_UNPROTECT],
604
+ {
605
+ input,
606
+ encoding: 'utf8',
607
+ windowsHide: true,
608
+ maxBuffer: 1024 * 1024,
609
+ },
610
+ )
611
+ if (result.error || result.status !== 0) return null
612
+ const output = String(result.stdout || '').trim()
613
+ return output === '' ? null : output
614
+ }
615
+
616
+ function getFallbackEncryptionKey(createIfMissing = true) {
617
+ if (fallbackEncryptionKey !== null) return fallbackEncryptionKey
618
+ try {
619
+ mkdirSync(dirname(ACCOUNTS_KEY_PATH), { recursive: true, mode: 0o700 })
620
+ if (existsSync(ACCOUNTS_KEY_PATH)) {
621
+ const existing = readFileSync(ACCOUNTS_KEY_PATH)
622
+ if (existing.length !== 32) throw new Error('account encryption key has an invalid length')
623
+ fallbackEncryptionKey = existing
624
+ } else {
625
+ if (!createIfMissing) {
626
+ accountStorageError = 'account-encryption-key-missing'
627
+ accountStorageLocked = true
628
+ return null
629
+ }
630
+ fallbackEncryptionKey = randomBytes(32)
631
+ writeFileSync(ACCOUNTS_KEY_PATH, fallbackEncryptionKey, { mode: 0o600 })
632
+ chmodSync(ACCOUNTS_KEY_PATH, 0o600)
633
+ }
634
+ return fallbackEncryptionKey
635
+ } catch {
636
+ accountStorageError = 'account-encryption-key-unavailable'
637
+ return null
638
+ }
639
+ }
640
+
641
+ function encryptApiKeyAes(apiKey, key, iv = randomBytes(12)) {
642
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('AES account key must contain 32 bytes')
643
+ if (!Buffer.isBuffer(iv) || iv.length !== 12) throw new Error('AES-GCM IV must contain 12 bytes')
644
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
645
+ const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
646
+ return {
647
+ version: ACCOUNTS_CRYPTO_VERSION,
648
+ scheme: 'aes-gcm-file-key',
649
+ iv: iv.toString('base64'),
650
+ tag: cipher.getAuthTag().toString('base64'),
651
+ payload: ciphertext.toString('base64'),
652
+ }
653
+ }
654
+
655
+ function decryptApiKeyAes(value, key) {
656
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('AES account key must contain 32 bytes')
657
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64'))
658
+ decipher.setAuthTag(Buffer.from(value.tag, 'base64'))
659
+ return Buffer.concat([decipher.update(Buffer.from(value.payload, 'base64')), decipher.final()]).toString('utf8')
660
+ }
661
+
662
+ export const __testing = { encryptApiKeyAes, decryptApiKeyAes, compareVersions }
663
+
664
+ function protectApiKey(apiKey) {
665
+ const dpapi = runWindowsDpapi('protect', apiKey)
666
+ if (dpapi !== null) {
667
+ accountStorageScheme = 'windows-dpapi'
668
+ accountStorageError = null
669
+ return { version: ACCOUNTS_CRYPTO_VERSION, scheme: 'windows-dpapi', payload: dpapi }
670
+ }
671
+ const key = getFallbackEncryptionKey()
672
+ if (key === null) return null
673
+ try {
674
+ const encrypted = encryptApiKeyAes(apiKey, key)
675
+ accountStorageScheme = 'aes-gcm-file-key'
676
+ return encrypted
677
+ } catch {
678
+ accountStorageError = 'account-encryption-failed'
679
+ return null
680
+ }
681
+ }
682
+
683
+ function unprotectApiKey(value) {
684
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
685
+ accountStorageError = 'account-encrypted-record-invalid'
686
+ accountStorageLocked = true
687
+ return ''
688
+ }
689
+ if (value.version !== ACCOUNTS_CRYPTO_VERSION || typeof value.scheme !== 'string' || typeof value.payload !== 'string') {
690
+ accountStorageError = 'account-encrypted-record-invalid'
691
+ accountStorageLocked = true
692
+ return ''
693
+ }
694
+ if (value.scheme === 'windows-dpapi') {
695
+ const decoded = runWindowsDpapi('unprotect', value.payload)
696
+ if (decoded === null) {
697
+ accountStorageError = 'account-decryption-failed'
698
+ accountStorageLocked = true
699
+ }
700
+ else {
701
+ try { return Buffer.from(decoded, 'base64').toString('utf8') } catch { /* fall through */ }
702
+ accountStorageError = 'account-decryption-failed'
703
+ accountStorageLocked = true
704
+ }
705
+ return ''
706
+ }
707
+ if (value.scheme !== 'aes-gcm-file-key' || typeof value.iv !== 'string' || typeof value.tag !== 'string') {
708
+ accountStorageError = 'account-encrypted-record-invalid'
709
+ accountStorageLocked = true
710
+ return ''
711
+ }
712
+ const key = getFallbackEncryptionKey(false)
713
+ if (key === null) return ''
714
+ try {
715
+ return decryptApiKeyAes(value, key)
716
+ } catch {
717
+ accountStorageError = 'account-decryption-failed'
718
+ accountStorageLocked = true
719
+ return ''
720
+ }
721
+ }
722
+
723
+ export function accountStorageSnapshot() {
724
+ return {
725
+ encryptedAtRest: true,
726
+ scheme: accountStorageScheme,
727
+ status: accountStorageLocked ? 'locked' : accountStorageRecovered ? 'recovered' : accountStorageError === null ? 'ready' : 'error',
728
+ error: accountStorageError,
729
+ locked: accountStorageLocked,
730
+ }
731
+ }
732
+
296
733
  function emptyAccounts() {
297
734
  return { version: ACCOUNTS_VERSION, accounts: [], activeId: null }
298
735
  }
@@ -305,7 +742,9 @@ export function normalizeAccountsData(value) {
305
742
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
306
743
  const id = typeof raw.id === 'string' && raw.id !== '' ? raw.id : null
307
744
  const name = typeof raw.name === 'string' ? raw.name.trim() : ''
308
- const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey : ''
745
+ // Accept the v1 plaintext shape once so an existing installation can be
746
+ // migrated on its next save; new writes use apiKeyEncrypted only.
747
+ const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey : unprotectApiKey(raw.apiKeyEncrypted)
309
748
  if (id === null || name === '' || apiKey === '') continue
310
749
  accounts.push({
311
750
  id,
@@ -321,16 +760,41 @@ export function normalizeAccountsData(value) {
321
760
  return { version: ACCOUNTS_VERSION, accounts, activeId }
322
761
  }
323
762
 
763
+ function readAccountsFile(path) {
764
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
765
+ const normalized = normalizeAccountsData(raw)
766
+ const legacyPlaintext = Array.isArray(raw?.accounts)
767
+ && raw.accounts.some((account) => account && typeof account.apiKey === 'string')
768
+ return { accounts: normalized, migrated: legacyPlaintext || raw?.version !== ACCOUNTS_VERSION }
769
+ }
770
+
324
771
  function loadAccounts() {
772
+ if (!existsSync(ACCOUNTS_PATH)) {
773
+ if (!existsSync(ACCOUNTS_BACKUP_PATH)) return { accounts: emptyAccounts(), migrated: false }
774
+ try {
775
+ const recovered = readAccountsFile(ACCOUNTS_BACKUP_PATH)
776
+ accountStorageRecovered = true
777
+ accountStorageError = 'account-store-restored-from-backup'
778
+ return { accounts: recovered.accounts, migrated: true }
779
+ } catch {
780
+ accountStorageError = 'account-backup-invalid'
781
+ accountStorageLocked = true
782
+ return { accounts: emptyAccounts(), migrated: false }
783
+ }
784
+ }
325
785
  try {
326
- return normalizeAccountsData(JSON.parse(readFileSync(ACCOUNTS_PATH, 'utf8')))
786
+ return readAccountsFile(ACCOUNTS_PATH)
327
787
  } catch {
328
- return emptyAccounts()
788
+ accountStorageError = 'account-store-invalid'
789
+ accountStorageLocked = true
790
+ return { accounts: emptyAccounts(), migrated: false }
329
791
  }
330
792
  }
331
793
 
332
794
  let accountsSaveTimer = null
333
- let accounts = loadAccounts()
795
+ const loadedAccounts = loadAccounts()
796
+ let accounts = loadedAccounts.accounts
797
+ let accountsNeedsSave = loadedAccounts.migrated
334
798
 
335
799
  function persistAccounts(logger) {
336
800
  if (accountsSaveTimer !== null) {
@@ -338,14 +802,33 @@ function persistAccounts(logger) {
338
802
  accountsSaveTimer = null
339
803
  }
340
804
  try {
805
+ if (accountStorageLocked) throw new Error('encrypted account store is locked')
341
806
  mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true, mode: 0o700 })
342
807
  const tmp = ACCOUNTS_PATH + '.tmp'
343
- // This file holds plaintext API keys: owner-only, never group/world readable.
344
- writeFileSync(tmp, JSON.stringify(accounts), { mode: 0o600 })
808
+ const encryptedAccounts = accounts.accounts.map((account) => {
809
+ const apiKeyEncrypted = protectApiKey(account.apiKey)
810
+ if (apiKeyEncrypted === null) throw new Error('account encryption unavailable')
811
+ return {
812
+ id: account.id,
813
+ name: account.name,
814
+ apiKeyEncrypted,
815
+ createdAt: account.createdAt,
816
+ }
817
+ })
818
+ writeFileSync(tmp, JSON.stringify({ version: ACCOUNTS_VERSION, accounts: encryptedAccounts, activeId: accounts.activeId }), { mode: 0o600 })
819
+ chmodSync(tmp, 0o600)
820
+ if (existsSync(ACCOUNTS_PATH)) {
821
+ copyFileSync(ACCOUNTS_PATH, ACCOUNTS_BACKUP_PATH)
822
+ chmodSync(ACCOUNTS_BACKUP_PATH, 0o600)
823
+ }
345
824
  renameSync(tmp, ACCOUNTS_PATH)
825
+ accountsNeedsSave = false
826
+ accountStorageRecovered = false
827
+ accountStorageError = null
346
828
  } catch (error) {
829
+ accountStorageError = accountStorageError || 'account-encryption-failed'
347
830
  if (logger && typeof logger.warn === 'function') {
348
- logger.warn('dsh-wallet: failed to persist accounts: ' + String(error))
831
+ logger.warn('dsh-wallet: failed to persist encrypted accounts')
349
832
  }
350
833
  }
351
834
  }
@@ -424,6 +907,7 @@ export function accountListView() {
424
907
  active: account.id === accounts.activeId,
425
908
  })),
426
909
  activeId: accounts.activeId,
910
+ storage: accountStorageSnapshot(),
427
911
  }
428
912
  }
429
913
 
@@ -698,15 +1182,7 @@ function snapshotView(sessionId) {
698
1182
  })(),
699
1183
  rechargeUrl: RECHARGE_URL,
700
1184
  // Peak/off-peak policy for the ring clock; billed in Asia/Shanghai.
701
- pricingWindows: {
702
- timezone: 'Asia/Shanghai',
703
- offsetMinutes: 480,
704
- windows: PEAK_WINDOWS.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
705
- offPeakRate: OFF_PEAK_RATE,
706
- isPeak: isBeijingPeak(now),
707
- weekendOffPeak: now >= WEEKEND_OFF_PEAK_SINCE && isBeijingWeekend(now),
708
- weekendOffPeakSince: WEEKEND_OFF_PEAK_SINCE,
709
- },
1185
+ pricingWindows: pricingWindowSnapshot(now),
710
1186
  accounts: {
711
1187
  activeId: accounts.activeId,
712
1188
  activeName: active !== null ? active.name : null,
@@ -768,6 +1244,7 @@ export function apply(ctx, config) {
768
1244
  storeNeedsSave = false
769
1245
  scheduleSave(ctx.logger)
770
1246
  }
1247
+ if (accountsNeedsSave) scheduleAccountsSave(ctx.logger)
771
1248
  // Thresholds live ONLY in the persisted store; an explicit row config may
772
1249
  // still override the CNY line for power users, but the bundle patch sets none.
773
1250
  if (Number.isFinite(config.threshold)) {
@@ -817,6 +1294,19 @@ export function apply(ctx, config) {
817
1294
  const balanceTimer = setInterval(() => void refreshBalance(ctx), BALANCE_REFRESH_MS)
818
1295
  if (typeof balanceTimer.unref === 'function') balanceTimer.unref()
819
1296
 
1297
+ if (config.pricingSync !== false) {
1298
+ ctx.effect(() => {
1299
+ const first = setTimeout(() => void syncOfficialPricing(ctx.logger), 1000)
1300
+ const timer = setInterval(() => void syncOfficialPricing(ctx.logger), PRICING_SYNC_INTERVAL_MS)
1301
+ if (typeof first.unref === 'function') first.unref()
1302
+ if (typeof timer.unref === 'function') timer.unref()
1303
+ return () => {
1304
+ clearTimeout(first)
1305
+ clearInterval(timer)
1306
+ }
1307
+ }, 'dsh-wallet: official pricing sync')
1308
+ }
1309
+
820
1310
  ctx.effect(() => {
821
1311
  const disposeSnapshot = ctx.webServer.register({
822
1312
  kind: 'exact',
@@ -826,6 +1316,23 @@ export function apply(ctx, config) {
826
1316
  return json(res, 200, snapshotView(sessionParam(req)))
827
1317
  },
828
1318
  })
1319
+ const disposeHealth = ctx.webServer.register({
1320
+ kind: 'exact',
1321
+ path: '/api/wallet/health',
1322
+ handler: (req, res) => {
1323
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1324
+ return json(res, 200, healthSnapshot())
1325
+ },
1326
+ })
1327
+ const disposePricingRefresh = ctx.webServer.register({
1328
+ kind: 'exact',
1329
+ path: '/api/wallet/pricing/refresh',
1330
+ handler: async (req, res) => {
1331
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1332
+ await syncOfficialPricing(ctx.logger)
1333
+ return json(res, 200, { ok: true, pricing: pricingSnapshot() })
1334
+ },
1335
+ })
829
1336
  const disposeThreshold = ctx.webServer.register({
830
1337
  kind: 'exact',
831
1338
  path: '/api/wallet/threshold',
@@ -900,6 +1407,7 @@ export function apply(ctx, config) {
900
1407
  handler: async (req, res) => {
901
1408
  if (req.method === 'GET') return json(res, 200, { ok: true, ...accountListView() })
902
1409
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1410
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
903
1411
  const body = await readBody(req)
904
1412
  if (body === null || typeof body.name !== 'string' || typeof body.apiKey !== 'string') {
905
1413
  return json(res, 400, { ok: false, error: 'name and apiKey are required' })
@@ -939,6 +1447,7 @@ export function apply(ctx, config) {
939
1447
  path: '/api/wallet/accounts/activate',
940
1448
  handler: async (req, res) => {
941
1449
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1450
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
942
1451
  const body = await readBody(req)
943
1452
  if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
944
1453
  const result = await activateAccount(ctx, body.id)
@@ -955,6 +1464,7 @@ export function apply(ctx, config) {
955
1464
  path: '/api/wallet/accounts/remove',
956
1465
  handler: async (req, res) => {
957
1466
  if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1467
+ if (accountStorageLocked) return json(res, 423, { ok: false, error: 'account-storage-locked' })
958
1468
  const body = await readBody(req)
959
1469
  if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
960
1470
  const result = removeAccount(body.id)
@@ -966,6 +1476,8 @@ export function apply(ctx, config) {
966
1476
  })
967
1477
  return () => {
968
1478
  disposeSnapshot()
1479
+ disposeHealth()
1480
+ disposePricingRefresh()
969
1481
  disposeThreshold()
970
1482
  disposeRefresh()
971
1483
  disposeClear()
package/lib/client.js CHANGED
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
23
23
 
24
24
  var POLL_MS = 15000
25
25
  // Keep in lockstep with package.json; a test enforces the sync.
26
- var WALLET_VERSION = '0.2.6'
26
+ var WALLET_VERSION = '0.3.1'
27
27
  var CONFIRM_KEY = 'dsh-wallet-recharge-confirmed'
28
28
  var CHIP_LAYOUT_KEY = 'dshw-chip-layout-v4'
29
29
  var PANEL_POS_KEY = 'dshw-panel-pos-v1'
@@ -206,7 +206,6 @@ window.__ModuleLoader__.load({
206
206
  }
207
207
  }
208
208
  var bridged = extension.notify(payload)
209
- if (bridged && typeof bridged.close === 'function') return bridged
210
209
  if (bridged !== false) {
211
210
  if (bridged && typeof bridged.then === 'function') {
212
211
  function attachDelegate(delegate) {
@@ -219,6 +218,14 @@ window.__ModuleLoader__.load({
219
218
  if (bridgeHandle.closed && delegate && typeof delegate.close === 'function') delegate.close()
220
219
  }
221
220
  Promise.resolve(bridged).then(attachDelegate).catch(function () { attachDelegate(false) })
221
+ } else if (bridged && typeof bridged === 'object') {
222
+ // Always return the wrapper whose onclick/onclose fields the
223
+ // wallet sets. Desktop bridges are asked to call payload
224
+ // callbacks; returning the raw native handle here disconnected
225
+ // those callbacks for synchronous Electron/Tauri adapters.
226
+ bridgeHandle.delegate = bridged
227
+ try { bridged.onclick = payload.onClick } catch (e) { /* optional callback */ }
228
+ try { bridged.onclose = payload.onClose } catch (e) { /* optional callback */ }
222
229
  }
223
230
  return bridgeHandle
224
231
  }
@@ -285,6 +292,18 @@ window.__ModuleLoader__.load({
285
292
 
286
293
  var compatibility = createCompatibilityAdapter(window)
287
294
 
295
+ function openOfficialRecharge() {
296
+ var url = 'https://platform.deepseek.com/top_up'
297
+ var confirmed = false
298
+ try { confirmed = compatibility.storage.getItem(CONFIRM_KEY) === '1' } catch (e) { /* ignore */ }
299
+ if (!confirmed) {
300
+ if (typeof window.confirm !== 'function') return false
301
+ if (!window.confirm('将打开 DeepSeek 官方充值页:\nplatform.deepseek.com\n\n确认继续?')) return false
302
+ try { compatibility.storage.setItem(CONFIRM_KEY, '1') } catch (e) { /* ignore */ }
303
+ }
304
+ return compatibility.openExternal(url)
305
+ }
306
+
288
307
  var css = [
289
308
  '.dshw_anchor{display:inline-flex;align-items:center;min-height:22px;min-width:0;vertical-align:middle}',
290
309
  '.dshw_anchorHome{overflow:hidden;min-width:44px}',
@@ -1140,6 +1159,8 @@ window.__ModuleLoader__.load({
1140
1159
  props = props || {}
1141
1160
  var close = typeof props.close === 'function' ? props.close : null
1142
1161
  var [snapshot, setSnapshot] = React.useState(null)
1162
+ var [health, setHealth] = React.useState(null)
1163
+ var [healthNotice, setHealthNotice] = React.useState(null)
1143
1164
  var [thresholdDraft, setThresholdDraft] = React.useState('')
1144
1165
  var [thresholdNotice, setThresholdNotice] = React.useState(null)
1145
1166
  var [accounts, setAccounts] = React.useState(null)
@@ -1213,8 +1234,14 @@ window.__ModuleLoader__.load({
1213
1234
  }
1214
1235
  refresh()
1215
1236
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
1216
- if (!stopped && json && json.ok) setAccounts(json)
1237
+ if (!stopped && json && json.ok) {
1238
+ setAccounts(json)
1239
+ setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
1240
+ }
1217
1241
  }).catch(function () { if (!stopped) setAccountsError('\u8d26\u6237\u63a5\u53e3\u4e0d\u53ef\u7528') })
1242
+ fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
1243
+ if (!stopped && json && json.ok) setHealth(json)
1244
+ }).catch(function () { if (!stopped) setHealthNotice('健康检查不可用') })
1218
1245
  try {
1219
1246
  var savedLayout = compatibility.storage.getItem(CHIP_LAYOUT_KEY)
1220
1247
  var dock = savedLayout === null ? 'home' : normalizeChipLayout(JSON.parse(savedLayout)).dock
@@ -1223,6 +1250,52 @@ window.__ModuleLoader__.load({
1223
1250
  return function () { stopped = true }
1224
1251
  }, [])
1225
1252
 
1253
+ function refreshHealth() {
1254
+ setHealthNotice('正在检测…')
1255
+ fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
1256
+ if (json && json.ok) { setHealth(json); setHealthNotice('检测完成') }
1257
+ else setHealthNotice('检测失败')
1258
+ }).catch(function () { setHealthNotice('检测失败') })
1259
+ }
1260
+
1261
+ function refreshPricing() {
1262
+ setHealthNotice('正在同步官方价格…')
1263
+ fetch('/api/wallet/pricing/refresh', { method: 'POST' }).then(function (resp) { return resp.json() }).then(function (json) {
1264
+ if (json && json.ok) {
1265
+ setHealth(function (current) { return current ? Object.assign({}, current, { pricing: json.pricing }) : current })
1266
+ setHealthNotice(json.pricing && json.pricing.status === 'synced' ? '官方价格已同步' : '已保留内置价格,等待复核')
1267
+ } else setHealthNotice('价格同步失败')
1268
+ }).catch(function () { setHealthNotice('价格同步失败') })
1269
+ }
1270
+
1271
+ function copyDiagnostics() {
1272
+ if (!health) return
1273
+ var safe = {
1274
+ plugin: health.plugin,
1275
+ host: health.host,
1276
+ pricing: health.pricing && {
1277
+ status: health.pricing.status,
1278
+ ruleVersion: health.pricing.ruleVersion,
1279
+ checkedAt: health.pricing.checkedAt,
1280
+ modelCount: health.pricing.modelCount,
1281
+ },
1282
+ accounts: health.accounts && {
1283
+ encryptedAtRest: health.accounts.encryptedAtRest,
1284
+ scheme: health.accounts.scheme,
1285
+ status: health.accounts.status,
1286
+ },
1287
+ runtime: health.runtime && { node: health.runtime.node, platform: health.runtime.platform },
1288
+ }
1289
+ var text = JSON.stringify(safe, null, 2)
1290
+ try {
1291
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
1292
+ navigator.clipboard.writeText(text).then(function () { setHealthNotice('诊断信息已复制') }).catch(function () { setHealthNotice('复制失败') })
1293
+ return
1294
+ }
1295
+ } catch (e) { /* fall through to the message */ }
1296
+ setHealthNotice('当前环境不支持自动复制')
1297
+ }
1298
+
1226
1299
  function persistVisibility(next) {
1227
1300
  next = normalizeDataVisibility(next)
1228
1301
  setVisibility(next)
@@ -1313,7 +1386,10 @@ window.__ModuleLoader__.load({
1313
1386
 
1314
1387
  function reloadAccounts() {
1315
1388
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
1316
- if (json && json.ok) { setAccounts(json); setAccountsError(null) }
1389
+ if (json && json.ok) {
1390
+ setAccounts(json)
1391
+ setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
1392
+ }
1317
1393
  }).catch(function () { /* ignore */ })
1318
1394
  fetch('/api/wallet/snapshot').then(function (resp) { return resp.json() }).then(function (json) {
1319
1395
  if (json && json.ok) {
@@ -1406,6 +1482,51 @@ window.__ModuleLoader__.load({
1406
1482
  React.createElement('span', { className: 'dshw_balDot' }, '·'),
1407
1483
  React.createElement('span', null, currencyCode))))
1408
1484
 
1485
+ // —— 健康检查 ——
1486
+ var hostHealth = health && health.host ? health.host : null
1487
+ var hostCompatibility = hostHealth && hostHealth.compatibility ? hostHealth.compatibility : null
1488
+ var pricing = health && health.pricing ? health.pricing : null
1489
+ var accountStorage = health && health.accounts ? health.accounts : null
1490
+ var compatibilityText = hostCompatibility
1491
+ ? (hostCompatibility.status === 'compatible' ? '兼容' : hostCompatibility.status === 'upgrade-recommended' ? '建议升级' : '待检测')
1492
+ : '检测中'
1493
+ var pricingText = pricing
1494
+ ? (pricing.status === 'synced'
1495
+ ? '已同步'
1496
+ : pricing.status === 'review-required'
1497
+ ? '待复核'
1498
+ : pricing.status === 'offline'
1499
+ ? (String(pricing.ruleVersion || '').indexOf('official-') === 0 ? '离线,沿用已验证规则' : '离线,使用内置规则')
1500
+ : '内置规则')
1501
+ : '检测中'
1502
+ var storageText = accountStorage
1503
+ ? (accountStorage.status === 'locked'
1504
+ ? '无法解密(已锁定写入)'
1505
+ : accountStorage.status === 'recovered'
1506
+ ? '已从加密备份恢复'
1507
+ : accountStorage.status === 'error'
1508
+ ? '加密存储异常'
1509
+ : accountStorage.scheme === 'windows-dpapi' ? 'Windows DPAPI 加密' : 'AES-GCM 加密')
1510
+ : '检测中'
1511
+ rows.push(React.createElement('div', { key: 'health', className: 'dshw_setCard', style: { display: 'block' } },
1512
+ React.createElement('div', { className: 'dshw_settingsGroupHeader' },
1513
+ React.createElement('div', { className: 'dshw_settingsGroupTitle' }, 'Harness 健康检查'),
1514
+ React.createElement('div', { className: 'dshw_settingsGroupHint' }, healthNotice || '不包含 API Key 或本地路径')),
1515
+ React.createElement('div', { className: 'dshw_settingsHeroMeta' },
1516
+ React.createElement('span', null, 'Harness v' + (hostHealth && hostHealth.version ? hostHealth.version : '未识别')),
1517
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1518
+ React.createElement('span', null, '插件 v' + WALLET_VERSION),
1519
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1520
+ React.createElement('span', null, '兼容:' + compatibilityText)),
1521
+ React.createElement('div', { className: 'dshw_settingsHeroMeta' },
1522
+ React.createElement('span', null, '价格规则:' + pricingText),
1523
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1524
+ React.createElement('span', null, '账户存储:' + storageText)),
1525
+ React.createElement('div', { className: 'dshw_settingsFooterActions', style: { marginTop: '8px' } },
1526
+ React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: refreshHealth }, '重新检测'),
1527
+ React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: refreshPricing }, '同步官方价格'),
1528
+ React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: !health, onClick: copyDiagnostics }, '复制诊断信息'))))
1529
+
1409
1530
  // —— 设置卡 ——
1410
1531
  var cells = []
1411
1532
  cells.push(React.createElement('div', { key: 'vis', className: 'dshw_setCell' },
@@ -1705,7 +1826,7 @@ window.__ModuleLoader__.load({
1705
1826
  }, '刷新余额'),
1706
1827
  React.createElement('button', {
1707
1828
  type: 'button', className: 'dshw_btn dshw_btnPrimary',
1708
- onClick: function () { if (close) close(); window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener') }
1829
+ onClick: function () { if (close) close(); openOfficialRecharge() }
1709
1830
  }, '↗ 去官方充值'))))
1710
1831
  return React.createElement('div', { className: 'dshw_settingsSection', style: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '12px', color: 'var(--dsw-alias-label-primary,inherit)' } }, rows)
1711
1832
  }
@@ -1739,38 +1860,27 @@ window.__ModuleLoader__.load({
1739
1860
  var offsetMinutes = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
1740
1861
  var beijingNow = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutes * 60000) : null
1741
1862
  var day = beijingNow && Number.isFinite(beijingNow.getTime()) ? beijingNow.getUTCDay() : 6
1742
- var daysUntilMonday = day === 6 ? 2 : 1
1743
- var nextSwitchMs = beijingNow && Number.isFinite(beijingNow.getTime())
1744
- ? Date.UTC(
1745
- beijingNow.getUTCFullYear(), beijingNow.getUTCMonth(), beijingNow.getUTCDate() + daysUntilMonday,
1746
- 9 - (offsetMinutes / 60), 0, 0, 0
1747
- ) - offsetMinutes * 60000
1748
- : NaN
1749
- var weekendMsLeft = Number.isFinite(nextSwitchMs) && Number.isFinite(nowMs)
1750
- ? Math.max(0, nextSwitchMs - nowMs)
1751
- : 0
1752
- var weekendHours = Math.floor(weekendMsLeft / 3600000)
1753
- var weekendMinutes = Math.floor((weekendMsLeft % 3600000) / 60000)
1754
- var weekendLeftText = weekendHours > 0
1755
- ? weekendHours + ' 小时 ' + weekendMinutes + ' 分'
1756
- : weekendMinutes + ' 分钟'
1757
- var weekendLeftShort = weekendHours > 0
1758
- ? weekendHours + 'h' + (weekendMinutes > 0 ? weekendMinutes + 'm' : '')
1759
- : weekendMinutes + 'm'
1760
- var weekendDateKey = beijingNow && Number.isFinite(beijingNow.getTime())
1761
- ? [beijingNow.getUTCFullYear(), String(beijingNow.getUTCMonth() + 1).padStart(2, '0'), String(beijingNow.getUTCDate()).padStart(2, '0')].join('-')
1863
+ // Friday after 18:00, Saturday, Sunday and Monday before 09:00 are
1864
+ // one continuous off-peak period. Anchor it to Friday so unchanged
1865
+ // pricing does not emit duplicate notifications at each midnight.
1866
+ var weekendAnchor = beijingNow && Number.isFinite(beijingNow.getTime())
1867
+ ? new Date(Date.UTC(beijingNow.getUTCFullYear(), beijingNow.getUTCMonth(), beijingNow.getUTCDate() - (day === 6 ? 1 : 2)))
1868
+ : null
1869
+ var weekendDateKey = weekendAnchor
1870
+ ? [weekendAnchor.getUTCFullYear(), String(weekendAnchor.getUTCMonth() + 1).padStart(2, '0'), String(weekendAnchor.getUTCDate()).padStart(2, '0')].join('-')
1762
1871
  : 'weekend'
1763
1872
  var weekendTzName = policy.timezone || 'Asia/Shanghai'
1873
+ var weekendDayLabel = day === 6 ? '周六' : '周日'
1764
1874
  return {
1765
1875
  configured: true, windows: [], weekendOffPeak: true, inPeak: false,
1766
1876
  nextHour: 9,
1767
- ariaText: '当前为周末低谷时段,全天按半价计费,周一 09:00 恢复标准价',
1768
- tip: '峰谷时钟 · 当前周末低谷 · 周一 09:00 恢复标准价 · 还有 ' + weekendLeftText + ' · ' + weekendTzName,
1877
+ ariaText: '当前为' + weekendDayLabel + '低谷时段,全天按半价计费,周一 09:00 恢复标准价',
1878
+ tip: '峰谷时钟 · 当前' + weekendDayLabel + '低谷 · ' + weekendDayLabel + '全天半价 · 周一 09:00 恢复工作日规则 · ' + weekendTzName,
1769
1879
  periodName: '周末低谷', rateBadge: '半价',
1770
- countdownSummary: '周一 09:00 切换 · 剩 ' + weekendLeftShort,
1771
- windowSummary: '周末全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
1772
- periodId: 'weekend-off-' + weekendDateKey,
1773
- switchBody: '周末低谷结束,已进入工作日高峰时段,按标准价计费',
1880
+ countdownSummary: weekendDayLabel + '全天低谷',
1881
+ windowSummary: weekendDayLabel + '全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
1882
+ periodId: 'weekend-chain-' + weekendDateKey,
1883
+ switchBody: '已进入周末低谷,全天按半价计费',
1774
1884
  }
1775
1885
  }
1776
1886
  if (windows.length === 0) {
@@ -1815,8 +1925,24 @@ window.__ModuleLoader__.load({
1815
1925
  var period = inPeak ? '高峰' : '低谷' + rateWord
1816
1926
  var periodName = inPeak ? '高峰时段' : '低谷时段'
1817
1927
  var rateBadge = inPeak ? '标准价' : (rate === 0.5 ? '半价' : '×' + rate)
1818
- var countdownSummary = hh(nextHour) + ' 切换 · 剩 ' + leftShort
1819
- var windowSummary = '高峰 ' + winText
1928
+ var offsetMinutesForDay = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
1929
+ var localWallDate = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutesForDay * 60000) : null
1930
+ var localDay = localWallDate ? localWallDate.getUTCDay() : -1
1931
+ var weekendRuleSince = policy && Number.isFinite(policy.weekendOffPeakSince) ? policy.weekendOffPeakSince : -Infinity
1932
+ var nextLocalMidnightMs = localWallDate
1933
+ ? Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate() + 1) - offsetMinutesForDay * 60000
1934
+ : Infinity
1935
+ var currentLocalMidnightMs = localWallDate
1936
+ ? Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate()) - offsetMinutesForDay * 60000
1937
+ : Infinity
1938
+ var fridayAfterLastPeak = !inPeak && localDay === 5 && nowHour >= windows[windows.length - 1].endHour && nextLocalMidnightMs >= weekendRuleSince
1939
+ var mondayBeforeFirstPeak = !inPeak && localDay === 1 && nowHour < windows[0].startHour && currentLocalMidnightMs >= weekendRuleSince
1940
+ var countdownSummary = fridayAfterLastPeak
1941
+ ? '周末全天低谷'
1942
+ : mondayBeforeFirstPeak ? ('剩 ' + leftShort + ' 进入高峰') : (hh(nextHour) + ' 切换 · 剩 ' + leftShort)
1943
+ var windowSummary = fridayAfterLastPeak
1944
+ ? '周五 18:00 起低谷 · 周六/周日全天低谷'
1945
+ : '高峰 ' + winText
1820
1946
  // Dual timezone: billing is judged in the policy's base zone; a device
1821
1947
  // elsewhere also sees the local-clock span of the CURRENT segment.
1822
1948
  var localNote = ''
@@ -1834,15 +1960,24 @@ window.__ModuleLoader__.load({
1834
1960
  var ariaText = inPeak
1835
1961
  ? '当前为高峰时段,' + tzName + ' ' + winText + ',按标准价计费'
1836
1962
  : '当前为低谷时段,按平价的 ' + rate + ' 倍计费,' + tzName + ' 高峰 ' + winText
1837
- var tip = '峰谷时钟 · 当前' + period + ' · ' + switchText + ' · 还有 ' + leftText
1838
- + ' · 高峰 ' + winText + '(' + tzName + ')' + localNote
1963
+ var tip = fridayAfterLastPeak
1964
+ ? '峰谷时钟 · 当前低谷半价 · 下次高峰:周一 09:00 · ' + tzName + localNote
1965
+ : '峰谷时钟 · 当前' + period + ' · ' + switchText + ' · 还有 ' + leftText
1966
+ + ' · 高峰 ' + winText + '(' + tzName + ')' + localNote
1967
+ var periodId = (inPeak ? 'p' : 'o') + Math.round(segStart * 100)
1968
+ if ((fridayAfterLastPeak || mondayBeforeFirstPeak) && localWallDate) {
1969
+ var chainAnchorDays = mondayBeforeFirstPeak ? 3 : 0
1970
+ var chainAnchor = new Date(Date.UTC(localWallDate.getUTCFullYear(), localWallDate.getUTCMonth(), localWallDate.getUTCDate() - chainAnchorDays))
1971
+ var chainKey = [chainAnchor.getUTCFullYear(), String(chainAnchor.getUTCMonth() + 1).padStart(2, '0'), String(chainAnchor.getUTCDate()).padStart(2, '0')].join('-')
1972
+ periodId = 'weekend-chain-' + chainKey
1973
+ }
1839
1974
  return {
1840
1975
  configured: true, windows: windows, weekendOffPeak: false, inPeak: inPeak,
1841
1976
  nextHour: nextHour, ariaText: ariaText, tip: tip,
1842
1977
  periodName: periodName, rateBadge: rateBadge,
1843
1978
  countdownSummary: countdownSummary, windowSummary: windowSummary,
1844
1979
  // Reminder dedup id: entering this period at this boundary fires once.
1845
- periodId: (inPeak ? 'p' : 'o') + Math.round(segStart * 100),
1980
+ periodId: periodId,
1846
1981
  switchBody: inPeak ? '已进入高峰时段,按标准价计费' : '已进入低谷时段,按平价的 ' + rate + ' 倍计费',
1847
1982
  }
1848
1983
  }
@@ -2269,7 +2404,7 @@ window.__ModuleLoader__.load({
2269
2404
  onPointerDown: isRail ? undefined : handlePointerDown,
2270
2405
  onClick: function (e) {
2271
2406
  if (isRail) {
2272
- window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener')
2407
+ openOfficialRecharge()
2273
2408
  return
2274
2409
  }
2275
2410
  if (didDragRef.current !== true) {
@@ -2325,7 +2460,7 @@ window.__ModuleLoader__.load({
2325
2460
  'aria-label': '前往官方充值',
2326
2461
  onClick: function (e) {
2327
2462
  if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
2328
- window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener')
2463
+ openOfficialRecharge()
2329
2464
  }
2330
2465
  }, '充值') : null
2331
2466
  )
@@ -2367,7 +2502,7 @@ window.__ModuleLoader__.load({
2367
2502
  type: 'button',
2368
2503
  className: 'dshw_btn dshw_btnPrimary',
2369
2504
  style: { height: '22px', fontSize: '11px', padding: '0 8px' },
2370
- onClick: function () { window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener') }
2505
+ onClick: function () { openOfficialRecharge() }
2371
2506
  }, '↗ 充值'))),
2372
2507
  React.createElement('div', { className: 'dshw_divider' }),
2373
2508
  React.createElement('div', { className: 'dshw_peakRow' },
@@ -3199,7 +3334,9 @@ window.__ModuleLoader__.load({
3199
3334
  }).then(function (json) {
3200
3335
  if (json && json.ok) {
3201
3336
  setAccounts(json)
3202
- setAccountsError(null)
3337
+ setAccountsError(json.storage && json.storage.locked
3338
+ ? '账户加密文件无法解密,已锁定写入以保护原数据'
3339
+ : null)
3203
3340
  }
3204
3341
  }).catch(function () {
3205
3342
  setAccountsError('\u8d26\u6237\u5217\u8868\u52a0\u8f7d\u5931\u8d25')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-harness-wallet",
3
- "description": "Local-first account monitoring, usage accounting, official recharge, completion reminders, flexible layout, host-gated session controls, and multi-account management with hot account switching for DeepSeek Harness.",
4
- "version": "0.2.6",
3
+ "description": "Local-first DeepSeek Harness control center for balance monitoring, usage accounting, official pricing sync, peak/off-peak status, encrypted multi-account switching, health checks, completion reminders, recharge, and host-gated session controls.",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {