dsh-router-core 0.1.0 → 0.2.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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
3
+ *
4
+ * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
5
+ * - 按 poolOrder 排序(未配置的按插件报告顺序追加)
6
+ * - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
7
+ * - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
8
+ *
9
+ * 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
10
+ * 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
11
+ * stateFile 即可,接口不用变。
12
+ */
13
+ import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
14
+ export declare class AccountPool {
15
+ private entries;
16
+ private rrCursor;
17
+ private entry;
18
+ /** 是否可服务:未禁用且不在冷却中。 */
19
+ private healthy;
20
+ /**
21
+ * 按策略选一个健康账号。
22
+ * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
23
+ * @param poolOrder 用户在面板拖出来的顺序(核心管)
24
+ * @returns 选中的 uid;无健康账号返回 undefined
25
+ */
26
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined;
27
+ /** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
28
+ noteFailure(uid: string, state: AccountState, message: string): void;
29
+ /** 记录一次成功:清零连续错误。 */
30
+ noteSuccess(uid: string): void;
31
+ /** 手动冷却(面板/管理用途)。 */
32
+ cooldown(uid: string, ms: number, reason: string): void;
33
+ /** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
34
+ decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
35
+ cooling: boolean;
36
+ disabled: boolean;
37
+ err_count: number;
38
+ until?: string;
39
+ reason?: string;
40
+ }>;
41
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ServerResponse } from 'node:http';
2
2
  import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts';
3
+ import { SupplierConfigStore } from '../supplier-config.ts';
3
4
  /** 路由器。 */
4
5
  export declare class Router {
5
6
  private suppliers;
@@ -7,7 +8,11 @@ export declare class Router {
7
8
  private customCombos;
8
9
  /** round-robin 轮转游标(按组合 id 记忆)。 */
9
10
  private rrCursors;
10
- constructor(stateFile?: string);
11
+ /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
12
+ private store;
13
+ /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
14
+ private modelsCache;
15
+ constructor(stateFile?: string, store?: SupplierConfigStore);
11
16
  private loadCombos;
12
17
  private saveCombos;
13
18
  add(supplier: Supplier): void;
@@ -30,7 +35,22 @@ export declare class Router {
30
35
  listModels(): Promise<ModelInfo[]>;
31
36
  /** 组合列表(面板用):用户自定义组合。 */
32
37
  combos(): Promise<Combo[]>;
33
- /** 可用模型(按供应商分组,仅启用),面板加模型用。 */
38
+ /**
39
+ * 取某供应商的模型(核心统一缓存)。
40
+ *
41
+ * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
42
+ * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
43
+ * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
44
+ * 按钮(force)触发。
45
+ *
46
+ * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
47
+ * 就得让供应商暴露 etag/版本号,目前没有这个需求。
48
+ */
49
+ modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
50
+ /** 失效某供应商的模型缓存(增删改模型后调用)。 */
51
+ invalidateModels(supplierId: string): void;
52
+ /** 可用模型(按供应商分组,仅启用),面板加模型用。
53
+ * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
34
54
  supplierModels(): Promise<Array<{
35
55
  supplier: {
36
56
  id: string;
@@ -41,7 +61,10 @@ export declare class Router {
41
61
  }>>;
42
62
  private validModels;
43
63
  private validStrategy;
44
- /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。 */
64
+ /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
65
+ * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
66
+ * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
67
+ * 命名空间一起吃掉,后面请求必然 404。 */
45
68
  private normalizeModelIds;
46
69
  /** 创建组合(name 唯一,非 default)。 */
47
70
  createCombo(name: string, strategy: string, models: string[]): {
@@ -67,7 +90,23 @@ export declare class Router {
67
90
  * - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
68
91
  */
69
92
  chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void>;
70
- /** 把 model 改写为组合选中的模型名后尝试供应商。 */
93
+ /** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
71
94
  private chatWithModel;
95
+ /**
96
+ * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
97
+ * AccountState 处置 → 失败换号;写响应只在成功后发生。
98
+ *
99
+ * 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
100
+ * 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
101
+ * 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
102
+ */
103
+ private chatWithSupplier;
104
+ /** 测试某供应商的某模型是否可用。
105
+ * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
106
+ * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
107
+ testModel(supplierId: string, model: string): Promise<{
108
+ ok: boolean;
109
+ error?: string;
110
+ }>;
72
111
  dispose(): void;
73
112
  }
@@ -3,7 +3,8 @@
3
3
  * 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
4
4
  * 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
5
5
  */
6
- import type { ServerResponse } from 'node:http';
6
+ import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts';
7
+ import type { AccountPool } from './account-pool.ts';
7
8
  /** 面板展示的账号状态(脱敏)。 */
8
9
  export interface SupplierAccount {
9
10
  uid: string;
@@ -49,9 +50,8 @@ export interface ChatRequest {
49
50
  model: string;
50
51
  }
51
52
  /**
52
- * 供应商接口。chatCompletions 直接向 res 写响应(流式 SSE 或 JSON),
53
- * 成功返回 true;返回 false 表示"该供应商无健康账号可服务"(触发路由器
54
- * 轮换到下一个供应商)。写响应后不得再改 res。
53
+ * 供应商接口。核心负责账号池(选号/冷却/禁用/错误累计/遍历回退),
54
+ * 供应商只对**单个账号**调一次上游并报告结果,不碰 res。
55
55
  */
56
56
  export interface Supplier {
57
57
  readonly id: string;
@@ -68,13 +68,15 @@ export interface Supplier {
68
68
  getAlias(): string;
69
69
  /** 用户手动添加的模型 id(可选,/v1/models 用它聚合自定义模型)。 */
70
70
  customModelIds?(): string[];
71
- chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>;
72
- /** 测试模型(验证上游真实可用,必要差异化能力)。 */
73
- testModel(id: string): Promise<{
74
- ok: boolean;
75
- error?: string;
76
- }>;
71
+ /** 账号「现在状态」(从 status() 派生,插件只报它观察到的)。空 = 无账号供应商。 */
72
+ accounts(): SupplierAccountNow[];
73
+ /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
74
+ chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
75
+ /** 上次 chatOnce 失败原因(诊断用,可选)。 */
76
+ lastError?(): string | undefined;
77
77
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
78
78
  removeLink?(uid: string): Promise<boolean>;
79
79
  dispose(): void;
80
+ /** 该供应商的账号池(核心持有:选号/冷却/禁用/错误累计)。 */
81
+ readonly pool: AccountPool;
80
82
  }
@@ -1,12 +1,10 @@
1
1
  export type PoolStrategy = 'fallback' | 'round-robin';
2
- export type CheckinRule = 'all' | 'first';
3
2
  export interface SupplierConfig {
4
3
  alias: string;
5
4
  disabled: string[];
6
5
  custom: string[];
7
6
  poolOrder: string[];
8
7
  poolStrategy: PoolStrategy;
9
- checkinRule: CheckinRule;
10
8
  }
11
9
  /** 通用供应商配置存储。 */
12
10
  export declare class SupplierConfigStore {
@@ -22,7 +20,6 @@ export declare class SupplierConfigStore {
22
20
  removeCustomModel(supplierId: string, modelId: string): void;
23
21
  setPoolOrder(supplierId: string, uids: string[]): void;
24
22
  setPoolStrategy(supplierId: string, strategy: string): void;
25
- setCheckinRule(supplierId: string, rule: string): void;
26
23
  private load;
27
24
  private saveLocked;
28
25
  }
@@ -9,8 +9,7 @@
9
9
  * 用户自定义供应商 js 放到 `~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载。
10
10
  * 内置供应商与用户 js 同构(差异化能力驱动)。
11
11
  */
12
- import type { ServerResponse } from 'node:http';
13
- import type { ChatRequest, ModelInfo, SupplierAccount, SupplierStatus } from '../router/types.ts';
12
+ import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts';
14
13
  import type { SupplierConfigStore } from '../supplier-config.ts';
15
14
  import type { CredentialStore } from '../credential-store.ts';
16
15
  /** 供应商配置:loader 注入的运行时环境。 */
@@ -26,6 +25,58 @@ export interface SupplierEnv {
26
25
  }
27
26
  /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
28
27
  export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
28
+ /**
29
+ * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
30
+ *
31
+ * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
32
+ * 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
33
+ *
34
+ * 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
35
+ * 字符串——核心的策略表要能穷举。
36
+ */
37
+ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'unavailable' | 'transport' | 'unknown'
38
+ /**
39
+ * 这个模型不属于本供应商(不是账号的失败,也不是上游故障)。
40
+ * 核心据此**跳过整个供应商**换下一个,而不是记在账号头上——
41
+ * 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
42
+ * 把它冷却掉(历史上正是这个坑)。
43
+ */
44
+ | 'no_such_model';
45
+ /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
46
+ export interface SupplierAccountNow {
47
+ uid: string;
48
+ nickname?: string;
49
+ credits: number;
50
+ /** 插件观察到的上游状态。 */
51
+ state: AccountState;
52
+ /** 状态的人类可读补充(如上游错误串),可选。 */
53
+ message?: string;
54
+ }
55
+ /** 插件报的供应商状态(accounts 是「现在状态」,非核心加工后的面板态)。 */
56
+ export interface SupplierStatusNow {
57
+ id: string;
58
+ name: string;
59
+ accounts: SupplierAccountNow[];
60
+ }
61
+ /** 一次上游调用的结果。 */
62
+ export type ChatOnceResult =
63
+ /** 流式:插件已把上游协议转成 OpenAI SSE,核心只负责 pipe 到 res。 */
64
+ {
65
+ ok: true;
66
+ stream: ReadableStream<Uint8Array>;
67
+ }
68
+ /** 非流式:核心写 JSON。 */
69
+ | {
70
+ ok: true;
71
+ status: number;
72
+ body: string;
73
+ }
74
+ /** 失败:核心据此做冷却/禁用/换号。 */
75
+ | {
76
+ ok: false;
77
+ state: AccountState;
78
+ message: string;
79
+ };
29
80
  /** 供应商模块 —— 契约(核心必须,差异化可选)。 */
30
81
  export interface SupplierModule {
31
82
  /** 供应商唯一 id(小写字母数字 - _)。 */
@@ -36,18 +87,23 @@ export interface SupplierModule {
36
87
  readonly priority?: number;
37
88
  /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
38
89
  readonly icon?: string;
39
- status(): SupplierStatus;
90
+ /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
91
+ status(): SupplierStatusNow;
40
92
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
41
93
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
42
94
  /** 默认前缀(可被通用层 alias 覆盖)。 */
43
95
  getAlias(): string;
44
- chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>;
45
- /** 测试模型(验证上游真实可用,必要差异化能力)。 */
46
- testModel(id: string): Promise<{
47
- ok: boolean;
48
- error?: string;
49
- }>;
96
+ /**
97
+ * 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
98
+ * 选号/回退/健康判定全是核心的活。
99
+ * 无账号供应商(如 opencode)传空 uid,忽略即可。
100
+ */
101
+ chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
50
102
  dispose(): void;
103
+ /** 上次 chatOnce 失败的原因(诊断用)。
104
+ * 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
105
+ * 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
106
+ lastError?(): string | undefined;
51
107
  /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
52
108
  generateLoginUrl?(): string | {
53
109
  ok: boolean;
@@ -79,19 +135,12 @@ export interface SupplierModule {
79
135
  removeLink?(uid: string): Promise<boolean>;
80
136
  /** 轮询式登录标记:有它 → 面板隐藏「粘贴回调链接」步骤,登录后直接点完成(后台轮询)。 */
81
137
  pollLogin?(): boolean;
82
- /** 触发签到。签到规则(all/first)是 dsh-router 通用策略,不在此列。 */
83
- checkinNow?(): Promise<{
138
+ /** 单个链接签到。遍历所有链接 + 结果汇总是 dsh-router 核心的活,插件只管一个 uid。
139
+ * status: 'ok'(签到成功) / 'already'(今日已签到) / 'error'(失败)。 */
140
+ checkinNow?(uid: string): Promise<{
84
141
  ok: boolean;
85
- total: number;
86
- succeeded: number;
87
- already?: number;
88
- error?: string;
89
- results?: Array<{
90
- uid: string;
91
- ok: boolean;
92
- status?: string;
93
- message?: string;
94
- }>;
142
+ status: string;
143
+ message?: string;
95
144
  }>;
96
145
  }
97
146
  /** 供应商账号(复用 router 类型,供 loader 包装)。 */
@@ -17,10 +17,11 @@
17
17
  * POST /suppliers/:id/login 生成登录链接
18
18
  * POST /suppliers/:id/login/callback {callbackUrl}
19
19
  * POST /suppliers/:id/links/remove {uid}
20
- * GET /suppliers/:id/checkin/rule + POST {rule}
21
- * POST /suppliers/:id/checkin 触发签到
20
+ * POST /suppliers/:id/links/refresh 刷新链接池(积分,核心调 status() 等落地)
21
+ * POST /suppliers/:id/checkin 签到所有链接(核心遍历 + 汇总)
22
22
  */
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
+ import type { Router } from '../router/index.ts';
24
25
  import type { SupplierConfigStore } from '../supplier-config.ts';
25
26
  import type { LoadedSupplier } from './loader.ts';
26
27
  /** webServer 路由形状(与 index.ts 的 WebServerRoute 一致)。 */
@@ -30,4 +31,4 @@ export interface WebServerRoute {
30
31
  handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
31
32
  }
32
33
  /** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
33
- export declare function supplierRoutes(base: string, loaded: LoadedSupplier, store: SupplierConfigStore): WebServerRoute[];
34
+ export declare function supplierRoutes(base: string, loaded: LoadedSupplier, store: SupplierConfigStore, router: Router): WebServerRoute[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -52,6 +52,7 @@
52
52
  "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
53
53
  "build:client": "DSH_CLIENT_ID=dsh-router DSH_CLIENT_FILE=client.js vite build -c client/vite.config.ts && DSH_CLIENT_ID=dsh-external/dsh-router DSH_CLIENT_FILE=client-registry.js vite build -c client/vite.config.ts",
54
54
  "typecheck": "tsc --noEmit",
55
+ "test": "node --test \"src/**/*.test.ts\"",
55
56
  "bundle": "tsdown",
56
57
  "watch": "tsdown --watch"
57
58
  },
@@ -49,15 +49,17 @@ interface ComboForm {
49
49
  }
50
50
 
51
51
  export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.Element {
52
- /** 当前供应商前缀(单供应商取第一个)。 */
53
- const primaryAlias = aliases[0]?.alias ?? ''
54
-
55
- /** 模型 → 所属供应商前缀(按 groups 分组匹配;找不到兜底 primaryAlias)。 */
52
+ /** 模型 → 所属供应商前缀(按 groups 分组匹配)。
53
+ * 组合只存裸 id,同名模型可能属于多个供应商,故优先取「已启用」的那个;
54
+ * 谁都没有的模型显示裸名——不能兜底套第一个供应商的前缀,那样前缀是假的。 */
56
55
  const aliasOf = (model: string): string => {
56
+ for (const g of groups) {
57
+ if (g.models.some(m => m.id === model && m.enabled)) return g.supplier.alias
58
+ }
57
59
  for (const g of groups) {
58
60
  if (g.models.some(m => m.id === model)) return g.supplier.alias
59
61
  }
60
- return primaryAlias
62
+ return ''
61
63
  }
62
64
  const [showForm, setShowForm] = useState(false)
63
65
  const [editing, setEditing] = useState<RouterCombo | null>(null)
@@ -39,6 +39,7 @@ const I = {
39
39
  edit: 'M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3zM13.5 6.5l3 3',
40
40
  loading: 'M12 3a9 9 0 1 0 9 9M12 3a9 9 0 0 1 9 9',
41
41
  search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
42
+ refresh: 'M20.5 12a8.5 8.5 0 1 1-2.6-6.1M20.5 4v5h-5',
42
43
  }
43
44
 
44
45
  interface SupplierDetailProps {
@@ -108,8 +109,8 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
108
109
  const [removeModelTarget, setRemoveModelTarget] = useState<string | null>(null)
109
110
  const [poolStrategy, setPoolStrategy] = useState<'fallback' | 'round-robin'>('fallback')
110
111
  const [poolOrder, setPoolOrder] = useState<string[]>([])
111
- const [checkinRule, setCheckinRule] = useState<'all' | 'first'>('all')
112
112
  const [checkingIn, setCheckingIn] = useState(false)
113
+ const [refreshing, setRefreshing] = useState(false)
113
114
  const [dragIndex, setDragIndex] = useState<number | null>(null)
114
115
  const mounted = useRef(true)
115
116
 
@@ -144,20 +145,17 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
144
145
 
145
146
  const loadPoolConfig = useCallback(async (): Promise<void> => {
146
147
  try {
147
- const [strategyRes, orderRes, ruleRes] = await Promise.all([
148
+ const [strategyRes, orderRes] = await Promise.all([
148
149
  fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, { cache: 'no-store' }),
149
150
  fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' }),
150
- fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin/rule`, { cache: 'no-store' }),
151
151
  ])
152
- const [strategyData, orderData, ruleData] = await Promise.all([strategyRes.json(), orderRes.json(), ruleRes.json()]) as [
152
+ const [strategyData, orderData] = await Promise.all([strategyRes.json(), orderRes.json()]) as [
153
153
  { ok: boolean; strategy?: 'fallback' | 'round-robin' },
154
154
  { ok: boolean; order?: string[] },
155
- { ok: boolean; rule?: 'all' | 'first' },
156
155
  ]
157
156
  if (mounted.current) {
158
157
  if (strategyData.ok && strategyData.strategy) setPoolStrategy(strategyData.strategy)
159
158
  if (orderData.ok && Array.isArray(orderData.order)) setPoolOrder(orderData.order)
160
- if (ruleData.ok && ruleData.rule) setCheckinRule(ruleData.rule)
161
159
  }
162
160
  } catch {
163
161
  // 连接池配置加载失败不阻断
@@ -209,6 +207,45 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
209
207
  }
210
208
  }
211
209
 
210
+ /** 刷新链接池:积分刷一遍 + 全供应商健康探测(最简会话,走真实 chatCompletions
211
+ * 并按账号池回退,所以「这个供应商还有没有活着的链接」能回答;不按链接细分 —— 那需要
212
+ * 按 uid 指定账号的管道,会给插件增负)。 */
213
+ const runRefresh = async (): Promise<void> => {
214
+ if (refreshing) return
215
+ setRefreshing(true)
216
+ let msg = ''
217
+ try {
218
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/links/refresh`, { method: 'POST', cache: 'no-store' })
219
+ const data = await response.json() as { ok: boolean; changed?: boolean; accounts?: unknown[]; error?: string }
220
+ if (!data.ok) {
221
+ showToast(data.error ?? '刷新失败')
222
+ return
223
+ }
224
+ // 健康探测:拿一个启用模型跑最简会话;没有启用模型就退回任意第一个
225
+ const probe = models?.find(m => m.enabled) ?? models?.[0]
226
+ if (probe === undefined) {
227
+ msg = `已刷新 ${data.accounts?.length ?? 0} 链接 · 无模型可探测`
228
+ } else {
229
+ const probeRes = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/test`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({ id: probe.id }),
233
+ cache: 'no-store',
234
+ })
235
+ const probeData = await probeRes.json() as { ok: boolean; error?: string }
236
+ msg = probeData.ok
237
+ ? `已刷新 ${data.accounts?.length ?? 0} 链接 · 健康`
238
+ : `已刷新 ${data.accounts?.length ?? 0} 链接 · 不可用:${probeData.error ?? '未知'}`
239
+ }
240
+ showToast(msg)
241
+ onRefresh()
242
+ } catch (err) {
243
+ showToast(`刷新失败:${(err as Error).message}`)
244
+ } finally {
245
+ setRefreshing(false)
246
+ }
247
+ }
248
+
212
249
  const startLogin = async (): Promise<void> => {
213
250
  // API key 供应商:弹窗直接填名字+key,不走 URL 登录
214
251
  if (canApiKey) {
@@ -238,15 +275,15 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
238
275
  }
239
276
  }
240
277
 
241
- const completeLogin = async (): Promise<void> => {
242
- if (callbackUrl.trim() === '') return
278
+ const completeLogin = async (poll = false): Promise<void> => {
279
+ if (!poll && callbackUrl.trim() === '') return
243
280
  setBusy(true)
244
281
  setError('')
245
282
  try {
246
283
  const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/login/callback`, {
247
284
  method: 'POST',
248
285
  headers: { 'Content-Type': 'application/json' },
249
- body: JSON.stringify({ callbackUrl: callbackUrl.trim() }),
286
+ body: JSON.stringify({ callbackUrl: poll ? '' : callbackUrl.trim() }),
250
287
  cache: 'no-store',
251
288
  })
252
289
  const data = await response.json() as { ok: boolean; error?: string; account?: { uid: string; nickname: string } }
@@ -470,7 +507,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
470
507
  const data = await response.json() as { ok: boolean; error?: string }
471
508
  if (data.ok) {
472
509
  setAlias(aliasDraft.trim())
473
- // 同时保存连接池策略 + 签到规则
510
+ // 同时保存连接池策略
474
511
  try {
475
512
  await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, {
476
513
  method: 'POST',
@@ -481,16 +518,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
481
518
  } catch {
482
519
  // 策略保存失败不阻断前缀保存
483
520
  }
484
- try {
485
- await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin/rule`, {
486
- method: 'POST',
487
- headers: { 'Content-Type': 'application/json' },
488
- body: JSON.stringify({ rule: checkinRule }),
489
- cache: 'no-store',
490
- })
491
- } catch {
492
- // 签到规则保存失败不阻断前缀保存
493
- }
494
521
  setShowAlias(false)
495
522
  } else {
496
523
  setAliasError(data.error ?? '保存失败')
@@ -559,9 +586,16 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
559
586
  <div className="dshr-cardHead">
560
587
  <div className="dshr-cardTitle">链接池</div>
561
588
  <div className="dshr-cardActions">
589
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm" onClick={() => void runRefresh()} disabled={refreshing || supplierAccounts.length === 0} title="刷新积分并探测健康" aria-label="刷新积分并探测健康">
590
+ <span className={refreshing ? 'dshr-spin' : undefined}>
591
+ <Icon d={I.refresh} size={15} />
592
+ </span>
593
+ </button>
562
594
  {canCheckin && (
563
- <button type="button" className="dshr-miniButton" onClick={() => void runCheckin()} disabled={checkingIn || supplierAccounts.length === 0}>
564
- {checkingIn ? '签到中…' : '签到'}
595
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm" onClick={() => void runCheckin()} disabled={checkingIn || supplierAccounts.length === 0} title="签到所有链接" aria-label="签到所有链接">
596
+ <span className={checkingIn ? 'dshr-spin' : undefined}>
597
+ <Icon d={I.checkCircle} size={15} />
598
+ </span>
565
599
  </button>
566
600
  )}
567
601
  {canLogin && (
@@ -792,7 +826,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
792
826
  </Modal>
793
827
  )}
794
828
 
795
- {/* ---- 编辑供应商弹窗(前缀 + 连接池策略 + 签到规则) ---- */}
829
+ {/* ---- 编辑供应商弹窗(前缀 + 连接池策略) ---- */}
796
830
  {showAlias && (
797
831
  <Modal title="编辑供应商" onClose={() => setShowAlias(false)}>
798
832
  <div className="dshr-modalForm">
@@ -818,16 +852,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
818
852
  <option value="round-robin">轮询</option>
819
853
  </select>
820
854
  </>
821
- <label className="dshr-fieldLabel">签到规则</label>
822
- <p className="dshr-muted">所有链接 = 签到每个链接;首个链接 = 只签到第一个链接。(dsh-router 通用策略)</p>
823
- <select
824
- className="dshr-select dshr-input"
825
- value={checkinRule}
826
- onChange={(e) => setCheckinRule(e.target.value as 'all' | 'first')}
827
- >
828
- <option value="all">所有链接</option>
829
- <option value="first">首个链接</option>
830
- </select>
831
855
  {aliasError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{aliasError}</span></div>}
832
856
  <div className="dshr-modalActions">
833
857
  <button type="button" className="dshr-miniButton" onClick={() => setShowAlias(false)}>取消</button>
@@ -895,7 +919,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
895
919
  <div className="dshr-loginStep"><span className="dshr-loginNum">2</span>登录成功后点「完成添加」,自动获取凭证</div>
896
920
  {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
897
921
  <div className="dshr-modalActions">
898
- <button type="button" className="dshr-primaryButton" onClick={() => void completeLogin()} disabled={busy}>
922
+ <button type="button" className="dshr-primaryButton" onClick={() => void completeLogin(true)} disabled={busy}>
899
923
  {busy ? '轮询中…' : '完成添加'}
900
924
  </button>
901
925
  </div>
@@ -430,14 +430,17 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
430
430
 
431
431
  .dshr-tabBody {
432
432
  display: flex;
433
+ flex: 1 1 auto; /* 填满 content;自身滚动 */
433
434
  min-width: 0;
434
435
  min-height: 0;
435
436
  flex-direction: column;
436
437
  gap: 14px;
437
438
  padding: 14px 18px;
439
+ overflow-y: auto;
438
440
  }
439
441
 
440
442
  .dshr-card {
443
+ flex: none; /* 不参与 flex 伸缩:高度=内容,避免被压缩裁切 */
441
444
  min-width: 0;
442
445
  border: 1px solid var(--rs-line);
443
446
  border-radius: 10px;
@@ -1100,6 +1103,12 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
1100
1103
 
1101
1104
  .dshr-iconBtn-sm { width: 26px; height: 26px; }
1102
1105
 
1106
+ /* 禁用态必须看得出来:纯图标按钮没有文字可读(不像「刷新中…」),
1107
+ 只靠 hover 反馈会让人以为还能点。 */
1108
+ .dshr-iconBtn:disabled { opacity: .4; cursor: default; }
1109
+
1110
+ .dshr-iconBtn:disabled:hover { color: var(--rs-muted); background: transparent; }
1111
+
1103
1112
  /* Require API key 行 */
1104
1113
  .dshr-requireRow {
1105
1114
  display: flex;
package/src/index.ts CHANGED
@@ -128,7 +128,7 @@ export function apply(rawContext: unknown): void {
128
128
  const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
129
129
  const store = new SupplierConfigStore(stateFile)
130
130
  const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
131
- const router = new Router(stateFile)
131
+ const router = new Router(stateFile, store)
132
132
 
133
133
  const keys = new KeysStore(stateFile)
134
134
 
@@ -146,7 +146,7 @@ export function apply(rawContext: unknown): void {
146
146
  const supplierDisposers = new Map<string, Array<() => void>>()
147
147
  const registerSupplierRoutes = (loaded: LoadedSupplier): void => {
148
148
  const dis: Array<() => void> = []
149
- for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) {
149
+ for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store, router)) {
150
150
  dis.push(ctx.webServer.register(r))
151
151
  }
152
152
  supplierDisposers.set(loaded.supplier.id, dis)