@gehennawu/dsh-service 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -4
- package/README.md +5 -4
- package/client.js +259 -8
- package/index.js +115 -6
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -44,14 +44,15 @@ The plugin also appears under **Plugins → Plugin configuration**, with six hos
|
|
|
44
44
|
|
|
45
45
|
### Quota lookup
|
|
46
46
|
|
|
47
|
-
- A dedicated "Quota lookup" tab shows adapted providers as separate cards, each window rendered as a percentage with its own bar and a reset countdown on its own line, plus a refresh icon beside the updated time that force-refreshes that provider on click (bypassing the poll interval); unadapted providers take no space — a "Manual adapt" row at the bottom enables one by picking its type, and each card's footer can switch type, fall back to auto-detect, or disable lookup at any time
|
|
47
|
+
- A dedicated "Quota lookup" tab shows adapted providers as separate cards, each window rendered as a percentage with its own bar and a reset countdown on its own line, plus a refresh icon beside the updated time that force-refreshes that provider on click (bypassing the poll interval); adapted card titles link to the provider's official usage page (DeepSeek Platform, Zhipu GLM Coding Plan, OpenCode Go) in a new tab; a "Reorder" toggle at the top-right of the card list (shown with two or more cards) reveals ↑/↓ buttons on each card header (auto-disabled at the ends), and clicking it again hides them; the order is remembered in this browser and newly seen providers append at the end; unadapted providers take no space — a "Manual adapt" row at the bottom enables one by picking its type, and each card's footer can switch type, fall back to auto-detect, or disable lookup at any time
|
|
48
48
|
- A quota ring inside the conversation composer follows the provider selected by the current session and shows the tightest budget window as a percentage (green below 80%, amber at or above); clicking opens a panel headed by the provider name, with a bar and used percentage for each window and reset times on their own lines; on narrow viewports (phones) the panel switches to a viewport-centered floating layer — fully visible, scrolling internally when too tall — and rotates or resizing wider flips it back to the anchored placement above the ring
|
|
49
49
|
- A "Show a remote quota entry in the settings left navigation" switch lives in the Quota lookup tab (off by default), mirroring the Restart entry
|
|
50
|
-
- Built-in adaptations: **OpenCode Go** (`{baseURL}/usage`), **Zhipu GLM Coding Plan / zai-coding-cn** (official monitor `quota/limit` endpoint with three windows — 5-hour rolling tokens, weekly tokens, monthly MCP quota; an idle 5-hour window hides its reset time, matching the official console), **OpenRouter** (credits used %), **Kimi/Moonshot** and **SiliconFlow** (CNY balance text); dialects that natively report remaining percentage flip the panel word to "Remaining" and invert the warn threshold; transient network errors retry automatically and the Zhipu dual-domain candidate chain switches automatically; the provider-to-kind mapping lives in `DSH_HOME/dsh-service-quota.json`, and known services are auto-detected from their baseURL (e.g. opencode.ai, bigmodel.cn) with no manual picking; no upstream request is ever made for unadapted or disabled providers; disabling can be done via "Disable" in a card's footer or by writing `"<provider>": null` in the config file (both are equivalent). Zhipu reset cards have no API-key-queryable endpoint yet — you can add any number of them via each provider card's "Add reset card" button on the tab (name plus expiry time, down to the minute), and remove each one independently — the ring panel shows them too (stored in the same config file); expired cards are flagged automatically
|
|
50
|
+
- Built-in adaptations: **OpenCode Go** (`{baseURL}/usage`), **Zhipu GLM Coding Plan / zai-coding-cn** (official monitor `quota/limit` endpoint with three windows — 5-hour rolling tokens, weekly tokens, monthly MCP quota; an idle 5-hour window hides its reset time, matching the official console), **OpenRouter** (credits used %), **Kimi/Moonshot** and **SiliconFlow** (CNY balance text), **DeepSeek Platform** (official balance, see next bullet); dialects that natively report remaining percentage flip the panel word to "Remaining" and invert the warn threshold; transient network errors retry automatically and the Zhipu dual-domain candidate chain switches automatically; the provider-to-kind mapping lives in `DSH_HOME/dsh-service-quota.json`, and known services are auto-detected from their baseURL (e.g. opencode.ai, bigmodel.cn, api.deepseek.com) with no manual picking; no upstream request is ever made for unadapted or disabled providers; disabling can be done via "Disable" in a card's footer or by writing `"<provider>": null` in the config file (both are equivalent). Zhipu reset cards have no API-key-queryable endpoint yet — you can add any number of them via each provider card's "Add reset card" button on the tab (name plus expiry time, down to the minute), and remove each one independently — the ring panel shows them too (stored in the same config file); expired cards are flagged automatically
|
|
51
|
+
- **DeepSeek official balance with peak/off-peak hint (deepseek)**: calls DeepSeek Platform's official `GET /user/balance` and shows the total balance per currency (CNY→¥, USD→$), plus a separate "Granted balance" line whenever unexpired granted credit is positive; each DeepSeek card embeds a **peak/off-peak block** — a status badge on top (orange = "Peak now · standard price", green = "Off-peak now · half price") with a countdown to the next switch in Beijing time (e.g. "Peak pricing from 09:00 (in 12 h 41 min)", correctly spanning weekends to Monday's morning peak), below it a two-segment ribbon: the first block is the remainder of the current period and the second is the next opposite period (spanning days when needed — e.g. Friday evening draws straight to Monday's morning peak), widths proportional to actual durations with the left edge marking "now"; blocks are tagged inline as Peak / Off-peak, advancing every 30 seconds, and a caption stating the official rule "off-peak price is half the peak price"; the ring panel shows a compact version without the caption. Credentials resolve via the `DEEPSEEK_API_KEY` hint from the DSH credential store or environment variables, or can be written directly through the card's credential form. DSH's built-in `@deepseek-ai/dsh-llm-deepseek` official channel (the DeepSeek V4 models in the model picker, route id `deepseek-official`) needs **no extra settings route** — the quota lookup merges that runtime channel automatically and adapts it to the official balance endpoint, so the ring also appears when you switch to one of its models
|
|
51
52
|
- **CLIProxyAPI accounts (cliproxy)**: queries the **official remaining quota** of each OAuth upstream account inside a CLIProxyAPI (CPA) deployment (Codex 5-hour/weekly windows, per-model quotas for GeminiCLI and Antigravity) — not proxy-key usage. Prerequisites: CPA's `remote-management.secret-key` must be set (the whole management API is unavailable without it), remote access requires `allow-remote-management`, and the management secret goes into the DSH credential store as `CPA_MANAGEMENT_KEY` or an environment variable of the same name — it is independent of the proxy API key, and the plugin never sends the proxy key to the management plane. When you save the "CLIProxyAPI accounts" adaptation, the plugin pins the hostname of that provider's settings baseURL into the config file and afterwards only sends requests to that pinned domain (re-save the adaptation after changing baseURL); a single refresh queries at most 8 accounts (disabled accounts and account types without a supported quota endpoint are skipped automatically), and failures on some accounts do not affect the rest. Note that CPA's management plane has built-in key throttling — 5 wrong attempts ban your IP for 30 minutes — so make sure the secret is correct before testing
|
|
52
|
-
- Credential fill-in form: when an adapted provider shows "credential missing", its card offers an inline **"Set API credential"**
|
|
53
|
+
- Credential fill-in form: when an adapted provider shows "credential missing", its card offers an inline form — regular adaptations show **"Set API credential"**, while the CLIProxyAPI adaptation shows **"Set management key (web login key)"** (the remote-management secret you use to log into CPA's web management UI — not the proxy API key, which the management plane bans wrong attempts for); the credential name comes from a host-derived whitelist per adaptation type (CLIProxyAPI only ever offers `CPA_MANAGEMENT_KEY`/`CLIPROXY_MANAGEMENT_KEY` — these are **alias slots for the same secret; storing one is enough**, discovery takes the first configured value in order; the primary name is marked and the form defaults to a configured slot. Never the proxy key); saving writes the value into the DSH credential provider (the `refs:` section of `$DSH_HOME/.credentials.yaml`, hot-effective without restarts) and then force-refreshes that provider; the form can also clear a stored file-layer credential in one click. If a process environment variable is currently shadowing that name, the host refuses the write (credential-store contract) — change the environment variable itself instead
|
|
53
54
|
- Anti-rate-limit pacing is enforced by the host: successful results are cached for 60 seconds (shared across tabs), failures back off exponentially (30 s doubling, capped at 15 minutes), upstream timeout is 15 s; the panel can set auto query to manual only / 1 / 2 / 5 / 10 minutes (manual only by default), paused automatically while the page is hidden
|
|
54
|
-
- API keys are resolved and used only inside the host process — the browser receives normalized percentages only; data flows through the plugin's own loopback RPC with no webServer routes exposed
|
|
55
|
+
- API keys are resolved and used only inside the host process — the browser receives normalized window data (percentages or balance text) only; data flows through the plugin's own loopback RPC with no webServer routes exposed
|
|
55
56
|
|
|
56
57
|
### Backup management
|
|
57
58
|
|
package/README.md
CHANGED
|
@@ -44,14 +44,15 @@
|
|
|
44
44
|
|
|
45
45
|
### 额度查询
|
|
46
46
|
|
|
47
|
-
-
|
|
47
|
+
- 独立的「额度查询」标签:以卡片分区展示已适配的供应商,每个窗口显示百分比、独立进度条,重置时间单独一行;卡片头部更新时间旁有刷新图标,点击即强制重拉该供应商(不受轮询间隔限制);已适配卡片的标题链接到对应官网用量页(DeepSeek 平台、智谱 GLM Coding Plan、OpenCode Go),点击新标签页打开;卡片列表右上角有「调整排序」开关(至少两张卡时显示),点击后各卡头部出现 ↑/↓ 按钮(首末卡对应方向自动禁用),再点一次收起;顺序记忆在本浏览器,新出现的供应商排在末尾;未适配的不占位置,统一收进底部「手动适配」行选择类型启用,卡片脚部可随时切换适配类型、回退自动识别或停用查询
|
|
48
48
|
- 对话输入框内一枚额度圆环,跟随当前会话所选模型的供应商,显示最紧预算窗口的已用百分比(<80% 绿色、≥80% 黄色);点击弹出面板——头部标明供应商,各窗口带独立进度条与已用百分比、重置时间单独一行;手机等窄屏上面板自动切换为视口居中的浮层(完整可见、超高时内部滚动),旋转或拖宽窗口即时切回圆环上方锚定
|
|
49
49
|
- 可在「额度查询」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「额度查询」快捷入口(与「重启」入口同模式)
|
|
50
|
-
- 内置适配:**OpenCode Go**(`{baseURL}/usage`)、**智谱 GLM Coding Plan / zai-coding-cn**(官方监控端点 `quota/limit`,含 5 小时滚动 Token、每周 Token、MCP 月度配额三个窗口,5 小时窗口空闲时与官网一致地不显示重置时间)、**OpenRouter**(credits 已用%)、**Kimi/Moonshot** 与
|
|
50
|
+
- 内置适配:**OpenCode Go**(`{baseURL}/usage`)、**智谱 GLM Coding Plan / zai-coding-cn**(官方监控端点 `quota/limit`,含 5 小时滚动 Token、每周 Token、MCP 月度配额三个窗口,5 小时窗口空闲时与官网一致地不显示重置时间)、**OpenRouter**(credits 已用%)、**Kimi/Moonshot** 与 **硅基流动**(人民币余额)、**DeepSeek 开放平台**(官方余额,见下一条);原生报「剩余百分比」的方言会自动把面板头部切换为「剩余」并把预警阈值反向;上游瞬时网络错误自动重试,智谱双域候选链自动切换;供应商与适配类型的对应关系保存在 `DSH_HOME/dsh-service-quota.json`,已知服务商按 baseURL 自动识别适配(如 opencode.ai、bigmodel.cn、api.deepseek.com),无需手选;未适配或已停用的供应商不会被发起任何上游请求,停用可在卡片脚部选「停用查询」或在配置文件写 `"<provider>": null`(两者等价)。智谱的重置卡暂无 API Key 可查的接口,可在「额度查询」标签内点各供应商卡片的「添加重置卡」填写名称与到期时间(可精确到分钟),可连续添加多条、每条独立移除;圆环面板同步显示(数据存入同一配置文件),过期自动标注
|
|
51
|
+
- **DeepSeek 官方余额与峰谷提示(deepseek)**:调用 DeepSeek 开放平台官方 `GET /user/balance`,按币种显示总额余额(CNY→¥、USD→$),未过期赠金大于 0 时追加一行「赠送余额」;卡片内嵌**峰谷时段提示块**——顶部为当前状态徽标(橙=「当前高峰时段 · 标准价」、绿=「当前空闲时段 · 半价计费」)和下一次换挡的北京时间倒计时(如「09:00 转高峰(12 小时 41 分钟后)」,跨周末也能算到周一早高峰),下方是一条**两段式**峰谷色带:第一段是当前时段的剩余部分,第二段是下一个相反时段(可跨天,如周五晚直接画到周一早高峰),宽度按实际时长比例、左缘细标线即当前时刻;段内只标「忙时 / 闲时」,每 30 秒自动前进,底部说明行写明官方规则「空闲时段价格为高峰时段的一半」;圆环面板内同步显示紧凑版(无说明行)。凭据按 `DEEPSEEK_API_KEY` 线索从 DSH 凭据库或环境变量解析,也可直接在卡片上用「填写 API 密钥」表单写入。DSH 内置的 `@deepseek-ai/dsh-llm-deepseek` 官方渠道(模型选择器里的 DeepSeek V4 系列,路由名 `deepseek-official`)**无需在 settings 另建路由**——额度查询会自动并入这个运行时渠道并按官方余额适配,切换到它的模型时圆环同样出现
|
|
51
52
|
- **CLIProxyAPI 账号额度(cliproxy)**:查询 CLIProxyAPI(CPA)部署内各 OAuth 上游账号的**官方剩余额度**(Codex 的 5 小时/本周窗口、GeminiCLI 与 Antigravity 的各模型配额),而不是代理 key 的用量。前置条件:CPA 配置 `remote-management.secret-key` 非空(为空时管理面整体不可用)、远程访问需开启 `allow-remote-management`;管理密钥放入 DSH 凭据库 `CPA_MANAGEMENT_KEY` 或同名环境变量——它与代理用的 API key 相互独立,插件绝不会把代理 key 发往管理面。在「手动适配」中选择「CLIProxyAPI 账号额度」保存时,插件会把该供应商 settings baseURL 的域名钉进配置文件,此后外呼只发往这个域(修改 baseURL 后需重新保存适配);单次刷新最多查询 8 个账号(已禁用或无受支持配额端点的账号自动跳过),部分账号失败不影响其余账号的结果。请注意 CPA 管理面对错误密钥有内建封禁(连续错 5 次封 IP 30 分钟),务必填对再测
|
|
52
|
-
-
|
|
53
|
+
- 凭据填写窗口:已适配供应商因缺凭据显示「凭据未配置」时,卡片上会出现内联表单——普通适配显示**「填写 API 密钥」**,CLIProxyAPI 适配显示**「填写管理密钥(网页登录的 key)」**(即登录 CPA 网页管理面所用的 remote-management 密钥,不是代理 API key——管理面对错密钥有封禁,别填错);凭据名称由宿主按适配类型派生白名单(CLIProxyAPI 只会出现 `CPA_MANAGEMENT_KEY`/`CLIPROXY_MANAGEMENT_KEY`——两者是同一密钥的**别名存放槽,二选一即可**,查找按顺序取第一个已配置的值;主名带「主名」标记且表单默认选中已配置的槽位。绝不混入代理 key),输入密钥值保存即写入 DSH 凭据提供方(`$DSH_HOME/.credentials.yaml` 的 `refs:` 分节,热生效无需重启),随后自动强制重拉该供应商;表单里还能一键清除已存的文件层凭据。进程环境变量正在遮蔽该名字时宿主会拒绝写入(凭据库契约),此时请改环境变量本身
|
|
53
54
|
- 防风控节律由宿主统一控制:成功结果缓存 60 秒(多标签共享)、失败指数退避(30 秒起 ×2、封顶 15 分钟)、上游超时 15 秒;面板可把自动查询调成仅手动 / 1 / 2 / 5 / 10 分钟(默认仅手动),页面不可见时自动暂停
|
|
54
|
-
- API key
|
|
55
|
+
- API key 只在宿主进程内解析使用,浏览器只会收到归一化后的窗口数据(百分比或余额文本);数据走插件自有 loopback RPC,不在 webServer 上暴露任何路由
|
|
55
56
|
|
|
56
57
|
### 备份管理
|
|
57
58
|
|
package/client.js
CHANGED
|
@@ -404,12 +404,17 @@ window.__ModuleLoader__.load({
|
|
|
404
404
|
'quota.resetCard.remove': '移除',
|
|
405
405
|
'quota.retryAt': '{time} 后可重试',
|
|
406
406
|
'quota.refresh': '刷新',
|
|
407
|
+
'quota.card.moveUp': '上移',
|
|
408
|
+
'quota.card.moveDown': '下移',
|
|
409
|
+
'quota.reorder': '调整排序',
|
|
410
|
+
'quota.usageLink': '打开官网用量页',
|
|
407
411
|
'quota.adapt': '适配',
|
|
408
412
|
'quota.kind.opencode-go': 'OpenCode Go',
|
|
409
413
|
'quota.kind.zai-coding-cn': '智谱 GLM Coding Plan',
|
|
410
414
|
'quota.kind.openrouter': 'OpenRouter',
|
|
411
415
|
'quota.kind.kimi': 'Kimi / Moonshot',
|
|
412
416
|
'quota.kind.siliconflow': '硅基流动',
|
|
417
|
+
'quota.kind.deepseek': 'DeepSeek 开放平台',
|
|
413
418
|
'quota.kind.cliproxy': 'CLIProxyAPI 账号额度',
|
|
414
419
|
'quota.kindAuto': '自动识别',
|
|
415
420
|
'quota.noAdapted': '暂无已适配的供应商,可在下方手动适配',
|
|
@@ -432,6 +437,7 @@ window.__ModuleLoader__.load({
|
|
|
432
437
|
'quota.error.host-not-pinned': 'baseURL 与适配时记录的域名不一致,请重新保存适配',
|
|
433
438
|
'quota.error.upstream-status': '上游官方接口返回错误状态',
|
|
434
439
|
'quota.credential.edit': '填写 API 密钥',
|
|
440
|
+
'quota.credential.editManagement': '填写管理密钥(网页登录的 key)',
|
|
435
441
|
'quota.credential.nameLabel': '凭据名称',
|
|
436
442
|
'quota.credential.valueLabel': '密钥值',
|
|
437
443
|
'quota.credential.save': '保存',
|
|
@@ -449,6 +455,14 @@ window.__ModuleLoader__.load({
|
|
|
449
455
|
'quota.unit.day': '{count} 天',
|
|
450
456
|
'quota.unit.hour': '{count} 小时',
|
|
451
457
|
'quota.unit.minute': '{count} 分钟',
|
|
458
|
+
'quota.window.granted-balance': '赠送余额(未过期)',
|
|
459
|
+
'quota.peak.nowIdle': '当前空闲时段 · 半价计费',
|
|
460
|
+
'quota.peak.nowPeak': '当前高峰时段 · 标准价',
|
|
461
|
+
'quota.peak.untilIdle': '{time} 转空闲({dur}后)',
|
|
462
|
+
'quota.peak.untilPeak': '{time} 转高峰({dur}后)',
|
|
463
|
+
'quota.peak.tag.peak': '忙时',
|
|
464
|
+
'quota.peak.tag.idle': '闲时',
|
|
465
|
+
'quota.peak.caption': '空闲时段价格为高峰时段的一半。高峰时段:北京时间周一至周五 09:00–12:00、14:00–18:00;其余时间为空闲时段,周六和周日全天空闲。',
|
|
452
466
|
}
|
|
453
467
|
const en = {
|
|
454
468
|
'nav.label': 'Service Control',
|
|
@@ -846,6 +860,10 @@ window.__ModuleLoader__.load({
|
|
|
846
860
|
'quota.resetCard.remove': 'Remove',
|
|
847
861
|
'quota.retryAt': 'Retry allowed after {time}',
|
|
848
862
|
'quota.refresh': 'Refresh',
|
|
863
|
+
'quota.card.moveUp': 'Move up',
|
|
864
|
+
'quota.card.moveDown': 'Move down',
|
|
865
|
+
'quota.reorder': 'Reorder',
|
|
866
|
+
'quota.usageLink': 'Open the official usage page',
|
|
849
867
|
'quota.adapt': 'Adapt',
|
|
850
868
|
'quota.kind.opencode-go': 'OpenCode Go',
|
|
851
869
|
'quota.kind.zai-coding-cn': 'Zhipu GLM Coding Plan',
|
|
@@ -853,6 +871,7 @@ window.__ModuleLoader__.load({
|
|
|
853
871
|
'quota.kind.kimi': 'Kimi / Moonshot',
|
|
854
872
|
'quota.kind.siliconflow': 'SiliconFlow',
|
|
855
873
|
'quota.kind.cliproxy': 'CLIProxyAPI accounts',
|
|
874
|
+
'quota.kind.deepseek': 'DeepSeek Platform',
|
|
856
875
|
'quota.kindAuto': 'Auto-detected',
|
|
857
876
|
'quota.noAdapted': 'No adapted providers yet — adapt manually below',
|
|
858
877
|
'quota.disable': 'Disable',
|
|
@@ -874,6 +893,7 @@ window.__ModuleLoader__.load({
|
|
|
874
893
|
'quota.error.host-not-pinned': 'baseURL differs from the domain recorded when adapting; save the adapter again',
|
|
875
894
|
'quota.error.upstream-status': 'Upstream official API returned an error status',
|
|
876
895
|
'quota.credential.edit': 'Set API credential',
|
|
896
|
+
'quota.credential.editManagement': 'Set management key (web login key)',
|
|
877
897
|
'quota.credential.nameLabel': 'Credential name',
|
|
878
898
|
'quota.credential.valueLabel': 'Secret value',
|
|
879
899
|
'quota.credential.save': 'Save',
|
|
@@ -891,6 +911,14 @@ window.__ModuleLoader__.load({
|
|
|
891
911
|
'quota.unit.day': '{count} d',
|
|
892
912
|
'quota.unit.hour': '{count} h',
|
|
893
913
|
'quota.unit.minute': '{count} min',
|
|
914
|
+
'quota.window.granted-balance': 'Granted balance (unexpired)',
|
|
915
|
+
'quota.peak.nowIdle': 'Off-peak now · half price',
|
|
916
|
+
'quota.peak.nowPeak': 'Peak now · standard price',
|
|
917
|
+
'quota.peak.untilIdle': 'Half price from {time} (in {dur})',
|
|
918
|
+
'quota.peak.untilPeak': 'Peak pricing from {time} (in {dur})',
|
|
919
|
+
'quota.peak.tag.peak': 'Peak',
|
|
920
|
+
'quota.peak.tag.idle': 'Off-peak',
|
|
921
|
+
'quota.peak.caption': 'Off-peak price is half the peak price. Peak hours (GMT+8): Mon–Fri 09:00–12:00 and 14:00–18:00. All other times are off-peak, including all day Saturday and Sunday.',
|
|
894
922
|
}
|
|
895
923
|
|
|
896
924
|
// 设置页导航自定义图标:settings.section 协议没有 icon 字段,外壳 navIcon(id) 只认
|
|
@@ -1671,7 +1699,7 @@ window.__ModuleLoader__.load({
|
|
|
1671
1699
|
const QUOTA_POLL_KEY = 'dsh-service-quota-poll'
|
|
1672
1700
|
const QUOTA_POLL_CHOICES = [0, 1, 2, 5, 10]
|
|
1673
1701
|
// 适配类型下拉选项:与宿主 QUOTA_KINDS 白名单保持一致(词典键 quota.kind.<kind>)。
|
|
1674
|
-
const QUOTA_KIND_OPTIONS = ['opencode-go', 'zai-coding-cn', 'openrouter', 'kimi', 'siliconflow', 'cliproxy']
|
|
1702
|
+
const QUOTA_KIND_OPTIONS = ['opencode-go', 'zai-coding-cn', 'openrouter', 'kimi', 'siliconflow', 'deepseek', 'cliproxy']
|
|
1675
1703
|
function readQuotaPollMinutes() {
|
|
1676
1704
|
try {
|
|
1677
1705
|
const raw = Number.parseInt(localStorage.getItem(QUOTA_POLL_KEY), 10)
|
|
@@ -1683,6 +1711,28 @@ window.__ModuleLoader__.load({
|
|
|
1683
1711
|
function writeQuotaPollMinutes(minutes) {
|
|
1684
1712
|
try { localStorage.setItem(QUOTA_POLL_KEY, String(minutes)) } catch (_) {}
|
|
1685
1713
|
}
|
|
1714
|
+
const QUOTA_CARD_ORDER_KEY = 'dsh-service-quota-card-order'
|
|
1715
|
+
// 卡片手动排序(用户点名):localStorage 只存 provider 名单;坏形状/超限整体回退快照序。
|
|
1716
|
+
function readQuotaCardOrder() {
|
|
1717
|
+
try {
|
|
1718
|
+
const raw = JSON.parse(localStorage.getItem(QUOTA_CARD_ORDER_KEY) ?? 'null')
|
|
1719
|
+
if (!Array.isArray(raw)) return []
|
|
1720
|
+
return raw.filter((name) => typeof name === 'string' && name !== '').slice(0, 64)
|
|
1721
|
+
} catch (_) {
|
|
1722
|
+
return []
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
function writeQuotaCardOrder(order) {
|
|
1726
|
+
try { localStorage.setItem(QUOTA_CARD_ORDER_KEY, JSON.stringify(order.slice(0, 64))) } catch (_) {}
|
|
1727
|
+
}
|
|
1728
|
+
/** 快照序 → 记忆序:名单内的按存储位次在前,名单外的保持快照相对顺序追加在后。 */
|
|
1729
|
+
function applyQuotaCardOrder(rows, order) {
|
|
1730
|
+
const rank = new Map(order.map((name, index) => [name, index]))
|
|
1731
|
+
return rows
|
|
1732
|
+
.map((row, index) => ({ row, key: rank.has(row.provider) ? rank.get(row.provider) : order.length + index }))
|
|
1733
|
+
.sort((a, b) => a.key - b.key)
|
|
1734
|
+
.map((entry) => entry.row)
|
|
1735
|
+
}
|
|
1686
1736
|
let quotaSnapshotPromise = null
|
|
1687
1737
|
let quotaSnapshotQueuedPayload = null
|
|
1688
1738
|
function normalizedQuotaPayload(payload) {
|
|
@@ -1877,6 +1927,136 @@ window.__ModuleLoader__.load({
|
|
|
1877
1927
|
const digits = (value) => String(value).padStart(2, '0')
|
|
1878
1928
|
return `${date.getFullYear()}-${digits(date.getMonth() + 1)}-${digits(date.getDate())}`
|
|
1879
1929
|
}
|
|
1930
|
+
|
|
1931
|
+
// ─── DeepSeek 峰谷时段(v0.25)───────────────────────────────────────────
|
|
1932
|
+
// 官方计费口径:空闲时段价格为高峰的一半。高峰 = 北京时间周一至周五 9:00–12:00、14:00–18:00;
|
|
1933
|
+
// 其余全为空闲(周六日全天空闲)。北京时间固定 UTC+8 无夏令时:nowMs 平移 8h 后读 UTC 字段即得。
|
|
1934
|
+
const DEEPSEEK_PEAK_SEGMENTS = [[540, 720], [840, 1080]] // 当日分钟数(北京零点起算)
|
|
1935
|
+
const DEEPSEEK_PEAK_COLOR = '#f0952f'
|
|
1936
|
+
const DEEPSEEK_IDLE_COLOR = 'var(--dsw-alias-state-success-primary)'
|
|
1937
|
+
const DEEPSEEK_PEAK_TICK_MS = 30000
|
|
1938
|
+
// 换挡倒计时用数字钟时刻(formatBeijingClockTime),无 boundary 类词典键。
|
|
1939
|
+
// 色带无外部刻度:只画两段——当前时段剩余 + 下一个相反时段(可跨天,用户点名隐藏
|
|
1940
|
+
// 过去时间),左缘细标线即当前时刻,段内短词「忙时/闲时」过窄自动隐藏。
|
|
1941
|
+
function beijingCivilParts(nowMs) {
|
|
1942
|
+
const shifted = new Date(nowMs + 8 * 3600 * 1000)
|
|
1943
|
+
return { dayIndex: shifted.getUTCDay(), minutesOfDay: shifted.getUTCHours() * 60 + shifted.getUTCMinutes() }
|
|
1944
|
+
}
|
|
1945
|
+
function deepseekIsPeakMinute(dayIndex, minutesOfDay) {
|
|
1946
|
+
if (dayIndex === 0 || dayIndex === 6) return false
|
|
1947
|
+
return DEEPSEEK_PEAK_SEGMENTS.some(([start, end]) => minutesOfDay >= start && minutesOfDay < end)
|
|
1948
|
+
}
|
|
1949
|
+
/** 从当前时刻起收集前 count 个峰谷切换时刻(毫秒,升序)。
|
|
1950
|
+
* 工作日的四个边界点必为状态翻转;周末无边界。最坏情形(周五 18:00 后)下个翻转在
|
|
1951
|
+
* 周一 09:00,扫 7 天必然覆盖(现有调用最多取 2 个)。 */
|
|
1952
|
+
function deepseekUpcomingFlips(nowMs, count) {
|
|
1953
|
+
const shifted = new Date(nowMs + 8 * 3600 * 1000)
|
|
1954
|
+
shifted.setUTCHours(0, 0, 0, 0)
|
|
1955
|
+
const beijingDayStart = shifted.getTime() - 8 * 3600 * 1000
|
|
1956
|
+
const flips = []
|
|
1957
|
+
for (let dayOffset = 0; dayOffset <= 6 && flips.length < count; dayOffset++) {
|
|
1958
|
+
const dayStart = beijingDayStart + dayOffset * 86400000
|
|
1959
|
+
const dayIndex = new Date(dayStart + 8 * 3600 * 1000).getUTCDay()
|
|
1960
|
+
if (dayIndex === 0 || dayIndex === 6) continue
|
|
1961
|
+
for (const minute of DEEPSEEK_PEAK_SEGMENTS.flat()) {
|
|
1962
|
+
const candidate = dayStart + minute * 60000
|
|
1963
|
+
if (candidate <= nowMs) continue
|
|
1964
|
+
flips.push(candidate)
|
|
1965
|
+
if (flips.length >= count) return flips
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return flips
|
|
1969
|
+
}
|
|
1970
|
+
function formatBeijingClockTime(ms) {
|
|
1971
|
+
const shifted = new Date(ms + 8 * 3600 * 1000)
|
|
1972
|
+
const digits = (value) => String(value).padStart(2, '0')
|
|
1973
|
+
return `${digits(shifted.getUTCHours())}:${digits(shifted.getUTCMinutes())}`
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** 峰谷块显隐判定:仅 DeepSeek 行且有窗口数据(额度卡与圆环面板共用一处口径)。 */
|
|
1977
|
+
function deepseekPeakVisible(row, windows) {
|
|
1978
|
+
return row?.kind === 'deepseek' && Array.isArray(windows) && windows.length > 0
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/** DeepSeek 余额卡专属的峰谷提示块:当前状态徽标 + 数字钟换挡倒计时、两段式峰谷色带
|
|
1982
|
+
* (橙=高峰、绿=空闲;当前时段剩余 + 下一个相反时段,可跨天)、左缘细标线即当前时刻,
|
|
1983
|
+
* 规则说明行可选。额度卡与圆环面板共用,圆环面板窄所以不渲染说明行(showCaption:false)。
|
|
1984
|
+
* 时刻推进用 ctx.timer 自续链而非 setInterval:测试桩里不会留真实定时器挂住进程,卸载即断链。 */
|
|
1985
|
+
function QuotaPeakTimeline({ showCaption }) {
|
|
1986
|
+
const translate = useTranslation()
|
|
1987
|
+
// 测试桩的 useState 不调用函数式初始化器,直接传值(多算一次 Date.now 无副作用)。
|
|
1988
|
+
const [now, setNow] = useState(Date.now())
|
|
1989
|
+
useEffect(() => {
|
|
1990
|
+
let disposed = false
|
|
1991
|
+
let disposer = null
|
|
1992
|
+
const schedule = () => {
|
|
1993
|
+
if (disposed) return
|
|
1994
|
+
disposer = ctx.timer.timeout(() => {
|
|
1995
|
+
setNow(Date.now())
|
|
1996
|
+
schedule()
|
|
1997
|
+
}, DEEPSEEK_PEAK_TICK_MS)
|
|
1998
|
+
}
|
|
1999
|
+
schedule()
|
|
2000
|
+
return () => {
|
|
2001
|
+
disposed = true
|
|
2002
|
+
if (disposer !== null && disposer !== undefined) disposer()
|
|
2003
|
+
}
|
|
2004
|
+
}, [])
|
|
2005
|
+
const civil = beijingCivilParts(now)
|
|
2006
|
+
const inPeak = deepseekIsPeakMinute(civil.dayIndex, civil.minutesOfDay)
|
|
2007
|
+
const flips = deepseekUpcomingFlips(now, 2)
|
|
2008
|
+
const nextFlip = flips[0] ?? null
|
|
2009
|
+
const accentColor = inPeak ? DEEPSEEK_PEAK_COLOR : DEEPSEEK_IDLE_COLOR
|
|
2010
|
+
// pctOf 复用 /1440 归一:传入「权重占比 ×1440」得到百分比(保留 4 位小数)。
|
|
2011
|
+
const pctOf = (minute) => Math.round((minute / 1440) * 1000000) / 10000
|
|
2012
|
+
return React.createElement('div', { 'data-testid': 'quota-peak-timeline', style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
2013
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px', minHeight: '16px' } },
|
|
2014
|
+
React.createElement('span', {
|
|
2015
|
+
'data-testid': 'quota-peak-state',
|
|
2016
|
+
'data-in-peak': String(inPeak),
|
|
2017
|
+
// nowrap + flexShrink:0:容器过窄时整块折行,绝不把文字挤成一列竖排(用户点名)。
|
|
2018
|
+
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '12px', lineHeight: '16px', color: accentColor, whiteSpace: 'nowrap', flexShrink: 0 },
|
|
2019
|
+
},
|
|
2020
|
+
React.createElement('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: accentColor, flexShrink: 0 } }),
|
|
2021
|
+
translate(inPeak ? 'quota.peak.nowPeak' : 'quota.peak.nowIdle')),
|
|
2022
|
+
nextFlip !== null ? React.createElement('span', {
|
|
2023
|
+
'data-testid': 'quota-peak-next',
|
|
2024
|
+
style: { marginLeft: 'auto', fontSize: '11px', lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' },
|
|
2025
|
+
}, translate(inPeak ? 'quota.peak.untilIdle' : 'quota.peak.untilPeak', {
|
|
2026
|
+
time: formatBeijingClockTime(nextFlip),
|
|
2027
|
+
dur: humanizeDuration(nextFlip - now, translate),
|
|
2028
|
+
})) : null),
|
|
2029
|
+
React.createElement('div', {
|
|
2030
|
+
'data-testid': 'quota-peak-bar',
|
|
2031
|
+
style: { position: 'relative', height: '14px', borderRadius: 999, overflow: 'hidden', background: 'var(--dsw-alias-interactive-bg-hover)' },
|
|
2032
|
+
},
|
|
2033
|
+
// 两段式(用户点名):第一段 = 当前时段剩余,第二段 = 下一个相反时段(可跨天),
|
|
2034
|
+
// 宽度按实际时长比例;段内只标「忙时/闲时」,过窄自动隐藏(精确时刻在倒计时与说明行)。
|
|
2035
|
+
(flips.length > 0 ? [
|
|
2036
|
+
{ peak: inPeak, weight: flips[0] - now },
|
|
2037
|
+
...(flips.length > 1 ? [{ peak: !inPeak, weight: flips[1] - flips[0] }] : []),
|
|
2038
|
+
] : [{ peak: inPeak, weight: 1 }]).map((segment, index, all) => {
|
|
2039
|
+
const totalWeight = all.reduce((sum, part) => sum + part.weight, 0)
|
|
2040
|
+
const widthPct = pctOf(segment.weight / totalWeight * 1440)
|
|
2041
|
+
return React.createElement('span', {
|
|
2042
|
+
key: index,
|
|
2043
|
+
'data-testid': `quota-peak-segment-${index}`,
|
|
2044
|
+
'data-peak': String(segment.peak),
|
|
2045
|
+
style: { position: 'absolute', top: 0, bottom: 0, left: `${index === 0 ? 0 : pctOf(all[0].weight / totalWeight * 1440)}%`, width: `${widthPct}%`, background: segment.peak ? DEEPSEEK_PEAK_COLOR : DEEPSEEK_IDLE_COLOR, overflow: 'hidden' },
|
|
2046
|
+
},
|
|
2047
|
+
widthPct >= 11 ? React.createElement('span', { style: { position: 'absolute', inset: 0, textAlign: 'center', fontSize: '9px', lineHeight: '14px', fontWeight: 500, color: 'rgba(255,255,255,0.95)' } }, translate(segment.peak ? 'quota.peak.tag.peak' : 'quota.peak.tag.idle')) : null)
|
|
2048
|
+
}),
|
|
2049
|
+
// 左缘细标线即「当前」时刻(原移动圆点在 now 起点域下恒在左缘,退化为标线)。
|
|
2050
|
+
React.createElement('span', {
|
|
2051
|
+
'data-testid': 'quota-peak-now',
|
|
2052
|
+
style: { position: 'absolute', top: 0, bottom: 0, left: 0, width: '3px', borderRadius: '3px', background: 'var(--dsw-specific-menu)' },
|
|
2053
|
+
})),
|
|
2054
|
+
showCaption === true ? React.createElement('div', {
|
|
2055
|
+
'data-testid': 'quota-peak-caption',
|
|
2056
|
+
style: { fontSize: '11px', lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary)', marginTop: '2px' },
|
|
2057
|
+
}, translate('quota.peak.caption')) : null)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1880
2060
|
/** 手录重置卡的统一文案与过期态:卡片行与圆环面板共用。v0.20 起免次数。 */
|
|
1881
2061
|
function resetCardContent(card, translate) {
|
|
1882
2062
|
const rawExpiry = typeof card.expiresAt === 'string' && card.expiresAt.trim() !== '' ? card.expiresAt.trim() : ''
|
|
@@ -2063,12 +2243,17 @@ window.__ModuleLoader__.load({
|
|
|
2063
2243
|
}, [open])
|
|
2064
2244
|
if (row === undefined || row === null) return null
|
|
2065
2245
|
const percent = tightest === null ? 0 : tightest.percent
|
|
2066
|
-
|
|
2246
|
+
// 纯文本窗口(余额类:DeepSeek/Kimi/硅基流动)没有百分比可「已用」,头部按剩余口径显示;
|
|
2247
|
+
// 百分比窗口仍按方言的 remaining 标记切换已用/剩余。
|
|
2248
|
+
const hasPercentWindow = tightest !== null
|
|
2249
|
+
const remainingBasis = !hasPercentWindow || tightest.remaining === true
|
|
2067
2250
|
const usedWord = remainingBasis ? translate('quota.panel.remaining') : translate('quota.panel.used')
|
|
2068
2251
|
const color = (remainingBasis ? percent <= 20 : percent >= 80) ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)'
|
|
2069
2252
|
const radius = 5.5
|
|
2070
2253
|
const circumference = 2 * Math.PI * radius
|
|
2071
|
-
const ariaText =
|
|
2254
|
+
const ariaText = hasPercentWindow
|
|
2255
|
+
? `${translate('quota.ring.label')} · ${provider} · ${usedWord} ${percent}%`
|
|
2256
|
+
: `${translate('quota.ring.label')} · ${provider}`
|
|
2072
2257
|
const retrySuffix = typeof row.nextAllowedAt === 'number' && row.nextAllowedAt > Date.now()
|
|
2073
2258
|
? ` · ${translate('quota.retryAt', { time: formatClockTime(row.nextAllowedAt) })}`
|
|
2074
2259
|
: ''
|
|
@@ -2120,6 +2305,9 @@ window.__ModuleLoader__.load({
|
|
|
2120
2305
|
React.createElement('span', { style: { marginLeft: 'auto', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, provider)),
|
|
2121
2306
|
React.createElement('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
2122
2307
|
windows.map((window) => renderQuotaWindowRow(window, translate, null))),
|
|
2308
|
+
...(deepseekPeakVisible(row, windows)
|
|
2309
|
+
? [React.createElement(QuotaPeakTimeline, { key: 'panel-peak-timeline', showCaption: false })]
|
|
2310
|
+
: []),
|
|
2123
2311
|
...(Array.isArray(row.resetCards) && row.resetCards.length > 0
|
|
2124
2312
|
? [React.createElement('div', { key: 'panel-reset-cards', style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
2125
2313
|
row.resetCards.map((card, cardIndex) => {
|
|
@@ -2743,10 +2931,24 @@ window.__ModuleLoader__.load({
|
|
|
2743
2931
|
const adaptProvider = (providerName, kind) => requestQuotaConfig({ provider: providerName, kind })
|
|
2744
2932
|
// 删掉手动覆盖键,回退 baseURL 自动推断。
|
|
2745
2933
|
const clearAdaptedKind = (providerName) => requestQuotaConfig({ provider: providerName, clear: true })
|
|
2934
|
+
// 卡片手动排序:localStorage 名单驱动展示序(纯客户端,宿主契约不动);新供应商自然排末尾。
|
|
2935
|
+
const [cardOrder, setCardOrder] = useState(readQuotaCardOrder())
|
|
2936
|
+
// 排序模式开关(用户点名):平时不显示 ↑↓,点「调整排序」才出现,避免卡片头部常驻小钮。
|
|
2937
|
+
const [reorderMode, setReorderMode] = useState(false)
|
|
2746
2938
|
// 卡片分区(v0.20):只展示已适配供应商;未适配/已停用的不渲染灰行,统一收进底部「手动适配」行。
|
|
2747
|
-
const adaptedRows = providers.filter((row) => row.adapted === true)
|
|
2939
|
+
const adaptedRows = applyQuotaCardOrder(providers.filter((row) => row.adapted === true), cardOrder)
|
|
2748
2940
|
const candidateRows = providers.filter((row) => row.adapted !== true)
|
|
2749
2941
|
const quotaSelectStyle = { fontSize: '12px', padding: '3px 6px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)' }
|
|
2942
|
+
// ↑↓ 换位:以当前可见卡序列表交换相邻项并整体落盘(新见过的供应商一并入册)。
|
|
2943
|
+
const moveQuotaCard = (providerName, delta) => {
|
|
2944
|
+
const names = adaptedRows.map((row) => row.provider)
|
|
2945
|
+
const from = names.indexOf(providerName)
|
|
2946
|
+
const to = from + delta
|
|
2947
|
+
if (from < 0 || to < 0 || to >= names.length || from === to) return
|
|
2948
|
+
names.splice(to, 0, names.splice(from, 1)[0])
|
|
2949
|
+
setCardOrder(names)
|
|
2950
|
+
writeQuotaCardOrder(names)
|
|
2951
|
+
}
|
|
2750
2952
|
// 手动适配行(未适配/已停用的候选供应商)的选择状态;拆成两个独立 state 避免对象草稿接力更新。
|
|
2751
2953
|
const [addProvider, setAddProvider] = useState('')
|
|
2752
2954
|
const [addKind, setAddKind] = useState('')
|
|
@@ -2785,12 +2987,34 @@ window.__ModuleLoader__.load({
|
|
|
2785
2987
|
style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: `1px solid ${quotaNav ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`, background: quotaNav ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: 'pointer', lineHeight: 0 },
|
|
2786
2988
|
}, React.createElement('span', { style: { position: 'absolute', top: '1px', left: quotaNav ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: quotaNav ? '#fff' : 'var(--dsw-alias-label-tertiary)' } }))),
|
|
2787
2989
|
configError !== '' ? React.createElement('p', { 'data-testid': 'quota-config-error', style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, configError) : null,
|
|
2990
|
+
// 「调整排序」开关(≥2 张卡才有意义):进入后卡片头部出现 ↑↓,再点一次收起。
|
|
2991
|
+
...(adaptedRows.length >= 2
|
|
2992
|
+
? [React.createElement('div', { key: 'quota-reorder-row', style: { display: 'flex', justifyContent: 'flex-end', margin: '2px 0 8px' } },
|
|
2993
|
+
React.createElement('button', {
|
|
2994
|
+
type: 'button',
|
|
2995
|
+
'data-testid': 'quota-reorder-toggle',
|
|
2996
|
+
'aria-pressed': String(reorderMode),
|
|
2997
|
+
title: translate('quota.reorder'),
|
|
2998
|
+
onClick: () => setReorderMode(!reorderMode),
|
|
2999
|
+
style: { fontSize: '12px', lineHeight: '20px', padding: '2px 12px', borderRadius: 999, border: `1px solid ${reorderMode ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l2)'}`, background: 'transparent', color: reorderMode ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)', cursor: 'pointer' },
|
|
3000
|
+
}, translate('quota.reorder')))]
|
|
3001
|
+
: []),
|
|
2788
3002
|
adaptedRows.length === 0
|
|
2789
3003
|
? React.createElement('p', { style: hint }, translate('quota.noAdapted'))
|
|
2790
|
-
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
|
|
2791
|
-
adaptedRows.map((row) => {
|
|
3004
|
+
: React.createElement('div', { 'data-testid': 'quota-card-list', style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
|
|
3005
|
+
adaptedRows.map((row, index) => {
|
|
2792
3006
|
const nameNode = React.createElement('span', { style: { fontWeight: 600, fontSize: '12px', overflowWrap: 'anywhere' } },
|
|
2793
|
-
|
|
3007
|
+
// 官网用量页链接(用户点名):宿主按 kind 下发 usageUrl 时,展示名本身即外链。
|
|
3008
|
+
typeof row.usageUrl === 'string' && row.usageUrl !== ''
|
|
3009
|
+
? React.createElement('a', {
|
|
3010
|
+
'data-testid': `quota-usage-link-${row.provider}`,
|
|
3011
|
+
href: row.usageUrl,
|
|
3012
|
+
target: '_blank',
|
|
3013
|
+
rel: 'noreferrer',
|
|
3014
|
+
title: translate('quota.usageLink'),
|
|
3015
|
+
style: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'underline' },
|
|
3016
|
+
}, row.displayName || row.provider)
|
|
3017
|
+
: (row.displayName || row.provider),
|
|
2794
3018
|
row.kindSource === 'auto'
|
|
2795
3019
|
? React.createElement('span', {
|
|
2796
3020
|
'data-testid': `quota-auto-tag-${row.provider}`,
|
|
@@ -2812,6 +3036,10 @@ window.__ModuleLoader__.load({
|
|
|
2812
3036
|
} else {
|
|
2813
3037
|
body = React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('quota.empty'))
|
|
2814
3038
|
}
|
|
3039
|
+
// DeepSeek 余额卡专属:峰谷提示(状态徽标 + 换挡倒计时 + 24h 色带 + 随北京时间移动的圆点)。
|
|
3040
|
+
const peakTimeline = deepseekPeakVisible(row, windows)
|
|
3041
|
+
? React.createElement(QuotaPeakTimeline, { key: 'peak-timeline', showCaption: true })
|
|
3042
|
+
: null
|
|
2815
3043
|
// 手录重置卡(v0.19 过渡方案;v0.20 免次数、可多条):每条一行,行尾自带「移除」。
|
|
2816
3044
|
const resetCardNodes = Array.isArray(row.resetCards)
|
|
2817
3045
|
? row.resetCards.map((card, cardIndex) => {
|
|
@@ -2848,6 +3076,28 @@ window.__ModuleLoader__.load({
|
|
|
2848
3076
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '10px' } },
|
|
2849
3077
|
nameNode,
|
|
2850
3078
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '5px' } },
|
|
3079
|
+
// 手动排序(用户点名):仅在「调整排序」模式下出现;↑↓ 与相邻卡换位,
|
|
3080
|
+
// 首/末卡对应方向禁用;顺序存本浏览器。
|
|
3081
|
+
...(reorderMode ? [
|
|
3082
|
+
React.createElement('button', {
|
|
3083
|
+
type: 'button',
|
|
3084
|
+
'data-testid': `quota-move-up-${row.provider}`,
|
|
3085
|
+
'aria-label': translate('quota.card.moveUp'),
|
|
3086
|
+
title: translate('quota.card.moveUp'),
|
|
3087
|
+
disabled: index === 0,
|
|
3088
|
+
onClick: () => moveQuotaCard(row.provider, -1),
|
|
3089
|
+
style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px', padding: 0, border: 'none', background: 'transparent', color: index === 0 ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)', cursor: index === 0 ? 'default' : 'pointer', opacity: index === 0 ? 0.45 : 1, fontSize: '12px', lineHeight: '16px' },
|
|
3090
|
+
}, '↑'),
|
|
3091
|
+
React.createElement('button', {
|
|
3092
|
+
type: 'button',
|
|
3093
|
+
'data-testid': `quota-move-down-${row.provider}`,
|
|
3094
|
+
'aria-label': translate('quota.card.moveDown'),
|
|
3095
|
+
title: translate('quota.card.moveDown'),
|
|
3096
|
+
disabled: index === adaptedRows.length - 1,
|
|
3097
|
+
onClick: () => moveQuotaCard(row.provider, 1),
|
|
3098
|
+
style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px', padding: 0, border: 'none', background: 'transparent', color: index === adaptedRows.length - 1 ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)', cursor: index === adaptedRows.length - 1 ? 'default' : 'pointer', opacity: index === adaptedRows.length - 1 ? 0.45 : 1, fontSize: '12px', lineHeight: '16px' },
|
|
3099
|
+
}, '↓'),
|
|
3100
|
+
] : []),
|
|
2851
3101
|
// 手动刷新:SVG 图标按钮,点击强制该 provider 重拉上游;在途时置灰防重入。
|
|
2852
3102
|
React.createElement('button', {
|
|
2853
3103
|
type: 'button',
|
|
@@ -2862,6 +3112,7 @@ window.__ModuleLoader__.load({
|
|
|
2862
3112
|
? React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('quota.updated', { time: formatClockTime(row.fetchedAt) }))
|
|
2863
3113
|
: null)),
|
|
2864
3114
|
body,
|
|
3115
|
+
...(peakTimeline !== null ? [peakTimeline] : []),
|
|
2865
3116
|
...(row.status === 'unconfigured' && Array.isArray(row.credentialHints) && row.credentialHints.length > 0
|
|
2866
3117
|
? (() => {
|
|
2867
3118
|
const editingCred = credEditor !== null && credEditor.provider === row.provider
|
|
@@ -2902,7 +3153,7 @@ window.__ModuleLoader__.load({
|
|
|
2902
3153
|
'data-testid': `quota-cred-edit-${row.provider}`,
|
|
2903
3154
|
onClick: () => openCredEditor(row),
|
|
2904
3155
|
style: { fontSize: '12px', lineHeight: '20px', padding: '4px 14px', borderRadius: 999, border: '1px solid var(--dsw-alias-brand-primary)', background: 'transparent', color: 'var(--dsw-alias-brand-primary)', cursor: 'pointer', width: 'auto', minWidth: 0, overflow: 'visible', flex: '0 0 auto', whiteSpace: 'nowrap' },
|
|
2905
|
-
}, translate('quota.credential.edit')))]
|
|
3156
|
+
}, translate(row.kind === 'cliproxy' ? 'quota.credential.editManagement' : 'quota.credential.edit')))]
|
|
2906
3157
|
})()
|
|
2907
3158
|
: []),
|
|
2908
3159
|
...resetCardNodes,
|
package/index.js
CHANGED
|
@@ -79,6 +79,8 @@ const KIND_REGISTRY = {
|
|
|
79
79
|
parser: normalizeOpencodeUsage,
|
|
80
80
|
keyHints: ['OPENCODE_GO_API_KEY', 'OPENCODE_API_KEY'],
|
|
81
81
|
hosts: ['opencode.ai'],
|
|
82
|
+
// 官网用户页(卡片标题外链;宿主常量,零浏览器输入)。
|
|
83
|
+
usageUrl: 'https://opencode.ai/',
|
|
82
84
|
},
|
|
83
85
|
'zai-coding-cn': {
|
|
84
86
|
parser: normalizeZaiCodingUsage,
|
|
@@ -88,6 +90,7 @@ const KIND_REGISTRY = {
|
|
|
88
90
|
],
|
|
89
91
|
keyHints: ['ZAI_CODING_CN_API_KEY', 'ZAI_API_KEY', 'BIGMODEL_API_KEY'],
|
|
90
92
|
hosts: ['open.bigmodel.cn', 'bigmodel.cn'],
|
|
93
|
+
usageUrl: 'https://open.bigmodel.cn/coding-plan/personal/usage',
|
|
91
94
|
},
|
|
92
95
|
openrouter: {
|
|
93
96
|
parser: normalizeOpenRouterCredits,
|
|
@@ -107,6 +110,15 @@ const KIND_REGISTRY = {
|
|
|
107
110
|
keyHints: ['SILICONFLOW_API_KEY'],
|
|
108
111
|
hosts: ['siliconflow.cn'],
|
|
109
112
|
},
|
|
113
|
+
// DeepSeek 开放平台官方余额(v0.25):GET /user/balance,Bearer 即 API key。
|
|
114
|
+
// balance_infos[] 每项一个币种(文档枚举 CNY/USD),金额是字符串,total = granted + topped_up。
|
|
115
|
+
deepseek: {
|
|
116
|
+
parser: normalizeDeepseekBalance,
|
|
117
|
+
endpoints: ['https://api.deepseek.com/user/balance'],
|
|
118
|
+
keyHints: ['DEEPSEEK_API_KEY'],
|
|
119
|
+
hosts: ['api.deepseek.com', 'deepseek.com'],
|
|
120
|
+
usageUrl: 'https://platform.deepseek.com/usage',
|
|
121
|
+
},
|
|
110
122
|
// CLIProxyAPI(router-for-me/CLIProxyAPI)管理面:查它托管的 OAuth 上游账号官方额度。
|
|
111
123
|
// 无固定 endpoints / 全局 hosts——每个部署一个域名,保存适配时把 settings baseURL 的
|
|
112
124
|
// hostname 钉进配置 allowedHosts[provider](见 quota-config 写入口),外呼前复验精确命中。
|
|
@@ -724,6 +736,53 @@ function readLlmProviders(settings) {
|
|
|
724
736
|
}))
|
|
725
737
|
}
|
|
726
738
|
|
|
739
|
+
/**
|
|
740
|
+
* 运行时 LLM 渠道别名表(v0.25):DSH 有 settings 之外的内置 LLM 渠道(如 dsh-llm-deepseek
|
|
741
|
+
* 注册的 deepseek-official 路由),它们不出现在 llm-pi-ai.providers 清单里,额度查询却同样适用。
|
|
742
|
+
* 只有「kind 固定端点 + keyHints 齐备」的渠道才允许进表——进表即自证兼容(官方域名、凭据线索
|
|
743
|
+
* 宿主常量),延续零意外外呼口径:不在表内的运行时渠道一概不产生行。
|
|
744
|
+
*/
|
|
745
|
+
const QUOTA_RUNTIME_CHANNEL_KINDS = {
|
|
746
|
+
'deepseek-official': 'deepseek',
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** 运行时 llm 服务已注册渠道 → 合成 provider 行(runtimeKind 标记别名来源)。服务缺席/异常返回空表。
|
|
750
|
+
* 注意 listProviders() 的真实契约(dsh-llm 源码核实):下发 [{id,name,…}] 对象数组、注册顺序,
|
|
751
|
+
* 不是字符串——v0.25 首版按字符串过滤导致全部条目被静默跳过(测试假桩喂字符串掩盖了 bug)。 */
|
|
752
|
+
function readRuntimeQuotaChannels(llm) {
|
|
753
|
+
if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') return []
|
|
754
|
+
let providers
|
|
755
|
+
try {
|
|
756
|
+
providers = llm.listProviders()
|
|
757
|
+
} catch (_) {
|
|
758
|
+
return []
|
|
759
|
+
}
|
|
760
|
+
if (!Array.isArray(providers)) return []
|
|
761
|
+
const channels = []
|
|
762
|
+
for (const entry of providers) {
|
|
763
|
+
const id = typeof entry === 'string' ? entry : entry !== null && typeof entry === 'object' ? entry.id : undefined
|
|
764
|
+
if (typeof id !== 'string' || id.length === 0 || id.length > MAX_QUOTA_PROVIDER_NAME) continue
|
|
765
|
+
if (!Object.prototype.hasOwnProperty.call(QUOTA_RUNTIME_CHANNEL_KINDS, id)) continue
|
|
766
|
+
const kind = QUOTA_RUNTIME_CHANNEL_KINDS[id]
|
|
767
|
+
if (KIND_REGISTRY[kind] === undefined) continue
|
|
768
|
+
const displayName = typeof entry === 'object' && entry !== null && typeof entry.name === 'string' && entry.name.trim() !== ''
|
|
769
|
+
? entry.name.trim().slice(0, 128)
|
|
770
|
+
: id
|
|
771
|
+
channels.push({ name: id, displayName, baseURL: '', apiKeyEnv: '', runtimeKind: kind })
|
|
772
|
+
}
|
|
773
|
+
return channels
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** 额度查询的供应商清单 = settings 路由在前 + 运行时别名渠道殿后(settings 同名条目优先)。 */
|
|
777
|
+
function readQuotaProfiles(settings, llm) {
|
|
778
|
+
const profiles = readLlmProviders(settings)
|
|
779
|
+
const known = new Set(profiles.map((profile) => profile.name))
|
|
780
|
+
for (const channel of readRuntimeQuotaChannels(llm)) {
|
|
781
|
+
if (!known.has(channel.name)) profiles.push(channel)
|
|
782
|
+
}
|
|
783
|
+
return profiles
|
|
784
|
+
}
|
|
785
|
+
|
|
727
786
|
/**
|
|
728
787
|
* opencode-go 方言 → 统一窗口形状。真实端点只有 percent 与 resetsAt(ISO),
|
|
729
788
|
* 没有金额字段:percent 是一等公民,缺字段/非数字跳过该窗口,percent 截到 [0,100]。
|
|
@@ -822,7 +881,8 @@ function cliproxyFetchGuard(profile, config) {
|
|
|
822
881
|
|
|
823
882
|
/**
|
|
824
883
|
* 单个 provider 的 kind 解析(quota 与 quota-refresh 共用)。
|
|
825
|
-
* 优先序:配置显式 kind > 配置 null(手动停用,永不外呼)> baseURL
|
|
884
|
+
* 优先序:配置显式 kind > 配置 null(手动停用,永不外呼)> baseURL 自动推断 >
|
|
885
|
+
* 运行时渠道别名(readRuntimeQuotaChannels 合成的行带 runtimeKind);未命中返回空对象。
|
|
826
886
|
*/
|
|
827
887
|
function resolveQuotaKind(config, profile) {
|
|
828
888
|
if (Object.prototype.hasOwnProperty.call(config.kinds, profile.name)) {
|
|
@@ -832,6 +892,7 @@ function resolveQuotaKind(config, profile) {
|
|
|
832
892
|
return {}
|
|
833
893
|
}
|
|
834
894
|
const inferred = inferQuotaKind(profile.baseURL)
|
|
895
|
+
?? (profile.runtimeKind !== undefined && KIND_REGISTRY[profile.runtimeKind] !== undefined ? profile.runtimeKind : undefined)
|
|
835
896
|
if (inferred !== undefined && KIND_REGISTRY[inferred] !== undefined) return { kind: inferred, kindSource: 'auto' }
|
|
836
897
|
return {}
|
|
837
898
|
}
|
|
@@ -966,6 +1027,50 @@ function normalizeSiliconFlowInfo(payload) {
|
|
|
966
1027
|
return { windows: [{ id: 'balance', text: `¥${(Math.round(n * 100) / 100).toFixed(2)}` }] }
|
|
967
1028
|
}
|
|
968
1029
|
|
|
1030
|
+
/**
|
|
1031
|
+
* DeepSeek 官方余额(v0.25):{is_available, balance_infos:[{currency,total_balance,granted_balance,topped_up_balance}]}
|
|
1032
|
+
* → 文本窗口。每币种一行总额;赠金 > 0 时追加一行(官方口径:granted 是未过期赠金,total 含赠金+充值)。
|
|
1033
|
+
* currency 作为 label(纯数据,多币种区分),窗口名走客户端词典(双语教义:宿主不拼用户可见句子,
|
|
1034
|
+
* 货币符号 ¥/$ 属于数字格式化惯例,与 kimi 解析器同口径)。is_available 不改写金额——余额为 0 时
|
|
1035
|
+
* 数字本身已说明问题,不伪造状态文案。
|
|
1036
|
+
*/
|
|
1037
|
+
function normalizeDeepseekBalance(payload) {
|
|
1038
|
+
const infos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : []
|
|
1039
|
+
const windows = []
|
|
1040
|
+
const seenCurrencies = new Set()
|
|
1041
|
+
for (const info of infos) {
|
|
1042
|
+
if (info === null || typeof info !== 'object') continue
|
|
1043
|
+
const rawCurrency = typeof info.currency === 'string' ? info.currency.trim().toUpperCase() : ''
|
|
1044
|
+
// 文档枚举 CNY/USD;其他三~八字母码照收(上游扩展不炸),形状不对的条目丢弃。
|
|
1045
|
+
const currency = /^[A-Z]{3,8}$/.test(rawCurrency) ? rawCurrency : ''
|
|
1046
|
+
if (currency === '' || seenCurrencies.has(currency)) continue
|
|
1047
|
+
const total = normalizeDeepseekMoney(info.total_balance)
|
|
1048
|
+
if (total === null) continue
|
|
1049
|
+
seenCurrencies.add(currency)
|
|
1050
|
+
const idSuffix = currency.toLowerCase()
|
|
1051
|
+
windows.push({ id: `balance-${idSuffix}`, text: deepseekMoneyText(total, currency), label: currency, kindKey: 'balance' })
|
|
1052
|
+
const granted = normalizeDeepseekMoney(info.granted_balance)
|
|
1053
|
+
if (granted !== null && Number(granted) > 0) {
|
|
1054
|
+
windows.push({ id: `granted-${idSuffix}`, text: deepseekMoneyText(granted, currency), label: currency, kindKey: 'granted-balance' })
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return { windows }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** DeepSeek 金额字段归一:字符串/数字均可(官方下发字符串),负数或非有限值拒绝;
|
|
1061
|
+
* 空白串也拒绝——Number('') 是 0,会把上游缺数据伪装成 ¥0.00。保留两位小数。 */
|
|
1062
|
+
function normalizeDeepseekMoney(value) {
|
|
1063
|
+
if (typeof value === 'string' && value.trim() === '') return null
|
|
1064
|
+
const n = Number(value)
|
|
1065
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
1066
|
+
return (Math.round(n * 100) / 100).toFixed(2)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function deepseekMoneyText(amount, currency) {
|
|
1070
|
+
const symbol = currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : ''
|
|
1071
|
+
return symbol === '' ? `${amount} ${currency}` : `${symbol}${amount}`
|
|
1072
|
+
}
|
|
1073
|
+
|
|
969
1074
|
// ─── CLIProxyAPI(cliproxy)管理面编排 ──────────────────────────────────────
|
|
970
1075
|
// 上游官方额度端点注册表(经 CPA api-call 代调,header 的 $TOKEN$ 由 CPA 替换为对应账号凭据)。
|
|
971
1076
|
// 形状来源:muyouzhi6/astrbot_plugin_cliproxy_stats + CPA main 分支源码(2026-08 核实);
|
|
@@ -3511,7 +3616,7 @@ function apply(ctx) {
|
|
|
3511
3616
|
if (endpoint === 'quota') {
|
|
3512
3617
|
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
3513
3618
|
try {
|
|
3514
|
-
const providers =
|
|
3619
|
+
const providers = readQuotaProfiles(ctx.get('settings'), ctx.get('llm'))
|
|
3515
3620
|
quotaThrottle.prune(new Set(providers.map((profile) => profile.name)))
|
|
3516
3621
|
const config = await refreshQuotaConfigCache()
|
|
3517
3622
|
const allResetCards = Array.isArray(config.resetCards) ? config.resetCards : []
|
|
@@ -3576,6 +3681,8 @@ function apply(ctx) {
|
|
|
3576
3681
|
nextAllowedAt: view.nextAllowedAt,
|
|
3577
3682
|
...(providerResetCards.length > 0 ? { resetCards: providerResetCards } : {}),
|
|
3578
3683
|
...(credentialHints !== undefined ? { credentialHints } : {}),
|
|
3684
|
+
// 官网用户页余额网址(宿主常量白名单随 kind 下发;无则缺省)。
|
|
3685
|
+
...(typeof KIND_REGISTRY[kind].usageUrl === 'string' && KIND_REGISTRY[kind].usageUrl !== '' ? { usageUrl: KIND_REGISTRY[kind].usageUrl } : {}),
|
|
3579
3686
|
})
|
|
3580
3687
|
}
|
|
3581
3688
|
return { ok: true, value: { providers: rows, serverTime: Date.now() } }
|
|
@@ -3590,7 +3697,7 @@ function apply(ctx) {
|
|
|
3590
3697
|
// 手动刷新入口:provider 过白名单且 kind 已适配;清掉节流闸后立即 kick。
|
|
3591
3698
|
// 单飞仍生效(在途时本次点击为 no-op);上游结果经后续 quota 快照带出,不在此等待。
|
|
3592
3699
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
3593
|
-
const profile =
|
|
3700
|
+
const profile = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
|
|
3594
3701
|
if (profile === undefined) return { ok: false, error: 'unknown-provider' }
|
|
3595
3702
|
const config = await refreshQuotaConfigCache()
|
|
3596
3703
|
const { kind } = resolveQuotaKind(config, profile)
|
|
@@ -3613,7 +3720,7 @@ function apply(ctx) {
|
|
|
3613
3720
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
3614
3721
|
// 三种写法,语义对齐配置文件解析(显式 kind > 显式 null 停用 > 自动推断):
|
|
3615
3722
|
// {clear:true} 删掉覆盖键回退自动推断;{kind:null} 存显式停用(baseURL 可推断也不外呼);{kind:<name>} 指定适配。
|
|
3616
|
-
const profileForProvider =
|
|
3723
|
+
const profileForProvider = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
|
|
3617
3724
|
if (profileForProvider === undefined) return { ok: false, error: 'unknown-provider' }
|
|
3618
3725
|
return await serializeQuotaConfigWrite(async (config) => {
|
|
3619
3726
|
if (payload?.clear === true) {
|
|
@@ -3653,7 +3760,7 @@ function apply(ctx) {
|
|
|
3653
3760
|
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
3654
3761
|
try {
|
|
3655
3762
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
3656
|
-
const profileForProvider =
|
|
3763
|
+
const profileForProvider = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
|
|
3657
3764
|
if (profileForProvider === undefined) return { ok: false, error: 'unknown-provider' }
|
|
3658
3765
|
const config = await refreshQuotaConfigCache()
|
|
3659
3766
|
const { kind } = resolveQuotaKind(config, profileForProvider)
|
|
@@ -3694,7 +3801,7 @@ function apply(ctx) {
|
|
|
3694
3801
|
// provider 过宿主清单白名单;{remove:true,id} 删除宿主下发 id 对应的那一条,
|
|
3695
3802
|
// 其余载荷为追加一条(label/expiresAt 截断限长),单 provider 上限 10 条防配置膨胀。
|
|
3696
3803
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
3697
|
-
if (!
|
|
3804
|
+
if (!readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).some((candidate) => candidate.name === providerName)) {
|
|
3698
3805
|
return { ok: false, error: 'unknown-provider' }
|
|
3699
3806
|
}
|
|
3700
3807
|
return await serializeQuotaConfigWrite(async (config) => {
|
|
@@ -3758,6 +3865,7 @@ export {
|
|
|
3758
3865
|
name,
|
|
3759
3866
|
normalizeAntigravityModels,
|
|
3760
3867
|
normalizeCodexRateLimit,
|
|
3868
|
+
normalizeDeepseekBalance,
|
|
3761
3869
|
normalizeGeminiBuckets,
|
|
3762
3870
|
normalizeKimiBalance,
|
|
3763
3871
|
normalizeOpencodeUsage,
|
|
@@ -3799,6 +3907,7 @@ export default {
|
|
|
3799
3907
|
name,
|
|
3800
3908
|
normalizeAntigravityModels,
|
|
3801
3909
|
normalizeCodexRateLimit,
|
|
3910
|
+
normalizeDeepseekBalance,
|
|
3802
3911
|
normalizeGeminiBuckets,
|
|
3803
3912
|
normalizeKimiBalance,
|
|
3804
3913
|
normalizeOpencodeUsage,
|