dsh-router-core 0.3.2 → 0.3.4

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.
Files changed (37) hide show
  1. package/README.md +6 -2
  2. package/docs/suppliers.md +87 -30
  3. package/lib/client-registry.js +20 -11
  4. package/lib/client.js +20 -11
  5. package/lib/index.js +751 -264
  6. package/lib/types/llm/adapter.d.ts +7 -1
  7. package/lib/types/router/account-pool.d.ts +54 -19
  8. package/lib/types/router/index.d.ts +28 -5
  9. package/lib/types/router/types.d.ts +0 -2
  10. package/lib/types/router/usage-store.d.ts +20 -3
  11. package/lib/types/shared.d.ts +2 -0
  12. package/lib/types/supplier-config.d.ts +23 -0
  13. package/lib/types/suppliers/contract.d.ts +9 -6
  14. package/package.json +1 -1
  15. package/src/client/RouterView.tsx +1 -1
  16. package/src/client/SupplierDetail.tsx +15 -10
  17. package/src/llm/adapter.test.ts +404 -1
  18. package/src/llm/adapter.ts +242 -34
  19. package/src/router/account-pool.test.ts +163 -61
  20. package/src/router/account-pool.ts +200 -68
  21. package/src/router/index.ts +225 -66
  22. package/src/router/models-cache.test.ts +224 -6
  23. package/src/router/precise-dispatch.test.ts +110 -7
  24. package/src/router/types.ts +0 -2
  25. package/src/router/usage-store.ts +174 -74
  26. package/src/router/usage.test.ts +115 -0
  27. package/src/shared.ts +2 -0
  28. package/src/supplier-config.test.ts +98 -0
  29. package/src/supplier-config.ts +60 -1
  30. package/src/suppliers/contract.ts +9 -9
  31. package/src/suppliers/loader.test.ts +118 -0
  32. package/src/suppliers/loader.ts +21 -5
  33. package/src/suppliers/nvidia/plugin.ts +12 -15
  34. package/src/suppliers/opencode/plugin.ts +10 -13
  35. package/src/suppliers/openrouter/plugin.ts +12 -15
  36. package/src/suppliers/registry.test.ts +23 -3
  37. package/src/suppliers/registry.ts +11 -3
package/README.md CHANGED
@@ -45,7 +45,7 @@ dsh plugin --profile web add dsh-router-core
45
45
  | 零额外进程 | 就是 DSH 插件,随 `dsh web` 启停,天然同源(`/router/api/*` 无 CORS、面板嵌在设置里)。 |
46
46
  | 供应商即插即拔 | 内置供应商随插件分发;更多供应商 = 装一个 DSH 插件(`dsh-router-*`)或放一个 js 文件到 `~/.dsh/profiles/web/suppliers/`。 |
47
47
  | 模型不内置 | 供应商只实现差异化能力,模型拉取与缓存由核心统一管,不写死、不过时。 |
48
- | 策略只写一次 | 组合回退、账号池(选号/冷却/禁用/连续错误累计)、响应写入、凭证存储、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
48
+ | 策略只写一次 | 组合回退、账号池(选号/冷却/禁用)、响应写入、凭证存储、积分持久化、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表、不落盘积分——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
49
49
  | 凭证单库 | `auths/credentials.sqlite`,供应商凭证不透明 blob,核心统一生命周期,干净可备份。 |
50
50
  | 组合即模型 | 建好的组合自动带出为 DSH 模型目录里的 `router` provider 选项,设置 → 模型直接选组合名即可。 |
51
51
  | 用量可观测 | 面板概览看板:周期切换、汇总卡、趋势折线、Top 榜、最近请求。 |
@@ -71,7 +71,11 @@ dsh plugin --profile web add dsh-router-core
71
71
  - Top 榜:按供应商 / 按模型(请求数带失败计数);
72
72
  - 最近请求:时间 / 模型 / 供应商 / in↑ out↓ / 耗时,显示最近 10 条;
73
73
  - **清空** — 清掉全部用量统计(不影响供应商、账号、组合配置);
74
- - 数据落盘 `data/usage.json`(按天聚合 + 最近 500 条明细 + 累计计数)。
74
+ - 数据落盘 `data/usage.json`(按天聚合 + 每天 24 个小时桶 + 最近 500 条明细 +
75
+ 累计计数)。今日/7 天/30 天读天桶、24 小时读小时桶,都不受明细环容量限制;
76
+ 明细环只服务「最近请求」列表。
77
+ 小时桶从新数据开始累积,升级前那几天的天内分布查不到(明细环只剩 500 条
78
+ 回溯不回去),那段历史的小时柱状图留空、24 小时口径按整桶计入 —— 不编数据。
75
79
  token 口径:上游返回 `usage` 就用真值(分散在多帧时按字段取最大值合并);
76
80
  上游不发时按 ~4 字符/token 估算,面板上标 `~`。**失败请求不估算**——
77
81
  它没到上游,编造输入 token 只会把总量灌水;
package/docs/suppliers.md CHANGED
@@ -10,16 +10,18 @@
10
10
  | | 谁负责 |
11
11
  |---|---|
12
12
  | 组合回退、按策略选中模型 | 核心 |
13
- | **账号池**:选号、冷却、禁用、连续错误累计、遍历回退 | 核心 |
13
+ | **账号池**:选号、冷却、禁用、遍历回退 | 核心 |
14
14
  | 响应写入(状态行、SSE 头、JSON body) | 核心 |
15
15
  | 模型启用/禁用、自定义模型、别名 | 核心 |
16
16
  | 凭证存储(`env.credentials`) | 核心 |
17
+ | 积分**持久化**(`supplier-config.json` 的 `credits`) | 核心 |
17
18
  | **对单个账号调通上游**(协议、鉴权、SSE 解析/生成) | **js** |
18
- | 列模型、登录、加 key、删链接、签到、拉积分 | **js** |
19
+ | 列模型、登录、加 key、删链接、签到、拉积分(只报值,不落盘) | **js** |
19
20
 
20
- 关键推论:**js 不遍历账号、不维护冷却表、不写 `res`**。
21
+ 关键推论:**js 不遍历账号、不维护冷却表、不写 `res`、不落盘积分**。
21
22
  `chatOnce(uid, req)` 一次只服务一个账号,成败告诉核心,换不换号由核心决定。
22
23
  这样「哪个账号坏了」「为什么坏」是核心的一等信息,组合回退才做得了判断。
24
+ 积分同理:js 报值,核心存。
23
25
 
24
26
  ## 契约
25
27
 
@@ -58,6 +60,7 @@ export const priority = 10 // 数字越小越优先(免费优先)
58
60
  js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给核心解读:
59
61
 
60
62
  ```ts
63
+ // credits: 剩余额度。拿不到(含不支持积分)报 -1——0 是真值「用完了」,别拿它当未知
61
64
  { uid, nickname?, credits: number, state: AccountState, message? }
62
65
  ```
63
66
 
@@ -74,10 +77,10 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
74
77
  | `unknown` | 说不清是什么错 |
75
78
  | `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
76
79
 
77
- **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里
78
- 会计一次连续错误——组合里每有一个别人家的模型,就会给无关账号攒一次错误,
79
- 攒够 3 次把它冷却 10 分钟,表现为「组合越用越没号可用」。所以「这个模型不属于我」
80
- 一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
80
+ **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里会
81
+ 把**这个号 × 这个模型**冷掉——组合里每有一个别人家的模型,js 就会对每个账号
82
+ 都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
83
+ 不属于我」一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
81
84
 
82
85
  **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
83
86
  都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
@@ -85,6 +88,16 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
85
88
 
86
89
  核心在 `status()` 之上叠加 `cooling` / `disabled` / `err_count` / `until` / `reason`
87
90
  再给面板,所以 js 不用(也不该)填这些字段。`accounts` 为空数组 = 无账号供应商。
91
+ 注意 `err_count` 现在是**限流退避等级**(反复被限流的号等级递增),不是
92
+ 「连续失败次数」——面板按等级展示,等级在成功后清零。
93
+
94
+ ### 冷却颗粒度 = (供应商, 模型, 连接)
95
+
96
+ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「这个号 × 这个
97
+ 模型」**,不会牵连该号上的其它模型。一个连接服务多个模型时,模型 A 被限流不该
98
+ 让模型 B 也不可用。
99
+
100
+ 例外是 `session_dead`:凭证失效是连接级问题,该号**所有模型**都停用。
88
101
 
89
102
  ## `chatOnce(uid, req)`:一次只服务一个账号
90
103
 
@@ -105,17 +118,23 @@ type ChatOnceResult =
105
118
 
106
119
  | state | 核心处置 |
107
120
  |---|---|
108
- | `rate_limit` | 冷却 1 分钟 |
121
+ | `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
109
122
  | `quota` | 冷却 10 分钟 |
110
- | `session_dead` | **永久禁用**(需重新登录才能恢复) |
111
- | `unavailable` / `transport` / `unknown` | 计一次连续错误,攒够 3 次冷却 10 分钟 |
123
+ | `session_dead` | **永久禁用该号**(所有模型都停用,需重新登录才能恢复) |
124
+ | `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
112
125
  | `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
113
126
 
114
127
  - 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
115
128
  `{ ok:false, state:'unknown' }` 而不是 `ok:true`——空响应交给核心换号/换模型回退,
116
129
  比当作成功更有用
117
130
 
118
- 冷却/禁用/错误累计目前是**内存态**,重启归零(与凭证/积分的持久化不同)。
131
+ 所以 js 别报「保险」的状态:把瞬时抖动报成 `quota` 会让好号白躺 10 分钟,把
132
+ 限流报成 `unknown` 则只冷 30s、等下个请求再撞一次。网关自带业务码的(如
133
+ 「请求参数非法」「上游临时不可用」)建议归 `rate_limit`——瞬时/请求类的错,
134
+ 短冷换号比长冷划算。
135
+
136
+ 冷却/禁用/退避等级都是**内存态**,重启归零(与凭证、积分的持久化不同)。
137
+ 重启后所有号都会重新被选中一次,所以**上游确实挂了的号会各撞一次**,属预期。
119
138
  要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
120
139
 
121
140
  ### 流式一旦写出第一个字节才绑死
@@ -128,6 +147,22 @@ type ChatOnceResult =
128
147
  - 要让「上游吐到一半才断」也能回退,必须在写响应头前缓冲判定,代价是
129
148
  首字节延迟,目前没做
130
149
 
150
+ ### 终止帧 `[DONE]` 由核心兜底
151
+
152
+ 客户端(dsh-llm adapter)严格等 `data: [DONE]` 才认为流正常结束;缺了就抛
153
+ `SSE payload stream ended without [DONE]`,整轮判失败——已经流式吐出去的内容
154
+ **全部作废**。
155
+
156
+ 核心是 SSE 的唯一写入方,所以这个兜底由核心做,不要求 js 合成:
157
+
158
+ - 上游流结束时**没发过** `[DONE]` → 核心补发一个(降级为「拿到截断内容并
159
+ 正常收尾」,而不是整轮白干)
160
+ - 上游**已经发了** → 不再重复发(重复 `[DONE]` 同样坑客户端)
161
+ - **一个字节都没写出就断** → **不补**,留给换号/换模型回退(补了就把这次
162
+ 失败坐实成一个空响应,组合回退就没了)
163
+
164
+ js 因此不必自己合成终止帧(合成也不算错,核心会认)。
165
+
131
166
  所以 js 的流转换要**尽早产出第一个字节**,别攒着。攒着不仅延迟高,还会把
132
167
  自己的失败伪装成「可回退」,让组合以为没成功而反复重试别的模型。
133
168
 
@@ -160,9 +195,21 @@ js 只需把失败原因记下来并通过 `lastError()` 暴露,核心就能给
160
195
  - `message` 直接透给用户看;某个 uid 抛错由核心兜住记成 `error`,不会带垮其它链接
161
196
  - 核心汇总后给面板:`{ ok, total, succeeded, already, results }`
162
197
 
163
- `credits`(`status().accounts[].credits`)用于面板显示积分/额度;上游接口不实时时
164
- 建议缓存 + 签到后刷新。**填「剩余额度」而不是「已消耗」**——额度类接口两者常常
165
- 都有,填反了面板数字会越用越多。
198
+ `credits`(`status().accounts[].credits`)用于面板显示积分/额度。**填「剩余额度」
199
+ 而不是「已消耗」**——额度类接口两者常常都有,填反了面板数字会越用越多。
200
+
201
+ ### 积分归核心持久化,js 只报值
202
+
203
+ 积分的持久化由核心统一管(存 `supplier-config.json` 的 `credits` 字段),js **不要
204
+ 自己落盘**——一份数据两处存,两边都可能过期。js 只管两件事:内存 TTL、报真值。
205
+
206
+ 报值的规矩(见上「`status()`」一节):**拿不到就报 `-1`,不是 `0`**。核心靠这个
207
+ 区分「没拿到」和「拿到了 0」,报 0 会把缓存冲成 0(`0` 是真值「用完了」)。
208
+ 不支持积分的供应商也报 `-1`,面板显示「积分未知」。
209
+
210
+ 天花板:核心存的是「最后一次拿到的值」,不带时间戳。所以插件重启后短暂显示
211
+ 上次的值,真值等异步拉完自动覆盖(面板下一次刷新即可见)。要消掉这个窗口,
212
+ 得让核心存时间戳并在过期时标旧,目前没做。
166
213
 
167
214
  上游幂等要处理好:有的上游「今日已签到」返回 **HTTP 400 + 业务 code**,
168
215
  所以要先解析 body 的 code 再判 HTTP 状态,否则幂等情况会被误报成失败。
@@ -206,8 +253,8 @@ RPC 失败,表现为「历史加载失败」。
206
253
 
207
254
  面板链接池的「刷新」按钮是核心的活,js 不用做任何事:
208
255
 
209
- 1. **积分**:核心连着调 `status()` 等它落地;`status().accounts[].credits`
210
- 该缓存就缓存,不该缓存就现拉,核心只认这个字段
256
+ 1. **积分**:核心连着调 `status()` 等它落地(最多 3s),只认
257
+ `status().accounts[].credits` 这一个字段,并统一持久化
211
258
  2. **健康**:核心拿一个启用模型跑一次最简会话(复用「测试模型」的 `chatOnce`
212
259
  路径),回答「这个供应商还有没有活着的链接」——**不按链接细分**
213
260
 
@@ -223,8 +270,9 @@ export const id = 'echo'
223
270
  export const name = 'Echo'
224
271
  export const priority = 10
225
272
 
226
- // 只报「现在状态」:冷却/禁用/错误累计由核心叠加
227
- const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: 100, state: 'ok' }]
273
+ // 只报「现在状态」:冷却/禁用/退避等级由核心叠加,积分由核心持久化
274
+ // credits: -1 = 不支持/没拉到(别写 0,0 会被当成「用完了」存下来)
275
+ const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: -1, state: 'ok' }]
228
276
 
229
277
  export function status() { return { id, name, accounts } }
230
278
  export function listModels() { return [{ id: 'echo-model' }] }
@@ -262,17 +310,25 @@ export function dispose() {}
262
310
 
263
311
  单个供应商加载失败(缺 `id`/`name`、构造异常)不阻断其他供应商,错误记入 DSH 日志。
264
312
 
265
- **模型统一策略**:供应商的模型列表**优先从上游拉取、不自行缓存**——
266
- `listModels` 每次都从上游拉取(失败回退上次成功结果避免面板空模型),**缓存由
267
- 核心统一管**(`Router.modelsOf`,TTL 60s)。
313
+ **模型统一策略**:模型列表**优先从上游拉取**,缓存由核心统一管
314
+ (`Router.modelsOf`,TTL 10 分钟),js **不要自己缓存**——自己缓存一份会和核心
315
+ 那份一起过期两次,「获取模型」按钮就成了摆设。
316
+
317
+ 核心缓存是 **stale-while-revalidate**:过期后**先返回旧值**让面板立刻有内容,
318
+ 后台再重新拉。所以 `listModels` 被调用的时机是:
319
+
320
+ - **冷启动**(该供应商第一次被问,没有旧值)——**会同步等**,全程只有这一次
321
+ - **TTL 过期后的第一次**——返回旧值,`listModels` 在**后台**被调用
322
+ - **手动点「获取模型」**(`force=true`)——**会同步等**,用户要看到结果
268
323
 
269
- 这意味着 **js 的 `listModels` 会被核心频繁调用**,所以:
324
+ 因此:
270
325
 
271
- - 只有这两种场景会真的打到上游:**首次打开供应商详情**(缓存还没建)和
272
- **手动点「获取模型」**(`force=true`)。组合面板只**读缓存**,不触发拉取
273
- - 因此 `listModels` 要**快**:别在里面做全量探测之类的重活。重活放到
274
- `force=true` 时做(如按模型探测可用性),别拖慢每次只读
275
- - 失败时回退上次成功的结果,避免面板空模型
326
+ - `listModels` 要**快**:别在里面做全量探测之类的重活。重活放到 `force=true`
327
+ 时做(如按模型探测可用性)
328
+ - 冷启动那次会拖慢面板首屏(实测 0.3~1.0s/供应商,组合页取最慢那家),
329
+ **别在这条路上加重活**——这是唯一一次用户真会等到的调用
330
+ - 拉到空列表时核心会保住旧值不当成新结果,所以别用「返回空」表达
331
+ 「上游出错」;出错就抛,核心会退回旧值(首次则整个供应商剔除)
276
332
 
277
333
  `/suppliers/:id/models` 与 `/combos` 共用这份缓存(模型增删改后失效),
278
334
  `/v1/models`(LLM 侧)保持实时。
@@ -294,9 +350,10 @@ export function dispose() {}
294
350
 
295
351
  ### 为什么组合要存供应商 id 而不是裸模型 id
296
352
 
297
- 存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答
298
- 「不是我的」,而这个状态在池策略里**计一次连续错误**——组合里每有一个别人家的
299
- 模型就给无关账号攒一次,攒够 3 次冷却 10 分钟,表现为「组合越用越没号可用」。
353
+ 存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答「不是我的」,
354
+ 而这个状态在池策略里会把**这个号 × 这个模型**冷掉——组合里每有一个别人家的
355
+ 模型,每个账号在那模型上就都被冷一次,表现为「组合越用越没号可用」。
356
+ (`no_such_model` 是为此专门加的状态:它不冷任何东西。)
300
357
 
301
358
  存了供应商 id 就不需要问了:直接调那一个。查不到供应商(拼错/供应商没加载)
302
359
  直接失败,**不兜底遍历**——兜底会让拼错的 id 静默落到别的供应商上,更难查。
@@ -243,16 +243,21 @@ window.__ModuleLoader__.load({
243
243
  cache: "no-store"
244
244
  })).json();
245
245
  if (data.ok && data.total > 0) {
246
- const failed = (data.results ?? []).filter((r) => r.status !== "ok" && r.status !== "already");
247
246
  let msg = `签到完成:${data.succeeded}/${data.total} 成功`;
248
247
  const already = data.already ?? 0;
249
248
  if (already > 0) msg += ` · ${already} 今日已签`;
250
- if (failed.length > 0) {
251
- const first = failed[0];
252
- msg += ` · ${first?.message ?? "有失败"}`;
253
- }
254
249
  showToast(msg);
255
- } else showToast(data.error ?? (data.total === 0 ? "没有可签到的链接" : "签到失败"));
250
+ } else {
251
+ const firstFail = (data.results ?? []).find((r) => r.status !== "ok" && r.status !== "already");
252
+ const reason = firstFail ? `${firstFail.message ?? "有链接签到失败"}` : "";
253
+ const already = data.already ?? 0;
254
+ const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : "没有可签到的链接";
255
+ showToast(data.error ?? [
256
+ head,
257
+ already > 0 ? `${already} 今日已签` : "",
258
+ reason
259
+ ].filter(Boolean).join(" · "));
260
+ }
256
261
  onRefresh();
257
262
  } catch (err) {
258
263
  showToast(`签到失败:${err.message}`);
@@ -833,16 +838,20 @@ window.__ModuleLoader__.load({
833
838
  className: "dshr-linkDot",
834
839
  children: "·"
835
840
  }),
836
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
837
842
  className: "dshr-mono dshr-linkCredits",
838
- children: [Math.ceil(account.credits), " 积分"]
843
+ children: account.credits < 0 ? "积分未知" : `${Math.ceil(account.credits)} 积分`
839
844
  }),
840
845
  account.err_count !== void 0 && account.err_count > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
841
846
  className: "dshr-linkDot",
842
847
  children: "·"
843
848
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
844
849
  className: "dshr-linkErr",
845
- children: ["连续错误 ", account.err_count]
850
+ children: [
851
+ "退避 ",
852
+ account.err_count,
853
+ " 级"
854
+ ]
846
855
  })] }),
847
856
  coolingText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
848
857
  className: "dshr-reason dshr-linkCooling",
@@ -3392,9 +3401,9 @@ window.__ModuleLoader__.load({
3392
3401
  children: [supplierAccounts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3393
3402
  className: "dshr-muted",
3394
3403
  children: [
3395
- supplierAccounts.length,
3396
- "/",
3397
3404
  healthy,
3405
+ "/",
3406
+ supplierAccounts.length,
3398
3407
  " 健康"
3399
3408
  ]
3400
3409
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
package/lib/client.js CHANGED
@@ -243,16 +243,21 @@ window.__ModuleLoader__.load({
243
243
  cache: "no-store"
244
244
  })).json();
245
245
  if (data.ok && data.total > 0) {
246
- const failed = (data.results ?? []).filter((r) => r.status !== "ok" && r.status !== "already");
247
246
  let msg = `签到完成:${data.succeeded}/${data.total} 成功`;
248
247
  const already = data.already ?? 0;
249
248
  if (already > 0) msg += ` · ${already} 今日已签`;
250
- if (failed.length > 0) {
251
- const first = failed[0];
252
- msg += ` · ${first?.message ?? "有失败"}`;
253
- }
254
249
  showToast(msg);
255
- } else showToast(data.error ?? (data.total === 0 ? "没有可签到的链接" : "签到失败"));
250
+ } else {
251
+ const firstFail = (data.results ?? []).find((r) => r.status !== "ok" && r.status !== "already");
252
+ const reason = firstFail ? `${firstFail.message ?? "有链接签到失败"}` : "";
253
+ const already = data.already ?? 0;
254
+ const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : "没有可签到的链接";
255
+ showToast(data.error ?? [
256
+ head,
257
+ already > 0 ? `${already} 今日已签` : "",
258
+ reason
259
+ ].filter(Boolean).join(" · "));
260
+ }
256
261
  onRefresh();
257
262
  } catch (err) {
258
263
  showToast(`签到失败:${err.message}`);
@@ -833,16 +838,20 @@ window.__ModuleLoader__.load({
833
838
  className: "dshr-linkDot",
834
839
  children: "·"
835
840
  }),
836
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
837
842
  className: "dshr-mono dshr-linkCredits",
838
- children: [Math.ceil(account.credits), " 积分"]
843
+ children: account.credits < 0 ? "积分未知" : `${Math.ceil(account.credits)} 积分`
839
844
  }),
840
845
  account.err_count !== void 0 && account.err_count > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
841
846
  className: "dshr-linkDot",
842
847
  children: "·"
843
848
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
844
849
  className: "dshr-linkErr",
845
- children: ["连续错误 ", account.err_count]
850
+ children: [
851
+ "退避 ",
852
+ account.err_count,
853
+ " 级"
854
+ ]
846
855
  })] }),
847
856
  coolingText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
848
857
  className: "dshr-reason dshr-linkCooling",
@@ -3392,9 +3401,9 @@ window.__ModuleLoader__.load({
3392
3401
  children: [supplierAccounts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3393
3402
  className: "dshr-muted",
3394
3403
  children: [
3395
- supplierAccounts.length,
3396
- "/",
3397
3404
  healthy,
3405
+ "/",
3406
+ supplierAccounts.length,
3398
3407
  " 健康"
3399
3408
  ]
3400
3409
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {