dsh-connect-minimaxcode 0.2.3 → 0.4.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.md CHANGED
@@ -41,9 +41,11 @@
41
41
 
42
42
  - **零配置**:自动读取 MiniMax Code 桌面 App 的登录态,不在 DSH 中存储任何明文 Token
43
43
  - **动态模型目录**:从上游接口实时拉取,模型增减无需更新插件
44
+ - **积分余额可见**:在对话框底部的胶囊里显示 MiniMax Code 的剩余积分,以及本机累计消耗
45
+ - **实时消耗统计**:每次请求的 token 用量取自上游响应本身,可按官方价目表折算等效金额
44
46
  - **复用官方适配器**:上游为标准 Anthropic Messages 协议,直接复用 DSH 自带 provider,工具调用、推理块、上下文压缩均由 DSH 托管
45
- - **只读**:插件不写入、不修改 MiniMax 的任何文件,也不执行任何登录或续期动作
46
- - **不含客户端 UI**:不注册浏览器侧插件,因此不会影响 DSH 的启动(见「已知限制」)
47
+ - **只读为主**:除「续期已过期的桌面 App 登录态」外,不写入、不修改 MiniMax 的任何文件,也不执行任何登录动作
48
+ - **界面不打扰**:胶囊只在输入框下方的 dock 里占一行,不改动 DSH 的其它界面
47
49
 
48
50
  ## 前置条件
49
51
 
@@ -70,6 +72,8 @@ Web profile 把 `--profile desktop` 换成 `--profile web`。
70
72
 
71
73
  安装后**无需重启**,插件会热加载;模型随后出现在模型选择器的 **MiniMax Code** 分组中。
72
74
 
75
+ > 界面胶囊(积分余额)属于浏览器侧插件,**首次安装或升级到 0.3.0 后需要重启一次 DSH** 才会出现在输入框下方。
76
+
73
77
  ### 若安装被 `allowBuilds` 拦截
74
78
 
75
79
  本仓库把构建产物 `lib/` 一并提交,因此正常情况下安装不会触发构建。若你看到:
@@ -97,15 +101,24 @@ Web profile 把 `--profile desktop` 换成 `--profile web`。
97
101
  直接使用即可。工具调用由 DSH 本地工具执行,插件只负责提供模型通路。
98
102
 
99
103
  5. **令牌过期时重新登录**
100
- MiniMax Code 的登录令牌为短期 JWT(通常约 15 天),本插件**不会自动续期**。过期后:
104
+ MiniMax Code 有**两份互不通用**的登录态,本插件各用其一:
105
+
106
+ | 凭证 | 位置 | 有效期 | 用途 |
107
+ |---|---|---|---|
108
+ | JWT | `~/.minimax/local-runtime.auth.json` | 约 15 天 | 调模型网关(`/mavis/api/v1/llm/v1/messages`) |
109
+ | OAuth(`mmoat_`) | `~/.minimax/auth/prod/<region>/mcode-public/auth.json` | 约 1 小时 | 读账户与积分(`/matrix/api/v1/commerce/get_membership_info`) |
110
+
111
+ 两份凭证互不通用:拿 JWT 打账户接口一律 `401`,拿 OAuth 令牌打模型网关同样不通。**模型对话**用 JWT,**积分显示**用 OAuth。
101
112
 
102
113
  | 现象 | 含义 | 处理方式 |
103
114
  |---|---|---|
104
- | 正常使用 | 令牌有效 | — |
105
- | 剩余不足 24 小时 | 令牌即将失效 | 打开一次 MiniMax Code 桌面 App 刷新 |
106
- | 请求返回鉴权错误 | 令牌已失效 | 在 MiniMax Code 中**重新登录**,然后重启 DSH |
115
+ | 正常使用 | 两份凭证都有效 | — |
116
+ | 模型报鉴权错误 | JWT 已失效 | 在 MiniMax Code 中**重新登录**,然后重启 DSH |
117
+ | 积分胶囊显示「登录态已过期」 | OAuth 令牌过期 | 插件会用 App 自己的 refresh token 续期并写回;若续期失败,请打开一次 MiniMax Code 桌面 App 让它自行续期 |
107
118
 
108
- > 令牌过期不会丢失数据。在 MiniMax Code 桌面 App 重新登录后,它会自动写入新的令牌,插件下次启动即可读到。
119
+ > OAuth 令牌过期**不会**影响模型对话:JWT 仍然有效,模型照常可用,只是积分胶囊暂时拿不到余额。
120
+ >
121
+ > 续期会**轮换** refresh token。插件因此把新的一对令牌按客户端**原格式原子写回**同一个文件,并保持 `generation` 不变——客户端靠它判断「这份凭据是否仍属于本次登录」,擅自抬高会让桌面 App 误判为异地登录。
109
122
 
110
123
  ### 关于响应速度
111
124
 
@@ -122,6 +135,8 @@ MiniMax 的这些模型**强制要求先推理再输出**,思考无法关闭
122
135
  1. 读取 `~/.minimax/local-runtime.auth.json`(MiniMax Code 写入的明文登录信息),**仅在内存中**解析其 JWT
123
136
  2. 调用 `GET /mavis/api/v1/models` 获取实时模型目录(含上下文长度、多模态、工具调用等能力声明)
124
137
  3. 注册 `minimaxcode` provider,指向 `POST /mavis/api/v1/llm/v1/messages`(标准 Anthropic Messages 协议)
138
+ 4. 读取 `~/.minimax/auth/prod/<region>/mcode-public/auth.json` 的 OAuth 令牌,按需续期
139
+ 5. 调用 `POST /matrix/api/v1/commerce/get_membership_info` 获取积分余额,通过插件自己的状态接口 `/plugins/dsh-connect-minimaxcode/status` 交给界面胶囊
125
140
 
126
141
  ### 与网关对接时的三个兼容处理
127
142
 
@@ -135,45 +150,114 @@ MiniMax 的这些模型**强制要求先推理再输出**,思考无法关闭
135
150
 
136
151
  插件**不**包含任何硬编码的 Token、账号或接口密钥。
137
152
 
138
- ## 为什么没有额度显示
153
+ ## 积分余额与用量
154
+
155
+ 对话框输入框下方的胶囊会显示 MiniMax Code 的**剩余积分**;点开还能看到赠送/购买拆分、两次读取之间的消耗,以及本机累计的 token 与等效金额。
139
156
 
140
- 「显示剩余额度」这个功能被实测排除过,结论写在这里以免后来者重复调查。
157
+ ### 余额从哪里来
141
158
 
142
- **额度数据是真实存在的**。逆向 MiniMax Code 客户端可确认它使用了这样一组字段:
159
+ MiniMax Code 桌面 App 自己每天在调一个「会员信息」接口,响应里就带着客户端设置页显示的那三个数字:
143
160
 
144
161
  ```
145
- op_credit_summary: {
146
- total_remaining_amount, // 总剩余额度
147
- purchased_remaining_amount, // 购买额度剩余
148
- free_remaining_amount // 免费额度剩余
162
+ POST https://agent.minimax.cn/matrix/api/v1/commerce/get_membership_info
163
+ Authorization: Bearer <桌面 App 的 mmoat_ OAuth 令牌>
164
+ Content-Type: application/json
165
+
166
+ {}
167
+ ```
168
+
169
+ ```json
170
+ "op_credit_summary": {
171
+ "total_remaining_amount": "11459.454", // 总剩余积分
172
+ "purchased_remaining_amount": "0", // 其中购买的
173
+ "free_remaining_amount": "11459.454" // 其中赠送的
149
174
  }
150
175
  ```
151
176
 
152
- **但找不到为它服务的接口**。已验证的部分:
177
+ 插件读的就是 `op_credit_summary.total_remaining_amount`。**注意别取错字段**:同一个响应里的 `total_remains_credit` 与 `opcredit_balance` 是旧账,在已迁移到积分池的账号上恒为 `0`,取它们会显示「0 积分」。
178
+
179
+ 顺带一提,这里有三个**单位不同**的「钱」,别混:
180
+
181
+ | 数值 | 含义 | 本插件是否显示 |
182
+ |---|---|---|
183
+ | `op_credit_summary.total_remaining_amount` | Code 积分余额 | ✅ 就是它 |
184
+ | `token_plan_credit.remaining_credits` | 已购积分池 | ❌ |
185
+ | `query_balance.available_amount` | 开放平台钱包(元) | ❌ |
186
+
187
+ ### 积分会过期吗?会
188
+
189
+ 积分不是一池子,而是**按笔发放的批次**,每批有自己的到期日。客户端每天发一批,每批大约在**发放后 29 天**到期,于是形成一条逐日错开的 30 天阶梯:
190
+
191
+ ```
192
+ GET https://agent.minimax.cn/minimax-cloud/api/v1/credit/details?page=1&page_size=100
193
+ → details[] { granted_amount, remaining_amount, consumed_amount, granted_at_ms, expire_at_ms }
194
+ ```
195
+
196
+ 实测本机 23 笔:09-20 发的在 10-19 到期,09-21 发的在 10-20 到期……另有一笔 08-25 发的在 **2027-08-25** 到期(一年期批次)。
153
197
 
154
- | 尝试 | 结果 |
155
- |---|---|
156
- | `GET /v1/api/user/info` + 模型网关令牌 | `401`,两类凭证互不通用 |
157
- | `GET /v1/api/user/info` + 客户端 OAuth 令牌(`mmoat_`) | `200`,但响应只有昵称、头像、`vipInfo` 等账户资料,**不含任何额度字段** |
158
- | 14 个候选额度路径(`user/credits`、`user/quota`、`workspace/list`、`credit/summary` 等) | 全部 `404` |
159
- | `account.minimax.cn` 同一批路径 | 同样无额度字段 |
198
+ 插件据此在**插件页面**(设置 → 插件 → DSH Connect MiniMax Code)与输入框下方的胶囊里给出:
160
199
 
161
- 上述额度字段只出现在客户端**已混淆的前端 bundle** 中,属于渲染逻辑;真正提供数据的接口路径未能定位。实测所用的 OAuth 凭证有效期约 1 小时,由客户端自动续期,不适合作为插件的长期依赖。
200
+ - **7 天内到期**的剩余积分,以及最近一笔的到期日
201
+ - **30 天内到期**的剩余积分
202
+ - 接下来几笔**到期批次**的清单(日期 + 金额)
203
+ - 余额条与本机消耗(token 数、等效金额)
162
204
 
163
- 因此本插件不显示额度。若你已知可用路径,欢迎提Issue 或 PR;额度请直接在 MiniMax Code 客户端中查看。
205
+ 只统计**仍有余额**的批次:已发放但用光的批次到期时并没有东西可损失,把它算进去会让每个账号都看起来"快过期了"。
206
+
207
+ > 台账的 `remaining_amount` 合计与余额接口的 `total_remaining_amount` 会**略有出入**(实测差 0.006,浮点累加所致;两次采样之间余额本身也会变)。插件以**余额接口**显示总额,台账只用来回答"什么时候过期"。
208
+
209
+ ### 消耗怎么算
210
+
211
+ 两路数据各管一件事:
212
+
213
+ - **真实积分消耗**:余额是个活值,两次读取的差值就是这段时间真正花掉的积分(客户端也会并发消耗,所以差值含其它客户端的开销)。
214
+ - **本机消耗**:每次请求的响应都自带 `usage`(`input_tokens` / `output_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens`),插件原样累计。折算金额时**只用这四个分量自己算**,不能用任何 `total_token` 汇总值——上游的两级汇总口径不同(差 1.94 倍,差额正是缓存读取部分)。单价取自 [MiniMax 官方价目表](https://platform.minimax.cn/docs/pricing/overview):
215
+
216
+ | 模型 | 输入 | 输出 | 缓存读取 |
217
+ |---|---|---|---|
218
+ | `MiniMax-M3.1-Flash` | 1.00 元/百万 tokens | 4.00 | 0.10 |
219
+ | `MiniMax-M3`(≤512k 输入) | 2.10 | 8.40 | 0.42 |
220
+ | `MiniMax-M3`(>512k 输入) | 4.20 | 16.80 | 0.84 |
221
+
222
+ 折算出的金额是**等效金额**,不是扣费:积分与金额之间 MiniMax 没有公开换算关系(套餐只公布了 1×/3×/12× 的相对倍数),所以插件不会把它说成「花了多少积分」。
223
+
224
+ > 官方价目表没有单列**缓存写入**(`cache_creation_input_tokens`)的单价,因此插件只统计它的 token 数、**不折算金额**,以免给出一个没有公开依据的数字。
225
+
226
+ ### 关于续期
227
+
228
+ OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自己的令牌已过期时**才用 App 已存的 refresh token 续一次,并把轮换后的新令牌按客户端**原格式原子写回**同一个文件(`generation` 保持不变),因此桌面 App 的登录态不受影响。插件不会发起登录、不会登出、也不会调用任何付费/订阅类接口。
229
+
230
+ > 这一节的结论与 0.2.x 的「无法显示额度」相反。当时的调查按 `credits` / `quota` / `summary` 这类英文语义猜路径,真实接口用的是 `commerce` / `membership` 这套计费域词汇,因此全部落空;后来是从桌面 App 自己的运行日志(`%APPDATA%\MiniMax\logs\api-*.log`)里读出它每天真实调用的 URL,才定位到这条路径。
164
231
 
165
232
  ## 已知限制
166
233
 
167
234
  - **仅支持国内版(`agent.minimax.cn`)**。国际版网关是否可用**未经验证**,请勿据此推断其他区域可用。
168
235
  - **必须开启推理**。MiniMax 的全部模型都声明需要推理,关不掉。首字延迟因此高于非推理模型。
169
236
  - **未登录时模型列表是内置兜底**。此时选择器中仍会显示 `M3`、`M3.1-Flash` 两个内置条目,但它们无法真正调用;登录后才会切换为上游实时目录。判断是否可用,请以实际调用结果为准。
170
- - **不提供余额/用量显示**。这一项经过实测后确认无法实现,原因记录在下方「为什么没有额度显示」中。
171
- - **不自动续期令牌**,请参照上方「令牌有效期」。
172
- - **不提供设置界面卡片**。本插件为纯宿主侧插件,不在 DSH 设置页注册任何卡片,因此不会参与浏览器端的启动流程(这同时也是它不会导致 DSH 启动失败的原因)。
173
- - **上游接口非公开**,MiniMax 客户端更新后可能失效。
237
+ - **积分只覆盖 MiniMax Code 的赠送/购买积分**,不包含开放平台钱包余额与已购积分池,插件也不会把金额折算成积分。
238
+ - **余额是账户级数值**,两次读取的差值包含 MiniMax Code 其它客户端的并发消耗;本机单独的消耗看胶囊里的 token 数与等效金额。
239
+ - **等效金额是按公开价目表折算的估值**,不是实际扣费;官方未公开 token 与积分的换算关系。
240
+ - **OAuth 令牌过期时积分胶囊会暂时失效**,需打开一次 MiniMax Code 桌面 App 让它自行续期(或等待插件续期成功)。这不影响模型对话。
241
+ - **不提供设置界面卡片**。插件不在 DSH 设置页注册卡片;界面只增加输入框下方的一枚胶囊。
242
+ - **上游接口非公开**,MiniMax 客户端更新后可能失效。余额接口失效时胶囊会显示「积分不可用」,不会影响模型对话。
174
243
 
175
244
  ## 更新记录
176
245
 
246
+ ### 0.4.0(2026-10-19)
247
+
248
+ - **新增「积分即将过期」**:积分是按笔发放、每批独立到期的(发放后约 29 天)。插件读取积分台账 `GET /minimax-cloud/api/v1/credit/details`,在**插件页面**与胶囊里显示 7 天内到期、30 天内到期、最近一笔到期日与到期批次清单。
249
+ - **只统计仍有余额的批次**,避免把「已用光但快到期」的批次算成损失。
250
+ - **插件页面卡片**:在「设置 → 插件 → DSH Connect MiniMax Code」新增一张卡片(余额、赠送/购买拆分、到期分布、到期批次、本机消耗与等效金额),列表里则显示一行摘要。
251
+ - 胶囊与卡片共用一个状态存储与同一个轮询器,多个界面不会重复请求账户接口。
252
+
253
+ ### 0.3.0(2026-10-19)
254
+
255
+ - **新增积分余额显示**:对话框输入框下方的胶囊显示 MiniMax Code 的剩余积分(赠送/购买拆分),点开可看两次读取之间的消耗、本机累计 token 与按官方价目表折算的等效金额。
256
+ - **新增用量计量**:每次请求的 `usage` 取自上游响应本身,累计到本机并折算金额。
257
+ - **新增账户接口定位**:找到桌面 App 每天真实调用的 `POST /matrix/api/v1/commerce/get_membership_info`,并**推翻了 0.2.x 「无法显示额度」的结论**——原因见「积分余额与用量」末尾。
258
+ - **令牌策略调整**:模型网关用 JWT、账户接口用桌面 App 的 OAuth 令牌;后者过期时插件会用 App 已存的 refresh token 续期,并按客户端原格式写回,不影响桌面 App 登录态。
259
+ - **首次带浏览器侧插件**:新增 `lib/client.js`(classic script),只注册一枚 composer dock 胶囊。**升级后需重启一次 DSH**。
260
+
177
261
  ### 0.2.3(2026-10-19)
178
262
 
179
263
  - **同步上游模型变动**:MiniMax 下架了 `M2.7`、`M2.7-highspeed` 两个旧模型;此前以匿名形式灰度的测试模型(代号 "space bunny" / 太空兔)正式发布为 **`M3.1-Flash`**。当前上游实时目录为 `M3`、`M3.1-Flash`。
@@ -198,6 +282,8 @@ pnpm build # 构建
198
282
 
199
283
  构建依赖 `@earendil-works/pi-ai`,需与宿主中 `dsh-llm-pi-ai` 解析出的版本一致(当前为 `0.85.x`),否则类型不兼容。
200
284
 
285
+ 界面半边(`src/client.ts`)由 `tsconfig.client.json` 单独编译成 **classic script**(`lib/client.js`):宿主以 `<script>` 方式加载客户端 bundle,若产出 ESM 会直接导致 Web 启动失败(`Cannot use import statement outside a module`),所以这里刻意不用打包器、也不自带 React——React 来自宿主的浏览器模块表。
286
+
201
287
  **`lib/` 是纳入版本管理的构建产物**,不是生成物。这是刻意为之:git 源安装拿到的是仓库原样内容,若不含 `lib/`,pnpm 会判定该包"需要构建"并触发 `prepare`,随即被构建脚本白名单拦下。改动源码后请重新构建并提交:
202
288
 
203
289
  ```bash
package/lib/client.js ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ /**
3
+ * The browser half: the credit balance, in the composer and on the page.
4
+ *
5
+ * The host loads a client bundle as a classic script and calls
6
+ * `window.__ModuleLoader__.load`, handing the factory a `require` that
7
+ * resolves against the browser's own module table. React therefore comes from
8
+ * the host — this file installs no React of its own, and ships no bundler
9
+ * output: it is a script, so it can be dropped into the page as-is.
10
+ *
11
+ * Two surfaces read the same data: a pill in the composer's dock, and a card
12
+ * on the plugin's own page. Both read it through one store with one ticker,
13
+ * so the account API is asked once per interval no matter how many surfaces
14
+ * are on screen.
15
+ */
16
+ ;
17
+ (function bootstrapMinimaxCodeClient() {
18
+ const BUNDLE_NAME = 'dsh-connect-minimaxcode';
19
+ const NAMESPACE = 'dsh-connect-minimaxcode';
20
+ const STATUS_PATH = '/plugins/dsh-connect-minimaxcode/status';
21
+ const POLL_INTERVAL_MS = 20000;
22
+ const zh = {
23
+ title: 'MiniMax Code 积分',
24
+ balance: '积分 {total}',
25
+ free: '赠送积分',
26
+ purchased: '购买积分',
27
+ consumed: '上次读取后消耗',
28
+ tokens: '本机累计 {count} tokens',
29
+ cost: '等效金额 ≈ ¥{amount}',
30
+ requests: '{count} 次请求',
31
+ updated: '更新于 {time}',
32
+ unavailable: '积分不可用',
33
+ expired: '登录态已过期',
34
+ signedOut: '未登录 MiniMax Code',
35
+ expiring: '即将过期',
36
+ expiringIn: '{amount} 积分 · {date} 到期',
37
+ expiringNone: '7 天内没有积分到期',
38
+ expiringSoon: '30 天内到期 {amount} 积分',
39
+ nextExpiry: '最近一笔到期',
40
+ lots: '到期批次',
41
+ noLots: '没有待到期的积分批次',
42
+ };
43
+ const en = {
44
+ title: 'MiniMax Code credits',
45
+ balance: '{total} credits',
46
+ free: 'Free credits',
47
+ purchased: 'Purchased credits',
48
+ consumed: 'Spent since last read',
49
+ tokens: '{count} tokens used here',
50
+ cost: 'Equivalent ≈ ¥{amount}',
51
+ requests: '{count} requests',
52
+ updated: 'Updated {time}',
53
+ unavailable: 'Credits unavailable',
54
+ expired: 'Sign-in expired',
55
+ signedOut: 'MiniMax Code not signed in',
56
+ expiring: 'Expiring soon',
57
+ expiringIn: '{amount} credits · expires {date}',
58
+ expiringNone: 'No credits expire within 7 days',
59
+ expiringSoon: '{amount} credits expire within 30 days',
60
+ nextExpiry: 'Next expiry',
61
+ lots: 'Expiring grants',
62
+ noLots: 'No grant is waiting to expire',
63
+ };
64
+ const loader = globalThis.__ModuleLoader__;
65
+ if (loader === undefined)
66
+ return;
67
+ loader.load({
68
+ id: BUNDLE_NAME,
69
+ factory(require) {
70
+ const React = require('react');
71
+ const h = React.createElement;
72
+ /** Format a credit amount without pretending to more precision. */
73
+ function credits(value, digits) {
74
+ if (value === undefined || !Number.isFinite(value))
75
+ return '—';
76
+ return value.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: 0 });
77
+ }
78
+ /** Format a token count compactly. */
79
+ function tokens(value) {
80
+ if (!Number.isFinite(value))
81
+ return '—';
82
+ if (value < 1000)
83
+ return String(value);
84
+ if (value < 1000000)
85
+ return `${(value / 1000).toFixed(1)}k`;
86
+ return `${(value / 1000000).toFixed(2)}M`;
87
+ }
88
+ /** Format a small amount of money. */
89
+ function money(value) {
90
+ if (value === undefined || !Number.isFinite(value))
91
+ return '—';
92
+ if (value === 0)
93
+ return '0';
94
+ if (value < 0.01)
95
+ return value.toFixed(4);
96
+ return value.toFixed(2);
97
+ }
98
+ /** Format a date as a short day. */
99
+ function day(value) {
100
+ if (value === undefined)
101
+ return '—';
102
+ const date = new Date(value);
103
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
104
+ }
105
+ /**
106
+ * The shared status store.
107
+ *
108
+ * One ticker serves every surface, so a second surface costs no extra
109
+ * request. The store starts when the first surface subscribes and stops
110
+ * when the last one leaves.
111
+ */
112
+ const listeners = new Set();
113
+ let snapshot;
114
+ let timer;
115
+ async function load() {
116
+ try {
117
+ const response = await fetch(STATUS_PATH, { headers: { accept: 'application/json' } });
118
+ if (!response.ok)
119
+ return;
120
+ const document = await response.json();
121
+ snapshot = document;
122
+ for (const listener of listeners)
123
+ listener(snapshot);
124
+ }
125
+ catch {
126
+ // Keep the last value: a failed poll is not worth a blank pill.
127
+ }
128
+ }
129
+ function subscribe(listener) {
130
+ listeners.add(listener);
131
+ if (listeners.size === 1) {
132
+ void load();
133
+ timer = setInterval(() => { void load(); }, POLL_INTERVAL_MS);
134
+ }
135
+ return () => {
136
+ listeners.delete(listener);
137
+ if (listeners.size === 0 && timer !== undefined) {
138
+ clearInterval(timer);
139
+ timer = undefined;
140
+ }
141
+ };
142
+ }
143
+ /** Read the shared status into a component's state. */
144
+ function useStatus() {
145
+ const [status, setStatus] = React.useState(undefined);
146
+ React.useEffect(() => subscribe(setStatus), []);
147
+ return status;
148
+ }
149
+ /** A row of label and value. */
150
+ function row(label, value) {
151
+ return h('div', { style: rowStyle }, h('span', null, label), h('span', null, value));
152
+ }
153
+ /** The expiry block, shared by the pill's detail and the page card. */
154
+ function expiryBlock(t, credits_) {
155
+ if (credits_ === undefined)
156
+ return null;
157
+ const expiring = credits_.expiring ?? 0;
158
+ const next = credits_.nextExpiryAtMs;
159
+ return h('div', { style: expiryBoxStyle, 'data-minimax-credits-expiry': true }, h('div', { style: rowStyle }, h('span', null, t('expiring')), h('span', { style: expiring > 0 ? warnStyle : undefined }, expiring > 0
160
+ ? t('expiringIn', { amount: credits(expiring, 2), date: day(next) })
161
+ : t('expiringNone'))), credits_.expiringSoon !== undefined && credits_.expiringSoon > 0
162
+ ? row(t('expiringSoon', { amount: credits(credits_.expiringSoon, 2) }), '')
163
+ : null, next !== undefined ? row(t('nextExpiry'), day(next)) : null);
164
+ }
165
+ /** The pill in the composer's dock. */
166
+ function CreditPill(props) {
167
+ const status = useStatus();
168
+ const [open, setOpen] = React.useState(false);
169
+ const t = props.t ?? ((key, params) => {
170
+ const template = zh[key] ?? key;
171
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ''));
172
+ });
173
+ const credits_ = status?.credits;
174
+ const usage = status?.usage;
175
+ const available = credits_?.state === 'ok' && credits_.total !== undefined;
176
+ const label = available
177
+ ? t('balance', { total: credits(credits_.total, 2) })
178
+ : t(credits_?.state === 'expired' ? 'expired' : credits_?.state === 'signed-out' ? 'signedOut' : 'unavailable');
179
+ const detail = available
180
+ ? h('div', { style: detailStyle, 'data-minimax-credits-detail': true }, row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), credits_.consumed !== undefined && credits_.consumed > 0
181
+ ? row(t('consumed'), credits(credits_.consumed, 3))
182
+ : null, expiryBlock(t, credits_), usage !== undefined
183
+ ? h('div', { style: rowStyle }, h('span', null, t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) })), h('span', null, t('cost', { amount: money(usage.estimatedCny) })))
184
+ : null, credits_.updatedAtMs !== undefined
185
+ ? row(t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }), '')
186
+ : null)
187
+ : null;
188
+ return h('div', { style: wrapStyle, 'data-minimax-credits': true }, h('button', {
189
+ type: 'button',
190
+ style: buttonStyle,
191
+ title: t('title'),
192
+ onClick: () => setOpen(!open),
193
+ }, label), open ? detail : null);
194
+ }
195
+ /**
196
+ * The card on the plugin's own page.
197
+ *
198
+ * The summary view is the one line the plugin list shows; the page view
199
+ * is the full card, where the expiry detail belongs.
200
+ */
201
+ function PluginPage(props) {
202
+ const status = useStatus();
203
+ const t = props.t ?? ((key, params) => {
204
+ const template = zh[key] ?? key;
205
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ''));
206
+ });
207
+ const credits_ = status?.credits;
208
+ const usage = status?.usage;
209
+ const available = credits_?.state === 'ok' && credits_.total !== undefined;
210
+ if (props.view === 'summary') {
211
+ if (!available)
212
+ return h('span', null, t('unavailable'));
213
+ return h('span', { 'data-minimax-credits-summary': true }, t('balance', { total: credits(credits_.total, 2) }), credits_.expiring !== undefined && credits_.expiring > 0
214
+ ? ` · ${t('expiringIn', { amount: credits(credits_.expiring, 2), date: day(credits_.nextExpiryAtMs) })}`
215
+ : '');
216
+ }
217
+ if (!available) {
218
+ return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: rowStyle }, h('span', null, t('unavailable')), h('span', null, credits_?.state === 'expired' ? t('expired') : '')));
219
+ }
220
+ return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: bigNumberStyle }, t('balance', { total: credits(credits_.total, 2) })), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), expiryBlock(t, credits_), h('div', { style: sectionTitleStyle }, t('lots')), (credits_.upcoming ?? []).length === 0
221
+ ? h('div', { style: mutedStyle }, t('noLots'))
222
+ : h('ul', { style: listStyle }, ...(credits_.upcoming ?? []).map((lot, index) => h('li', { key: `${lot.expireAtMs}-${index}`, style: rowStyle }, h('span', null, day(lot.expireAtMs)), h('span', null, credits(lot.amount, 2))))), usage !== undefined
223
+ ? h('div', { style: usageBoxStyle }, row(t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) }), ''), row(t('cost', { amount: money(usage.estimatedCny) }), ''), usage.requests > 0 ? row(t('requests', { count: usage.requests }), usage.lastModel ?? '') : null)
224
+ : null, credits_.updatedAtMs !== undefined
225
+ ? h('div', { style: mutedStyle }, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }))
226
+ : null);
227
+ }
228
+ const wrapStyle = {
229
+ position: 'relative',
230
+ display: 'inline-flex',
231
+ alignItems: 'center',
232
+ };
233
+ const buttonStyle = {
234
+ appearance: 'none',
235
+ background: 'transparent',
236
+ border: '1px solid var(--dsh-composer-dock-inset, currentColor)',
237
+ borderRadius: '999px',
238
+ color: 'inherit',
239
+ cursor: 'pointer',
240
+ font: 'inherit',
241
+ fontSize: '12px',
242
+ lineHeight: 1.4,
243
+ opacity: 0.8,
244
+ padding: '1px 8px',
245
+ };
246
+ const detailStyle = {
247
+ position: 'absolute',
248
+ bottom: 'calc(100% + 6px)',
249
+ left: 0,
250
+ zIndex: 10,
251
+ minWidth: '240px',
252
+ borderRadius: '8px',
253
+ background: 'var(--dsh-panel-background, Canvas)',
254
+ color: 'var(--dsh-panel-foreground, CanvasText)',
255
+ border: '1px solid var(--dsh-panel-border, currentColor)',
256
+ boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
257
+ padding: '8px 10px',
258
+ fontSize: '12px',
259
+ lineHeight: 1.6,
260
+ textAlign: 'left',
261
+ };
262
+ const rowStyle = {
263
+ display: 'flex',
264
+ justifyContent: 'space-between',
265
+ gap: '16px',
266
+ };
267
+ const expiryBoxStyle = {
268
+ marginTop: '6px',
269
+ paddingTop: '6px',
270
+ borderTop: '1px solid var(--dsh-panel-border, currentColor)',
271
+ opacity: 0.95,
272
+ };
273
+ const warnStyle = { color: 'var(--dsh-warning, #b45309)' };
274
+ const cardStyle = {
275
+ display: 'flex',
276
+ flexDirection: 'column',
277
+ gap: '6px',
278
+ fontSize: '13px',
279
+ lineHeight: 1.6,
280
+ };
281
+ const cardTitleStyle = { fontWeight: 600 };
282
+ const bigNumberStyle = { fontSize: '20px', fontWeight: 600, marginBottom: '2px' };
283
+ const sectionTitleStyle = { marginTop: '8px', fontWeight: 600 };
284
+ const mutedStyle = { opacity: 0.7, fontSize: '12px' };
285
+ const listStyle = { listStyle: 'none', margin: 0, padding: 0 };
286
+ const usageBoxStyle = {
287
+ marginTop: '8px',
288
+ paddingTop: '6px',
289
+ borderTop: '1px solid var(--dsh-panel-border, currentColor)',
290
+ opacity: 0.9,
291
+ };
292
+ return {
293
+ inject: ['slots', 'locale'],
294
+ apply(ctx) {
295
+ const context = ctx;
296
+ // Visible text goes through the host's locale service.
297
+ context.effect(() => context.locale.register(NAMESPACE, { zh, en }), `${BUNDLE_NAME}: locale`);
298
+ // The composer dock is the strip under the input box, which is where
299
+ // the host itself renders its activity and usage pills.
300
+ context.slots.inject('conversation.composer.dock', () => context.slots.register({
301
+ name: 'conversation.composer.dock',
302
+ id: 'minimax-credits',
303
+ order: 2,
304
+ locale: NAMESPACE,
305
+ }, CreditPill));
306
+ // The plugin's own page, keyed by the package name, where the
307
+ // balance and the expiry schedule are spelled out.
308
+ context.slots.inject('plugins.bundle.config', () => context.slots.register({
309
+ name: 'plugins.bundle.config',
310
+ key: BUNDLE_NAME,
311
+ locale: NAMESPACE,
312
+ }, PluginPage));
313
+ },
314
+ };
315
+ },
316
+ });
317
+ })();