deepseek-harness-wallet 0.2.6 → 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 +7 -0
- package/README.md +7 -4
- package/index.js +427 -19
- package/lib/client.js +85 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
+
|
|
5
12
|
## 0.2.6 - 2026-08-23
|
|
6
13
|
|
|
7
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.
|
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
```
|
|
24
24
|
|
|
25
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.
|
|
26
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).
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
- Open the wallet panel → **Account Management(账户管理)** to add accounts (name + API key), switch the active one, or remove them.
|
|
41
43
|
- The first account added becomes the active account automatically and is synced into the credentials seam.
|
|
42
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.
|
|
43
|
-
- Account keys are
|
|
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.
|
|
44
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.
|
|
45
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.
|
|
46
48
|
|
|
@@ -81,7 +83,7 @@ Restart `dsh web`, then hard-refresh the page.
|
|
|
81
83
|
|
|
82
84
|
## Quick use
|
|
83
85
|
|
|
84
|
-
1. Click the wallet or peak/off-peak card to open its control panel.
|
|
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.
|
|
85
87
|
2. Choose horizontal or vertical layout, then use the scale control between 100% and 120%.
|
|
86
88
|
3. Turn the official recharge button off when you need a smaller card; official and third-party rows can also be shown independently.
|
|
87
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.
|
|
@@ -139,7 +141,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
|
|
|
139
141
|
| --- | --- |
|
|
140
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. |
|
|
141
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. |
|
|
142
|
-
| Accounts | Keys live in `$DSH_HOME/storages/accounts.json
|
|
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. |
|
|
143
145
|
| Session log | The plugin writes no events; its data lives in `$DSH_HOME/storages/wallet.json`. |
|
|
144
146
|
| Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
|
|
145
147
|
| Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
|
|
@@ -151,10 +153,11 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
|
|
|
151
153
|
CNY per 1M tokens, curated from official announcements (cache writes are not billed):
|
|
152
154
|
|
|
153
155
|
- Since 2025-02-09 — deepseek-chat 2/8 (cache read 0.5), deepseek-reasoner 4/16 (cache read 1)
|
|
154
|
-
- 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)
|
|
155
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):
|
|
156
158
|
- v4-flash (off-peak / peak): cache read 0.05 / 0.10, input 1.5 / 3, output 4.5 / 9
|
|
157
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
|
|
158
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.
|
|
159
162
|
|
|
160
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.
|
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 {
|
|
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,10 +27,17 @@ 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
|
|
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
|
|
@@ -39,10 +47,12 @@ const STORE_VERSION = 2
|
|
|
39
47
|
// From 2026-08-23 00:00 Beijing, Saturday and Sunday are off-peak all day.
|
|
40
48
|
// Exposed to clients via snapshot.pricingWindows so the ring clock renders
|
|
41
49
|
// from the active policy instead of hard-coding hours in the bundle.
|
|
42
|
-
const
|
|
50
|
+
const DEFAULT_PEAK_WINDOWS = [{ startHour: 9, endHour: 12 }, { startHour: 14, endHour: 18 }]
|
|
43
51
|
const OFF_PEAK_RATE = 0.5
|
|
44
52
|
const BEIJING_OFFSET_MS = 8 * 3600_000
|
|
45
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
|
|
46
56
|
|
|
47
57
|
function isBeijingWeekend(atMs) {
|
|
48
58
|
if (!Number.isFinite(atMs)) return false
|
|
@@ -52,9 +62,9 @@ function isBeijingWeekend(atMs) {
|
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export function isBeijingPeak(atMs) {
|
|
55
|
-
if (atMs >=
|
|
65
|
+
if (atMs >= weekendOffPeakSince && isBeijingWeekend(atMs)) return false
|
|
56
66
|
const hours = ((atMs + BEIJING_OFFSET_MS) % 86_400_000) / 3_600_000
|
|
57
|
-
return (
|
|
67
|
+
return peakWindows.some((window) => hours >= window.startHour && hours < window.endHour)
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
// Pricing timeline (CNY per 1M tokens; cacheWrite is not billed by DeepSeek).
|
|
@@ -72,6 +82,7 @@ export const PRICE_POLICIES = [
|
|
|
72
82
|
models: {
|
|
73
83
|
'deepseek-v4-flash': { cacheHit: 0.02, input: 1, output: 2 },
|
|
74
84
|
'deepseek-v4-pro': { cacheHit: 0.025, input: 3, output: 6 },
|
|
85
|
+
'deepseek-v4-flash-vision-exp': { cacheHit: 0.02, input: 1, output: 2 },
|
|
75
86
|
},
|
|
76
87
|
},
|
|
77
88
|
{
|
|
@@ -81,13 +92,241 @@ export const PRICE_POLICIES = [
|
|
|
81
92
|
models: {
|
|
82
93
|
'deepseek-v4-flash': { cacheHit: [0.05, 0.1], input: [1.5, 3], output: [4.5, 9] },
|
|
83
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] },
|
|
84
96
|
},
|
|
85
97
|
},
|
|
86
98
|
]
|
|
87
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(/&/gi, '&')
|
|
116
|
+
.replace(/ /gi, ' ')
|
|
117
|
+
.replace(/'/g, "'")
|
|
118
|
+
.replace(/"/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
|
+
|
|
88
327
|
export function ratesFor(model, atMs) {
|
|
89
328
|
let entry
|
|
90
|
-
for (const policy of
|
|
329
|
+
for (const policy of activePricePolicies) {
|
|
91
330
|
// Own-property check: a model named `__proto__`/`toString` would otherwise
|
|
92
331
|
// resolve to an Object.prototype member and produce NaN costs.
|
|
93
332
|
if (atMs >= policy.since && Object.hasOwn(policy.models, model)) entry = policy
|
|
@@ -289,10 +528,128 @@ function scheduleSave(logger) {
|
|
|
289
528
|
|
|
290
529
|
// ---------------------------------------------------------------------------
|
|
291
530
|
// Multi-account store (accounts.json): list, add, remove, hot-switch.
|
|
292
|
-
// Keys
|
|
293
|
-
//
|
|
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.
|
|
294
534
|
// ---------------------------------------------------------------------------
|
|
295
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
|
+
|
|
296
653
|
function emptyAccounts() {
|
|
297
654
|
return { version: ACCOUNTS_VERSION, accounts: [], activeId: null }
|
|
298
655
|
}
|
|
@@ -305,7 +662,9 @@ export function normalizeAccountsData(value) {
|
|
|
305
662
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
306
663
|
const id = typeof raw.id === 'string' && raw.id !== '' ? raw.id : null
|
|
307
664
|
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
|
308
|
-
|
|
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)
|
|
309
668
|
if (id === null || name === '' || apiKey === '') continue
|
|
310
669
|
accounts.push({
|
|
311
670
|
id,
|
|
@@ -323,14 +682,20 @@ export function normalizeAccountsData(value) {
|
|
|
323
682
|
|
|
324
683
|
function loadAccounts() {
|
|
325
684
|
try {
|
|
326
|
-
|
|
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 }
|
|
327
690
|
} catch {
|
|
328
|
-
return emptyAccounts()
|
|
691
|
+
return { accounts: emptyAccounts(), migrated: false }
|
|
329
692
|
}
|
|
330
693
|
}
|
|
331
694
|
|
|
332
695
|
let accountsSaveTimer = null
|
|
333
|
-
|
|
696
|
+
const loadedAccounts = loadAccounts()
|
|
697
|
+
let accounts = loadedAccounts.accounts
|
|
698
|
+
let accountsNeedsSave = loadedAccounts.migrated
|
|
334
699
|
|
|
335
700
|
function persistAccounts(logger) {
|
|
336
701
|
if (accountsSaveTimer !== null) {
|
|
@@ -340,12 +705,24 @@ function persistAccounts(logger) {
|
|
|
340
705
|
try {
|
|
341
706
|
mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true, mode: 0o700 })
|
|
342
707
|
const tmp = ACCOUNTS_PATH + '.tmp'
|
|
343
|
-
|
|
344
|
-
|
|
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)
|
|
345
720
|
renameSync(tmp, ACCOUNTS_PATH)
|
|
721
|
+
accountsNeedsSave = false
|
|
346
722
|
} catch (error) {
|
|
723
|
+
accountStorageError = accountStorageError || 'account-encryption-failed'
|
|
347
724
|
if (logger && typeof logger.warn === 'function') {
|
|
348
|
-
logger.warn('dsh-wallet: failed to persist accounts
|
|
725
|
+
logger.warn('dsh-wallet: failed to persist encrypted accounts')
|
|
349
726
|
}
|
|
350
727
|
}
|
|
351
728
|
}
|
|
@@ -701,11 +1078,11 @@ function snapshotView(sessionId) {
|
|
|
701
1078
|
pricingWindows: {
|
|
702
1079
|
timezone: 'Asia/Shanghai',
|
|
703
1080
|
offsetMinutes: 480,
|
|
704
|
-
windows:
|
|
1081
|
+
windows: peakWindows.map(w => ({ startHour: w.startHour, endHour: w.endHour })),
|
|
705
1082
|
offPeakRate: OFF_PEAK_RATE,
|
|
706
1083
|
isPeak: isBeijingPeak(now),
|
|
707
|
-
weekendOffPeak: now >=
|
|
708
|
-
weekendOffPeakSince
|
|
1084
|
+
weekendOffPeak: now >= weekendOffPeakSince && isBeijingWeekend(now),
|
|
1085
|
+
weekendOffPeakSince,
|
|
709
1086
|
},
|
|
710
1087
|
accounts: {
|
|
711
1088
|
activeId: accounts.activeId,
|
|
@@ -768,6 +1145,7 @@ export function apply(ctx, config) {
|
|
|
768
1145
|
storeNeedsSave = false
|
|
769
1146
|
scheduleSave(ctx.logger)
|
|
770
1147
|
}
|
|
1148
|
+
if (accountsNeedsSave) scheduleAccountsSave(ctx.logger)
|
|
771
1149
|
// Thresholds live ONLY in the persisted store; an explicit row config may
|
|
772
1150
|
// still override the CNY line for power users, but the bundle patch sets none.
|
|
773
1151
|
if (Number.isFinite(config.threshold)) {
|
|
@@ -817,6 +1195,17 @@ export function apply(ctx, config) {
|
|
|
817
1195
|
const balanceTimer = setInterval(() => void refreshBalance(ctx), BALANCE_REFRESH_MS)
|
|
818
1196
|
if (typeof balanceTimer.unref === 'function') balanceTimer.unref()
|
|
819
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
|
+
|
|
820
1209
|
ctx.effect(() => {
|
|
821
1210
|
const disposeSnapshot = ctx.webServer.register({
|
|
822
1211
|
kind: 'exact',
|
|
@@ -826,6 +1215,23 @@ export function apply(ctx, config) {
|
|
|
826
1215
|
return json(res, 200, snapshotView(sessionParam(req)))
|
|
827
1216
|
},
|
|
828
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
|
+
})
|
|
829
1235
|
const disposeThreshold = ctx.webServer.register({
|
|
830
1236
|
kind: 'exact',
|
|
831
1237
|
path: '/api/wallet/threshold',
|
|
@@ -966,6 +1372,8 @@ export function apply(ctx, config) {
|
|
|
966
1372
|
})
|
|
967
1373
|
return () => {
|
|
968
1374
|
disposeSnapshot()
|
|
1375
|
+
disposeHealth()
|
|
1376
|
+
disposePricingRefresh()
|
|
969
1377
|
disposeThreshold()
|
|
970
1378
|
disposeRefresh()
|
|
971
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.
|
|
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'
|
|
@@ -1140,6 +1140,8 @@ window.__ModuleLoader__.load({
|
|
|
1140
1140
|
props = props || {}
|
|
1141
1141
|
var close = typeof props.close === 'function' ? props.close : null
|
|
1142
1142
|
var [snapshot, setSnapshot] = React.useState(null)
|
|
1143
|
+
var [health, setHealth] = React.useState(null)
|
|
1144
|
+
var [healthNotice, setHealthNotice] = React.useState(null)
|
|
1143
1145
|
var [thresholdDraft, setThresholdDraft] = React.useState('')
|
|
1144
1146
|
var [thresholdNotice, setThresholdNotice] = React.useState(null)
|
|
1145
1147
|
var [accounts, setAccounts] = React.useState(null)
|
|
@@ -1215,6 +1217,9 @@ window.__ModuleLoader__.load({
|
|
|
1215
1217
|
fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
|
|
1216
1218
|
if (!stopped && json && json.ok) setAccounts(json)
|
|
1217
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('健康检查不可用') })
|
|
1218
1223
|
try {
|
|
1219
1224
|
var savedLayout = compatibility.storage.getItem(CHIP_LAYOUT_KEY)
|
|
1220
1225
|
var dock = savedLayout === null ? 'home' : normalizeChipLayout(JSON.parse(savedLayout)).dock
|
|
@@ -1223,6 +1228,52 @@ window.__ModuleLoader__.load({
|
|
|
1223
1228
|
return function () { stopped = true }
|
|
1224
1229
|
}, [])
|
|
1225
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
|
+
|
|
1226
1277
|
function persistVisibility(next) {
|
|
1227
1278
|
next = normalizeDataVisibility(next)
|
|
1228
1279
|
setVisibility(next)
|
|
@@ -1406,6 +1457,39 @@ window.__ModuleLoader__.load({
|
|
|
1406
1457
|
React.createElement('span', { className: 'dshw_balDot' }, '·'),
|
|
1407
1458
|
React.createElement('span', null, currencyCode))))
|
|
1408
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
|
+
|
|
1409
1493
|
// —— 设置卡 ——
|
|
1410
1494
|
var cells = []
|
|
1411
1495
|
cells.push(React.createElement('div', { key: 'vis', className: 'dshw_setCell' },
|
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
|
|
4
|
-
"version": "0.
|
|
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": {
|