dsh-router-core 0.3.10 → 0.3.12

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 (43) hide show
  1. package/README.md +30 -1
  2. package/docs/ext.md +224 -0
  3. package/docs/pool-sticky-block.md +206 -0
  4. package/docs/suppliers.md +5 -1
  5. package/lib/client-registry.js +270 -96
  6. package/lib/client.js +270 -96
  7. package/lib/index.js +372 -13
  8. package/lib/suppliers/openrouter.js +6 -1
  9. package/lib/types/data-dir.d.ts +11 -0
  10. package/lib/types/ext/contract.d.ts +95 -0
  11. package/lib/types/ext/store.d.ts +16 -0
  12. package/lib/types/llm/adapter.d.ts +7 -0
  13. package/lib/types/router/account-pool.d.ts +47 -1
  14. package/lib/types/router/index.d.ts +15 -0
  15. package/lib/types/router/types.d.ts +2 -0
  16. package/lib/types/router/usage-store.d.ts +8 -0
  17. package/lib/types/shared.d.ts +20 -0
  18. package/lib/types/suppliers/contract.d.ts +21 -0
  19. package/package.json +1 -1
  20. package/src/client/ExtTab.tsx +143 -0
  21. package/src/client/RouterView.tsx +12 -4
  22. package/src/client/StatsTab.tsx +4 -2
  23. package/src/client/SupplierDetail.tsx +2 -2
  24. package/src/client/router.css +19 -0
  25. package/src/data-dir.test.ts +54 -0
  26. package/src/data-dir.ts +46 -0
  27. package/src/ext/contract.ts +115 -0
  28. package/src/ext/store.test.ts +144 -0
  29. package/src/ext/store.ts +125 -0
  30. package/src/index.ts +101 -6
  31. package/src/llm/adapter.test.ts +17 -0
  32. package/src/llm/adapter.ts +8 -1
  33. package/src/router/account-pool.test.ts +118 -1
  34. package/src/router/account-pool.ts +115 -3
  35. package/src/router/index.ts +41 -1
  36. package/src/router/models-cache.test.ts +38 -0
  37. package/src/router/types.ts +2 -0
  38. package/src/router/usage-store.ts +8 -0
  39. package/src/shared.ts +22 -0
  40. package/src/suppliers/contract.ts +22 -0
  41. package/src/suppliers/loader.test.ts +66 -1
  42. package/src/suppliers/loader.ts +22 -2
  43. package/src/suppliers/openrouter/plugin.ts +5 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 扩展 (Ext) 通用契约 —— dsh-router 的「扩展插件」扩展点(不是供应商)。
3
+ *
4
+ * 分工(2026-09 重构:核心只做管理,不做拦截):
5
+ * - **dsh-router 核心 = 管理面**:持有 `router.ext` 注册表(发现)、渲染面板与
6
+ * `/router/api/ext`(展示)、代存开关与插件数据(`router.extStore` →
7
+ * `<dataDir>/ext.json`)。核心**不挂 `tools/execute`、不裁决、不改写**。
8
+ * - **扩展插件 = 执行面**:自己挂监听、自己问 `isEnabled`、自己按 ready 裁决、
9
+ * 自己短路执行。怎么改一条命令是插件的**私事**,不在本契约里。
10
+ *
11
+ * 为什么把拦截从核心挪走:
12
+ * `tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在
13
+ * agent scope 下就能收到全部派发)。核心代挂只在一个消费者(rtk)时是纯亏损:为留
14
+ * 40 行委派逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面
15
+ * 后,扩展插件与 dsh-router 只在**注册表 + 存储**两处耦合。
16
+ *
17
+ * ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
18
+ * 不可控」。这个保护现在没了;目前只有 rtk 一家消费,无所谓。**第二家 ext 出现时
19
+ * 要重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
20
+ *
21
+ * 契约要点:
22
+ * - 扩展器只有**声明**(id / name / description)+ `getState()`;核心拿这些渲染面板。
23
+ * - `getState()` 只报**运行时事实**(ready 是否可用、detail 说明),不报开关。
24
+ * - **开关与数据归核心持久化**(`router.extStore` → `<dataDir>/ext.json`),插件不自己
25
+ * file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
26
+ */
27
+ import type { Context } from '@deepseek-ai/cordis';
28
+ /** 扩展器唯一 id(注册键)。 */
29
+ export type ExtId = string;
30
+ /**
31
+ * 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
32
+ *
33
+ * 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
34
+ * 插件只负责做事 + 报自己是否可用。核心把两者合并后再给面板用。
35
+ */
36
+ export interface ExtState {
37
+ /** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
38
+ ready: boolean;
39
+ /** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
40
+ detail?: string;
41
+ }
42
+ /**
43
+ * 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员,
44
+ * 插件可以携带更多内部状态。
45
+ */
46
+ export interface RouterExt {
47
+ /** 扩展器 id,也是 `router.ext` 表里的注册键。 */
48
+ readonly id: ExtId;
49
+ /** 面板显示名(如「RTK」)。 */
50
+ readonly name: string;
51
+ /** 面板副标题说明。 */
52
+ readonly description?: string;
53
+ /** 当前运行时状态(ready + 不就绪时的说明)。 */
54
+ getState(): ExtState;
55
+ /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
56
+ dispose?(): void;
57
+ }
58
+ /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
59
+ export interface ExtInfo {
60
+ id: string;
61
+ name: string;
62
+ description?: string;
63
+ /** 核心持久化的开关(默认关)。 */
64
+ enabled: boolean;
65
+ /** 插件报的运行时就绪状态。 */
66
+ ready: boolean;
67
+ /** 不就绪时的说明(面板红字)。 */
68
+ detail?: string;
69
+ }
70
+ /** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
71
+ export interface RouterExtService {
72
+ [extId: string]: RouterExt;
73
+ }
74
+ /**
75
+ * 扩展的存储面(核心 provide,插件 inject)。
76
+ *
77
+ * 插件**不自己 file IO**:落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定(不跟
78
+ * process.cwd() 跑),读写都经核心,同一文件、同一次原子写。
79
+ *
80
+ * 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
81
+ */
82
+ export interface ExtStoreService {
83
+ /** 是否开启(未记录过 = 默认关)。 */
84
+ isEnabled(id: string): boolean;
85
+ /** 置开关(面板/API 调)。 */
86
+ setEnabled(id: string, enabled: boolean): void;
87
+ /** 读插件自己的数据块(没有 = undefined)。 */
88
+ readData<T = unknown>(id: string): T | undefined;
89
+ /** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
90
+ writeData(id: string, value: unknown): void;
91
+ }
92
+ /** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
93
+ export declare function currentExts(ctx: Context): RouterExtService | undefined;
94
+ /** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
95
+ export declare function currentExtStore(ctx: Context): ExtStoreService | undefined;
@@ -0,0 +1,16 @@
1
+ /** 扩展开关 + 插件数据存储。 */
2
+ export declare class ExtStore {
3
+ private fp;
4
+ private byId;
5
+ constructor(stateFile: string);
6
+ private migrateLegacy;
7
+ private load;
8
+ private save;
9
+ /** 是否开启(未记录过 = 默认关)。 */
10
+ isEnabled(id: string): boolean;
11
+ setEnabled(id: string, enabled: boolean): void;
12
+ /** 读插件自己存的数据块(没存过 = undefined)。 */
13
+ readData<T = unknown>(id: string): T | undefined;
14
+ /** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
15
+ writeData(id: string, value: unknown): void;
16
+ }
@@ -9,9 +9,16 @@
9
9
  import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
10
10
  /** 模型目录来源:组合。 */
11
11
  export interface RouterAdapterSource {
12
+ /**
13
+ * 组合列表。`contextWindow` 可选,但**必须尽量给**——自动压缩靠它算阈值
14
+ * (`dsh-compaction-basic` 在 `context` 缺失时直接抛错并静默关闭自动压缩,
15
+ * 于是上下文会一路涨到模型硬上限才炸)。
16
+ * 组合背后是异构供应商时给**最小的那个**(保守,宁可早压缩)。
17
+ */
12
18
  comboModels: () => Promise<Array<{
13
19
  id: string;
14
20
  name?: string;
21
+ contextWindow?: number;
15
22
  }>>;
16
23
  }
17
24
  /** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
@@ -31,6 +31,10 @@ export declare class AccountPool {
31
31
  /** uid → 连接级禁用 / 手动暂停。 */
32
32
  private byUid;
33
33
  private rrCursor;
34
+ /** (supplier, model) → 块轮询驻留状态。 */
35
+ private blocks;
36
+ /** (supplier, model, uid) → 缓存无效降权。 */
37
+ private demoted;
34
38
  constructor(supplierId?: string);
35
39
  private key;
36
40
  private entry;
@@ -46,13 +50,55 @@ export declare class AccountPool {
46
50
  private healthy;
47
51
  /**
48
52
  * 按策略为「某个模型」选一个健康账号。
53
+ *
54
+ * round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
55
+ * 设计见 docs/pool-sticky-block.md。要点:
56
+ * - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
57
+ * 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
58
+ * 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
59
+ * - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
60
+ * (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
61
+ * - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
62
+ *
49
63
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
50
64
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
51
- * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
65
+ * @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
52
66
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
53
67
  * @returns 选中的 uid;无健康账号返回 undefined
54
68
  */
55
69
  pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
70
+ /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
71
+ private isDemoted;
72
+ /**
73
+ * 块是否还应驻留在当前号上。
74
+ *
75
+ * OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
76
+ * 单一账号内部:一个号先被轮到(冷启动/热),然后
77
+ * - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
78
+ * - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
79
+ * - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
80
+ *
81
+ * 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
82
+ * 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
83
+ *
84
+ * 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
85
+ * 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
86
+ * 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
87
+ */
88
+ private blockKeeps;
89
+ /**
90
+ * 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
91
+ *
92
+ * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
93
+ * @param uid 实际服务的号
94
+ * @param modelId 模型(块粒度)
95
+ * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
96
+ */
97
+ noteCache(uid: string, modelId: string, cachedTokens: number): void;
98
+ /** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
99
+ dropBlock(modelId: string): void;
100
+ /** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
101
+ private blockKey;
56
102
  /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
57
103
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
58
104
  /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
@@ -66,6 +66,21 @@ export declare class Router {
66
66
  * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
67
67
  * (冷启动,没有旧值可给)。后者只发生一次。
68
68
  */
69
+ /**
70
+ * 组合的上下文窗口(token):组合内各模型窗口的**最小值**,拿不到就 undefined。
71
+ *
72
+ * 只读缓存、绝不打上游:调用方是 llm adapter 的 `resolveModel`,它在每次
73
+ * 请求前后都可能被调用,不能为了报一个容量去触发按供应商的网络往返。
74
+ * 冷启动(还没缓存)时给 undefined —— 自动压缩会因此跳过这一轮,下次就
75
+ * 有了,比撒谎说一个窗口值安全。
76
+ *
77
+ * 单位:`ModelInfo.context_length` 存的是 **K**(供应商插件统一
78
+ * `Math.round(tokens / 1000)`,见 codebuddy plugin),这里换算回 token。
79
+ *
80
+ * 取最小而不是最大:组合会按策略 fallback 到任意一个模型,按最窄的声明
81
+ * 才能让「快满了」这件事在任何一条分支上都成立。
82
+ */
83
+ comboContextWindow(combo: Combo): number | undefined;
69
84
  modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
70
85
  /**
71
86
  * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
@@ -62,6 +62,8 @@ export interface Supplier {
62
62
  readonly priority: number;
63
63
  /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
64
64
  readonly icon?: string;
65
+ /** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。可选。 */
66
+ readonly apiKeyHint?: string;
65
67
  status(): SupplierStatus;
66
68
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
67
69
  /** 面板展示的模型(含启用/自定义标记)。 */
@@ -15,6 +15,14 @@ export interface UsageRecord {
15
15
  durationMs: number;
16
16
  /** 首字节延迟(ms);非流式为 0。 */
17
17
  ttfbMs: number;
18
+ /**
19
+ * 实际服务的账号 uid(成功时回填)。
20
+ *
21
+ * 有了它才能把命中率按**号**下钻——prompt cache 是 per-account 命名空间,
22
+ * 只看供应商/模型无法判断「是某个号的缓存没热,还是策略在乱切号」。
23
+ * 块轮询(docs/pool-sticky-block.md)的调参完全依赖这个归因。
24
+ */
25
+ uid?: string;
18
26
  /** 失败原因(ok=false 时)。 */
19
27
  error?: string;
20
28
  }
@@ -68,6 +68,8 @@ export interface RouterHealthResponse {
68
68
  name: string;
69
69
  /** 图标(URL 或 SVG data URI)。 */
70
70
  icon?: string;
71
+ /** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。 */
72
+ apiKeyHint?: string;
71
73
  /** 面板能力集合(登录/签到/连接池/模型管理等,按存在性)。 */
72
74
  capabilities?: string[];
73
75
  /** 来源:内置 / 用户目录 / 外部插件。 */
@@ -130,6 +132,24 @@ export interface RouterSettingsResponse {
130
132
  error?: string;
131
133
  requireApiKey?: boolean;
132
134
  }
135
+ /** 一条扩展器(`/router/api/ext`)。 */
136
+ export interface RouterExtItem {
137
+ id: string;
138
+ name: string;
139
+ description?: string;
140
+ /** 是否启用改写(面板开关)。 */
141
+ enabled?: boolean;
142
+ /** 运行时是否就绪(如 RTK 二进制是否可用)。 */
143
+ ready?: boolean;
144
+ /** 一句话状态说明(如「本机未装 rtk」)。 */
145
+ detail?: string;
146
+ }
147
+ /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
148
+ export interface RouterExtResponse {
149
+ ok: boolean;
150
+ error?: string;
151
+ enhancers?: RouterExtItem[];
152
+ }
133
153
  /** 供应商模型(含启用状态)。 */
134
154
  export interface RouterSupplierModel {
135
155
  id: string;
@@ -22,6 +22,21 @@ export interface SupplierEnv {
22
22
  store: SupplierConfigStore;
23
23
  /** 通用凭证存储(账号凭证,核心统一管)。 */
24
24
  credentials: CredentialStore;
25
+ /**
26
+ * 迟到的失败上报(可选)——**只在响应已提交之后**才用。
27
+ *
28
+ * 流式请求一旦写出第一个字节就绑死(HTTP 语义),此时上游再报错已经换不了
29
+ * 号、也改不了状态码。但「这个号坏了」这件事对**后续**请求仍然有价值:
30
+ * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
31
+ *
32
+ * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
33
+ * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
34
+ * 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
35
+ * (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
36
+ *
37
+ * 插件拿不到也不用兜底:不调用就退化成今天的行为,不影响正确性。
38
+ */
39
+ onLateFailure?: (uid: string, model: string, state: AccountState, message: string) => void;
25
40
  }
26
41
  /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
27
42
  export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
@@ -93,6 +108,12 @@ export interface SupplierModule {
93
108
  readonly priority?: number;
94
109
  /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
95
110
  readonly icon?: string;
111
+ /**
112
+ * 「添加 API key」弹窗的提示文案(HTML 未转义前是纯文本)。可选。
113
+ * 缺省时前端用通用文案。供应商若走 `addApiKey` 且有自己的 key 获取方式,
114
+ * 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
115
+ */
116
+ readonly apiKeyHint?: string;
96
117
  /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
97
118
  status(): SupplierStatusNow;
98
119
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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": {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 扩展 (Ext) 页 —— 列出所有注册进 `router.ext` 的扩展插件,每个一个开关。
3
+ *
4
+ * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
+ * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK),
6
+ * 开关与渲染都在核心。
7
+ *
8
+ * 布局贴近 EndpointTab 的「Require API key」开关行:每个扩展器一张卡片。
9
+ * 未装任何扩展 → 空状态提示。
10
+ */
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
13
+
14
+ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
15
+ return (
16
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
17
+ <path d={d} />
18
+ </svg>
19
+ )
20
+ }
21
+
22
+ const I = {
23
+ bolt: 'M13 2L4 14h6l-1 8 9-12h-6l1-8z',
24
+ puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a1 1 0 0 1 1 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
25
+ }
26
+
27
+ export function ExtTab(): JSX.Element {
28
+ const [items, setItems] = useState<RouterExtItem[]>([])
29
+ const [loaded, setLoaded] = useState(false)
30
+ const [error, setError] = useState('')
31
+ const mounted = useRef(true)
32
+
33
+ useEffect(() => {
34
+ mounted.current = true
35
+ void load()
36
+ return () => { mounted.current = false }
37
+ }, [])
38
+
39
+ const load = async (): Promise<void> => {
40
+ try {
41
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, { cache: 'no-store' })
42
+ const data = await response.json() as RouterExtResponse
43
+ if (mounted.current) {
44
+ setError(data.ok ? '' : (data.error ?? '加载扩展失败'))
45
+ setItems(data.enhancers ?? [])
46
+ setLoaded(true)
47
+ }
48
+ } catch (err) {
49
+ if (mounted.current) {
50
+ setError((err as Error).message)
51
+ setLoaded(true)
52
+ }
53
+ }
54
+ }
55
+
56
+ const toggle = async (item: RouterExtItem, value: boolean): Promise<void> => {
57
+ // 乐观更新
58
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: value } : i))
59
+ setError('')
60
+ try {
61
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
62
+ method: 'PATCH',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ id: item.id, enabled: value }),
65
+ cache: 'no-store',
66
+ })
67
+ const data = await response.json() as RouterExtResponse
68
+ if (mounted.current && data.enhancers) {
69
+ setItems(data.enhancers)
70
+ } else if (mounted.current && !data.ok) {
71
+ setError(data.error ?? '切换失败')
72
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
73
+ }
74
+ } catch (err) {
75
+ if (mounted.current) {
76
+ setError((err as Error).message)
77
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
78
+ }
79
+ }
80
+ }
81
+
82
+ return (
83
+ <div className="dshr-tabBody">
84
+ {error !== '' && (
85
+ <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>
86
+ )}
87
+
88
+ {!loaded ? (
89
+ <div className="dshr-empty">加载中…</div>
90
+ ) : items.length === 0 ? (
91
+ <div className="dshr-keyEmpty">
92
+ <span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
93
+ <p className="dshr-keyEmptyTitle">暂无扩展</p>
94
+ <p className="dshr-keyEmptyDesc">
95
+ 安装扩展插件(如 <code>dsh-router-ext-rtk</code>)后,这里会出现开关
96
+ </p>
97
+ </div>
98
+ ) : (
99
+ items.map(item => {
100
+ const notReady = item.ready === false
101
+ return (
102
+ <section key={item.id} className="dshr-card">
103
+ <div className="dshr-cardHead">
104
+ <span className="dshr-cardIcon"><Icon d={I.bolt} /></span>
105
+ <div className="dshr-cardTitle">{item.name}</div>
106
+ {/* 开关放标题行最右端:.dshr-cardAction 是现成的
107
+ margin-left:auto(EndpointTab 的「创建 Key」就靠它),复用不新增。
108
+ 不可用时禁开——禁了就无从开启成功。 */}
109
+ <button
110
+ type="button"
111
+ className={`dshr-toggle dshr-cardAction ${item.enabled ? 'dshr-toggle-on' : ''}`}
112
+ role="switch"
113
+ aria-checked={!!item.enabled}
114
+ disabled={notReady && !item.enabled}
115
+ onClick={() => void toggle(item, !item.enabled)}
116
+ title={notReady && !item.enabled
117
+ ? '当前不可用,无法开启'
118
+ : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
119
+ >
120
+ <span className="dshr-toggleKnob" />
121
+ </button>
122
+ </div>
123
+
124
+ {/* 内容区:说明 + 未就绪红字(左对齐、与卡片图标对齐) */}
125
+ <div className="dshr-extBody">
126
+ {item.description !== undefined && item.description !== '' && (
127
+ <div className="dshr-muted">{item.description}</div>
128
+ )}
129
+ {notReady && (
130
+ <div className="dshr-ext-error">
131
+ {item.detail !== undefined && item.detail !== ''
132
+ ? item.detail
133
+ : '未就绪,无法开启'}
134
+ </div>
135
+ )}
136
+ </div>
137
+ </section>
138
+ )
139
+ })
140
+ )}
141
+ </div>
142
+ )
143
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * 路由系统 (Routing System) panel — a simplified 9router.
3
3
  *
4
- * Four tabs fed by the host half (/router/api/*), mirroring 9router's
4
+ * Five tabs fed by the host half (/router/api/*), mirroring 9router's
5
5
  * dashboard: 概览 (usage), 供应商 (supplier cards → detail: accounts +
6
6
  * add-account + models), 组合 (combo fallback chains),
7
+ * 扩展 (extension plugins — one toggle each, self-checked),
7
8
  * 端点与密钥 (endpoint URL + API keys + auth).
8
9
  *
9
10
  * **挂载在「设置」里**(`settings.section` 座位),所以布局规则跟中心栏劫持时期不同:
@@ -27,8 +28,9 @@ import { SupplierDetail } from './SupplierDetail.tsx'
27
28
  import { EndpointTab } from './EndpointTab.tsx'
28
29
  import { CombosTab } from './CombosTab.tsx'
29
30
  import { StatsTab } from './StatsTab.tsx'
31
+ import { ExtTab } from './ExtTab.tsx'
30
32
 
31
- type TabId = 'overview' | 'suppliers' | 'combos' | 'endpoint'
33
+ type TabId = 'overview' | 'suppliers' | 'combos' | 'endpoint' | 'ext'
32
34
 
33
35
  interface Snapshot {
34
36
  health: RouterHealthResponse | null
@@ -54,7 +56,7 @@ interface RouterViewProps {
54
56
 
55
57
  export function RouterView({ onBack }: RouterViewProps): JSX.Element {
56
58
  const [tab, setTab] = useState<TabId>('overview')
57
- const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string } | null>(null)
59
+ const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string; apiKeyHint?: string } | null>(null)
58
60
  const [snapshot, setSnapshot] = useState<Snapshot>({ health: null, status: null, combos: null })
59
61
  const [refreshing, setRefreshing] = useState(false)
60
62
  const mounted = useRef(true)
@@ -90,7 +92,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
90
92
  const combosError = snapshot.combos?.ok === false ? snapshot.combos.error : undefined
91
93
 
92
94
  /** 渲染一组供应商卡片(内置 / 插件分开)。 */
93
- const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; source?: string }>): ReactNode => {
95
+ const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; apiKeyHint?: string; source?: string }>): ReactNode => {
94
96
  if (group.length === 0) return null
95
97
  return (
96
98
  <div className="dshr-supplierGroup">
@@ -142,6 +144,9 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
142
144
  <button type="button" role="tab" aria-selected={tab === 'combos'} className={`dshr-tab${tab === 'combos' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('combos')}>
143
145
  组合 <span className="dshr-navCount">{combos.length}</span>
144
146
  </button>
147
+ <button type="button" role="tab" aria-selected={tab === 'ext'} className={`dshr-tab${tab === 'ext' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('ext')}>
148
+ 扩展
149
+ </button>
145
150
  <button type="button" role="tab" aria-selected={tab === 'endpoint'} className={`dshr-tab${tab === 'endpoint' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('endpoint')}>
146
151
  端点与密钥
147
152
  </button>
@@ -205,6 +210,9 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
205
210
 
206
211
  {/* ---------------- 端点与密钥 ---------------- */}
207
212
  {tab === 'endpoint' && <EndpointTab />}
213
+
214
+ {/* ---------------- 增强 (扩展插件) ---------------- */}
215
+ {tab === 'ext' && <ExtTab />}
208
216
  </div>
209
217
  </div>
210
218
  )
@@ -70,10 +70,12 @@ function fmt(n: number | undefined): string {
70
70
  return new Intl.NumberFormat().format(n ?? 0)
71
71
  }
72
72
 
73
- /** 大数缩写:1.2K / 3.4M(卡片和柱图上放不下完整数字时用)。 */
73
+ /** 大数缩写:1.2K / 3.4M / 5.6B / 7.8T(卡片和柱图上放不下完整数字时用)。 */
74
74
  function fmtShort(n: number | undefined): string {
75
75
  const v = n ?? 0
76
- if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
76
+ if (v >= 1e12) return `${(v / 1e12).toFixed(1)}T`
77
+ if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`
78
+ if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`
77
79
  if (v >= 10_000) return `${(v / 1000).toFixed(1)}K`
78
80
  return fmt(v)
79
81
  }
@@ -43,7 +43,7 @@ const I = {
43
43
  }
44
44
 
45
45
  interface SupplierDetailProps {
46
- supplier: { id: string; name: string; capabilities?: string[]; icon?: string }
46
+ supplier: { id: string; name: string; capabilities?: string[]; icon?: string; apiKeyHint?: string }
47
47
  accounts: RouterAccount[]
48
48
  /** 状态是否还在加载。 */
49
49
  statusLoading: boolean
@@ -948,7 +948,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
948
948
  {canApiKey
949
949
  ? (
950
950
  <div className="dshr-modalForm">
951
- <p className="dshr-muted">填入 OpenRouter API key(https://openrouter.ai/settings/keys),可起名字区分多个 key。</p>
951
+ <p className="dshr-muted">{supplier.apiKeyHint ?? '填入 API key,可起名字区分多个 key。'}</p>
952
952
  {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
953
953
  <input
954
954
  className="dshr-input"
@@ -910,6 +910,25 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
910
910
 
911
911
  .dshr-toggle-on .dshr-toggleKnob { transform: translateX(14px); }
912
912
 
913
+ /* 开关禁用态:不可用(未就绪)且未开启时,灰显 + 不可点 */
914
+ .dshr-toggle:disabled {
915
+ cursor: default;
916
+ opacity: .45;
917
+ }
918
+
919
+ /* 扩展页:未就绪问题描述(红字) */
920
+ .dshr-ext-error {
921
+ color: var(--rs-danger);
922
+ font-size: 11px;
923
+ margin-top: 4px;
924
+ }
925
+
926
+ /* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
927
+ .dshr-extBody {
928
+ padding: 10px 14px 12px 48px;
929
+ text-align: left;
930
+ }
931
+
913
932
  /* 操作按钮组 */
914
933
  .dshr-ops {
915
934
  display: flex;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 数据目录解析测试 —— 锁死「落盘位置不跟 cwd 跑」这个 bug。
3
+ *
4
+ * 起因:以前写的是相对路径 `data/state.json`,宿主 Node 进程的 cwd 决定它
5
+ * 落到哪。从别的目录 `dsh web` 启动 → 配置静默写进另一个目录,面板显示空
6
+ * 配置,用户看到的就是「配置丢了」。
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { mkdtempSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { dataDirOf, profileDirOf } from './data-dir.ts'
15
+
16
+ /** 一个临时 profile 目录的 file: URL(宿主 `ctx.baseUrl` 的形状)。 */
17
+ function tempProfileUrl(): { url: string; dir: string } {
18
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-profile-'))
19
+ writeFileSync(join(dir, 'cordis.yml'), '[]\n') // 让它像个 profile
20
+ return { url: pathToFileURL(dir).href + '/', dir }
21
+ }
22
+
23
+ test('有 baseUrl → 数据目录钉在 profile 里(绝对路径)', () => {
24
+ const { url, dir } = tempProfileUrl()
25
+ assert.equal(profileDirOf(url), dir)
26
+ assert.equal(dataDirOf(url), join(dir, 'data'))
27
+ })
28
+
29
+ test('换 cwd 不影响落盘位置(回归:不跟 process.cwd() 跑)', () => {
30
+ const { url, dir } = tempProfileUrl()
31
+ const before = dataDirOf(url)
32
+ const cwd = process.cwd()
33
+ try {
34
+ process.chdir(tmpdir())
35
+ assert.equal(dataDirOf(url), before)
36
+ } finally {
37
+ process.chdir(cwd)
38
+ }
39
+ assert.ok(resolve(before).startsWith(resolve(dir)))
40
+ })
41
+
42
+ test('baseUrl 缺失/异常 → 兜底 $DSH_HOME 的 web profile', () => {
43
+ const saved = process.env.DSH_HOME
44
+ const home = mkdtempSync(join(tmpdir(), 'dshr-home-'))
45
+ try {
46
+ process.env.DSH_HOME = home
47
+ assert.equal(profileDirOf(undefined), join(home, 'profiles', 'web'))
48
+ assert.equal(profileDirOf(''), join(home, 'profiles', 'web'))
49
+ assert.equal(profileDirOf('http://example.com/x'), join(home, 'profiles', 'web'))
50
+ } finally {
51
+ if (saved === undefined) delete process.env.DSH_HOME
52
+ else process.env.DSH_HOME = saved
53
+ }
54
+ })