deepseek-harness-wallet 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.3.0 - 2026-08-23
6
+
7
+ - 新增 `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.
8
+ - 新增官方价格页自动检查:成功解析并完整校验后才应用新规则,网络失败或页面结构变化时保留内置规则并显示状态。 / 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.
9
+ - 新增 Harness 健康检查:显示宿主/插件版本、兼容性、价格同步状态与无敏感信息的诊断复制。 / Added Harness health checks for host/plugin versions, compatibility, pricing-sync status, and safe diagnostic copying.
10
+ - 多账户 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.
11
+
12
+ ## 0.2.6 - 2026-08-23
13
+
14
+ - 跟随 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.
15
+ - 环形卡片同步显示周末全天低谷,并将倒计时指向下一个工作日 09:00。 / Updated the ring card to show weekend all-day off-peak and count down to the next weekday 09:00.
16
+
5
17
  ## 0.2.5 - 2026-08-23
6
18
 
7
19
  - 修复浮动峰谷卡片在窗口缩放、卡片放大或历史坐标过期后可能跑出视口的问题;加载、缩放、排版切换和 resize 都会重新钳制位置。 / Fixed floating peak cards escaping the viewport after resize, scale changes, or stale saved coordinates; load, scale, layout changes, and resize now re-clamp the position.
package/README.md CHANGED
@@ -22,8 +22,10 @@
22
22
  余额 ¥5.89 · 本场 ¥0.72 · 官 18.8M | 三方 800K · ↗充
23
23
  ```
24
24
 
25
- - **Official DeepSeek** — live balance (60s global refresh with fast boot retries), current-session cost locked to the price active for each usage event (including the 2026-08-17 peak/off-peak rollout), and token breakdown.
26
- - **24h peak/off-peak ring clock** — resident sidebar footer widget indicating real-time pricing windows (peak vs. 50% discount off-peak), countdown to next switch, and optional desktop switch notifications.
25
+ - **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
+ - **Vision model accounting** — `deepseek-v4-flash-vision-exp` is priced like V4 Flash; image tokens reported by the Harness are included with text tokens.
27
+ - **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
+ - **Official pricing sync** — periodically checks the official DeepSeek pricing page, applies only a fully validated table, and visibly keeps the built-in rules when the page is unavailable or changes format.
27
29
  - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
28
30
  - **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
31
  - **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).
@@ -34,13 +36,14 @@
34
36
  - **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
37
  - **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.
36
38
  - **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
-
38
39
 
39
- - Open the wallet panel → **账户管理** to add accounts (name + API key), switch the active one, or remove them.
40
+ ## Multi-account
41
+
42
+ - Open the wallet panel → **Account Management(账户管理)** to add accounts (name + API key), switch the active one, or remove them.
40
43
  - The first account added becomes the active account automatically and is synced into the credentials seam.
41
44
  - 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.
42
- - Account keys are stored plaintext in `$DSH_HOME/storages/accounts.json`; the UI only ever shows masked keys. Balance lookups prefer the active account's key and fall back to the credentials seam when no account is active.
43
- - Session cost follows the active account's currency: USD-settled accounts show `本约 $x` — a clearly-labeled estimate converted from the CNY price table at the vendor's long-standing list ratio (not a live FX rate); CNY accounts show the exact `本场 ¥x`.
45
+ - 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.
46
+ - 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.
44
47
  - 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.
45
48
 
46
49
 
@@ -78,6 +81,14 @@ dsh plugin --profile web add github:feibi-mochi/deepseek-harness-control-center
78
81
 
79
82
  Restart `dsh web`, then hard-refresh the page.
80
83
 
84
+ ## Quick use
85
+
86
+ 1. Click the wallet or peak/off-peak card to open its control panel; open the Harness settings card for health and compatibility checks.
87
+ 2. Choose horizontal or vertical layout, then use the scale control between 100% and 120%.
88
+ 3. Turn the official recharge button off when you need a smaller card; official and third-party rows can also be shown independently.
89
+ 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.
90
+ 5. The card follows the host light/dark theme. A hard refresh after upgrading makes sure the new client bundle is loaded.
91
+
81
92
  ### Update
82
93
 
83
94
  ```sh
@@ -129,8 +140,8 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
129
140
  | Item | Behavior |
130
141
  | --- | --- |
131
142
  | 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. |
132
- | Balance | The key from the credentials seam (or the active account's key) never leaves this machine except as the `Authorization` header of the official `/user/balance` request. |
133
- | Accounts | Keys live in `$DSH_HOME/storages/accounts.json` (plaintext, matching the harness's own credential storage); the UI only ever shows masked keys, and switching writes the chosen key into the credentials seam for LLM billing. |
143
+ | 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. |
144
+ | Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`: Windows uses current-user DPAPI, while other platforms use an owner-only AES-GCM key file. 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. |
134
145
  | Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
135
146
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
136
147
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
@@ -142,10 +153,12 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
142
153
  CNY per 1M tokens, curated from official announcements (cache writes are not billed):
143
154
 
144
155
  - Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
145
- - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
156
+ - Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025), v4-flash-vision-exp 1/2 (cache read 0.02)
146
157
  - 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):
147
158
  - v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
148
159
  - v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
160
+ - v4-flash-vision-exp (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
161
+ - 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.
149
162
 
150
163
  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.
151
164
 
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, 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,25 +27,44 @@ 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.0'
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_VERSION = 2
40
+ const ACCOUNTS_CRYPTO_VERSION = 1
33
41
  const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
34
42
  const DEFAULT_THRESHOLD = 5
35
43
  const BALANCE_REFRESH_MS = 60_000
36
44
  const STORE_VERSION = 2
37
45
 
38
- // Beijing (UTC+8, no DST) peak windows: 09:00-12:00 and 14:00-18:00.
46
+ // Beijing (UTC+8, no DST) weekday peak windows: 09:00-12:00 and 14:00-18:00.
47
+ // From 2026-08-23 00:00 Beijing, Saturday and Sunday are off-peak all day.
39
48
  // Exposed to clients via snapshot.pricingWindows so the ring clock renders
40
49
  // from the active policy instead of hard-coding hours in the bundle.
41
- const PEAK_WINDOWS = [{ startHour: 9, endHour: 12 }, { startHour: 14, endHour: 18 }]
50
+ const DEFAULT_PEAK_WINDOWS = [{ startHour: 9, endHour: 12 }, { startHour: 14, endHour: 18 }]
42
51
  const OFF_PEAK_RATE = 0.5
43
52
  const BEIJING_OFFSET_MS = 8 * 3600_000
53
+ const WEEKEND_OFF_PEAK_SINCE = Date.UTC(2026, 7, 22, 16)
54
+ let peakWindows = DEFAULT_PEAK_WINDOWS.map((window) => ({ ...window }))
55
+ let weekendOffPeakSince = WEEKEND_OFF_PEAK_SINCE
56
+
57
+ function isBeijingWeekend(atMs) {
58
+ if (!Number.isFinite(atMs)) return false
59
+ const beijingDate = new Date(atMs + BEIJING_OFFSET_MS)
60
+ const day = beijingDate.getUTCDay()
61
+ return day === 0 || day === 6
62
+ }
44
63
 
45
64
  export function isBeijingPeak(atMs) {
65
+ if (atMs >= weekendOffPeakSince && isBeijingWeekend(atMs)) return false
46
66
  const hours = ((atMs + BEIJING_OFFSET_MS) % 86_400_000) / 3_600_000
47
- return (hours >= 9 && hours < 12) || (hours >= 14 && hours < 18)
67
+ return peakWindows.some((window) => hours >= window.startHour && hours < window.endHour)
48
68
  }
49
69
 
50
70
  // Pricing timeline (CNY per 1M tokens; cacheWrite is not billed by DeepSeek).
@@ -62,6 +82,7 @@ export const PRICE_POLICIES = [
62
82
  models: {
63
83
  'deepseek-v4-flash': { cacheHit: 0.02, input: 1, output: 2 },
64
84
  'deepseek-v4-pro': { cacheHit: 0.025, input: 3, output: 6 },
85
+ 'deepseek-v4-flash-vision-exp': { cacheHit: 0.02, input: 1, output: 2 },
65
86
  },
66
87
  },
67
88
  {
@@ -71,13 +92,241 @@ export const PRICE_POLICIES = [
71
92
  models: {
72
93
  'deepseek-v4-flash': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
73
94
  'deepseek-v4-pro': { cacheHit: [0.15, 0.3], input: [4.5, 9], output: [13.5, 27] },
95
+ 'deepseek-v4-flash-vision-exp': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
74
96
  },
75
97
  },
76
98
  ]
77
99
 
100
+ let activePricePolicies = PRICE_POLICIES
101
+ let pricingSync = {
102
+ status: 'built-in',
103
+ source: PRICING_SOURCE_URL,
104
+ checkedAt: 0,
105
+ etag: null,
106
+ lastModified: null,
107
+ ruleVersion: 'built-in-2026-08-23',
108
+ message: '尚未检查官方价格页',
109
+ }
110
+
111
+ function decodeHtmlText(value) {
112
+ return String(value)
113
+ .replace(/<br\s*\/?>/gi, ' ')
114
+ .replace(/<[^>]*>/g, ' ')
115
+ .replace(/&amp;/gi, '&')
116
+ .replace(/&nbsp;/gi, ' ')
117
+ .replace(/&#39;/g, "'")
118
+ .replace(/&quot;/gi, '"')
119
+ .replace(/\s+/g, ' ')
120
+ .trim()
121
+ }
122
+
123
+ function numericCells(row, count) {
124
+ const values = row.slice(Math.max(0, row.length - count)).map((cell) => {
125
+ const match = decodeHtmlText(cell).match(/\d+(?:\.\d+)?/)
126
+ return match === null ? null : Number(match[0])
127
+ })
128
+ return values.length === count && values.every((value) => Number.isFinite(value)) ? values : null
129
+ }
130
+
131
+ /**
132
+ * Parse the stable, server-rendered pricing table from DeepSeek's official
133
+ * documentation. A failed parse is deliberately treated as review-required;
134
+ * the wallet never silently bills from a partially understood page.
135
+ */
136
+ export function parseOfficialPricingHtml(html) {
137
+ if (typeof html !== 'string' || html.length < 500) throw new Error('official pricing page is empty')
138
+ const rows = [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((match) =>
139
+ [...match[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((cell) => decodeHtmlText(cell[1])),
140
+ ).filter((row) => row.length > 0)
141
+ const header = rows.find((row) => row.some((cell) => cell === 'deepseek-v4-flash'))
142
+ const models = header ? header.filter((cell) => /^deepseek-v4-[a-z0-9-]+$/i.test(cell)) : []
143
+ const requiredModels = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp']
144
+ if (models.length < requiredModels.length || requiredModels.some((model) => !models.includes(model))) {
145
+ throw new Error('official pricing model table changed')
146
+ }
147
+
148
+ function findPair(label) {
149
+ const index = rows.findIndex((row) => row.some((cell) => cell.includes(label)) && row.some((cell) => cell.includes('空闲时段')))
150
+ if (index < 0 || !rows[index + 1]?.some((cell) => cell.includes('高峰时段'))) throw new Error('official pricing rate rows changed')
151
+ const offPeak = numericCells(rows[index], models.length)
152
+ const peak = numericCells(rows[index + 1], models.length)
153
+ if (offPeak === null || peak === null) throw new Error('official pricing values changed')
154
+ return { offPeak, peak }
155
+ }
156
+
157
+ const cacheHit = findPair('缓存命中')
158
+ const input = findPair('缓存未命中')
159
+ const output = findPair('百万tokens输出')
160
+ const modelsWithRates = {}
161
+ for (const model of requiredModels) {
162
+ const index = models.indexOf(model)
163
+ modelsWithRates[model] = {
164
+ cacheHit: [cacheHit.offPeak[index], cacheHit.peak[index]],
165
+ input: [input.offPeak[index], input.peak[index]],
166
+ output: [output.offPeak[index], output.peak[index]],
167
+ }
168
+ }
169
+ const text = decodeHtmlText(html)
170
+ 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/)
171
+ if (windowMatch === null) throw new Error('official peak window changed')
172
+ const parsedWindows = [
173
+ { startHour: Number(windowMatch[1]), endHour: Number(windowMatch[2]) },
174
+ { startHour: Number(windowMatch[3]), endHour: Number(windowMatch[4]) },
175
+ ]
176
+ const weekendMatch = text.match(/北京时间\s*(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日[^。]*?00:00起[^。]*?周末[^。]*?低谷/)
177
+ if (weekendMatch === null) throw new Error('official weekend rule changed')
178
+ const weekendSince = Date.UTC(
179
+ Number(weekendMatch[1]), Number(weekendMatch[2]) - 1, Number(weekendMatch[3]), 0, 0, 0, 0,
180
+ ) - BEIJING_OFFSET_MS
181
+ return {
182
+ models: modelsWithRates,
183
+ peakWindows: parsedWindows,
184
+ weekendOffPeakSince: weekendSince,
185
+ ruleVersion: 'official-' + createHash('sha256').update(JSON.stringify({ modelsWithRates, parsedWindows, weekendSince })).digest('hex').slice(0, 12),
186
+ }
187
+ }
188
+
189
+ function applyOfficialPricing(parsed, response) {
190
+ const base = PRICE_POLICIES[PRICE_POLICIES.length - 1]
191
+ activePricePolicies = [
192
+ ...PRICE_POLICIES.slice(0, -1),
193
+ { ...base, models: parsed.models },
194
+ ]
195
+ peakWindows = parsed.peakWindows.map((window) => ({ ...window }))
196
+ weekendOffPeakSince = parsed.weekendOffPeakSince
197
+ pricingSync = {
198
+ status: 'synced',
199
+ source: PRICING_SOURCE_URL,
200
+ checkedAt: Date.now(),
201
+ etag: response?.headers?.get?.('etag') ?? pricingSync.etag,
202
+ lastModified: response?.headers?.get?.('last-modified') ?? pricingSync.lastModified,
203
+ ruleVersion: parsed.ruleVersion,
204
+ message: '已同步官方价格页',
205
+ }
206
+ }
207
+
208
+ async function syncOfficialPricing(logger) {
209
+ if (typeof fetch !== 'function') return
210
+ const controller = new AbortController()
211
+ const timeout = setTimeout(() => controller.abort(), PRICING_SYNC_TIMEOUT_MS)
212
+ try {
213
+ const headers = {}
214
+ if (pricingSync.etag) headers['if-none-match'] = pricingSync.etag
215
+ if (pricingSync.lastModified) headers['if-modified-since'] = pricingSync.lastModified
216
+ const response = await fetch(PRICING_SOURCE_URL, { headers, signal: controller.signal })
217
+ if (response.status === 304) {
218
+ pricingSync = { ...pricingSync, status: 'synced', checkedAt: Date.now(), message: '官方价格页未变化' }
219
+ return
220
+ }
221
+ if (!response.ok) throw new Error('official pricing page returned ' + response.status)
222
+ const parsed = parseOfficialPricingHtml(await response.text())
223
+ applyOfficialPricing(parsed, response)
224
+ } catch (error) {
225
+ pricingSync = {
226
+ ...pricingSync,
227
+ status: pricingSync.checkedAt > 0 ? 'review-required' : 'offline',
228
+ checkedAt: Date.now(),
229
+ message: pricingSync.checkedAt > 0 ? '官方价格页结构有变化,需要复核' : '官方价格页暂时不可用,使用内置规则',
230
+ }
231
+ if (logger && typeof logger.warn === 'function') logger.warn('dsh-wallet: official pricing sync unavailable')
232
+ } finally {
233
+ clearTimeout(timeout)
234
+ }
235
+ }
236
+
237
+ export function pricingSnapshot() {
238
+ return {
239
+ status: pricingSync.status,
240
+ source: pricingSync.source,
241
+ checkedAt: pricingSync.checkedAt,
242
+ ruleVersion: pricingSync.ruleVersion,
243
+ message: pricingSync.message,
244
+ modelCount: Object.keys(activePricePolicies[activePricePolicies.length - 1].models).length,
245
+ peakWindows: peakWindows.map((window) => ({ ...window })),
246
+ weekendOffPeakSince,
247
+ }
248
+ }
249
+
250
+ function readPackageManifest(path) {
251
+ try {
252
+ const manifest = JSON.parse(readFileSync(path, 'utf8'))
253
+ return manifest !== null && typeof manifest === 'object' ? manifest : null
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
259
+ function detectHostManifest() {
260
+ const starts = [
261
+ typeof process.cwd === 'function' ? process.cwd() : null,
262
+ typeof process.argv?.[1] === 'string' ? dirname(process.argv[1]) : null,
263
+ ].filter(Boolean)
264
+ const seen = new Set()
265
+ for (const start of starts) {
266
+ let current = start
267
+ for (let depth = 0; depth < 10 && current !== dirname(current); depth += 1) {
268
+ if (seen.has(current)) break
269
+ seen.add(current)
270
+ const manifest = readPackageManifest(join(current, 'package.json'))
271
+ if (manifest && ['@deepseek-ai/dsh-root', '@deepseek-ai/dsh'].includes(manifest.name)) {
272
+ return { name: manifest.name, version: typeof manifest.version === 'string' ? manifest.version : null }
273
+ }
274
+ current = dirname(current)
275
+ }
276
+ }
277
+ return { name: null, version: null }
278
+ }
279
+
280
+ function compareVersions(left, right) {
281
+ const parse = (value) => {
282
+ const match = typeof value === 'string' && value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
283
+ if (match === null) return null
284
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre: match[4] || '' }
285
+ }
286
+ const a = parse(left)
287
+ const b = parse(right)
288
+ if (a === null || b === null) return null
289
+ for (const key of ['major', 'minor', 'patch']) {
290
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
291
+ }
292
+ if (a.pre === b.pre) return 0
293
+ if (a.pre === '') return 1
294
+ if (b.pre === '') return -1
295
+ return a.pre.localeCompare(b.pre, 'en', { numeric: true })
296
+ }
297
+
298
+ const HOST_MANIFEST = detectHostManifest()
299
+
300
+ export function hostHealthSnapshot() {
301
+ const hostVersion = HOST_MANIFEST.version
302
+ const comparison = compareVersions(hostVersion, MIN_HOST_VERSION)
303
+ const compatibility = comparison === null
304
+ ? { status: 'unknown', minimumVersion: MIN_HOST_VERSION, message: '无法读取 Harness 版本' }
305
+ : comparison >= 0
306
+ ? { status: 'compatible', minimumVersion: MIN_HOST_VERSION, message: '满足插件最低版本要求' }
307
+ : { status: 'upgrade-recommended', minimumVersion: MIN_HOST_VERSION, message: 'Harness 版本低于插件建议版本' }
308
+ return {
309
+ name: HOST_MANIFEST.name,
310
+ version: hostVersion,
311
+ detected: hostVersion !== null,
312
+ compatibility,
313
+ }
314
+ }
315
+
316
+ export function healthSnapshot() {
317
+ return {
318
+ ok: true,
319
+ plugin: { name: 'deepseek-harness-wallet', version: PLUGIN_VERSION },
320
+ host: hostHealthSnapshot(),
321
+ pricing: pricingSnapshot(),
322
+ accounts: accountStorageSnapshot(),
323
+ runtime: { node: process.version, platform: process.platform },
324
+ }
325
+ }
326
+
78
327
  export function ratesFor(model, atMs) {
79
328
  let entry
80
- for (const policy of PRICE_POLICIES) {
329
+ for (const policy of activePricePolicies) {
81
330
  // Own-property check: a model named `__proto__`/`toString` would otherwise
82
331
  // resolve to an Object.prototype member and produce NaN costs.
83
332
  if (atMs >= policy.since && Object.hasOwn(policy.models, model)) entry = policy
@@ -279,10 +528,128 @@ function scheduleSave(logger) {
279
528
 
280
529
  // ---------------------------------------------------------------------------
281
530
  // Multi-account store (accounts.json): list, add, remove, hot-switch.
282
- // Keys are stored plaintext next to .credentials.yaml, matching the harness's
283
- // own credential storage; the API surface only ever exposes masked keys.
531
+ // Keys remain available in memory for the active LLM/balance request, but are
532
+ // encrypted at rest. Windows uses the current user's DPAPI; other platforms
533
+ // use an owner-only AES-GCM key file as a best-effort local fallback.
284
534
  // ---------------------------------------------------------------------------
285
535
 
536
+ const WINDOWS_DPAPI_PROTECT = [
537
+ '$ErrorActionPreference = "Stop"',
538
+ 'Add-Type -AssemblyName System.Security',
539
+ '$encoded = [Console]::In.ReadToEnd().Trim()',
540
+ '$bytes = [Convert]::FromBase64String($encoded)',
541
+ '$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
542
+ '[Console]::Out.Write([Convert]::ToBase64String($protected))',
543
+ ].join(';')
544
+ const WINDOWS_DPAPI_UNPROTECT = [
545
+ '$ErrorActionPreference = "Stop"',
546
+ 'Add-Type -AssemblyName System.Security',
547
+ '$encoded = [Console]::In.ReadToEnd().Trim()',
548
+ '$protected = [Convert]::FromBase64String($encoded)',
549
+ '$bytes = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
550
+ '[Console]::Out.Write([Convert]::ToBase64String($bytes))',
551
+ ].join(';')
552
+ let fallbackEncryptionKey = null
553
+ let accountStorageScheme = process.platform === 'win32' ? 'windows-dpapi' : 'aes-gcm-file-key'
554
+ let accountStorageError = null
555
+
556
+ function runWindowsDpapi(operation, value) {
557
+ if (process.platform !== 'win32') return null
558
+ const result = spawnSync(
559
+ 'powershell.exe',
560
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', operation === 'protect' ? WINDOWS_DPAPI_PROTECT : WINDOWS_DPAPI_UNPROTECT],
561
+ {
562
+ input: Buffer.from(value, 'utf8').toString('base64'),
563
+ encoding: 'utf8',
564
+ windowsHide: true,
565
+ maxBuffer: 1024 * 1024,
566
+ },
567
+ )
568
+ if (result.error || result.status !== 0) return null
569
+ const output = String(result.stdout || '').trim()
570
+ return output === '' ? null : output
571
+ }
572
+
573
+ function getFallbackEncryptionKey() {
574
+ if (fallbackEncryptionKey !== null) return fallbackEncryptionKey
575
+ try {
576
+ mkdirSync(dirname(ACCOUNTS_KEY_PATH), { recursive: true, mode: 0o700 })
577
+ if (existsSync(ACCOUNTS_KEY_PATH)) {
578
+ const existing = readFileSync(ACCOUNTS_KEY_PATH)
579
+ if (existing.length !== 32) throw new Error('account encryption key has an invalid length')
580
+ fallbackEncryptionKey = existing
581
+ } else {
582
+ fallbackEncryptionKey = randomBytes(32)
583
+ writeFileSync(ACCOUNTS_KEY_PATH, fallbackEncryptionKey, { mode: 0o600 })
584
+ chmodSync(ACCOUNTS_KEY_PATH, 0o600)
585
+ }
586
+ return fallbackEncryptionKey
587
+ } catch {
588
+ accountStorageError = 'account-encryption-key-unavailable'
589
+ return null
590
+ }
591
+ }
592
+
593
+ function protectApiKey(apiKey) {
594
+ const dpapi = runWindowsDpapi('protect', apiKey)
595
+ if (dpapi !== null) {
596
+ accountStorageScheme = 'windows-dpapi'
597
+ accountStorageError = null
598
+ return { version: ACCOUNTS_CRYPTO_VERSION, scheme: 'windows-dpapi', payload: dpapi }
599
+ }
600
+ const key = getFallbackEncryptionKey()
601
+ if (key === null) return null
602
+ try {
603
+ const iv = randomBytes(12)
604
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
605
+ const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
606
+ accountStorageScheme = 'aes-gcm-file-key'
607
+ return {
608
+ version: ACCOUNTS_CRYPTO_VERSION,
609
+ scheme: 'aes-gcm-file-key',
610
+ iv: iv.toString('base64'),
611
+ tag: cipher.getAuthTag().toString('base64'),
612
+ payload: ciphertext.toString('base64'),
613
+ }
614
+ } catch {
615
+ accountStorageError = 'account-encryption-failed'
616
+ return null
617
+ }
618
+ }
619
+
620
+ function unprotectApiKey(value) {
621
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return ''
622
+ if (value.version !== ACCOUNTS_CRYPTO_VERSION || typeof value.scheme !== 'string' || typeof value.payload !== 'string') return ''
623
+ if (value.scheme === 'windows-dpapi') {
624
+ const decoded = runWindowsDpapi('unprotect', value.payload)
625
+ if (decoded === null) accountStorageError = 'account-decryption-failed'
626
+ else {
627
+ try { return Buffer.from(decoded, 'base64').toString('utf8') } catch { /* fall through */ }
628
+ }
629
+ return ''
630
+ }
631
+ if (value.scheme !== 'aes-gcm-file-key' || typeof value.iv !== 'string' || typeof value.tag !== 'string') return ''
632
+ const key = getFallbackEncryptionKey()
633
+ if (key === null) return ''
634
+ try {
635
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64'))
636
+ decipher.setAuthTag(Buffer.from(value.tag, 'base64'))
637
+ return Buffer.concat([decipher.update(Buffer.from(value.payload, 'base64')), decipher.final()]).toString('utf8')
638
+ } catch {
639
+ accountStorageError = 'account-decryption-failed'
640
+ return ''
641
+ }
642
+ }
643
+
644
+ export function accountStorageSnapshot() {
645
+ return {
646
+ encryptedAtRest: true,
647
+ scheme: accountStorageScheme,
648
+ status: accountStorageError === null ? 'ready' : 'error',
649
+ error: accountStorageError,
650
+ }
651
+ }
652
+
286
653
  function emptyAccounts() {
287
654
  return { version: ACCOUNTS_VERSION, accounts: [], activeId: null }
288
655
  }
@@ -295,7 +662,9 @@ export function normalizeAccountsData(value) {
295
662
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
296
663
  const id = typeof raw.id === 'string' && raw.id !== '' ? raw.id : null
297
664
  const name = typeof raw.name === 'string' ? raw.name.trim() : ''
298
- const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey : ''
665
+ // Accept the v1 plaintext shape once so an existing installation can be
666
+ // migrated on its next save; new writes use apiKeyEncrypted only.
667
+ const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey : unprotectApiKey(raw.apiKeyEncrypted)
299
668
  if (id === null || name === '' || apiKey === '') continue
300
669
  accounts.push({
301
670
  id,
@@ -313,14 +682,20 @@ export function normalizeAccountsData(value) {
313
682
 
314
683
  function loadAccounts() {
315
684
  try {
316
- return normalizeAccountsData(JSON.parse(readFileSync(ACCOUNTS_PATH, 'utf8')))
685
+ const raw = JSON.parse(readFileSync(ACCOUNTS_PATH, 'utf8'))
686
+ const normalized = normalizeAccountsData(raw)
687
+ const legacyPlaintext = Array.isArray(raw?.accounts)
688
+ && raw.accounts.some((account) => account && typeof account.apiKey === 'string')
689
+ return { accounts: normalized, migrated: legacyPlaintext || raw?.version !== ACCOUNTS_VERSION }
317
690
  } catch {
318
- return emptyAccounts()
691
+ return { accounts: emptyAccounts(), migrated: false }
319
692
  }
320
693
  }
321
694
 
322
695
  let accountsSaveTimer = null
323
- let accounts = loadAccounts()
696
+ const loadedAccounts = loadAccounts()
697
+ let accounts = loadedAccounts.accounts
698
+ let accountsNeedsSave = loadedAccounts.migrated
324
699
 
325
700
  function persistAccounts(logger) {
326
701
  if (accountsSaveTimer !== null) {
@@ -330,12 +705,24 @@ function persistAccounts(logger) {
330
705
  try {
331
706
  mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true, mode: 0o700 })
332
707
  const tmp = ACCOUNTS_PATH + '.tmp'
333
- // This file holds plaintext API keys: owner-only, never group/world readable.
334
- writeFileSync(tmp, JSON.stringify(accounts), { mode: 0o600 })
708
+ const encryptedAccounts = accounts.accounts.map((account) => {
709
+ const apiKeyEncrypted = protectApiKey(account.apiKey)
710
+ if (apiKeyEncrypted === null) throw new Error('account encryption unavailable')
711
+ return {
712
+ id: account.id,
713
+ name: account.name,
714
+ apiKeyEncrypted,
715
+ createdAt: account.createdAt,
716
+ }
717
+ })
718
+ writeFileSync(tmp, JSON.stringify({ version: ACCOUNTS_VERSION, accounts: encryptedAccounts, activeId: accounts.activeId }), { mode: 0o600 })
719
+ chmodSync(tmp, 0o600)
335
720
  renameSync(tmp, ACCOUNTS_PATH)
721
+ accountsNeedsSave = false
336
722
  } catch (error) {
723
+ accountStorageError = accountStorageError || 'account-encryption-failed'
337
724
  if (logger && typeof logger.warn === 'function') {
338
- logger.warn('dsh-wallet: failed to persist accounts: ' + String(error))
725
+ logger.warn('dsh-wallet: failed to persist encrypted accounts')
339
726
  }
340
727
  }
341
728
  }
@@ -661,6 +1048,7 @@ function sessionView(sessionId) {
661
1048
  function snapshotView(sessionId) {
662
1049
  const currency = balanceCurrency(balance.balances)
663
1050
  const active = activeAccount()
1051
+ const now = Date.now()
664
1052
  return {
665
1053
  ok: true,
666
1054
  balance: {
@@ -690,9 +1078,11 @@ function snapshotView(sessionId) {
690
1078
  pricingWindows: {
691
1079
  timezone: 'Asia/Shanghai',
692
1080
  offsetMinutes: 480,
693
- windows: PEAK_WINDOWS.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
1081
+ windows: peakWindows.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
694
1082
  offPeakRate: OFF_PEAK_RATE,
695
- isPeak: isBeijingPeak(Date.now()),
1083
+ isPeak: isBeijingPeak(now),
1084
+ weekendOffPeak: now >= weekendOffPeakSince && isBeijingWeekend(now),
1085
+ weekendOffPeakSince,
696
1086
  },
697
1087
  accounts: {
698
1088
  activeId: accounts.activeId,
@@ -755,6 +1145,7 @@ export function apply(ctx, config) {
755
1145
  storeNeedsSave = false
756
1146
  scheduleSave(ctx.logger)
757
1147
  }
1148
+ if (accountsNeedsSave) scheduleAccountsSave(ctx.logger)
758
1149
  // Thresholds live ONLY in the persisted store; an explicit row config may
759
1150
  // still override the CNY line for power users, but the bundle patch sets none.
760
1151
  if (Number.isFinite(config.threshold)) {
@@ -804,6 +1195,17 @@ export function apply(ctx, config) {
804
1195
  const balanceTimer = setInterval(() => void refreshBalance(ctx), BALANCE_REFRESH_MS)
805
1196
  if (typeof balanceTimer.unref === 'function') balanceTimer.unref()
806
1197
 
1198
+ ctx.effect(() => {
1199
+ const first = setTimeout(() => void syncOfficialPricing(ctx.logger), 1000)
1200
+ const timer = setInterval(() => void syncOfficialPricing(ctx.logger), PRICING_SYNC_INTERVAL_MS)
1201
+ if (typeof first.unref === 'function') first.unref()
1202
+ if (typeof timer.unref === 'function') timer.unref()
1203
+ return () => {
1204
+ clearTimeout(first)
1205
+ clearInterval(timer)
1206
+ }
1207
+ }, 'dsh-wallet: official pricing sync')
1208
+
807
1209
  ctx.effect(() => {
808
1210
  const disposeSnapshot = ctx.webServer.register({
809
1211
  kind: 'exact',
@@ -813,6 +1215,23 @@ export function apply(ctx, config) {
813
1215
  return json(res, 200, snapshotView(sessionParam(req)))
814
1216
  },
815
1217
  })
1218
+ const disposeHealth = ctx.webServer.register({
1219
+ kind: 'exact',
1220
+ path: '/api/wallet/health',
1221
+ handler: (req, res) => {
1222
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1223
+ return json(res, 200, healthSnapshot())
1224
+ },
1225
+ })
1226
+ const disposePricingRefresh = ctx.webServer.register({
1227
+ kind: 'exact',
1228
+ path: '/api/wallet/pricing/refresh',
1229
+ handler: async (req, res) => {
1230
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
1231
+ await syncOfficialPricing(ctx.logger)
1232
+ return json(res, 200, { ok: true, pricing: pricingSnapshot() })
1233
+ },
1234
+ })
816
1235
  const disposeThreshold = ctx.webServer.register({
817
1236
  kind: 'exact',
818
1237
  path: '/api/wallet/threshold',
@@ -953,6 +1372,8 @@ export function apply(ctx, config) {
953
1372
  })
954
1373
  return () => {
955
1374
  disposeSnapshot()
1375
+ disposeHealth()
1376
+ disposePricingRefresh()
956
1377
  disposeThreshold()
957
1378
  disposeRefresh()
958
1379
  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.5'
26
+ var WALLET_VERSION = '0.3.0'
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'
@@ -552,7 +552,7 @@ window.__ModuleLoader__.load({
552
552
  // Noon = 0h at 12 o'clock, clockwise. Optional size scales the 76-unit
553
553
  // viewBox down for tight hosts (sidebar foot row / rail circle); optional
554
554
  // ariaText overrides the fallback label with the full screen-reader state.
555
- function peakRingSVG(windows, nowHour, size, ariaText) {
555
+ function peakRingSVG(windows, nowHour, size, ariaText, allOffPeak) {
556
556
  var sizePx = typeof size === 'number' && size > 0 ? size : 76
557
557
  var R = 28
558
558
  var CX = 38
@@ -561,6 +561,18 @@ window.__ModuleLoader__.load({
561
561
  // No pricing policy resolved: one neutral full ring, no arcs, no pointer
562
562
  // — the clock shows an unconfigured state and claims no price.
563
563
  if (!Array.isArray(windows) || windows.length === 0) {
564
+ if (allOffPeak === true) {
565
+ return React.createElement('svg', {
566
+ width: sizePx, height: sizePx, viewBox: '0 0 76 76',
567
+ role: 'img', className: 'dshw_peakRing',
568
+ 'aria-label': ariaText || '当前为周末低谷时段,全天半价'
569
+ },
570
+ React.createElement('circle', {
571
+ key: 'weekend-off', cx: CX, cy: CY, r: R, fill: 'none',
572
+ className: 'dshw_ringOff dshw_ringNow', strokeWidth: 6.5,
573
+ strokeDasharray: CIRC.toFixed(2) + ' 0', strokeDashoffset: '0'
574
+ }))
575
+ }
564
576
  return React.createElement('svg', {
565
577
  width: sizePx, height: sizePx, viewBox: '0 0 76 76',
566
578
  role: 'img', className: 'dshw_peakRing',
@@ -1128,6 +1140,8 @@ window.__ModuleLoader__.load({
1128
1140
  props = props || {}
1129
1141
  var close = typeof props.close === 'function' ? props.close : null
1130
1142
  var [snapshot, setSnapshot] = React.useState(null)
1143
+ var [health, setHealth] = React.useState(null)
1144
+ var [healthNotice, setHealthNotice] = React.useState(null)
1131
1145
  var [thresholdDraft, setThresholdDraft] = React.useState('')
1132
1146
  var [thresholdNotice, setThresholdNotice] = React.useState(null)
1133
1147
  var [accounts, setAccounts] = React.useState(null)
@@ -1203,6 +1217,9 @@ window.__ModuleLoader__.load({
1203
1217
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
1204
1218
  if (!stopped && json && json.ok) setAccounts(json)
1205
1219
  }).catch(function () { if (!stopped) setAccountsError('\u8d26\u6237\u63a5\u53e3\u4e0d\u53ef\u7528') })
1220
+ fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
1221
+ if (!stopped && json && json.ok) setHealth(json)
1222
+ }).catch(function () { if (!stopped) setHealthNotice('健康检查不可用') })
1206
1223
  try {
1207
1224
  var savedLayout = compatibility.storage.getItem(CHIP_LAYOUT_KEY)
1208
1225
  var dock = savedLayout === null ? 'home' : normalizeChipLayout(JSON.parse(savedLayout)).dock
@@ -1211,6 +1228,52 @@ window.__ModuleLoader__.load({
1211
1228
  return function () { stopped = true }
1212
1229
  }, [])
1213
1230
 
1231
+ function refreshHealth() {
1232
+ setHealthNotice('正在检测…')
1233
+ fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
1234
+ if (json && json.ok) { setHealth(json); setHealthNotice('检测完成') }
1235
+ else setHealthNotice('检测失败')
1236
+ }).catch(function () { setHealthNotice('检测失败') })
1237
+ }
1238
+
1239
+ function refreshPricing() {
1240
+ setHealthNotice('正在同步官方价格…')
1241
+ fetch('/api/wallet/pricing/refresh', { method: 'POST' }).then(function (resp) { return resp.json() }).then(function (json) {
1242
+ if (json && json.ok) {
1243
+ setHealth(function (current) { return current ? Object.assign({}, current, { pricing: json.pricing }) : current })
1244
+ setHealthNotice(json.pricing && json.pricing.status === 'synced' ? '官方价格已同步' : '已保留内置价格,等待复核')
1245
+ } else setHealthNotice('价格同步失败')
1246
+ }).catch(function () { setHealthNotice('价格同步失败') })
1247
+ }
1248
+
1249
+ function copyDiagnostics() {
1250
+ if (!health) return
1251
+ var safe = {
1252
+ plugin: health.plugin,
1253
+ host: health.host,
1254
+ pricing: health.pricing && {
1255
+ status: health.pricing.status,
1256
+ ruleVersion: health.pricing.ruleVersion,
1257
+ checkedAt: health.pricing.checkedAt,
1258
+ modelCount: health.pricing.modelCount,
1259
+ },
1260
+ accounts: health.accounts && {
1261
+ encryptedAtRest: health.accounts.encryptedAtRest,
1262
+ scheme: health.accounts.scheme,
1263
+ status: health.accounts.status,
1264
+ },
1265
+ runtime: health.runtime && { node: health.runtime.node, platform: health.runtime.platform },
1266
+ }
1267
+ var text = JSON.stringify(safe, null, 2)
1268
+ try {
1269
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
1270
+ navigator.clipboard.writeText(text).then(function () { setHealthNotice('诊断信息已复制') }).catch(function () { setHealthNotice('复制失败') })
1271
+ return
1272
+ }
1273
+ } catch (e) { /* fall through to the message */ }
1274
+ setHealthNotice('当前环境不支持自动复制')
1275
+ }
1276
+
1214
1277
  function persistVisibility(next) {
1215
1278
  next = normalizeDataVisibility(next)
1216
1279
  setVisibility(next)
@@ -1394,6 +1457,39 @@ window.__ModuleLoader__.load({
1394
1457
  React.createElement('span', { className: 'dshw_balDot' }, '·'),
1395
1458
  React.createElement('span', null, currencyCode))))
1396
1459
 
1460
+ // —— 健康检查 ——
1461
+ var hostHealth = health && health.host ? health.host : null
1462
+ var hostCompatibility = hostHealth && hostHealth.compatibility ? hostHealth.compatibility : null
1463
+ var pricing = health && health.pricing ? health.pricing : null
1464
+ var accountStorage = health && health.accounts ? health.accounts : null
1465
+ var compatibilityText = hostCompatibility
1466
+ ? (hostCompatibility.status === 'compatible' ? '兼容' : hostCompatibility.status === 'upgrade-recommended' ? '建议升级' : '待检测')
1467
+ : '检测中'
1468
+ var pricingText = pricing
1469
+ ? (pricing.status === 'synced' ? '已同步' : pricing.status === 'review-required' ? '待复核' : pricing.status === 'offline' ? '离线,使用内置规则' : '内置规则')
1470
+ : '检测中'
1471
+ var storageText = accountStorage
1472
+ ? (accountStorage.scheme === 'windows-dpapi' ? 'Windows DPAPI 加密' : 'AES-GCM 加密')
1473
+ : '检测中'
1474
+ rows.push(React.createElement('div', { key: 'health', className: 'dshw_setCard', style: { display: 'block' } },
1475
+ React.createElement('div', { className: 'dshw_settingsGroupHeader' },
1476
+ React.createElement('div', { className: 'dshw_settingsGroupTitle' }, 'Harness 健康检查'),
1477
+ React.createElement('div', { className: 'dshw_settingsGroupHint' }, healthNotice || '不包含 API Key 或本地路径')),
1478
+ React.createElement('div', { className: 'dshw_settingsHeroMeta' },
1479
+ React.createElement('span', null, 'Harness v' + (hostHealth && hostHealth.version ? hostHealth.version : '未识别')),
1480
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1481
+ React.createElement('span', null, '插件 v' + WALLET_VERSION),
1482
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1483
+ React.createElement('span', null, '兼容:' + compatibilityText)),
1484
+ React.createElement('div', { className: 'dshw_settingsHeroMeta' },
1485
+ React.createElement('span', null, '价格规则:' + pricingText),
1486
+ React.createElement('span', { className: 'dshw_balDot' }, '·'),
1487
+ React.createElement('span', null, '账户存储:' + storageText)),
1488
+ React.createElement('div', { className: 'dshw_settingsFooterActions', style: { marginTop: '8px' } },
1489
+ React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: refreshHealth }, '重新检测'),
1490
+ React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: refreshPricing }, '同步官方价格'),
1491
+ React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: !health, onClick: copyDiagnostics }, '复制诊断信息'))))
1492
+
1397
1493
  // —— 设置卡 ——
1398
1494
  var cells = []
1399
1495
  cells.push(React.createElement('div', { key: 'vis', className: 'dshw_setCell' },
@@ -1722,8 +1818,47 @@ window.__ModuleLoader__.load({
1722
1818
  var windows = policy && Array.isArray(policy.windows) ? policy.windows.filter(function (w) {
1723
1819
  return w && Number.isFinite(w.startHour) && Number.isFinite(w.endHour) && w.endHour > w.startHour
1724
1820
  }) : []
1821
+ var weekendOffPeak = policy && policy.weekendOffPeak === true
1822
+ if (weekendOffPeak) {
1823
+ var offsetMinutes = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
1824
+ var beijingNow = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutes * 60000) : null
1825
+ var day = beijingNow && Number.isFinite(beijingNow.getTime()) ? beijingNow.getUTCDay() : 6
1826
+ var daysUntilMonday = day === 6 ? 2 : 1
1827
+ var nextSwitchMs = beijingNow && Number.isFinite(beijingNow.getTime())
1828
+ ? Date.UTC(
1829
+ beijingNow.getUTCFullYear(), beijingNow.getUTCMonth(), beijingNow.getUTCDate() + daysUntilMonday,
1830
+ 9 - (offsetMinutes / 60), 0, 0, 0
1831
+ ) - offsetMinutes * 60000
1832
+ : NaN
1833
+ var weekendMsLeft = Number.isFinite(nextSwitchMs) && Number.isFinite(nowMs)
1834
+ ? Math.max(0, nextSwitchMs - nowMs)
1835
+ : 0
1836
+ var weekendHours = Math.floor(weekendMsLeft / 3600000)
1837
+ var weekendMinutes = Math.floor((weekendMsLeft % 3600000) / 60000)
1838
+ var weekendLeftText = weekendHours > 0
1839
+ ? weekendHours + ' 小时 ' + weekendMinutes + ' 分'
1840
+ : weekendMinutes + ' 分钟'
1841
+ var weekendLeftShort = weekendHours > 0
1842
+ ? weekendHours + 'h' + (weekendMinutes > 0 ? weekendMinutes + 'm' : '')
1843
+ : weekendMinutes + 'm'
1844
+ var weekendDateKey = beijingNow && Number.isFinite(beijingNow.getTime())
1845
+ ? [beijingNow.getUTCFullYear(), String(beijingNow.getUTCMonth() + 1).padStart(2, '0'), String(beijingNow.getUTCDate()).padStart(2, '0')].join('-')
1846
+ : 'weekend'
1847
+ var weekendTzName = policy.timezone || 'Asia/Shanghai'
1848
+ return {
1849
+ configured: true, windows: [], weekendOffPeak: true, inPeak: false,
1850
+ nextHour: 9,
1851
+ ariaText: '当前为周末低谷时段,全天按半价计费,周一 09:00 恢复标准价',
1852
+ tip: '峰谷时钟 · 当前周末低谷 · 周一 09:00 恢复标准价 · 还有 ' + weekendLeftText + ' · ' + weekendTzName,
1853
+ periodName: '周末低谷', rateBadge: '半价',
1854
+ countdownSummary: '周一 09:00 切换 · 剩 ' + weekendLeftShort,
1855
+ windowSummary: '周末全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
1856
+ periodId: 'weekend-off-' + weekendDateKey,
1857
+ switchBody: '周末低谷结束,已进入工作日高峰时段,按标准价计费',
1858
+ }
1859
+ }
1725
1860
  if (windows.length === 0) {
1726
- return { configured: false, windows: [], ariaText: '峰谷计费时段未配置', tip: '峰谷时钟 · 计费时段未配置', periodId: null }
1861
+ return { configured: false, windows: [], weekendOffPeak: false, ariaText: '峰谷计费时段未配置', tip: '峰谷时钟 · 计费时段未配置', periodId: null }
1727
1862
  }
1728
1863
  var inPeak = false
1729
1864
  var segStart = null
@@ -1786,7 +1921,7 @@ window.__ModuleLoader__.load({
1786
1921
  var tip = '峰谷时钟 · 当前' + period + ' · ' + switchText + ' · 还有 ' + leftText
1787
1922
  + ' · 高峰 ' + winText + '(' + tzName + ')' + localNote
1788
1923
  return {
1789
- configured: true, windows: windows, inPeak: inPeak,
1924
+ configured: true, windows: windows, weekendOffPeak: false, inPeak: inPeak,
1790
1925
  nextHour: nextHour, ariaText: ariaText, tip: tip,
1791
1926
  periodName: periodName, rateBadge: rateBadge,
1792
1927
  countdownSummary: countdownSummary, windowSummary: windowSummary,
@@ -2240,7 +2375,7 @@ window.__ModuleLoader__.load({
2240
2375
  onClick: handleResetDock
2241
2376
  }, '↩ 归位') : null,
2242
2377
  React.createElement('div', { style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
2243
- peakRingSVG(state.configured ? state.windows : null, wallHourIn(tzName, offsetMinutes, new Date(nowMs)), clockSize, state.ariaText)
2378
+ peakRingSVG(state.configured ? state.windows : null, wallHourIn(tzName, offsetMinutes, new Date(nowMs)), clockSize, state.ariaText, state.weekendOffPeak)
2244
2379
  ),
2245
2380
  !isRail ? React.createElement('div', { className: 'dshw_footRingLabel' },
2246
2381
  React.createElement('div', { className: 'dshw_footRingHeader' },
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.5",
3
+ "description": "Local-first account monitoring, usage accounting, official pricing sync, health checks, encrypted multi-account management, recharge, completion reminders, flexible layout, and host-gated session controls for DeepSeek Harness.",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {