deepseek-harness-wallet 0.3.0 → 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 +8 -0
- package/README.md +16 -14
- package/index.js +165 -61
- package/lib/client.js +95 -42
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## 0.3.0 - 2026-08-23
|
|
6
14
|
|
|
7
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.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/deepseek-harness-wallet)
|
|
4
4
|
[](https://github.com/feibi-mochi/deepseek-harness-control-center/releases)
|
|
5
5
|
[](https://github.com/feibi-mochi/deepseek-harness-control-center/actions/workflows/validate.yml)
|
|
6
|
-
[](https://github.com/deepseek-ai/DeepSeek-Harness)
|
|
7
7
|
[](./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
|
|
@@ -24,8 +26,8 @@
|
|
|
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
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.
|
|
27
|
-
- **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash
|
|
28
|
-
- **Official pricing sync** — periodically checks the official DeepSeek pricing page
|
|
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.
|
|
29
31
|
- **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
|
|
30
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.
|
|
31
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).
|
|
@@ -34,7 +36,7 @@
|
|
|
34
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.
|
|
35
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.
|
|
36
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.
|
|
37
|
-
- **Theme-native UI** —
|
|
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.
|
|
38
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.
|
|
39
41
|
|
|
40
42
|
## Multi-account
|
|
@@ -67,13 +69,13 @@ Details: [compatibility](#browser-desktop-and-os-compatibility) · [data and tru
|
|
|
67
69
|
|
|
68
70
|
## Install
|
|
69
71
|
|
|
70
|
-
From npm:
|
|
72
|
+
From npm (stable v0.3.0):
|
|
71
73
|
|
|
72
74
|
```sh
|
|
73
75
|
dsh plugin --profile web add deepseek-harness-wallet
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
or from GitHub
|
|
78
|
+
or from GitHub `main` (current v0.3.1 source):
|
|
77
79
|
|
|
78
80
|
```sh
|
|
79
81
|
dsh plugin --profile web add github:feibi-mochi/deepseek-harness-control-center
|
|
@@ -84,7 +86,7 @@ Restart `dsh web`, then hard-refresh the page.
|
|
|
84
86
|
## Quick use
|
|
85
87
|
|
|
86
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.
|
|
87
|
-
2.
|
|
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.
|
|
88
90
|
3. Turn the official recharge button off when you need a smaller card; official and third-party rows can also be shown independently.
|
|
89
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.
|
|
90
92
|
5. The card follows the host light/dark theme. A hard refresh after upgrading makes sure the new client bundle is loaded.
|
|
@@ -141,7 +143,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
|
|
|
141
143
|
| --- | --- |
|
|
142
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. |
|
|
143
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. |
|
|
144
|
-
| Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json
|
|
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. |
|
|
145
147
|
| Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
|
|
146
148
|
| Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
|
|
147
149
|
| Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
|
|
@@ -153,20 +155,20 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
|
|
|
153
155
|
CNY per 1M tokens, curated from official announcements (cache writes are not billed):
|
|
154
156
|
|
|
155
157
|
- Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
|
|
156
|
-
- Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
|
|
158
|
+
- Since 2026-04-24 — v4-flash 1/2 (cache read 0.02), v4-pro 3/6 (cache read 0.025)
|
|
157
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):
|
|
158
160
|
- v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
|
|
159
161
|
- v4-pro (off-peak / peak): cache read 0.15 / 0.30, input 4.5 / 9, output 13.5 / 27
|
|
160
|
-
|
|
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.
|
|
161
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.
|
|
162
164
|
|
|
163
|
-
deepseek-chat and deepseek-reasoner
|
|
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.
|
|
164
166
|
|
|
165
167
|
## Roadmap
|
|
166
168
|
|
|
167
|
-
- [ ]
|
|
168
|
-
- [ ]
|
|
169
|
-
- [ ]
|
|
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
|
|
170
172
|
|
|
171
173
|
## License
|
|
172
174
|
|
package/index.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
20
20
|
import { spawnSync } from 'node:child_process'
|
|
21
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
23
|
import { dirname, join } from 'node:path'
|
|
24
24
|
|
|
@@ -27,7 +27,7 @@ export const inject = ['webServer']
|
|
|
27
27
|
|
|
28
28
|
const OFFICIAL_PROVIDER = 'deepseek-official'
|
|
29
29
|
const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
|
|
30
|
-
const PLUGIN_VERSION = '0.3.
|
|
30
|
+
const PLUGIN_VERSION = '0.3.1'
|
|
31
31
|
const PRICING_SOURCE_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/'
|
|
32
32
|
const PRICING_SYNC_INTERVAL_MS = 6 * 60 * 60_000
|
|
33
33
|
const PRICING_SYNC_TIMEOUT_MS = 8_000
|
|
@@ -36,6 +36,7 @@ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
|
36
36
|
const STORE_PATH = join(DSH_HOME, 'storages', 'wallet.json')
|
|
37
37
|
const ACCOUNTS_PATH = join(DSH_HOME, 'storages', 'accounts.json')
|
|
38
38
|
const ACCOUNTS_KEY_PATH = ACCOUNTS_PATH + '.key'
|
|
39
|
+
const ACCOUNTS_BACKUP_PATH = ACCOUNTS_PATH + '.bak'
|
|
39
40
|
const ACCOUNTS_VERSION = 2
|
|
40
41
|
const ACCOUNTS_CRYPTO_VERSION = 1
|
|
41
42
|
const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
|
|
@@ -67,6 +68,22 @@ export function isBeijingPeak(atMs) {
|
|
|
67
68
|
return peakWindows.some((window) => hours >= window.startHour && hours < window.endHour)
|
|
68
69
|
}
|
|
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
// Pricing timeline (CNY per 1M tokens; cacheWrite is not billed by DeepSeek).
|
|
71
88
|
// Curated from the official announcements; later policies win per model.
|
|
72
89
|
export const PRICE_POLICIES = [
|
|
@@ -82,7 +99,6 @@ export const PRICE_POLICIES = [
|
|
|
82
99
|
models: {
|
|
83
100
|
'deepseek-v4-flash': { cacheHit: 0.02, input: 1, output: 2 },
|
|
84
101
|
'deepseek-v4-pro': { cacheHit: 0.025, input: 3, output: 6 },
|
|
85
|
-
'deepseek-v4-flash-vision-exp': { cacheHit: 0.02, input: 1, output: 2 },
|
|
86
102
|
},
|
|
87
103
|
},
|
|
88
104
|
{
|
|
@@ -92,6 +108,13 @@ export const PRICE_POLICIES = [
|
|
|
92
108
|
models: {
|
|
93
109
|
'deepseek-v4-flash': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
|
|
94
110
|
'deepseek-v4-pro': { cacheHit: [0.15, 0.3], input: [4.5, 9], output: [13.5, 27] },
|
|
111
|
+
},
|
|
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: {
|
|
95
118
|
'deepseek-v4-flash-vision-exp': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
|
|
96
119
|
},
|
|
97
120
|
},
|
|
@@ -105,6 +128,7 @@ let pricingSync = {
|
|
|
105
128
|
etag: null,
|
|
106
129
|
lastModified: null,
|
|
107
130
|
ruleVersion: 'built-in-2026-08-23',
|
|
131
|
+
modelCount: 3,
|
|
108
132
|
message: '尚未检查官方价格页',
|
|
109
133
|
}
|
|
110
134
|
|
|
@@ -167,17 +191,20 @@ export function parseOfficialPricingHtml(html) {
|
|
|
167
191
|
}
|
|
168
192
|
}
|
|
169
193
|
const text = decodeHtmlText(html)
|
|
170
|
-
const windowMatch = text.match(
|
|
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/)
|
|
171
195
|
if (windowMatch === null) throw new Error('official peak window changed')
|
|
172
196
|
const parsedWindows = [
|
|
173
197
|
{ startHour: Number(windowMatch[1]), endHour: Number(windowMatch[2]) },
|
|
174
198
|
{ startHour: Number(windowMatch[3]), endHour: Number(windowMatch[4]) },
|
|
175
199
|
]
|
|
200
|
+
const weekdayOnly = /高峰时段为北京时间周一至周五/.test(text)
|
|
176
201
|
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 =
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
208
|
return {
|
|
182
209
|
models: modelsWithRates,
|
|
183
210
|
peakWindows: parsedWindows,
|
|
@@ -187,11 +214,13 @@ export function parseOfficialPricingHtml(html) {
|
|
|
187
214
|
}
|
|
188
215
|
|
|
189
216
|
function applyOfficialPricing(parsed, response) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
})
|
|
195
224
|
peakWindows = parsed.peakWindows.map((window) => ({ ...window }))
|
|
196
225
|
weekendOffPeakSince = parsed.weekendOffPeakSince
|
|
197
226
|
pricingSync = {
|
|
@@ -201,6 +230,7 @@ function applyOfficialPricing(parsed, response) {
|
|
|
201
230
|
etag: response?.headers?.get?.('etag') ?? pricingSync.etag,
|
|
202
231
|
lastModified: response?.headers?.get?.('last-modified') ?? pricingSync.lastModified,
|
|
203
232
|
ruleVersion: parsed.ruleVersion,
|
|
233
|
+
modelCount: Object.keys(parsed.models).length,
|
|
204
234
|
message: '已同步官方价格页',
|
|
205
235
|
}
|
|
206
236
|
}
|
|
@@ -209,6 +239,7 @@ async function syncOfficialPricing(logger) {
|
|
|
209
239
|
if (typeof fetch !== 'function') return
|
|
210
240
|
const controller = new AbortController()
|
|
211
241
|
const timeout = setTimeout(() => controller.abort(), PRICING_SYNC_TIMEOUT_MS)
|
|
242
|
+
let stage = 'network'
|
|
212
243
|
try {
|
|
213
244
|
const headers = {}
|
|
214
245
|
if (pricingSync.etag) headers['if-none-match'] = pricingSync.etag
|
|
@@ -219,14 +250,18 @@ async function syncOfficialPricing(logger) {
|
|
|
219
250
|
return
|
|
220
251
|
}
|
|
221
252
|
if (!response.ok) throw new Error('official pricing page returned ' + response.status)
|
|
253
|
+
stage = 'parse'
|
|
222
254
|
const parsed = parseOfficialPricingHtml(await response.text())
|
|
223
255
|
applyOfficialPricing(parsed, response)
|
|
224
256
|
} catch (error) {
|
|
257
|
+
const hasValidatedOfficialRule = typeof pricingSync.ruleVersion === 'string' && pricingSync.ruleVersion.startsWith('official-')
|
|
225
258
|
pricingSync = {
|
|
226
259
|
...pricingSync,
|
|
227
|
-
status:
|
|
260
|
+
status: stage === 'parse' ? 'review-required' : 'offline',
|
|
228
261
|
checkedAt: Date.now(),
|
|
229
|
-
message:
|
|
262
|
+
message: stage === 'parse'
|
|
263
|
+
? '官方价格页结构有变化,需要复核'
|
|
264
|
+
: hasValidatedOfficialRule ? '官方价格页暂时不可用,沿用上次已验证规则' : '官方价格页暂时不可用,使用内置规则',
|
|
230
265
|
}
|
|
231
266
|
if (logger && typeof logger.warn === 'function') logger.warn('dsh-wallet: official pricing sync unavailable')
|
|
232
267
|
} finally {
|
|
@@ -241,7 +276,7 @@ export function pricingSnapshot() {
|
|
|
241
276
|
checkedAt: pricingSync.checkedAt,
|
|
242
277
|
ruleVersion: pricingSync.ruleVersion,
|
|
243
278
|
message: pricingSync.message,
|
|
244
|
-
modelCount:
|
|
279
|
+
modelCount: pricingSync.modelCount,
|
|
245
280
|
peakWindows: peakWindows.map((window) => ({ ...window })),
|
|
246
281
|
weekendOffPeakSince,
|
|
247
282
|
}
|
|
@@ -552,14 +587,22 @@ const WINDOWS_DPAPI_UNPROTECT = [
|
|
|
552
587
|
let fallbackEncryptionKey = null
|
|
553
588
|
let accountStorageScheme = process.platform === 'win32' ? 'windows-dpapi' : 'aes-gcm-file-key'
|
|
554
589
|
let accountStorageError = null
|
|
590
|
+
let accountStorageLocked = false
|
|
591
|
+
let accountStorageRecovered = false
|
|
555
592
|
|
|
556
593
|
function runWindowsDpapi(operation, value) {
|
|
557
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
|
|
558
601
|
const result = spawnSync(
|
|
559
602
|
'powershell.exe',
|
|
560
603
|
['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', operation === 'protect' ? WINDOWS_DPAPI_PROTECT : WINDOWS_DPAPI_UNPROTECT],
|
|
561
604
|
{
|
|
562
|
-
input
|
|
605
|
+
input,
|
|
563
606
|
encoding: 'utf8',
|
|
564
607
|
windowsHide: true,
|
|
565
608
|
maxBuffer: 1024 * 1024,
|
|
@@ -570,7 +613,7 @@ function runWindowsDpapi(operation, value) {
|
|
|
570
613
|
return output === '' ? null : output
|
|
571
614
|
}
|
|
572
615
|
|
|
573
|
-
function getFallbackEncryptionKey() {
|
|
616
|
+
function getFallbackEncryptionKey(createIfMissing = true) {
|
|
574
617
|
if (fallbackEncryptionKey !== null) return fallbackEncryptionKey
|
|
575
618
|
try {
|
|
576
619
|
mkdirSync(dirname(ACCOUNTS_KEY_PATH), { recursive: true, mode: 0o700 })
|
|
@@ -579,6 +622,11 @@ function getFallbackEncryptionKey() {
|
|
|
579
622
|
if (existing.length !== 32) throw new Error('account encryption key has an invalid length')
|
|
580
623
|
fallbackEncryptionKey = existing
|
|
581
624
|
} else {
|
|
625
|
+
if (!createIfMissing) {
|
|
626
|
+
accountStorageError = 'account-encryption-key-missing'
|
|
627
|
+
accountStorageLocked = true
|
|
628
|
+
return null
|
|
629
|
+
}
|
|
582
630
|
fallbackEncryptionKey = randomBytes(32)
|
|
583
631
|
writeFileSync(ACCOUNTS_KEY_PATH, fallbackEncryptionKey, { mode: 0o600 })
|
|
584
632
|
chmodSync(ACCOUNTS_KEY_PATH, 0o600)
|
|
@@ -590,6 +638,29 @@ function getFallbackEncryptionKey() {
|
|
|
590
638
|
}
|
|
591
639
|
}
|
|
592
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
|
+
|
|
593
664
|
function protectApiKey(apiKey) {
|
|
594
665
|
const dpapi = runWindowsDpapi('protect', apiKey)
|
|
595
666
|
if (dpapi !== null) {
|
|
@@ -600,17 +671,9 @@ function protectApiKey(apiKey) {
|
|
|
600
671
|
const key = getFallbackEncryptionKey()
|
|
601
672
|
if (key === null) return null
|
|
602
673
|
try {
|
|
603
|
-
const
|
|
604
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
605
|
-
const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
|
|
674
|
+
const encrypted = encryptApiKeyAes(apiKey, key)
|
|
606
675
|
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
|
-
}
|
|
676
|
+
return encrypted
|
|
614
677
|
} catch {
|
|
615
678
|
accountStorageError = 'account-encryption-failed'
|
|
616
679
|
return null
|
|
@@ -618,25 +681,41 @@ function protectApiKey(apiKey) {
|
|
|
618
681
|
}
|
|
619
682
|
|
|
620
683
|
function unprotectApiKey(value) {
|
|
621
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
622
|
-
|
|
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
|
+
}
|
|
623
694
|
if (value.scheme === 'windows-dpapi') {
|
|
624
695
|
const decoded = runWindowsDpapi('unprotect', value.payload)
|
|
625
|
-
if (decoded === null)
|
|
696
|
+
if (decoded === null) {
|
|
697
|
+
accountStorageError = 'account-decryption-failed'
|
|
698
|
+
accountStorageLocked = true
|
|
699
|
+
}
|
|
626
700
|
else {
|
|
627
701
|
try { return Buffer.from(decoded, 'base64').toString('utf8') } catch { /* fall through */ }
|
|
702
|
+
accountStorageError = 'account-decryption-failed'
|
|
703
|
+
accountStorageLocked = true
|
|
628
704
|
}
|
|
629
705
|
return ''
|
|
630
706
|
}
|
|
631
|
-
if (value.scheme !== 'aes-gcm-file-key' || typeof value.iv !== 'string' || typeof value.tag !== 'string')
|
|
632
|
-
|
|
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)
|
|
633
713
|
if (key === null) return ''
|
|
634
714
|
try {
|
|
635
|
-
|
|
636
|
-
decipher.setAuthTag(Buffer.from(value.tag, 'base64'))
|
|
637
|
-
return Buffer.concat([decipher.update(Buffer.from(value.payload, 'base64')), decipher.final()]).toString('utf8')
|
|
715
|
+
return decryptApiKeyAes(value, key)
|
|
638
716
|
} catch {
|
|
639
717
|
accountStorageError = 'account-decryption-failed'
|
|
718
|
+
accountStorageLocked = true
|
|
640
719
|
return ''
|
|
641
720
|
}
|
|
642
721
|
}
|
|
@@ -645,8 +724,9 @@ export function accountStorageSnapshot() {
|
|
|
645
724
|
return {
|
|
646
725
|
encryptedAtRest: true,
|
|
647
726
|
scheme: accountStorageScheme,
|
|
648
|
-
status: accountStorageError === null ? 'ready' : 'error',
|
|
727
|
+
status: accountStorageLocked ? 'locked' : accountStorageRecovered ? 'recovered' : accountStorageError === null ? 'ready' : 'error',
|
|
649
728
|
error: accountStorageError,
|
|
729
|
+
locked: accountStorageLocked,
|
|
650
730
|
}
|
|
651
731
|
}
|
|
652
732
|
|
|
@@ -680,14 +760,33 @@ export function normalizeAccountsData(value) {
|
|
|
680
760
|
return { version: ACCOUNTS_VERSION, accounts, activeId }
|
|
681
761
|
}
|
|
682
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
|
+
|
|
683
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
|
+
}
|
|
684
785
|
try {
|
|
685
|
-
|
|
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 }
|
|
786
|
+
return readAccountsFile(ACCOUNTS_PATH)
|
|
690
787
|
} catch {
|
|
788
|
+
accountStorageError = 'account-store-invalid'
|
|
789
|
+
accountStorageLocked = true
|
|
691
790
|
return { accounts: emptyAccounts(), migrated: false }
|
|
692
791
|
}
|
|
693
792
|
}
|
|
@@ -703,6 +802,7 @@ function persistAccounts(logger) {
|
|
|
703
802
|
accountsSaveTimer = null
|
|
704
803
|
}
|
|
705
804
|
try {
|
|
805
|
+
if (accountStorageLocked) throw new Error('encrypted account store is locked')
|
|
706
806
|
mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true, mode: 0o700 })
|
|
707
807
|
const tmp = ACCOUNTS_PATH + '.tmp'
|
|
708
808
|
const encryptedAccounts = accounts.accounts.map((account) => {
|
|
@@ -717,8 +817,14 @@ function persistAccounts(logger) {
|
|
|
717
817
|
})
|
|
718
818
|
writeFileSync(tmp, JSON.stringify({ version: ACCOUNTS_VERSION, accounts: encryptedAccounts, activeId: accounts.activeId }), { mode: 0o600 })
|
|
719
819
|
chmodSync(tmp, 0o600)
|
|
820
|
+
if (existsSync(ACCOUNTS_PATH)) {
|
|
821
|
+
copyFileSync(ACCOUNTS_PATH, ACCOUNTS_BACKUP_PATH)
|
|
822
|
+
chmodSync(ACCOUNTS_BACKUP_PATH, 0o600)
|
|
823
|
+
}
|
|
720
824
|
renameSync(tmp, ACCOUNTS_PATH)
|
|
721
825
|
accountsNeedsSave = false
|
|
826
|
+
accountStorageRecovered = false
|
|
827
|
+
accountStorageError = null
|
|
722
828
|
} catch (error) {
|
|
723
829
|
accountStorageError = accountStorageError || 'account-encryption-failed'
|
|
724
830
|
if (logger && typeof logger.warn === 'function') {
|
|
@@ -801,6 +907,7 @@ export function accountListView() {
|
|
|
801
907
|
active: account.id === accounts.activeId,
|
|
802
908
|
})),
|
|
803
909
|
activeId: accounts.activeId,
|
|
910
|
+
storage: accountStorageSnapshot(),
|
|
804
911
|
}
|
|
805
912
|
}
|
|
806
913
|
|
|
@@ -1075,15 +1182,7 @@ function snapshotView(sessionId) {
|
|
|
1075
1182
|
})(),
|
|
1076
1183
|
rechargeUrl: RECHARGE_URL,
|
|
1077
1184
|
// Peak/off-peak policy for the ring clock; billed in Asia/Shanghai.
|
|
1078
|
-
pricingWindows:
|
|
1079
|
-
timezone: 'Asia/Shanghai',
|
|
1080
|
-
offsetMinutes: 480,
|
|
1081
|
-
windows: peakWindows.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
|
|
1082
|
-
offPeakRate: OFF_PEAK_RATE,
|
|
1083
|
-
isPeak: isBeijingPeak(now),
|
|
1084
|
-
weekendOffPeak: now >= weekendOffPeakSince && isBeijingWeekend(now),
|
|
1085
|
-
weekendOffPeakSince,
|
|
1086
|
-
},
|
|
1185
|
+
pricingWindows: pricingWindowSnapshot(now),
|
|
1087
1186
|
accounts: {
|
|
1088
1187
|
activeId: accounts.activeId,
|
|
1089
1188
|
activeName: active !== null ? active.name : null,
|
|
@@ -1195,16 +1294,18 @@ export function apply(ctx, config) {
|
|
|
1195
1294
|
const balanceTimer = setInterval(() => void refreshBalance(ctx), BALANCE_REFRESH_MS)
|
|
1196
1295
|
if (typeof balanceTimer.unref === 'function') balanceTimer.unref()
|
|
1197
1296
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
+
}
|
|
1208
1309
|
|
|
1209
1310
|
ctx.effect(() => {
|
|
1210
1311
|
const disposeSnapshot = ctx.webServer.register({
|
|
@@ -1306,6 +1407,7 @@ export function apply(ctx, config) {
|
|
|
1306
1407
|
handler: async (req, res) => {
|
|
1307
1408
|
if (req.method === 'GET') return json(res, 200, { ok: true, ...accountListView() })
|
|
1308
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' })
|
|
1309
1411
|
const body = await readBody(req)
|
|
1310
1412
|
if (body === null || typeof body.name !== 'string' || typeof body.apiKey !== 'string') {
|
|
1311
1413
|
return json(res, 400, { ok: false, error: 'name and apiKey are required' })
|
|
@@ -1345,6 +1447,7 @@ export function apply(ctx, config) {
|
|
|
1345
1447
|
path: '/api/wallet/accounts/activate',
|
|
1346
1448
|
handler: async (req, res) => {
|
|
1347
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' })
|
|
1348
1451
|
const body = await readBody(req)
|
|
1349
1452
|
if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
|
|
1350
1453
|
const result = await activateAccount(ctx, body.id)
|
|
@@ -1361,6 +1464,7 @@ export function apply(ctx, config) {
|
|
|
1361
1464
|
path: '/api/wallet/accounts/remove',
|
|
1362
1465
|
handler: async (req, res) => {
|
|
1363
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' })
|
|
1364
1468
|
const body = await readBody(req)
|
|
1365
1469
|
if (body === null || typeof body.id !== 'string') return json(res, 400, { ok: false, error: 'id is required' })
|
|
1366
1470
|
const result = removeAccount(body.id)
|
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.3.
|
|
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}',
|
|
@@ -1215,7 +1234,10 @@ window.__ModuleLoader__.load({
|
|
|
1215
1234
|
}
|
|
1216
1235
|
refresh()
|
|
1217
1236
|
fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
|
|
1218
|
-
if (!stopped && json && json.ok)
|
|
1237
|
+
if (!stopped && json && json.ok) {
|
|
1238
|
+
setAccounts(json)
|
|
1239
|
+
setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
|
|
1240
|
+
}
|
|
1219
1241
|
}).catch(function () { if (!stopped) setAccountsError('\u8d26\u6237\u63a5\u53e3\u4e0d\u53ef\u7528') })
|
|
1220
1242
|
fetch('/api/wallet/health').then(function (resp) { return resp.json() }).then(function (json) {
|
|
1221
1243
|
if (!stopped && json && json.ok) setHealth(json)
|
|
@@ -1364,7 +1386,10 @@ window.__ModuleLoader__.load({
|
|
|
1364
1386
|
|
|
1365
1387
|
function reloadAccounts() {
|
|
1366
1388
|
fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
|
|
1367
|
-
if (json && json.ok) {
|
|
1389
|
+
if (json && json.ok) {
|
|
1390
|
+
setAccounts(json)
|
|
1391
|
+
setAccountsError(json.storage && json.storage.locked ? '账户加密文件无法解密,已锁定写入以保护原数据' : null)
|
|
1392
|
+
}
|
|
1368
1393
|
}).catch(function () { /* ignore */ })
|
|
1369
1394
|
fetch('/api/wallet/snapshot').then(function (resp) { return resp.json() }).then(function (json) {
|
|
1370
1395
|
if (json && json.ok) {
|
|
@@ -1466,10 +1491,22 @@ window.__ModuleLoader__.load({
|
|
|
1466
1491
|
? (hostCompatibility.status === 'compatible' ? '兼容' : hostCompatibility.status === 'upgrade-recommended' ? '建议升级' : '待检测')
|
|
1467
1492
|
: '检测中'
|
|
1468
1493
|
var pricingText = pricing
|
|
1469
|
-
? (pricing.status === 'synced'
|
|
1494
|
+
? (pricing.status === 'synced'
|
|
1495
|
+
? '已同步'
|
|
1496
|
+
: pricing.status === 'review-required'
|
|
1497
|
+
? '待复核'
|
|
1498
|
+
: pricing.status === 'offline'
|
|
1499
|
+
? (String(pricing.ruleVersion || '').indexOf('official-') === 0 ? '离线,沿用已验证规则' : '离线,使用内置规则')
|
|
1500
|
+
: '内置规则')
|
|
1470
1501
|
: '检测中'
|
|
1471
1502
|
var storageText = accountStorage
|
|
1472
|
-
? (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 加密')
|
|
1473
1510
|
: '检测中'
|
|
1474
1511
|
rows.push(React.createElement('div', { key: 'health', className: 'dshw_setCard', style: { display: 'block' } },
|
|
1475
1512
|
React.createElement('div', { className: 'dshw_settingsGroupHeader' },
|
|
@@ -1789,7 +1826,7 @@ window.__ModuleLoader__.load({
|
|
|
1789
1826
|
}, '刷新余额'),
|
|
1790
1827
|
React.createElement('button', {
|
|
1791
1828
|
type: 'button', className: 'dshw_btn dshw_btnPrimary',
|
|
1792
|
-
onClick: function () { if (close) close();
|
|
1829
|
+
onClick: function () { if (close) close(); openOfficialRecharge() }
|
|
1793
1830
|
}, '↗ 去官方充值'))))
|
|
1794
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)
|
|
1795
1832
|
}
|
|
@@ -1823,38 +1860,27 @@ window.__ModuleLoader__.load({
|
|
|
1823
1860
|
var offsetMinutes = policy && Number.isFinite(policy.offsetMinutes) ? policy.offsetMinutes : 480
|
|
1824
1861
|
var beijingNow = Number.isFinite(nowMs) ? new Date(nowMs + offsetMinutes * 60000) : null
|
|
1825
1862
|
var day = beijingNow && Number.isFinite(beijingNow.getTime()) ? beijingNow.getUTCDay() : 6
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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('-')
|
|
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('-')
|
|
1846
1871
|
: 'weekend'
|
|
1847
1872
|
var weekendTzName = policy.timezone || 'Asia/Shanghai'
|
|
1873
|
+
var weekendDayLabel = day === 6 ? '周六' : '周日'
|
|
1848
1874
|
return {
|
|
1849
1875
|
configured: true, windows: [], weekendOffPeak: true, inPeak: false,
|
|
1850
1876
|
nextHour: 9,
|
|
1851
|
-
ariaText: '
|
|
1852
|
-
tip: '峰谷时钟 ·
|
|
1877
|
+
ariaText: '当前为' + weekendDayLabel + '低谷时段,全天按半价计费,周一 09:00 恢复标准价',
|
|
1878
|
+
tip: '峰谷时钟 · 当前' + weekendDayLabel + '低谷 · ' + weekendDayLabel + '全天半价 · 周一 09:00 恢复工作日规则 · ' + weekendTzName,
|
|
1853
1879
|
periodName: '周末低谷', rateBadge: '半价',
|
|
1854
|
-
countdownSummary:
|
|
1855
|
-
windowSummary: '
|
|
1856
|
-
periodId: 'weekend-
|
|
1857
|
-
switchBody: '
|
|
1880
|
+
countdownSummary: weekendDayLabel + '全天低谷',
|
|
1881
|
+
windowSummary: weekendDayLabel + '全天低谷 · 工作日高峰 09:00–12:00 / 14:00–18:00',
|
|
1882
|
+
periodId: 'weekend-chain-' + weekendDateKey,
|
|
1883
|
+
switchBody: '已进入周末低谷,全天按半价计费',
|
|
1858
1884
|
}
|
|
1859
1885
|
}
|
|
1860
1886
|
if (windows.length === 0) {
|
|
@@ -1899,8 +1925,24 @@ window.__ModuleLoader__.load({
|
|
|
1899
1925
|
var period = inPeak ? '高峰' : '低谷' + rateWord
|
|
1900
1926
|
var periodName = inPeak ? '高峰时段' : '低谷时段'
|
|
1901
1927
|
var rateBadge = inPeak ? '标准价' : (rate === 0.5 ? '半价' : '×' + rate)
|
|
1902
|
-
var
|
|
1903
|
-
var
|
|
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
|
|
1904
1946
|
// Dual timezone: billing is judged in the policy's base zone; a device
|
|
1905
1947
|
// elsewhere also sees the local-clock span of the CURRENT segment.
|
|
1906
1948
|
var localNote = ''
|
|
@@ -1918,15 +1960,24 @@ window.__ModuleLoader__.load({
|
|
|
1918
1960
|
var ariaText = inPeak
|
|
1919
1961
|
? '当前为高峰时段,' + tzName + ' ' + winText + ',按标准价计费'
|
|
1920
1962
|
: '当前为低谷时段,按平价的 ' + rate + ' 倍计费,' + tzName + ' 高峰 ' + winText
|
|
1921
|
-
var tip =
|
|
1922
|
-
|
|
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
|
+
}
|
|
1923
1974
|
return {
|
|
1924
1975
|
configured: true, windows: windows, weekendOffPeak: false, inPeak: inPeak,
|
|
1925
1976
|
nextHour: nextHour, ariaText: ariaText, tip: tip,
|
|
1926
1977
|
periodName: periodName, rateBadge: rateBadge,
|
|
1927
1978
|
countdownSummary: countdownSummary, windowSummary: windowSummary,
|
|
1928
1979
|
// Reminder dedup id: entering this period at this boundary fires once.
|
|
1929
|
-
periodId:
|
|
1980
|
+
periodId: periodId,
|
|
1930
1981
|
switchBody: inPeak ? '已进入高峰时段,按标准价计费' : '已进入低谷时段,按平价的 ' + rate + ' 倍计费',
|
|
1931
1982
|
}
|
|
1932
1983
|
}
|
|
@@ -2353,7 +2404,7 @@ window.__ModuleLoader__.load({
|
|
|
2353
2404
|
onPointerDown: isRail ? undefined : handlePointerDown,
|
|
2354
2405
|
onClick: function (e) {
|
|
2355
2406
|
if (isRail) {
|
|
2356
|
-
|
|
2407
|
+
openOfficialRecharge()
|
|
2357
2408
|
return
|
|
2358
2409
|
}
|
|
2359
2410
|
if (didDragRef.current !== true) {
|
|
@@ -2409,7 +2460,7 @@ window.__ModuleLoader__.load({
|
|
|
2409
2460
|
'aria-label': '前往官方充值',
|
|
2410
2461
|
onClick: function (e) {
|
|
2411
2462
|
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
|
|
2412
|
-
|
|
2463
|
+
openOfficialRecharge()
|
|
2413
2464
|
}
|
|
2414
2465
|
}, '充值') : null
|
|
2415
2466
|
)
|
|
@@ -2451,7 +2502,7 @@ window.__ModuleLoader__.load({
|
|
|
2451
2502
|
type: 'button',
|
|
2452
2503
|
className: 'dshw_btn dshw_btnPrimary',
|
|
2453
2504
|
style: { height: '22px', fontSize: '11px', padding: '0 8px' },
|
|
2454
|
-
onClick: function () {
|
|
2505
|
+
onClick: function () { openOfficialRecharge() }
|
|
2455
2506
|
}, '↗ 充值'))),
|
|
2456
2507
|
React.createElement('div', { className: 'dshw_divider' }),
|
|
2457
2508
|
React.createElement('div', { className: 'dshw_peakRow' },
|
|
@@ -3283,7 +3334,9 @@ window.__ModuleLoader__.load({
|
|
|
3283
3334
|
}).then(function (json) {
|
|
3284
3335
|
if (json && json.ok) {
|
|
3285
3336
|
setAccounts(json)
|
|
3286
|
-
setAccountsError(
|
|
3337
|
+
setAccountsError(json.storage && json.storage.locked
|
|
3338
|
+
? '账户加密文件无法解密,已锁定写入以保护原数据'
|
|
3339
|
+
: null)
|
|
3287
3340
|
}
|
|
3288
3341
|
}).catch(function () {
|
|
3289
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
|
|
4
|
-
"version": "0.3.
|
|
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": {
|