dsh-subusage 0.7.0 → 0.8.2
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.md +85 -89
- package/lib/client.js +260 -22
- package/lib/index.js +67 -28
- package/lib/mimo-login.js +9 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,80 +1,35 @@
|
|
|
1
1
|
# dsh-subusage —— DeepSeek Harness 订阅用量显示
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在模型选择器旁显示当前提供商的订阅余量,点击查看各周期用量、重置时间和套餐信息。设置页集中管理十个提供商/区域的检测开关与凭据。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
支持 **Z.ai Coding(中国与国际)/ Kimi Coding / Xiaomi MiMo / OpenCode Go / Command Code / MiniMax(国际与中国)/ Synthetic / NanoGPT**。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Z.ai 国际版依据[官方查询脚本](https://github.com/zai-org/zai-coding-plugins/blob/main/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs)直连监控接口,区域凭据独立;补齐 `TOKENS_LIMIT` 格式。Synthetic 使用[官方 quotas API](https://dev.synthetic.new/docs/synthetic/quotas),只读取模型订阅请求池。
|
|
9
|
-
- NanoGPT 使用[官方订阅用量 API](https://docs.nano-gpt.com/api-reference/endpoint/subscription-usage),显示每日/每周输入 Token 及试用 Token 池;支持手动填写 `sk-nano-mgmt-…` [Usage only 管理令牌](https://docs.nano-gpt.com/api-reference/management-api),自动选择只读管理接口。忽略图像池,未开通、缺数据、降级响应不会显示虚假剩余额度。
|
|
10
|
-
- 修复额度重置时绕过 `Retry-After`、保存一家凭据导致其他用量消失,以及首次读取失败后无法重新初始化的问题。
|
|
11
|
-
- 空状态自动展开提供商管理;刷新错误始终在工具栏下可见;药丸弹层适应窄屏和深浅主题。
|
|
12
|
-
- Host/Client 必须一起更新。已做离线回归与浏览器布局检查,未使用真实账号在线验证。详见[审查记录](docs/code-review.md)。
|
|
7
|
+
## 安装与快速开始
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
当前仓库版本 **0.8.2**,可从 GitHub Release 获取。npm 自动发布采用 Trusted Publishing(GitHub OIDC);可用版本见 [npm](https://www.npmjs.com/package/dsh-subusage)。安装时使用目标 DSH 部署提供的插件管理器或 CLI,并启用本 bundle:
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- 所有导航与套餐选择改为按钮,窄屏自然换行,整个设置页不使用下拉列表。
|
|
22
|
-
- **Host 与 Client 必须一起加载新版**。本地 link 安装直接指向工作区;磁盘更新不代表正在运行的 Host/Client 已重载,必要时重启 DSH。在线账号与真实应用界面仍需验收。
|
|
11
|
+
| 来源 | 安装标识或下载地址 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| npm | `dsh-subusage`;支持 npm 源的插件管理器可使用该包名 |
|
|
14
|
+
| GitHub | `https://github.com/KouzakiUmi/dsh-subusage` |
|
|
15
|
+
| 安装包 | [最新 GitHub Release 安装包](https://github.com/KouzakiUmi/dsh-subusage/releases/latest/download/dsh-subusage.tgz) |
|
|
23
16
|
|
|
24
|
-
|
|
17
|
+
只需取得 npm 最新压缩包时可运行 `npm pack dsh-subusage`;指定版本前可用 `npm view dsh-subusage version` 核对发布结果。普通 `npm install` 或下载压缩包并不等于已经在 DSH 中启用插件;具体安装参数以目标部署的帮助信息为准。
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
- 直连对应区域的 `/v1/token_plan/remains`,只显示 `general` 或旧版 `MiniMax-M*` 编程池,忽略视频/图像池,不累加共享额度。
|
|
28
|
-
- 显示短周期和周已用百分比及重置时间;优先采用 `remaining_percent`,计数明细按[官方 CLI 消歧规则](https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts)校准,无法匹配时只显示百分比。旧版无百分比响应按剩余计数解释。
|
|
29
|
-
- 无上限、未报告或零总计且无百分比的窗口不绘制,标记部分数据;没有可绘制窗口时额度未知。不推断套餐名称,周加量不折算额外百分比。
|
|
30
|
-
- 设置导航扩展为七个区域条目,内部宽度 ≤699px 时切换选择框。
|
|
31
|
-
- **Host 与 Client 必须一起更新**;未执行在线账号验收或安装。接口参考:[官方 Token Plan FAQ](https://platform.minimax.io/docs/token-plan/faq)、[官方 CLI 端点](https://github.com/MiniMax-AI/cli/blob/main/src/client/endpoints.ts)。
|
|
19
|
+
开发目标是 **DeepSeek Harness 0.2.0-rc.2**。核心 peer 范围为 `>=0.2.0-rc.1 <0.3.0-0`,允许该范围内的预发布版本;声明范围不代表所有版本均已实机验证。
|
|
32
20
|
|
|
33
|
-
|
|
21
|
+
1. 安装并启用 bundle,按部署方式重载或重启,使 Host、Client 与依赖一起生效。
|
|
22
|
+
2. 打开 **设置 → 订阅用量 → 提供商管理**,为需要的订阅配置凭据。
|
|
23
|
+
3. **Z.ai 国际、Synthetic、NanoGPT 默认关闭**,必须手动开启。原有七项默认开启;升级保留已保存的开关。
|
|
24
|
+
4. 点击「刷新当前」检查结果;选中对应提供商的模型后,输入区显示余量药丸。
|
|
34
25
|
|
|
35
|
-
|
|
36
|
-
- 套餐及账期来自 `/alpha/billing/subscriptions`,失败时可回退 credits 内的 planId;没有账期时不编造重置日期。
|
|
37
|
-
- 月总额是[提供方插件套餐表](https://github.com/Mars-Sea/dsh-commandcode-provider/blob/main/src/capabilities.ts)的快照,不是计费接口直接报告的硬上限;未知套餐只显示剩余金额并标记部分数据。套餐变更后需核对更新。
|
|
38
|
-
- 月余额不包含已购/赠送余额;月池耗尽不据此连坐周/5小时窗口。
|
|
39
|
-
- 修复非法重置日期导致整包失败,以及旧配置中的 Command Code 手动 Key 绕过托管凭据来源的问题。
|
|
40
|
-
- 修复 MiMo 普通刷新/缓存中的接口 Cookie 回显;脱敏正确处理分号后空格和带引号的值。
|
|
41
|
-
- 修复 Netscape Cookie 导入:兼容注释、`#HttpOnly_` 域前缀及 TAB 末列空值,仍严格过滤外域。
|
|
42
|
-
- MiMo 登录只锁定本厂商的手动导入/清除,不再阻止其他厂商更换 Key,也不产生实际未执行的待清除提示。
|
|
43
|
-
|
|
44
|
-
## 0.4.0:Command Code 用量显示
|
|
45
|
-
|
|
46
|
-
- **支持 Command Code**:该路由由另一个插件 [`@mars-sea/dsh-commandcode-provider`](https://github.com/Mars-Sea/dsh-commandcode-provider) 注册;本插件显示其 5 小时 / 每周 / 月额度池、重置时间、套餐(planId)、月剩余与已购 + 赠送余额,周窗用尽会连坐 5 小时窗口。
|
|
47
|
-
- **快速直连刷新**:不消费提供方插件的用量服务(它按账户逐个串行多端点、超时预算长,作为药丸来源太慢),改为自读同一凭据后并行直连 `/alpha/billing/credits` 和 `/alpha/billing/subscriptions`(各 10 秒超时);不读取请求统计、不串行遍历账户。
|
|
48
|
-
- **同一凭据来源**:凭据服务 `COMMANDCODE_API_KEY` → 启动环境 → `~/.commandcode/auth.json`(`cmd login` 写入,解析方式与提供方插件一致)。本插件**不保存** Command Code 凭据;Key、登录与多账户在 设置 → Command Code 管理。
|
|
49
|
-
- **已知限制**:多账户轮换或固定 `activeAccount` 时显示默认(顶层 Key)账户的额度,可能与实际服务账户不同;提供方插件里自定义 `apiBase` 不会被跟随。
|
|
50
|
-
- **五等宽导航**:设置页厂商导航改为五等宽单行,窄内容区(≤499px)切换为选择框。
|
|
51
|
-
|
|
52
|
-
**两端必须同时更新**:旧 Host 会拒绝含 `commandcode` 的刷新请求。
|
|
53
|
-
|
|
54
|
-
## 0.3.0:按钮式凭据与 MiMo 自动登录
|
|
55
|
-
|
|
56
|
-
- **凭据按钮**:默认只展示当前来源;通过「更换 / 清除」进入编辑,再保存或取消,不再使用「保持 / 替换 / 清除」下拉菜单。
|
|
57
|
-
- **自动登录导入**:点击「登录并自动导入」,使用隔离的临时 Chrome 窗口完成官方登录,由 Host 验证并保存必需 Cookie。
|
|
58
|
-
- **原生菜单对比度**:剩余套餐/provider 选项显式配对系统前景和背景,避免深色界面出现白底白字。
|
|
59
|
-
|
|
60
|
-
### 延续的设置与可靠性改进
|
|
61
|
-
|
|
62
|
-
- **五等宽导航**:`Z.ai / Kimi / MiMo / OpenCode Go / Command` 单行显示,窄内容区改成选择框,不出现换行。
|
|
63
|
-
- **用量为主**:凭据与帮助折叠;顶部提供刷新当前/全部,保存按钮仅出现在编辑区。
|
|
64
|
-
- **共享刷新**:按厂商缓存与合并在途请求,药丸和设置页共享结果,活跃厂商每分钟检查更新。
|
|
65
|
-
- **两条状态轴**:数据获取成功不等于额度可用;额度不足、认证失效、缓存和部分数据均有独立说明。
|
|
66
|
-
- **准确限额**:原始比例决定是否限流,不把 99.5% 提前当作 100%;较长周期限额才向内层窗口连坐。
|
|
67
|
-
- **凭据来源**:可选择继承或自定义;读取结果不再回传完整 Key/Cookie。
|
|
68
|
-
- **编辑保护**:按厂商增量保存,未保存编辑不被刷新覆盖;配置版本检查防止多窗口旧表单覆盖新配置。
|
|
69
|
-
- **MiMo Cookie 验证**:支持标准串、多行 KV、成对 name/value、TAB 清单和 JSON 导出;解析失败保留原文,不再失焦自动破坏输入。
|
|
70
|
-
|
|
71
|
-
详细界面约定见 [设计说明](docs/design-ux.md),维护注意事项见 [开发说明](docs/development.md)。
|
|
26
|
+
「没有检测到API的默认隐藏」默认开启。首次没有可见条目时,管理区自动展开;关闭的提供商仍可配置凭据,但不会请求用量。插件不注册模型路由,药丸按提供商 ID 匹配,配置表见下文。
|
|
72
27
|
|
|
73
28
|
## 使用
|
|
74
29
|
|
|
75
30
|
### 药丸
|
|
76
31
|
|
|
77
|
-
选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d
|
|
32
|
+
选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d 已达限额`。多窗口取最差一窗,点击看完整详情。Command Code 药丸弹层底部可切换提供方插件的服务账户(自动轮换 / 默认账户 / 各额外账户,与其设置页同一开关,即时生效);切换后药丸与弹层上方的用量同步改为所选账户的额度(自动轮换时仍显示默认账户/顶层 Key 的用量,弹层有标注说明)。关闭默认隐藏后,缺凭据时显示配置指引;可保留的临时网络错误会明确标注缓存,而非冒充当前成功。
|
|
78
33
|
|
|
79
34
|
### 设置
|
|
80
35
|
|
|
@@ -103,11 +58,11 @@
|
|
|
103
58
|
| MiniMax 中国 | `MINIMAX_CN_API_KEY` | `minimax-cn` 路由;中国站订阅 Key |
|
|
104
59
|
| Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;也兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
|
|
105
60
|
|
|
106
|
-
Z.ai
|
|
61
|
+
Z.ai(中国与国际)/ Kimi / OpenCode Go / MiniMax / Synthetic / NanoGPT 的继承模式按凭据服务 → 启动环境 → 旧手动配置兜底解析;自定义模式仅使用保存的手动 Key。Command Code 仅沿用提供方凭据来源链,不接受本插件的手动 Key;MiMo 仅使用 Cookie。界面区分凭据服务、启动环境和自定义来源。修改启动环境后是否需要重启取决于目标部署,不能把用户环境即时变化当作已经被运行进程读到。
|
|
107
62
|
|
|
108
63
|
### MiMo 登录与 Cookie
|
|
109
64
|
|
|
110
|
-
推荐使用 MiMo
|
|
65
|
+
推荐使用 MiMo「登录与凭据」中的 **登录并自动导入**:
|
|
111
66
|
|
|
112
67
|
1. 先保存或取消当前编辑,再开始登录。
|
|
113
68
|
2. 插件调用 `playwright-core`,打开**独立临时 Chrome 会话**的官方平台;密码、验证码由你在官方页面自行输入,插件不读取这些字段,也不读取日常 Chrome 配置。
|
|
@@ -121,13 +76,27 @@ Z.ai / Kimi / OpenCode Go / MiniMax 的继承模式按凭据服务 → 启动环
|
|
|
121
76
|
|
|
122
77
|
JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过滤,无关站点的 Cookie 不导入。TAB 清单只读取 Name/Value,不把 Domain/Path 混入值;Netscape 导出支持注释和 `#HttpOnly_` 域前缀,并保留可选 Cookie 的空值(两个必需字段仍须非空)。
|
|
123
78
|
|
|
79
|
+
### Cookie 有效期与到期提醒
|
|
80
|
+
|
|
81
|
+
官方会话 Cookie **自签发起 24 小时有效**。无论自动登录还是手动导入,保存凭据的同一刻开始计时,并记录在 `~/.dsh/dsh-subusage.json` 的 `xiaomi.loginAt` 与 `xiaomi.expiresAt`(毫秒时间戳,**只是计时元数据,不是秘密**,不参与凭据有效性判断)。
|
|
82
|
+
|
|
83
|
+
| 剩余时间 | 用量药丸 | 设置页「登录与凭据」 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| 超过 2 小时 | 保持原余量文案 | 显示登录时间与剩余时间 |
|
|
86
|
+
| 2 小时内 | ⚠ 黄色药丸,文案「Cookie N 小时后到期」 | 黄色提示剩余时间 |
|
|
87
|
+
| 30 分钟内 | ⚠ 橙色药丸,同文案 | 橙色提示 |
|
|
88
|
+
| 已到期 | ✕ 红色药丸,文案「Cookie 已过期,请重新登录」 | 红色提示 |
|
|
89
|
+
| 额度已用尽 | 仍优先显示额度告警 | — |
|
|
90
|
+
|
|
91
|
+
药丸弹层常驻显示剩余时间与到期时刻。到期只是**提醒**:插件不会因为倒计时归零就停用 Cookie,是否真的失效仍以官方接口返回为准(失效时按认证错误处理,重新登录即可)。倒计时也永远不会盖过接口的判定——官方已判定凭据失效时,药丸显示的是失效原因而非剩余时间(唯一例外是「已过期」本身,因为它就是重新登录的提示)。若浏览器报告的 Cookie 过期时间早于 24 小时,以更早者为准;旧版本保存的 Cookie 没有计时记录,界面会明确说明「未记录登录时间」而不是编造倒计时。
|
|
92
|
+
|
|
124
93
|
**安全说明**:Key/Cookie 不再通过用量读取接口回传,界面不回填已保存的秘密;但本版仍兼容原本机 JSON 配置存储,**不是加密凭据库**。该配置位于 `~/.dsh/dsh-subusage.json`,新建目录/临时文件在 POSIX 上按 `0700`/`0600` 创建,替换前再次收紧文件权限;Windows 的实际访问权限仍取决于目录 ACL,`chmod` 不能替代 ACL 或加密库。请勿上传、分享或写入日志。隔离浏览器仅用于你主动发起的官方登录;它不是后台扫描或导入日常浏览器全部凭据的工具。加密凭据存储通道仍未实施。
|
|
125
94
|
|
|
126
95
|
## 支持的数据
|
|
127
96
|
|
|
128
97
|
| 模型商 | 窗口 | 明细 |
|
|
129
98
|
|---|---|---|
|
|
130
|
-
| Z.ai | 5
|
|
99
|
+
| Z.ai 中国 | 5 小时、每周(按响应) | Token / Credits 配额、套餐档 |
|
|
131
100
|
| Z.ai 国际 | 5 小时、每周(按响应) | Token / Credits 配额,区域凭据独立 |
|
|
132
101
|
| Synthetic | 滚动订阅池 | 已用/总请求次数、重置时间 |
|
|
133
102
|
| NanoGPT | 每日、每周、试用周期(按响应) | 输入 Token 数、重置时间;不包含图像额度 |
|
|
@@ -139,49 +108,76 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
|
|
|
139
108
|
|
|
140
109
|
非公开控制台接口可能变更;缺失或非法百分比不会当作零用量。部分数据、未知额度和暂时失败有明确状态,不能据此保证推理接口一定可用。
|
|
141
110
|
|
|
142
|
-
## 安装与生效
|
|
143
111
|
|
|
144
|
-
|
|
112
|
+
## 常见问题
|
|
145
113
|
|
|
146
|
-
|
|
114
|
+
| 现象 | 排查方法 |
|
|
115
|
+
|---|---|
|
|
116
|
+
| 设置页没有提供商标签 | 展开提供商管理,确认开关和凭据;无凭据的条目默认隐藏,可临时关闭自动隐藏查看指引 |
|
|
117
|
+
| 已配置凭据却没有药丸 | 确认当前模型的提供商 ID 与支持表一致、检测已开启;自定义 ID 可在设置页查看,但不会自动映射 |
|
|
118
|
+
| 显示认证失效 | 检查是否用了对应产品/区域的订阅 Key;MiMo 重新登录或导入 Cookie;旧额度不会当作有效数据保留 |
|
|
119
|
+
| 显示缓存、部分数据或额度未知 | 查看更新时间和错误说明;缓存来自之前的成功读取,部分数据不保证有可用额度,余额也不等于订阅余量 |
|
|
120
|
+
| 刷新后数字暂未变化 | 成功结果按提供商缓存 60 秒;手动刷新可跳过普通 TTL,但仍遵守限流退避和 Retry-After |
|
|
121
|
+
| MiMo 自动登录无法启动 | Host 所在机器需安装 Google Chrome 并有桌面环境;远程或无桌面部署使用手动导入 |
|
|
122
|
+
| 药丸提示「Cookie N 小时后到期」 | MiMo 会话 Cookie 自签发起 24 小时有效,按提示重新登录即可续期;手动导入同样重新计时 |
|
|
123
|
+
| 药丸显示「未记录登录时间」 | 凭据由旧版本保存,没有计时元数据;重新登录或重新导入一次后开始计时 |
|
|
124
|
+
| 保存成功但验证失败 | 凭据已保存,检查网络、区域或账号后重试;保存与在线验证分别反馈 |
|
|
125
|
+
| 保存提示配置已改变 | 另一窗口或实例更新了设置;重新读取配置后再编辑,避免旧表单覆盖新值 |
|
|
126
|
+
| 升级后仍是旧界面或 RPC 不匹配 | 确认运行中的 Host 和 Client 都已重载;仅刷新页面不能保证 Host 升级 |
|
|
127
|
+
| Command Code 额度与实际账户不同 | 可在药丸弹层切换提供方插件的服务账户(activeAccount),切换后药丸与弹层用量同步显示所选账户的额度;「自动轮换」时仍显示默认账户(顶层 Key)的用量,不跟随轮换中的实际服务账户,提供方自定义 apiBase 也不跟随 |
|
|
147
128
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
129
|
+
## 提供商 ID 与默认开关
|
|
130
|
+
|
|
131
|
+
| 提供商 | ID | 新安装默认值 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| Z.ai 中国 | `zai-coding-cn` | 开启 |
|
|
134
|
+
| Kimi Coding | `kimi-coding` | 开启 |
|
|
135
|
+
| MiMo | `xiaomi-token-plan-cn` | 开启 |
|
|
136
|
+
| OpenCode Go | `opencode-go` | 开启 |
|
|
137
|
+
| Command Code | `commandcode` | 开启 |
|
|
138
|
+
| MiniMax 国际 | `minimax` | 开启 |
|
|
139
|
+
| MiniMax 中国 | `minimax-cn` | 开启 |
|
|
140
|
+
| Z.ai 国际 | `zai-coding` | **关闭** |
|
|
141
|
+
| Synthetic | `synthetic` | **关闭** |
|
|
142
|
+
| NanoGPT | `nanogpt` | **关闭** |
|
|
152
143
|
|
|
153
|
-
|
|
144
|
+
开关只控制本插件的订阅检测与显示,关闭后保留凭据。默认隐藏只影响显示,不自动开启被关闭的提供商。新增三项在旧配置升级时也保持关闭。
|
|
154
145
|
|
|
155
|
-
|
|
146
|
+
## 界面预览
|
|
156
147
|
|
|
157
|
-
|
|
148
|
+
以下为 0.7.0 的离线组件测试截图,使用虚构用量数据,不包含真实账号信息,也不是运行中的 DSH 截图。市场截图由根目录的 `screenshots.json` 声明。
|
|
158
149
|
|
|
159
|
-
|
|
150
|
+
深色设置页:查看订阅用量、管理凭据与提供商;新增三家默认关闭。
|
|
160
151
|
|
|
161
|
-
|
|
152
|
+

|
|
162
153
|
|
|
163
|
-
|
|
154
|
+
浅色用量弹层:查看每日与每周额度、用量明细。
|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## 开发、发布与文档
|
|
160
|
+
|
|
161
|
+
基础检查无需安装 DSH 或连接账号:
|
|
164
162
|
|
|
165
163
|
```console
|
|
166
164
|
node tests/run-all.mjs
|
|
167
165
|
node scripts/check-manifest.mjs
|
|
168
166
|
node --check lib/index.js
|
|
169
167
|
node --check lib/client.js
|
|
168
|
+
node --check lib/mimo-login.js
|
|
170
169
|
```
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
- `read()`:初始化获取十个厂商/区域条目,使用每条目缓存;新增三项默认关闭。
|
|
175
|
-
- `refresh({ providerIds, force })`:按厂商刷新,结果条目由客户端合并。
|
|
176
|
-
- `save(settings)`:按厂商 patch 保存;读取/保存结果只含公开配置和凭据存在性。
|
|
177
|
-
- `startMimoLogin({ expectedRevision })`:立即返回任务状态,后台等待官方登录,不阻塞 RPC。
|
|
178
|
-
- `getMimoLoginStatus()`:轮询状态,成功结果只含公开配置和用量;账号变更后旧成功结果失效。
|
|
179
|
-
- `cancelMimoLogin({ jobId })`:只取消匹配的任务,阻止迟到验证保存。
|
|
171
|
+
推送到 `main` 后,GitHub Actions 检查并发布提交对应的 GitHub Release。推送与包版本一致的 `vX.Y.Z` 标签后,受 npm 信任的 `release.yml` 通过 Trusted Publishing 自动发布新 npm 版本;已有版本跳过。发布产物不会自动安装或重启 DSH。
|
|
180
172
|
|
|
181
|
-
|
|
173
|
+
- [开发与验证](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md):RPC、缓存、凭据、离线预览与实机验收。
|
|
174
|
+
- [UX 设计](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/design-ux.md):导航、状态、编辑保护与弹层行为。
|
|
175
|
+
- [发布与市场收录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/publish.md):GitHub Release、npm、条目与截图维护。
|
|
176
|
+
- [更新记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/changelog.md):各版本功能变化。
|
|
177
|
+
- [0.7.0 审查记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/code-review.md):修复、接口来源和验证边界。
|
|
182
178
|
|
|
183
|
-
|
|
179
|
+
测试使用虚构凭据、桩网络和离线组件。真实 DSH Loader、实际账号在线接口与多账户映射尚未验收;截图不代表已通过这些检查。
|
|
184
180
|
|
|
185
181
|
## 许可
|
|
186
182
|
|
|
187
|
-
MIT。与 DeepSeek Harness
|
|
183
|
+
MIT。与 DeepSeek Harness 及各服务商无官方关联。
|
package/lib/client.js
CHANGED
|
@@ -95,12 +95,28 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
needConfigShort: "需配置",
|
|
96
96
|
pillNeedKey: "未配置 API Key —— 请打开 设置 → 订阅用量 填写,或导出同名环境变量后重启。",
|
|
97
97
|
pillNeedCookie: "需登录 —— 请在 设置 → 订阅用量 访问官方平台并替换 Cookie。",
|
|
98
|
+
mimoCookieTtl: "官方会话 Cookie 有效期 24 小时,登录或导入时开始计时;临近到期时用量药丸会提示。",
|
|
99
|
+
mimoLoginAt: "登录于 {time}",
|
|
100
|
+
mimoCookieUntracked: "未记录登录时间(旧版本保存的 Cookie),重新登录后开始计时",
|
|
101
|
+
mimoCookieExpired: "Cookie 已过期,请重新登录",
|
|
102
|
+
pillCookieSoon: "Cookie {rest}后到期",
|
|
103
|
+
mimoCookieExpiresAt: "{time} 到期",
|
|
98
104
|
noUsageData: "暂无额度数据",
|
|
99
105
|
providerUnavailable: "用量暂不可用",
|
|
100
106
|
pillNeedCommandcode: "需配置 —— 可在 设置 → Command Code 登录、运行 cmd login,或导出 COMMANDCODE_API_KEY 后重试。",
|
|
101
107
|
accountLabel: "账户",
|
|
102
108
|
commandcodeManaged: "凭据由 Command Code 提供方插件(设置 → Command Code)管理;本页只读、不保存凭据。用量由本插件直接拉取其计费接口,取用同一凭据来源(凭据服务 COMMANDCODE_API_KEY → 启动环境 → ~/.commandcode/auth.json)。",
|
|
103
|
-
commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai
|
|
109
|
+
commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。显示药丸弹层中选择的账户的额度(未选择时为默认账户/顶层 Key),可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。",
|
|
110
|
+
ccAccountsTitle: "Command Code 账户",
|
|
111
|
+
ccAccountAuto: "自动轮换",
|
|
112
|
+
ccAccountDefault: "默认账户",
|
|
113
|
+
ccAccountActive: "使用中",
|
|
114
|
+
ccAccountsHint: "切换对 Command Code 提供方插件全局生效(与其设置页同一开关);上方用量同步切换为所选账户的额度。",
|
|
115
|
+
ccAccountsUnavailable: "账户列表不可用(需要 Command Code 提供方插件在运行)",
|
|
116
|
+
ccUsageAccount: "上方用量:{account}",
|
|
117
|
+
ccUsageAutoNote: "自动轮换时上方用量仍是默认账户(顶层 Key),不跟随实际服务账户",
|
|
118
|
+
ccSwitching: "切换中…",
|
|
119
|
+
ccSwitchFailed: "切换失败"
|
|
104
120
|
};
|
|
105
121
|
const en = {
|
|
106
122
|
nav: "Subscription usage",
|
|
@@ -179,12 +195,28 @@ window.__ModuleLoader__.load({
|
|
|
179
195
|
needConfigShort: "Setup",
|
|
180
196
|
pillNeedKey: "No API key — open Settings → Subscription usage to fill one in, or export the same-named env variable and restart.",
|
|
181
197
|
pillNeedCookie: "Login required — paste a cookie in Settings → Subscription usage.",
|
|
198
|
+
mimoCookieTtl: "The official session Cookie lasts 24 hours; the countdown starts at sign-in or import. The usage pill warns before it runs out.",
|
|
199
|
+
mimoLoginAt: "Signed in {time}",
|
|
200
|
+
mimoCookieUntracked: "Login time not recorded (Cookie saved by an older version); sign in again to start the countdown",
|
|
201
|
+
mimoCookieExpired: "Cookie expired — sign in again",
|
|
202
|
+
pillCookieSoon: "Cookie expires in {rest}",
|
|
203
|
+
mimoCookieExpiresAt: "Expires {time}",
|
|
182
204
|
noUsageData: "No quota data",
|
|
183
205
|
providerUnavailable: "Usage unavailable",
|
|
184
206
|
pillNeedCommandcode: "Setup required — sign in under Settings → Command Code, run cmd login, or export COMMANDCODE_API_KEY and retry.",
|
|
185
207
|
accountLabel: "Account",
|
|
186
208
|
commandcodeManaged: "Credentials are managed by the Command Code provider plugin (Settings → Command Code); this page is read-only and stores nothing. Usage is fetched directly by this plugin from its billing endpoint, using the same credential sources (credential service COMMANDCODE_API_KEY → launch environment → ~/.commandcode/auth.json).",
|
|
187
|
-
commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable.
|
|
209
|
+
commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. Shows the quota of the account selected in the pill popover (the default/top-level key account when none is selected), which may differ from the serving account; a custom apiBase in the provider plugin is not followed.",
|
|
210
|
+
ccAccountsTitle: "Command Code accounts",
|
|
211
|
+
ccAccountAuto: "Automatic (rotation order)",
|
|
212
|
+
ccAccountDefault: "Default account",
|
|
213
|
+
ccAccountActive: "in use",
|
|
214
|
+
ccAccountsHint: "Switching applies globally to the Command Code provider plugin (same switch as its settings page); the usage above switches to the selected account's quota.",
|
|
215
|
+
ccAccountsUnavailable: "Account list unavailable (requires the Command Code provider plugin to be running)",
|
|
216
|
+
ccUsageAccount: "Usage above: {account}",
|
|
217
|
+
ccUsageAutoNote: "with automatic rotation the usage above stays on the default (top-level key) account, not the serving one",
|
|
218
|
+
ccSwitching: "Switching…",
|
|
219
|
+
ccSwitchFailed: "Switch failed"
|
|
188
220
|
};
|
|
189
221
|
const NS = "settings.subusage";
|
|
190
222
|
const KIND_LABEL = { "5h": "w5h", "7d": "w7d", day: "wday", week: "wweek", month: "wmonth", period: "wperiod", sub: "wsub", rolling: "wrolling" };
|
|
@@ -272,16 +304,44 @@ window.__ModuleLoader__.load({
|
|
|
272
304
|
function extraLabel(x, t) {
|
|
273
305
|
return x.kind === "plan" ? t("planLabel") : x.kind === "account" ? t("accountLabel") : x.kind === "monthly-balance" ? t("monthlyBalanceLabel") : t("balanceLabel");
|
|
274
306
|
}
|
|
307
|
+
/** 时长紧凑文案:窗口重置倒计时与 Cookie 剩余有效期共用同一档位。 */
|
|
308
|
+
function durationText(ms, t) {
|
|
309
|
+
const mins = Math.max(0, Math.round(ms / 60000));
|
|
310
|
+
return mins >= 1440 ? t("restDays").replace("{n}", String(Math.round(mins / 1440)))
|
|
311
|
+
: mins >= 60 ? t("restHours").replace("{n}", String(Math.round(mins / 60)))
|
|
312
|
+
: t("restMins").replace("{n}", String(Math.max(1, mins)));
|
|
313
|
+
}
|
|
314
|
+
/** 官方 MiMo 会话 Cookie 自签发起 24 小时有效:到期前 2 小时起提醒,30 分钟内升级为紧急。 */
|
|
315
|
+
const MIMO_COOKIE_SOON_MS = 2 * 60 * 60 * 1000;
|
|
316
|
+
const MIMO_COOKIE_URGENT_MS = 30 * 60 * 1000;
|
|
317
|
+
/** Cookie 有效期判定:药丸、弹层与设置页共用;无记录时返回 null,绝不编造倒计时。 */
|
|
318
|
+
function cookieExpiry(entry, t) {
|
|
319
|
+
const expiresAt = Date.parse((entry && entry.cookieExpiresAt) || "");
|
|
320
|
+
if (!Number.isFinite(expiresAt)) return null;
|
|
321
|
+
const left = expiresAt - Date.now();
|
|
322
|
+
const rest = durationText(left, t);
|
|
323
|
+
if (left <= 0) return { rank: 4, icon: "✕", color: "#ef4444", expired: true, rest, expiresAt, label: t("mimoCookieExpired") };
|
|
324
|
+
const label = t("pillCookieSoon").replace("{rest}", rest);
|
|
325
|
+
if (left <= MIMO_COOKIE_URGENT_MS) return { rank: 3, icon: "⚠", color: "#f97316", rest, expiresAt, label };
|
|
326
|
+
if (left <= MIMO_COOKIE_SOON_MS) return { rank: 2, icon: "⚠", color: "#eab308", rest, expiresAt, label };
|
|
327
|
+
return { rank: 1, icon: "✓", color: "#22c55e", rest, expiresAt, label };
|
|
328
|
+
}
|
|
329
|
+
/** 设置页有效期说明:登录时间 + 剩余时间;旧版本保存的 Cookie 明确说明未记录。 */
|
|
330
|
+
function cookieLifetimeLine(xiaomi, t, getLocale) {
|
|
331
|
+
if (!xiaomi || !xiaomi.hasCookie) return null;
|
|
332
|
+
const expiry = cookieExpiry({ cookieExpiresAt: xiaomi.expiresAt }, t);
|
|
333
|
+
const parts = [];
|
|
334
|
+
if (xiaomi.loginAt) parts.push(t("mimoLoginAt").replace("{time}", new Date(xiaomi.loginAt).toLocaleString(getLocale())));
|
|
335
|
+
parts.push(expiry ? expiry.label : t("mimoCookieUntracked"));
|
|
336
|
+
return h("p", { role: "status", style: { margin: "6px 0 0", color: expiry && expiry.rank >= 2 ? expiry.color : "inherit" } }, parts.join(" · "));
|
|
337
|
+
}
|
|
275
338
|
/** 重置行:带倒计时;到期未刷新显式说明。 */
|
|
276
339
|
function resetLine(w, t, getLocale) {
|
|
277
340
|
if (!w.resetsAt) return null;
|
|
278
341
|
const at = new Date(w.resetsAt);
|
|
279
342
|
const ms = at.getTime() - Date.now();
|
|
280
343
|
if (!(ms > 0)) return t("detailResetDue");
|
|
281
|
-
const
|
|
282
|
-
const rest = mins >= 1440 ? t("restDays").replace("{n}", String(Math.round(mins / 1440)))
|
|
283
|
-
: mins >= 60 ? t("restHours").replace("{n}", String(Math.round(mins / 60)))
|
|
284
|
-
: t("restMins").replace("{n}", String(Math.max(1, mins)));
|
|
344
|
+
const rest = durationText(ms, t);
|
|
285
345
|
return t("detailResetIn").replace("{time}", at.toLocaleString(getLocale())).replace("{rest}", rest);
|
|
286
346
|
}
|
|
287
347
|
|
|
@@ -346,7 +406,7 @@ window.__ModuleLoader__.load({
|
|
|
346
406
|
namespace: "subUsage",
|
|
347
407
|
method: "read",
|
|
348
408
|
invocation: { kind: "direct" },
|
|
349
|
-
parameters: [],
|
|
409
|
+
parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: queryCodec }],
|
|
350
410
|
result: resultCodec
|
|
351
411
|
},
|
|
352
412
|
{
|
|
@@ -471,13 +531,18 @@ window.__ModuleLoader__.load({
|
|
|
471
531
|
let snapshot = { updatedAt: null, settings: null, configured: {}, entries: [] };
|
|
472
532
|
let disposed = false, initial = null, timer = null, epoch = 0, sequence = 0, metadataSequence = 0;
|
|
473
533
|
let loginGeneration = 0, loginJobId = null, loginPending = false;
|
|
534
|
+
// Command Code 显示账户:弹层账户区选中的账户("" = 默认账户/顶层 Key)。
|
|
535
|
+
// 所有 commandcode 读取(readAll/refresh/定时刷新)都携带它,切换后用量同步跟随。
|
|
536
|
+
let ccAccount = "";
|
|
474
537
|
const mergedLoginJobs = new Set();
|
|
475
538
|
const providerSequence = new Map();
|
|
476
539
|
const markRequest = (ids) => { const version = ++sequence; for (const id of ids) providerSequence.set(id, version); return { version, ids }; };
|
|
477
540
|
const listeners = new Set(), active = new Map(), inflight = new Map(), readers = new Map(), resetSeen = new Set();
|
|
478
541
|
const emit = () => { if (!disposed) for (const fn of listeners) fn(); };
|
|
479
|
-
|
|
480
|
-
|
|
542
|
+
// 凭据被替换或清除时不能沿用旧 cookieExpiresAt:否则已删除的 Cookie 仍显示过期倒计时。
|
|
543
|
+
// keep 表示“未改动”,保留计时以免保存后倒计时闪退。
|
|
544
|
+
const invalidate = (ids, keepCookieExpiry = true) => {
|
|
545
|
+
snapshot = { ...snapshot, entries: snapshot.entries.map((e) => ids.includes(e.providerId) ? { providerId: e.providerId, apiDetected: e.apiDetected, keySource: e.keySource, ...(keepCookieExpiry && e.cookieExpiresAt ? { cookieExpiresAt: e.cookieExpiresAt } : {}), state: "loading", coverage: "partial", freshness: "unknown", windows: [], extras: [] } : e) };
|
|
481
546
|
for (const id of ids) inflight.delete(id);
|
|
482
547
|
};
|
|
483
548
|
const merge = (incoming, request = null, localChange = false) => {
|
|
@@ -497,7 +562,7 @@ window.__ModuleLoader__.load({
|
|
|
497
562
|
const missing = unique.filter((id) => !inflight.has(id));
|
|
498
563
|
if (missing.length) {
|
|
499
564
|
const startedEpoch = epoch, request = markRequest(missing);
|
|
500
|
-
const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
|
|
565
|
+
const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
|
|
501
566
|
// 传输失败不能擅自 retainPrevious:只有 Host 明确标 stale 才保留。
|
|
502
567
|
if (startedEpoch === epoch) merge({ entries: missing.map((providerId) => ({ providerId, state: "error", error: error instanceof Error ? error.message : String(error), freshness: "unknown", coverage: "partial", windows: [], extras: [] })) }, request);
|
|
503
568
|
throw error;
|
|
@@ -536,16 +601,29 @@ window.__ModuleLoader__.load({
|
|
|
536
601
|
}
|
|
537
602
|
return value;
|
|
538
603
|
};
|
|
604
|
+
const setCommandCodeAccount = (id) => {
|
|
605
|
+
const next = typeof id === "string" ? id.trim() : "";
|
|
606
|
+
if (disposed || ccAccount === next) return Promise.resolve(snapshot);
|
|
607
|
+
ccAccount = next;
|
|
608
|
+
// 账户变了:旧账户的在途/缓存数据立即失效,强制按新账户重拉。
|
|
609
|
+
invalidate(["commandcode"]);
|
|
610
|
+
// invalidate 只重写快照不通知:补一次 emit 让药丸立即进入加载态,
|
|
611
|
+
// 避免强制刷新返回前「标注已是新账户、数字还是旧账户」的瞬态错标。
|
|
612
|
+
emit();
|
|
613
|
+
return refresh(["commandcode"], true);
|
|
614
|
+
};
|
|
539
615
|
return {
|
|
540
616
|
subscribe, getSnapshot: () => snapshot, refresh, activate,
|
|
617
|
+
getCommandCodeAccount: () => ccAccount, setCommandCodeAccount,
|
|
541
618
|
startMimoLogin: async (request) => { const generation = ++loginGeneration, startedEpoch = epoch; loginJobId = null; return acceptLogin(await call("startMimoLogin", request), generation, startedEpoch); },
|
|
542
619
|
getMimoLoginStatus: async () => { const generation = loginGeneration, startedEpoch = epoch, jobId = loginPending ? loginJobId : null; return acceptLogin(await call("getMimoLoginStatus"), generation, startedEpoch, jobId); },
|
|
543
620
|
cancelMimoLogin: async (request) => { if (loginJobId && request.jobId !== loginJobId) return null; const generation = ++loginGeneration, startedEpoch = epoch; return acceptLogin(await call("cancelMimoLogin", request), generation, startedEpoch, request.jobId); },
|
|
544
|
-
readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read")).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
|
|
621
|
+
readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read", { providerIds: PROVIDER_ORDER, force: false, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
|
|
545
622
|
save: async (patch) => {
|
|
546
623
|
const value = await call("save", patch); epoch++;
|
|
547
624
|
if (patch.visibility) { const entries = snapshot.entries; markRequest(PROVIDER_ORDER); return merge({ ...value, entries }, null, true); }
|
|
548
|
-
|
|
625
|
+
const cookie = patch.cookieUpdate;
|
|
626
|
+
invalidate([patch.providerId], !cookie || cookie.action === "keep"); return merge(value, null, true);
|
|
549
627
|
},
|
|
550
628
|
reader: (id) => {
|
|
551
629
|
if (!readers.has(id)) {
|
|
@@ -567,6 +645,122 @@ window.__ModuleLoader__.load({
|
|
|
567
645
|
return percent >= 80 ? "high" : void 0;
|
|
568
646
|
}
|
|
569
647
|
|
|
648
|
+
// ── Command Code 账户切换(对齐提供方插件的账户显示)────────────────────
|
|
649
|
+
// 账户列表与 id 规则对齐 @mars-sea/dsh-commandcode-provider 的 slots():
|
|
650
|
+
// 默认账户 id 固定 "default";额外账户 id 取其 apiKeyEnv(凭据引用名)。
|
|
651
|
+
// 与 slots() 的差异:仅配置 literal apiKey(无凭据引用)的槽位不进列表——
|
|
652
|
+
// 其 Key 存在提供方配置内,本插件无法按引用解析,列出只会选中即报 no-key。
|
|
653
|
+
// enrollment 进行中的账户不进列表(与其一致)。
|
|
654
|
+
const COMMANDCODE_SETTINGS_NS = "llm-commandcode";
|
|
655
|
+
function commandCodeAccountList(value) {
|
|
656
|
+
const list = [{ id: "default", label: "default" }];
|
|
657
|
+
const accounts = Array.isArray(value?.accounts) ? value.accounts : [];
|
|
658
|
+
const tasks = Array.isArray(value?.accountEnrollmentTasks) ? value.accountEnrollmentTasks : [];
|
|
659
|
+
accounts.forEach((account, index) => {
|
|
660
|
+
const refName = typeof account?.apiKeyEnv === "string" && account.apiKeyEnv.trim() !== "" ? account.apiKeyEnv.trim() : void 0;
|
|
661
|
+
// 与 slots() 一致:无凭据引用的槽位不显示;登记任务未完成的账户暂不可选。
|
|
662
|
+
if (refName === void 0) return;
|
|
663
|
+
if (tasks.some((task) => task?.ref === refName && task.phase !== "naming")) return;
|
|
664
|
+
list.push({ id: refName, label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `Account ${index + 2}` });
|
|
665
|
+
});
|
|
666
|
+
return list;
|
|
667
|
+
}
|
|
668
|
+
/** 当前固定账户:非空且命中账户列表才算固定;空串/"auto"/悬空引用都按自动轮换显示。 */
|
|
669
|
+
function commandCodeActiveId(value, accounts) {
|
|
670
|
+
const raw = typeof value?.activeAccount === "string" ? value.activeAccount.trim() : "";
|
|
671
|
+
return raw !== "" && accounts.some((account) => account.id === raw) ? raw : "";
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* 账户切换控制器:读走 settings remote 的 describe(llm-commandcode ns 的
|
|
675
|
+
* accounts/activeAccount/revision),写走同一 ns 的 mutate——与 Command Code
|
|
676
|
+
* 设置页完全同一 wire 通道与 op 形式。describe/mutate 由调用方注入,namespace
|
|
677
|
+
* 未挂载(提供方插件未运行)时返回 not-ok,界面降级为不可用。
|
|
678
|
+
*/
|
|
679
|
+
function createCommandCodeAccounts({ describe, mutate }) {
|
|
680
|
+
let disposed = false;
|
|
681
|
+
const listeners = new Set();
|
|
682
|
+
let state = { status: "idle", accounts: [], activeId: "", revision: null, writable: false, baseActive: "", switching: false, error: null };
|
|
683
|
+
const emit = () => { for (const fn of [...listeners]) fn(state); };
|
|
684
|
+
const parseRow = (row) => {
|
|
685
|
+
const accounts = commandCodeAccountList(row?.value);
|
|
686
|
+
return { accounts, activeId: commandCodeActiveId(row?.value, accounts), revision: typeof row?.revision === "number" ? row.revision : null, baseActive: typeof row?.base?.activeAccount === "string" ? row.base.activeAccount : "" };
|
|
687
|
+
};
|
|
688
|
+
const load = async (keepError = false) => {
|
|
689
|
+
if (disposed) return state;
|
|
690
|
+
state = { ...state, status: "loading", error: keepError ? state.error : null }; emit();
|
|
691
|
+
try {
|
|
692
|
+
const response = await describe();
|
|
693
|
+
if (!response?.ok) throw new Error(response?.error?.message || "settings describe failed");
|
|
694
|
+
const row = response.value?.namespaces?.find((candidate) => candidate?.ns === COMMANDCODE_SETTINGS_NS);
|
|
695
|
+
if (!row) throw new Error("Command Code settings namespace not found");
|
|
696
|
+
state = { ...state, status: "ready", writable: response.value.writable === true, ...parseRow(row) };
|
|
697
|
+
} catch (error) {
|
|
698
|
+
state = { ...state, status: "unavailable", error: String(error?.message || error) };
|
|
699
|
+
}
|
|
700
|
+
emit();
|
|
701
|
+
return state;
|
|
702
|
+
};
|
|
703
|
+
const switchTo = async (id) => {
|
|
704
|
+
if (disposed || state.status !== "ready" || state.switching) return state;
|
|
705
|
+
// 回自动轮换:组合层已固定(base 非空)时写空串覆盖,否则直接清除该字段——
|
|
706
|
+
// 与提供方插件设置页的 active intent 完全一致。
|
|
707
|
+
const ops = id === ""
|
|
708
|
+
? (state.baseActive !== "" ? [{ op: "set", path: ["activeAccount"], value: "" }] : [{ op: "unset", path: ["activeAccount"] }])
|
|
709
|
+
: [{ op: "set", path: ["activeAccount"], value: id }];
|
|
710
|
+
state = { ...state, switching: true, error: null }; emit();
|
|
711
|
+
try {
|
|
712
|
+
const response = await mutate(ops, state.revision);
|
|
713
|
+
if (!response?.ok) throw new Error(response?.error?.message || "settings mutate failed");
|
|
714
|
+
state = { ...state, switching: false, ...parseRow(response.value) };
|
|
715
|
+
} catch (error) {
|
|
716
|
+
const message = String(error?.message || error);
|
|
717
|
+
state = { ...state, switching: false, error: message };
|
|
718
|
+
void load(true); // 写失败(含 revision 冲突)后以 Host 实际状态为准;失败原因保留给界面。
|
|
719
|
+
}
|
|
720
|
+
emit();
|
|
721
|
+
return state;
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
load, switchTo,
|
|
725
|
+
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
|
|
726
|
+
getSnapshot: () => state,
|
|
727
|
+
dispose: () => { disposed = true; listeners.clear(); }
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** 弹层内的 Command Code 账户切换区:自动轮换 + 默认账户 + 各额外账户。 */
|
|
732
|
+
function CommandCodeAccountSwitch({ cc, ccState, t }) {
|
|
733
|
+
if (!cc || !ccState) return null;
|
|
734
|
+
const optionButton = (id, labelText) => h("button", {
|
|
735
|
+
type: "button",
|
|
736
|
+
onClick: () => void cc.switchTo(id),
|
|
737
|
+
disabled: ccState.switching || !ccState.writable,
|
|
738
|
+
"aria-pressed": ccState.activeId === id,
|
|
739
|
+
style: {
|
|
740
|
+
display: "flex", alignItems: "center", gap: 6, width: "100%", textAlign: "left",
|
|
741
|
+
padding: "4px 8px", marginTop: 4, borderRadius: 6,
|
|
742
|
+
border: `1px solid ${ccState.activeId === id ? "rgba(59,130,246,.55)" : "rgba(128,128,128,.3)"}`,
|
|
743
|
+
background: ccState.activeId === id ? "rgba(59,130,246,.10)" : "transparent",
|
|
744
|
+
color: "inherit", font: "inherit",
|
|
745
|
+
cursor: ccState.switching || !ccState.writable ? "default" : "pointer"
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
h("span", { "aria-hidden": "true" }, ccState.activeId === id ? "●" : "○"),
|
|
749
|
+
h("span", { style: { flex: 1 } }, labelText),
|
|
750
|
+
ccState.activeId === id ? h("span", { style: { fontSize: 11, opacity: 0.75 } }, t("ccAccountActive")) : null
|
|
751
|
+
);
|
|
752
|
+
return h("div", { style: { marginTop: 10, paddingTop: 8, borderTop: "1px solid rgba(128,128,128,.25)" } },
|
|
753
|
+
h("div", { style: { fontSize: 11, opacity: 0.7 } }, t("ccAccountsTitle")),
|
|
754
|
+
ccState.status === "ready"
|
|
755
|
+
? h("div", null,
|
|
756
|
+
optionButton("", t("ccAccountAuto")),
|
|
757
|
+
ccState.accounts.map((account) => optionButton(account.id, account.id === "default" ? t("ccAccountDefault") : account.label)),
|
|
758
|
+
ccState.error ? h("p", { style: { color: "#f87171", margin: "6px 0 0", whiteSpace: "pre-wrap" } }, `${t("ccSwitchFailed")}: ${ccState.error}`) : null,
|
|
759
|
+
h("p", { style: { margin: "6px 0 0", fontSize: 11, opacity: 0.65 } }, t("ccAccountsHint")))
|
|
760
|
+
: h("p", { style: { margin: "4px 0 0", opacity: 0.7 } }, ccState.status === "loading" || ccState.status === "idle" ? t("loading") : t("ccAccountsUnavailable"))
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
570
764
|
/** 单窗口行(药丸弹层与设置页共用):徽标 + 已用% 主数字 + 进度条 + 明细行。 */
|
|
571
765
|
function UsageWindowRow({ w, t, getLocale, uncertain = false }) {
|
|
572
766
|
const tier = uncertain && w.status !== "rate-limited" ? { icon: "?", badge: "badgeUnknown", color: "#b7791f", rank: 2 } : usageTier(w);
|
|
@@ -606,7 +800,7 @@ window.__ModuleLoader__.load({
|
|
|
606
800
|
);
|
|
607
801
|
}
|
|
608
802
|
|
|
609
|
-
function UsagePill({ providerId, label, readEntry, t, getLocale }) {
|
|
803
|
+
function UsagePill({ providerId, label, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
|
|
610
804
|
const [snapshot, setSnapshot] = react.useState(null);
|
|
611
805
|
const [failed, setFailed] = react.useState(null);
|
|
612
806
|
const [failState, setFailState] = react.useState(void 0);
|
|
@@ -614,6 +808,17 @@ window.__ModuleLoader__.load({
|
|
|
614
808
|
const [open, setOpen] = react.useState(false);
|
|
615
809
|
const root = react.useRef(null);
|
|
616
810
|
const retry = react.useRef(() => {});
|
|
811
|
+
// Command Code 账户区:cc 由装配层注入(提供方插件未运行时为 null,区块整体隐藏)。
|
|
812
|
+
const ccState = react.useSyncExternalStore(cc ? cc.subscribe : () => () => {}, cc ? cc.getSnapshot : () => null);
|
|
813
|
+
react.useEffect(() => { if (open && cc) void cc.load(); }, [open, cc]);
|
|
814
|
+
// 挂载即读一次账户配置:提供方插件固定了账户时,药丸本体不必等弹层打开就跟随。
|
|
815
|
+
react.useEffect(() => { if (providerId === "commandcode" && cc) void cc.load(); }, [providerId, cc]);
|
|
816
|
+
// 显示账户跟随账户区的当前固定项(含切换成功后的回读):ready 才同步,
|
|
817
|
+
// unavailable/loading 保持现状;store 内部对相同账户去重,不产生多余请求。
|
|
818
|
+
react.useEffect(() => {
|
|
819
|
+
if (providerId !== "commandcode" || !ccState || !setCommandCodeAccount) return;
|
|
820
|
+
if (ccState.status === "ready") void setCommandCodeAccount(ccState.activeId);
|
|
821
|
+
}, [providerId, setCommandCodeAccount, ccState && ccState.status, ccState && ccState.activeId]);
|
|
617
822
|
react.useEffect(() => {
|
|
618
823
|
let alive = true;
|
|
619
824
|
let busy = false;
|
|
@@ -690,25 +895,36 @@ window.__ModuleLoader__.load({
|
|
|
690
895
|
: null;
|
|
691
896
|
const isConfig = stateText === "no-key" || stateText === "no-cookie";
|
|
692
897
|
// 余额请求成功不代表有额度窗口(例如小米接口暂时只返回余额)。
|
|
693
|
-
|
|
694
|
-
const
|
|
898
|
+
// Cookie 临近到期是需要重新登录的行动项:除额度已用尽外,优先于普通余量与降级提示。
|
|
899
|
+
const expiry = cookieExpiry(rawEntry, t);
|
|
900
|
+
const quotaBlocked = !!(worstTier && worstTier.rank >= 4);
|
|
901
|
+
const expiring = !!(expiry && expiry.rank >= 2);
|
|
902
|
+
// 倒计时只用于展示,永远不能盖过官方接口的判定:凭据已被拒绝(state=error)时
|
|
903
|
+
// 主文案必须仍是失败原因,否则「还有 2 小时到期」会把重新登录推迟掉。
|
|
904
|
+
// 例外是已过期:它本身就是「请重新登录」的行动项,比通用错误文案更具体。
|
|
905
|
+
const expiryActionable = expiring && (!(rawEntry && rawEntry.state === "error") || (expiry && expiry.expired));
|
|
906
|
+
// 底色与文字沿用额度告警语汇:已过期与额度用尽同为红,30 分钟内为橙,2 小时内为黄。
|
|
907
|
+
const alertStyle = quotaBlocked || (expiry && expiry.expired) ? { background: "rgba(239,68,68,.10)", borderColor: "rgba(239,68,68,.45)" } : expiryActionable && expiry.rank >= 3 ? { background: "rgba(249,115,22,.10)", borderColor: "rgba(249,115,22,.45)" } : expiryActionable ? { background: "rgba(234,179,8,.10)", borderColor: "rgba(234,179,8,.45)" } : {};
|
|
908
|
+
const triggerColor = quotaBlocked ? worstTier.color : expiryActionable ? expiry.color : worstTier && worstTier.rank > 1 ? worstTier.color : null;
|
|
909
|
+
const triggerIcon = quotaBlocked ? worstTier.icon : expiryActionable ? expiry.icon : degraded ? "⚠" : worstTier ? worstTier.icon : ok ? "ℹ" : isConfig ? "⚙" : light;
|
|
910
|
+
const triggerText = quotaBlocked ? (worst ? pillText(worst, windows.length > 1, t) : entry ? t("noUsageData") : stateLabel) : expiryActionable ? expiry.label : worst ? `${degraded ? statusText(rawEntry, t) + " · " : ""}${pillText(worst, windows.length > 1, t)}` : entry ? `${degraded ? stateLabel + " · " : ""}${t("noUsageData")}` : isConfig ? t("needConfigShort") : stateLabel;
|
|
695
911
|
return h("span", { ref: root, style: { position: "relative", display: "inline-flex", alignItems: "center" } },
|
|
696
912
|
h("button", {
|
|
697
913
|
type: "button",
|
|
698
914
|
"aria-expanded": open,
|
|
699
915
|
"aria-haspopup": "dialog",
|
|
700
|
-
"aria-label": `${label}: ${stateLabel} · ${summary}`,
|
|
701
|
-
title: `${label}: ${stateLabel} · ${summary}`,
|
|
916
|
+
"aria-label": `${label}: ${stateLabel} · ${summary}${expiring && !quotaBlocked ? " · " + expiry.label : ""}`,
|
|
917
|
+
title: `${label}: ${stateLabel} · ${summary}${expiring && !quotaBlocked ? " · " + expiry.label : ""}`,
|
|
702
918
|
onClick: () => setOpen(!open),
|
|
703
919
|
style: {
|
|
704
920
|
display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px",
|
|
705
921
|
borderRadius: 999, border: "1px solid rgba(128,128,128,.35)", background: "transparent",
|
|
706
922
|
color: "inherit", font: "inherit", cursor: "pointer", whiteSpace: "nowrap",
|
|
707
|
-
...
|
|
923
|
+
...alertStyle
|
|
708
924
|
}
|
|
709
925
|
},
|
|
710
926
|
h("span", { "aria-hidden": "true", style: { fontSize: 11, lineHeight: 1 } }, triggerIcon),
|
|
711
|
-
h("span", { style:
|
|
927
|
+
h("span", { style: triggerColor ? { color: triggerColor } : null }, triggerText),
|
|
712
928
|
failed && ok ? h("span", { style: { opacity: 0.7, fontSize: 11 } }, `(${t("unavailable")})`) : null
|
|
713
929
|
),
|
|
714
930
|
open && h("div", {
|
|
@@ -727,11 +943,20 @@ window.__ModuleLoader__.load({
|
|
|
727
943
|
failed && !needConfig ? h("p", { style: { color: "#f87171", margin: "6px 0 0", whiteSpace: "pre-wrap" } }, failed) : null,
|
|
728
944
|
!ok && !snapshot && !failed ? h("p", { style: { margin: "6px 0 0", opacity: 0.8 } }, t("loading")) : null,
|
|
729
945
|
cacheAge(rawEntry, t) ? h("p", { style: { color: "#b7791f" } }, cacheAge(rawEntry, t)) : null,
|
|
946
|
+
// 到期提醒常驻弹层:即使用量正常,也让用户看见 Cookie 还剩多久。
|
|
947
|
+
expiry ? h("p", { role: "status", style: { margin: "6px 0 0", color: expiring ? expiry.color : "inherit" } }, `${expiry.label} · ${t("mimoCookieExpiresAt").replace("{time}", new Date(expiry.expiresAt).toLocaleString(getLocale()))}`) : null,
|
|
948
|
+
// 用量账户标注:账户区 ready 时说明上方数字属于哪个账户;自动轮换时明确不跟随实际服务账户。
|
|
949
|
+
providerId === "commandcode" && ccState && ccState.status === "ready"
|
|
950
|
+
? h("p", { style: { margin: "6px 0 0", fontSize: 11, opacity: 0.75 } },
|
|
951
|
+
t("ccUsageAccount").replace("{account}", ccState.activeId === "" || ccState.activeId === "default" ? t("ccAccountDefault") : (ccState.accounts.find((a) => a.id === ccState.activeId)?.label ?? ccState.activeId)),
|
|
952
|
+
ccState.activeId === "" ? h("span", { style: { opacity: 0.85 } }, ` · ${t("ccUsageAutoNote")}`) : null)
|
|
953
|
+
: null,
|
|
730
954
|
windows.map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: degraded })),
|
|
731
955
|
entry && entry.extras && entry.extras.length > 0
|
|
732
956
|
? h("div", { style: { marginTop: 10, opacity: 0.85 } },
|
|
733
957
|
entry.extras.map((x) => h("div", { key: x.kind }, `${extraLabel(x, t)}: ${x.value}`)))
|
|
734
958
|
: null,
|
|
959
|
+
providerId === "commandcode" ? h(CommandCodeAccountSwitch, { cc, ccState, t }) : null,
|
|
735
960
|
snapshot ? h("div", { style: { marginTop: 10, opacity: 0.7 } }, `${t("lastUpdated")} ${new Date(snapshot.updatedAt).toLocaleString(getLocale())}`) : null,
|
|
736
961
|
h("button", {
|
|
737
962
|
type: "button",
|
|
@@ -945,6 +1170,8 @@ window.__ModuleLoader__.load({
|
|
|
945
1170
|
login.state === "error" || login.state === "cancelled" ? h("p", { role: "status" }, login.state === "cancelled" ? text("登录已取消,可重新登录或手动导入。", "Login cancelled. Retry or import manually.") : text("自动登录失败,可重试或手动导入。", "Automatic login failed. Retry or import manually.") + (login.message ? " " + login.message : "")) : null,
|
|
946
1171
|
h("p", null, h("a", { href: "https://platform.xiaomimimo.com", target: "_blank", rel: "noopener noreferrer" }, text("仅打开 MiMo 官网(不会自动导入)", "Open MiMo website only (no automatic import)"))),
|
|
947
1172
|
h("p", null, result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie ? text("已保存 Cookie(不回填)", "Cookie saved (never filled back)") : t("statusNoCookie")),
|
|
1173
|
+
cookieLifetimeLine(result.settings && result.settings.xiaomi, t, getLocale),
|
|
1174
|
+
h("p", { style: { fontSize: 12, opacity: .75 } }, t("mimoCookieTtl")),
|
|
948
1175
|
credentialButtons("cookie", result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie),
|
|
949
1176
|
draft.cookieAction === "replace" ? h(react.Fragment, null,
|
|
950
1177
|
h("label", null, t("mimoCookie"), h("textarea", { rows: 3, autoComplete: "off", spellCheck: false, value: draft.cookie, disabled: busy || loginActive, onChange: (e) => editDraft({ cookie: e.target.value }), style: { ...inputStyle, resize: "vertical" } })),
|
|
@@ -1056,19 +1283,28 @@ window.__ModuleLoader__.load({
|
|
|
1056
1283
|
const usageStore = createUsageStore(call);
|
|
1057
1284
|
ctx.effect(() => () => { disposed = true; usageStore.dispose(); for (const [timer, reject] of pendingTimers) { clearTimeout(timer); reject(new Error("Client 已卸载")); } pendingTimers.clear(); }, "dsh-subusage: dispose shared store and RPC timers");
|
|
1058
1285
|
const readEntry = (id) => usageStore.reader(id);
|
|
1286
|
+
// Command Code 账户切换:settings namespace 由核心 remote 提供;提供方插件
|
|
1287
|
+
// 未运行时 describe 拿不到 llm-commandcode 行,账户区降级为不可用,不影响其他功能。
|
|
1288
|
+
let ccSettingsNamespace;
|
|
1289
|
+
const cc = createCommandCodeAccounts({
|
|
1290
|
+
describe: async () => ccSettingsNamespace ? await ccSettingsNamespace.describe() : { ok: false, error: { message: "settings remote namespace is not mounted" } },
|
|
1291
|
+
mutate: async (ops, revision) => ccSettingsNamespace ? await ccSettingsNamespace.mutate(COMMANDCODE_SETTINGS_NS, ops, revision) : { ok: false, error: { message: "settings remote namespace is not mounted" } }
|
|
1292
|
+
});
|
|
1293
|
+
ctx.inject(["remote.settings"], (settingsCtx) => { ccSettingsNamespace = settingsCtx.remote.settings; });
|
|
1294
|
+
ctx.effect(() => () => cc.dispose(), "dsh-subusage: commandcode account switch");
|
|
1059
1295
|
try {
|
|
1060
1296
|
ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "subusage", order: 22, label: () => t("nav"), inject: () => ({ usageStore, t, getLocale }) }, SubusageSection));
|
|
1061
1297
|
} catch (error) { console.error("[dsh-subusage] 设置页注册失败:", error); }
|
|
1062
1298
|
ctx.inject(["modelDirectories"], (scope) => {
|
|
1063
1299
|
try {
|
|
1064
1300
|
scope.slots.inject("conversation.input.right", () => scope.slots.register({ name: "conversation.input.right", id: "subusage-usage", order: 1001,
|
|
1065
|
-
inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale }) }, SubusagePillEntry));
|
|
1301
|
+
inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale, cc, setCommandCodeAccount: usageStore.setCommandCodeAccount }) }, SubusagePillEntry));
|
|
1066
1302
|
} catch (error) { console.error("[dsh-subusage] 药丸注册失败:", error); }
|
|
1067
1303
|
});
|
|
1068
1304
|
}
|
|
1069
1305
|
|
|
1070
1306
|
/** 槽包装:先看当前模型的 provider,再分发到对应药丸。 */
|
|
1071
|
-
function SubusagePillEntry({ store, readEntry, t, getLocale }) {
|
|
1307
|
+
function SubusagePillEntry({ store, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
|
|
1072
1308
|
const state = react.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
1073
1309
|
const provider = state.current && state.current.provider;
|
|
1074
1310
|
const info = PROVIDER_ORDER.includes(provider) ? PROVIDER_META[provider] : null;
|
|
@@ -1084,7 +1320,9 @@ window.__ModuleLoader__.load({
|
|
|
1084
1320
|
label: meta.label,
|
|
1085
1321
|
readEntry: reader,
|
|
1086
1322
|
t,
|
|
1087
|
-
getLocale
|
|
1323
|
+
getLocale,
|
|
1324
|
+
cc,
|
|
1325
|
+
setCommandCodeAccount
|
|
1088
1326
|
});
|
|
1089
1327
|
}
|
|
1090
1328
|
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-subusage Host:秘密仅保存在 Host,远端只暴露 provider 补丁和公开设置。
|
|
2
2
|
import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
|
-
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
3
|
+
import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
|
|
4
4
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
5
5
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
@@ -54,9 +54,13 @@ function commandCodeAuthFileKey(io) {
|
|
|
54
54
|
} catch { return undefined; }
|
|
55
55
|
}
|
|
56
56
|
const CONFIG_PATH = join(homedir(), ".dsh", "dsh-subusage.json");
|
|
57
|
+
// 官方 MiMo 会话 Cookie 自签发起 24 小时有效;从凭据写入时刻起计时,手动导入与自动登录同源。
|
|
58
|
+
const MIMO_COOKIE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
57
59
|
const MAX_BYTES = 1024 * 1024;
|
|
58
60
|
const TTL = 60000;
|
|
59
61
|
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
62
|
+
/** 计时元数据落库用毫秒、过 RPC 用 ISO:非正值一律视为未记录。 */
|
|
63
|
+
const isoStamp = (ms) => Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null;
|
|
60
64
|
function redactMimo(value, cookie) {
|
|
61
65
|
const sensitive = [cookie, ...cookie.split(";").map(part => { const trimmed = part.trim(); return trimmed.slice(trimmed.indexOf("=") + 1).replace(/^\"|\"$/g, ""); }).filter(Boolean)].filter(Boolean);
|
|
62
66
|
// 套餐名是未经结构约束的展示文本;余额/明细已严格限制为数值和货币代码。
|
|
@@ -65,13 +69,13 @@ function redactMimo(value, cookie) {
|
|
|
65
69
|
return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
|
|
66
70
|
}
|
|
67
71
|
function defaultSettings() {
|
|
68
|
-
return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "" }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
|
|
72
|
+
return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
|
|
69
73
|
}
|
|
70
74
|
function parseStored(value) {
|
|
71
75
|
const s = defaultSettings();
|
|
72
76
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw failure("subusage/config", "设置文件结构无效");
|
|
73
77
|
if (value.zai && typeof value.zai === "object") s.zai = { type: value.zai.type === 2 ? 2 : 1, organization: typeof value.zai.organization === "string" ? value.zai.organization : "", project: typeof value.zai.project === "string" ? value.zai.project : "" };
|
|
74
|
-
if (typeof value.xiaomi
|
|
78
|
+
if (value.xiaomi && typeof value.xiaomi === "object") { if (typeof value.xiaomi.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie; for (const field of ["loginAt", "expiresAt"]) if (Number.isSafeInteger(value.xiaomi[field]) && value.xiaomi[field] > 0) s.xiaomi[field] = value.xiaomi[field]; }
|
|
75
79
|
if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
|
|
76
80
|
for (const id of IDS) {
|
|
77
81
|
if (typeof value.visibility?.providers?.[id] === "boolean") s.visibility.providers[id] = value.visibility.providers[id];
|
|
@@ -82,7 +86,7 @@ function parseStored(value) {
|
|
|
82
86
|
return s;
|
|
83
87
|
}
|
|
84
88
|
function publicSettings(s) {
|
|
85
|
-
return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim() }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
|
|
89
|
+
return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
|
|
86
90
|
}
|
|
87
91
|
function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
|
|
88
92
|
function parseUpdate(update) {
|
|
@@ -129,15 +133,21 @@ function parseSettings(value) {
|
|
|
129
133
|
return out;
|
|
130
134
|
}
|
|
131
135
|
function parseQuery(value) {
|
|
136
|
+
// request 在 wire 层可选(read 描述符声明 acceptsUndefined):缺参/undefined/null
|
|
137
|
+
// = 全量默认账户查询,兼容旧 client bundle 的无参 read。
|
|
138
|
+
if (value === undefined || value === null) return { providerIds: [...IDS], force: false, commandCodeAccount: "" };
|
|
132
139
|
if (!value || !Array.isArray(value.providerIds) || value.providerIds.some(id => !IDS.includes(id)) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
|
|
133
|
-
|
|
140
|
+
// commandCodeAccount 是可选的显示账户选择:空串/缺省 = 默认账户(顶层 Key);
|
|
141
|
+
// 额外账户 id 即其凭据引用名(apiKeyEnv),由 identity 按引用解析。
|
|
142
|
+
if (value.commandCodeAccount !== undefined && typeof value.commandCodeAccount !== "string") throw invalid("Invalid account selection");
|
|
143
|
+
return { providerIds: [...new Set(value.providerIds)], force: value.force, commandCodeAccount: typeof value.commandCodeAccount === "string" ? value.commandCodeAccount.trim() : "" };
|
|
134
144
|
}
|
|
135
145
|
function failure(code, message, retryable = false, retryAfterMs) {
|
|
136
146
|
return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
137
147
|
}
|
|
138
148
|
async function fetchJson(fetcher, url, headers, lifetimeSignal) {
|
|
139
149
|
let response;
|
|
140
|
-
try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.
|
|
150
|
+
try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.8.2", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
|
|
141
151
|
catch { throw failure("subusage/network", "网络请求失败或超时", true); }
|
|
142
152
|
if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
|
|
143
153
|
if (!response.ok) {
|
|
@@ -337,7 +347,7 @@ const loginCodec = codec("SubUsageLoginState", value => {
|
|
|
337
347
|
export const subUsageRemote = {
|
|
338
348
|
package: name,
|
|
339
349
|
descriptors: [
|
|
340
|
-
{ id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [], result: resultCodec },
|
|
350
|
+
{ id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: codec("SubUsageQuery", parseQuery) }], result: resultCodec },
|
|
341
351
|
...[["refresh", "request", "SubUsageQuery", parseQuery], ["save", "settings", "SubUsageSettings", parseSettings]].map(([method, param, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: param, wire: param, source: "json", codec: codec(type, parse) }], result: resultCodec })),
|
|
342
352
|
{ id: `${name}#subUsage/getMimoLoginStatus`, service: "subUsage", namespace: "subUsage", method: "getMimoLoginStatus", invocation: { kind: "direct" }, parameters: [], result: loginCodec },
|
|
343
353
|
...[["startMimoLogin", "MimoLoginStart", parseLoginStart], ["cancelMimoLogin", "MimoLoginCancel", parseLoginCancel]].map(([method, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", codec: codec(type, parse) }], result: loginCodec }))
|
|
@@ -353,13 +363,17 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
353
363
|
this.now = options.now ?? Date.now;
|
|
354
364
|
this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
|
|
355
365
|
this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
|
|
366
|
+
// 额外账户按凭据引用名(apiKeyEnv)解析,与提供方插件 slots()/resolveRef 同一通道;
|
|
367
|
+
// 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
|
|
368
|
+
this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
|
|
369
|
+
this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
|
|
356
370
|
this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
|
|
357
371
|
this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
|
|
358
372
|
this.login = new MimoLogin({
|
|
359
373
|
error: failure,
|
|
360
374
|
capture: revision => { const s = this.load(); if (revision !== hash(s)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取"); return this.mimoMaterial(s); },
|
|
361
375
|
verify: (cookie, signal) => { parseSettings({ providerId: "xiaomi-token-plan-cn", cookieUpdate: { action: "replace", value: cookie } }); return this.fetchProvider("xiaomi-token-plan-cn", { key: cookie, source: "cookie" }, this.load(), signal); },
|
|
362
|
-
commit: (cookie, captured, verified) => this.commitMimoLogin(cookie, captured, verified),
|
|
376
|
+
commit: (cookie, captured, verified, observedExpiresAt) => this.commitMimoLogin(cookie, captured, verified, observedExpiresAt),
|
|
363
377
|
reconcileResult: result => {
|
|
364
378
|
const latest = this.load();
|
|
365
379
|
if (this.disposed || this.mimoMaterial(latest) !== this.loginResultMaterial) return null;
|
|
@@ -375,9 +389,19 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
375
389
|
catch (e) { if (e?.code === "ENOENT") return defaultSettings(); throw failure("subusage/config", "无法读取订阅设置"); }
|
|
376
390
|
}
|
|
377
391
|
result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
|
|
378
|
-
async identity(id, s) {
|
|
392
|
+
async identity(id, s, account) {
|
|
379
393
|
if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
|
|
380
394
|
if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
|
|
395
|
+
if (id === "commandcode" && account && account !== "default") {
|
|
396
|
+
// 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
|
|
397
|
+
// 插件的 slots,但 client 输入不可信)按未配置处理,不让 credentialRef 抛错。
|
|
398
|
+
if (!isCredentialRefName(account)) return { key: undefined, source: "none" };
|
|
399
|
+
const hit = await this.accountCredentials(account);
|
|
400
|
+
if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
|
|
401
|
+
const env = await this.accountEnvironment(account);
|
|
402
|
+
if (typeof env?.value === "string" && env.value.trim()) return { key: env.value.trim(), source: "env" };
|
|
403
|
+
return { key: undefined, source: "none" };
|
|
404
|
+
}
|
|
381
405
|
const hit = await this.credentials(id);
|
|
382
406
|
if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
|
|
383
407
|
const env = await this.environment(id);
|
|
@@ -428,19 +452,27 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
428
452
|
if (usageError) data.usageError = usageError;
|
|
429
453
|
return data;
|
|
430
454
|
}
|
|
431
|
-
base(id, source
|
|
432
|
-
|
|
455
|
+
base(id, source, settings, account = "") {
|
|
456
|
+
// MiMo 会话 Cookie 自签发起 24 小时有效;到期时间随每条 entry 下发,供药丸与设置页倒计时。
|
|
457
|
+
const expiresAt = id === "xiaomi-token-plan-cn" ? isoStamp(settings?.xiaomi?.expiresAt) : null;
|
|
458
|
+
// commandcode 记录本条用量所属账户(default 或额外账户引用名),供弹层标注与测试核对。
|
|
459
|
+
const shown = id === "commandcode" ? { account: account !== "" ? account : "default" } : {};
|
|
460
|
+
return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false, ...shown, ...(expiresAt ? { cookieExpiresAt: expiresAt } : {}) };
|
|
461
|
+
}
|
|
462
|
+
async provider(id, settings, force, account = "") {
|
|
433
463
|
const startedGeneration = this.generations.get(id) ?? 0;
|
|
434
464
|
let identity;
|
|
435
|
-
try { identity = await this.identity(id, settings); }
|
|
436
|
-
catch { this.cache.delete(id); return { ...this.base(id, "none"), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
|
|
465
|
+
try { identity = await this.identity(id, settings, account); }
|
|
466
|
+
catch { this.cache.delete(id); return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
|
|
437
467
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
438
|
-
if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force);
|
|
468
|
+
if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force, account);
|
|
439
469
|
if (!settings.visibility.providers[id]) {
|
|
440
470
|
this.cache.delete(id); this.inflight.delete(id);
|
|
441
|
-
return { ...this.base(id, identity.source), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
471
|
+
return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
442
472
|
}
|
|
443
|
-
|
|
473
|
+
// fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
|
|
474
|
+
// 空串与 "default" 同为默认账户,归一后共享缓存。
|
|
475
|
+
const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
|
|
444
476
|
if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
|
|
445
477
|
this.fingerprints.set(id, fingerprint);
|
|
446
478
|
const generation = this.generations.get(id) ?? 0;
|
|
@@ -453,8 +485,8 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
453
485
|
const resetDue = previous?.entry.windows?.some(w => Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > previous.at && Date.parse(w.resetsAt) <= now);
|
|
454
486
|
if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
|
|
455
487
|
const promise = (async () => {
|
|
456
|
-
const base = this.base(id, identity.source); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
|
|
457
|
-
if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)" : "未配置 API Key", errorCode: "subusage/no-key" };
|
|
488
|
+
const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
|
|
489
|
+
if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? (account && account !== "default" ? `未检测到账户 ${account} 的 Key:确认提供方插件已保存该账户的凭据(凭据服务或启动环境中无引用 ${account})` : "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)") : "未配置 API Key", errorCode: "subusage/no-key" };
|
|
458
490
|
else try {
|
|
459
491
|
const data = await this.fetchProvider(id, identity, settings);
|
|
460
492
|
const usageError = data.usageError; delete data.usageError;
|
|
@@ -470,41 +502,41 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
470
502
|
if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
|
|
471
503
|
}
|
|
472
504
|
if (!this.disposed && generation === (this.generations.get(id) ?? 0)) this.cache.set(id, { fingerprint, entry, at: this.now(), retryAt, failures });
|
|
473
|
-
if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
|
|
505
|
+
if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
|
|
474
506
|
return entry;
|
|
475
507
|
})();
|
|
476
508
|
this.inflight.set(id, { fingerprint, generation, promise });
|
|
477
509
|
try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
|
|
478
510
|
}
|
|
479
|
-
async read() { return this.refresh({ providerIds: IDS, force: false }); }
|
|
511
|
+
async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
|
|
480
512
|
async refresh(request) {
|
|
481
513
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
482
514
|
const query = parseQuery(request); const settings = this.load();
|
|
483
|
-
const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force).catch(() => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
|
|
515
|
+
const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force, query.commandCodeAccount).catch(() => ({ ...this.base(id, "none", settings, query.commandCodeAccount), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
|
|
484
516
|
const latest = this.load();
|
|
485
|
-
if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
|
|
517
|
+
if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none", latest, query.commandCodeAccount), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
|
|
486
518
|
return this.result(latest, entries);
|
|
487
519
|
}
|
|
488
520
|
startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
|
|
489
521
|
getMimoLoginStatus() { return this.login.status(); }
|
|
490
522
|
cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
|
|
491
523
|
mimoMaterial(s) { const id = "xiaomi-token-plan-cn"; return hash([s.xiaomi.cookie, s.keys[id] ?? "", s.keyModes[id]]); }
|
|
492
|
-
commitMimoLogin(cookie, captured, verified) {
|
|
524
|
+
commitMimoLogin(cookie, captured, verified, observedExpiresAt) {
|
|
493
525
|
const id = "xiaomi-token-plan-cn", s = this.load();
|
|
494
526
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
495
527
|
if (this.mimoMaterial(s) !== captured) throw failure("subusage/revision-conflict", "MiMo 设置已被修改,拒绝覆盖");
|
|
496
|
-
this.persist({ providerId: id, expectedRevision: hash(s), cookieUpdate: { action: "replace", value: cookie } });
|
|
528
|
+
this.persist({ providerId: id, expectedRevision: hash(s), cookieUpdate: { action: "replace", value: cookie } }, observedExpiresAt);
|
|
497
529
|
const stored = this.load(); const data = { ...verified }; const usageError = data.usageError; delete data.usageError;
|
|
498
|
-
const entry = { ...this.base(id, "cookie"), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
|
|
530
|
+
const entry = { ...this.base(id, "cookie", stored), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
|
|
499
531
|
// 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
|
|
500
532
|
const safeEntry = redactMimo(entry, cookie);
|
|
501
|
-
const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null]);
|
|
533
|
+
const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null, null]);
|
|
502
534
|
this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
|
|
503
535
|
this.loginResultMaterial = this.mimoMaterial(stored);
|
|
504
536
|
return this.result(stored, [safeEntry]);
|
|
505
537
|
}
|
|
506
538
|
async save(settings) { return this.persist(settings); }
|
|
507
|
-
persist(settings) {
|
|
539
|
+
persist(settings, cookieCap) {
|
|
508
540
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
509
541
|
const patch = parseSettings(settings); const stored = this.load();
|
|
510
542
|
if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
|
|
@@ -517,7 +549,14 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
517
549
|
if (patch.keyMode !== undefined) stored.keyModes[id] = patch.keyMode;
|
|
518
550
|
const update = (obj, key, op) => { if (op?.action === "replace") obj[key] = op.value; else if (op?.action === "clear") delete obj[key]; };
|
|
519
551
|
update(stored.keys, id, patch.keyUpdate);
|
|
520
|
-
if (patch.cookieUpdate?.action === "clear") stored.xiaomi.cookie = "";
|
|
552
|
+
if (patch.cookieUpdate?.action === "clear") { stored.xiaomi.cookie = ""; stored.xiaomi.loginAt = 0; stored.xiaomi.expiresAt = 0; } else if (patch.cookieUpdate?.action === "replace") {
|
|
553
|
+
// 官方会话 Cookie 自签发起 24 小时有效;浏览器报告了更早的过期时间时以更早者为准。
|
|
554
|
+
// 计时只在真正写入新 Cookie 时起算:keep 是“未改动”,不能借保存动作续满倒计时。
|
|
555
|
+
update(stored.xiaomi, "cookie", patch.cookieUpdate);
|
|
556
|
+
const at = this.now();
|
|
557
|
+
stored.xiaomi.loginAt = at;
|
|
558
|
+
stored.xiaomi.expiresAt = Math.min(at + MIMO_COOKIE_TTL_MS, Number.isSafeInteger(cookieCap) && cookieCap > at ? cookieCap : Number.POSITIVE_INFINITY);
|
|
559
|
+
}
|
|
521
560
|
if (patch.zai) stored.zai = patch.zai;
|
|
522
561
|
try { this.io.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); this.io.writeFileSync(`${this.path}.tmp`, JSON.stringify(stored, null, 2), { encoding: "utf8", mode: 0o600 }); this.io.chmodSync(`${this.path}.tmp`, 0o600); this.io.renameSync(`${this.path}.tmp`, this.path); }
|
|
523
562
|
catch { throw failure("subusage/config", "无法保存订阅设置"); }
|
package/lib/mimo-login.js
CHANGED
|
@@ -5,7 +5,8 @@ export const MIMO_API_URLS = ["balance", "tokenPlan/detail", "tokenPlan/usage"].
|
|
|
5
5
|
const ACTIVE = new Set(["launching", "waiting", "verifying"]);
|
|
6
6
|
const REQUIRED = ["api-platform_serviceToken", "userId"];
|
|
7
7
|
// Playwright 已按 URL 过滤;再防御性校验每一个必需 Cookie 对全部验证 URL 有效。
|
|
8
|
-
|
|
8
|
+
// 除 Cookie 串外一并给出平台声明的过期时刻(毫秒);会话 Cookie 无 expires,返回 0 由 24 小时上限兜底。
|
|
9
|
+
export function extractMimoSession(cookies, now = Date.now()) {
|
|
9
10
|
const selected = REQUIRED.map(name => cookies.filter(c => c.name === name && typeof c.value === "string" && c.value && (c.expires === undefined || c.expires === -1 || c.expires * 1000 > now) && MIMO_API_URLS.every(raw => {
|
|
10
11
|
const u = new URL(raw); const domain = (c.domain ?? "").replace(/^\./, "").toLowerCase();
|
|
11
12
|
const allowed = ["platform.xiaomimimo.com", "xiaomimimo.com"].includes(domain);
|
|
@@ -14,8 +15,10 @@ export function extractMimoCookie(cookies, now = Date.now()) {
|
|
|
14
15
|
return domainOK && (u.pathname === path || u.pathname.startsWith(path.endsWith("/") ? path : `${path}/`));
|
|
15
16
|
})));
|
|
16
17
|
if (selected.some(rows => rows.length !== 1)) return null;
|
|
17
|
-
|
|
18
|
+
const declared = selected.map(rows => rows[0].expires).filter(expires => Number.isFinite(expires) && expires > 0);
|
|
19
|
+
return { cookie: selected.map(rows => `${rows[0].name}=${rows[0].value}`).join("; "), expiresAt: declared.length ? Math.min(...declared) * 1000 : 0 };
|
|
18
20
|
}
|
|
21
|
+
export const extractMimoCookie = (cookies, now = Date.now()) => extractMimoSession(cookies, now)?.cookie ?? null;
|
|
19
22
|
const sleep = (ms, signal) => new Promise(resolve => {
|
|
20
23
|
if (signal.aborted) return resolve();
|
|
21
24
|
const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
|
|
@@ -87,19 +90,19 @@ export class MimoLogin {
|
|
|
87
90
|
phase = "verify";
|
|
88
91
|
let nextAttempt = 0, failures = 0;
|
|
89
92
|
while (this.alive(job)) {
|
|
90
|
-
const
|
|
93
|
+
const session = extractMimoSession(await context.cookies(MIMO_API_URLS), this.now());
|
|
91
94
|
if (!this.alive(job)) return;
|
|
92
|
-
if (
|
|
95
|
+
if (session && this.now() >= nextAttempt) {
|
|
93
96
|
job.public.state = "verifying"; job.public.message = "正在验证登录和订阅额度(尚未保存)";
|
|
94
97
|
try {
|
|
95
|
-
const verified = await this.callbacks.verify(cookie, job.controller.signal);
|
|
98
|
+
const verified = await this.callbacks.verify(session.cookie, job.controller.signal);
|
|
96
99
|
if (!this.alive(job)) return;
|
|
97
100
|
if (verified.usageError || !verified.balanceOk) {
|
|
98
101
|
job.public.state = "waiting"; job.public.message = "已读取账户,但订阅额度尚未完整验证;尚未保存,请确认套餐或继续登录";
|
|
99
102
|
nextAttempt = this.now() + 15000;
|
|
100
103
|
} else {
|
|
101
104
|
// commit 必须同步检查冲突并写入,不能在取消后写入。
|
|
102
|
-
const result = this.callbacks.commit(cookie, job.captured, verified);
|
|
105
|
+
const result = this.callbacks.commit(session.cookie, job.captured, verified, session.expiresAt);
|
|
103
106
|
this.finish(job, "success", undefined, verified.coverage === "complete" && verified.windows?.length ? "登录及订阅额度已验证,Cookie 已保存" : "登录已保存;仅余额可读,订阅额度未知", result); return;
|
|
104
107
|
}
|
|
105
108
|
} catch (e) {
|