dsh-router-core 0.2.0 → 0.3.1

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 (38) hide show
  1. package/README.md +40 -11
  2. package/docs/screenshot.png +0 -0
  3. package/docs/suppliers.md +302 -0
  4. package/lib/client-registry.js +1119 -213
  5. package/lib/client.js +1119 -213
  6. package/lib/index.js +1066 -50
  7. package/lib/types/llm/adapter.d.ts +2 -0
  8. package/lib/types/router/index.d.ts +34 -5
  9. package/lib/types/router/usage-store.d.ts +93 -0
  10. package/lib/types/router/usage-tokens.d.ts +118 -0
  11. package/lib/types/shared.d.ts +86 -1
  12. package/lib/types/supplier-config.d.ts +15 -1
  13. package/package.json +21 -4
  14. package/src/client/CheckinCard.tsx +180 -0
  15. package/src/client/CombosTab.tsx +28 -18
  16. package/src/client/RouterView.tsx +44 -27
  17. package/src/client/StatsTab.tsx +531 -0
  18. package/src/client/index.tsx +61 -11
  19. package/src/client/router.css +688 -35
  20. package/src/client/settings-nav-icon.ts +55 -0
  21. package/src/client/settings-section.tsx +21 -0
  22. package/src/client/workspace-mount.tsx +2 -9
  23. package/src/index.ts +51 -3
  24. package/src/llm/adapter.test.ts +108 -0
  25. package/src/llm/adapter.ts +18 -6
  26. package/src/router/index.ts +461 -49
  27. package/src/router/models-cache.test.ts +269 -0
  28. package/src/router/precise-dispatch.test.ts +198 -0
  29. package/src/router/usage-store.ts +499 -0
  30. package/src/router/usage-tokens.ts +327 -0
  31. package/src/router/usage.test.ts +558 -0
  32. package/src/shared.ts +90 -1
  33. package/src/supplier-config.ts +30 -2
  34. package/src/suppliers/loader.ts +2 -1
  35. package/src/suppliers/nvidia/plugin.ts +2 -3
  36. package/src/suppliers/opencode/plugin.ts +2 -3
  37. package/src/suppliers/openrouter/plugin.ts +2 -3
  38. package/src/suppliers/registry.ts +7 -2
@@ -14,6 +14,8 @@ export interface RouterAdapterSource {
14
14
  name?: string;
15
15
  }>>;
16
16
  }
17
+ /** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
18
+ export declare function translateSse(payloads: Iterable<string>): AsyncGenerator<StreamChunk>;
17
19
  /**
18
20
  * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
19
21
  * /v1/chat/completions(组合路由在 /v1 内完成)。
@@ -1,6 +1,7 @@
1
1
  import type { ServerResponse } from 'node:http';
2
2
  import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts';
3
3
  import { SupplierConfigStore } from '../supplier-config.ts';
4
+ import { UsageStore } from './usage-store.ts';
4
5
  /** 路由器。 */
5
6
  export declare class Router {
6
7
  private suppliers;
@@ -12,7 +13,17 @@ export declare class Router {
12
13
  private store;
13
14
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
14
15
  private modelsCache;
15
- constructor(stateFile?: string, store?: SupplierConfigStore);
16
+ /** 请求日志出口(面板/宿主 logger)。 */
17
+ private log;
18
+ /** 用量统计(概览看板)。 */
19
+ usage: UsageStore;
20
+ constructor(stateFile?: string, store?: SupplierConfigStore, log?: (msg: string) => void);
21
+ /**
22
+ * 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
23
+ * 排查「组合里某个模型不稳定」全靠它——没这个就只能猜。
24
+ * 格式刻意做成一行 grep 友好:`chat 请求model → supplier/model (uid) 结果 耗时`。
25
+ */
26
+ private logChat;
16
27
  private loadCombos;
17
28
  private saveCombos;
18
29
  add(supplier: Supplier): void;
@@ -65,6 +76,11 @@ export declare class Router {
65
76
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
66
77
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
67
78
  * 命名空间一起吃掉,后面请求必然 404。 */
79
+ /**
80
+ * 归一组合模型名为存储格式 `supplierId,modelId`。
81
+ * 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
82
+ * 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
83
+ */
68
84
  private normalizeModelIds;
69
85
  /** 创建组合(name 唯一,非 default)。 */
70
86
  createCombo(name: string, strategy: string, models: string[]): {
@@ -90,15 +106,28 @@ export declare class Router {
90
106
  * - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
91
107
  */
92
108
  chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void>;
93
- /** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
109
+ /** 调指定供应商。
110
+ * 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
111
+ * (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
112
+ * 命名空间(如 org/name)吃掉。 */
113
+ private chatWithTarget;
114
+ /** 把 model 改写为组合选中的模型名后,找对应的供应商 + 账号(策略全在核心)。
115
+ *
116
+ * `model` 形如 `supplierId,modelId`(组合的存储格式):直接用 supplierId 定位,
117
+ * 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
118
+ * 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
119
+ */
94
120
  private chatWithModel;
121
+ /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
122
+ supplierByAlias(alias: string): Supplier | undefined;
95
123
  /**
96
124
  * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
97
125
  * AccountState 处置 → 失败换号;写响应只在成功后发生。
98
126
  *
99
- * 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
100
- * 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
101
- * 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
127
+ * 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
128
+ * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
129
+ * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
130
+ * 延迟,目前未做。
102
131
  */
103
132
  private chatWithSupplier;
104
133
  /** 测试某供应商的某模型是否可用。
@@ -0,0 +1,93 @@
1
+ import type { Usage } from './usage-tokens.ts';
2
+ /** 一条明细(最近请求的环)。 */
3
+ export interface UsageRecord {
4
+ ts: number;
5
+ supplier: string;
6
+ model: string;
7
+ /** 客户端原始请求的 model(组合场景下就是组合名)。 */
8
+ requested: string;
9
+ ok: boolean;
10
+ promptTokens: number;
11
+ completionTokens: number;
12
+ cachedTokens: number;
13
+ inputEstimated: boolean;
14
+ outputEstimated: boolean;
15
+ durationMs: number;
16
+ /** 首字节延迟(ms);非流式为 0。 */
17
+ ttfbMs: number;
18
+ /** 失败原因(ok=false 时)。 */
19
+ error?: string;
20
+ }
21
+ export type Period = 'today' | '24h' | '7d' | '30d';
22
+ /** 本地日期键 `YYYY-MM-DD`(照 9router getLocalDateKey,不按 UTC 切天)。 */
23
+ export declare function localDateKey(ts: number): string;
24
+ /** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
25
+ export declare class UsageStore {
26
+ private fp;
27
+ private days;
28
+ private recent;
29
+ private lifetime;
30
+ private timer;
31
+ constructor(stateFile: string);
32
+ /**
33
+ * 记一次请求。
34
+ *
35
+ * **成功的零 token 请求不记**(照 9router `saveUsageStats` 的闸门:
36
+ * `in === 0 && out === 0` 直接 return,空请求混进来只会稀释统计)。
37
+ * **失败请求一定记** —— 不管有没有 token。成功率全靠它,挡掉的话
38
+ * 失败永远是 0 条,面板会显示 100% 成功,这个数字是骗人的。
39
+ */
40
+ record(r: Omit<UsageRecord, 'ts' | 'promptTokens' | 'completionTokens' | 'cachedTokens' | 'inputEstimated' | 'outputEstimated'> & {
41
+ ts?: number;
42
+ }, usage: Usage): void;
43
+ private bump;
44
+ /** 窗口内的明细(today/24h 路径)。 */
45
+ private inWindow;
46
+ /** 汇总 + Top 榜。 */
47
+ stats(period: Period, now?: number): StatsResult;
48
+ /** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
49
+ chart(period: Period, now?: number): ChartBucket[];
50
+ /** 最近请求明细。 */
51
+ recentList(limit?: number): UsageRecord[];
52
+ clear(): void;
53
+ /** 进程退出前把防抖中的写入落盘。 */
54
+ flush(): void;
55
+ private scheduleSave;
56
+ private load;
57
+ private save;
58
+ }
59
+ /** 柱状图一桶。 */
60
+ export interface ChartBucket {
61
+ label: string;
62
+ requests: number;
63
+ tokens: number;
64
+ }
65
+ export interface StatsResult {
66
+ requests: number;
67
+ ok: number;
68
+ failed: number;
69
+ promptTokens: number;
70
+ completionTokens: number;
71
+ cachedTokens: number;
72
+ /** 平均耗时(ms)。 */
73
+ avgDurationMs: number;
74
+ /** 平均首字节延迟(ms);没有 TTFB 记录时为 0。 */
75
+ avgTtfbMs: number;
76
+ /** 输入/输出为估算的请求数(面板提示用)。 */
77
+ estimatedInputs: number;
78
+ estimatedOutputs: number;
79
+ /** 累计请求数(不受周期影响)。 */
80
+ lifetime: number;
81
+ bySupplier: RankRow[];
82
+ byModel: RankRow[];
83
+ byRequested: RankRow[];
84
+ }
85
+ export interface RankRow {
86
+ name: string;
87
+ requests: number;
88
+ ok: number;
89
+ failed: number;
90
+ promptTokens: number;
91
+ completionTokens: number;
92
+ lastTs: number;
93
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Token 用量提取 —— 口径照 9router 的 `open-sse/utils/usageTracking.js`。
3
+ *
4
+ * 三件事:
5
+ * 1. **归一化**:上游字段名各不相同(prompt_tokens / input_tokens /
6
+ * promptTokenCount …),统一到 OpenAI 口径。
7
+ * 2. **合并**:流式里 usage 常被拆在多个事件里(Claude 系:
8
+ * message_start 给输入+缓存,message_delta 给输出)。所以是
9
+ * **字段级 max 合并**,不是覆盖——照 9router 的 `mergeUsage`。
10
+ * 3. **回退估算**:上游压根不发 usage 时,按字符数/4 估算(照 9router 的
11
+ * `estimateInputTokens`/`estimateOutputTokens`),并**分别**标记输入/输出
12
+ * 哪个是估算的——9router 只有一个 `estimated` 布尔,这里拆开更准,
13
+ * 面板才知道该给哪个数打「~」。
14
+ *
15
+ * 天花板(刻意不做):
16
+ * - 估算就是估算,~4 字符/token 是跨 tokenizer 的粗略平均。
17
+ * 要准得上真 tokenizer,代价是引入依赖,不值。
18
+ * - 不注入 `stream_options.include_usage`:那要改写客户端的请求体,
19
+ * 个别上游不认这个字段。上游不发我们就估算,不动请求语义。
20
+ */
21
+ import type { TokenUsage } from '@deepseek-ai/dsh-llm';
22
+ /** 归一化后的用量(不含估算标记)。 */
23
+ export interface UsageTokens {
24
+ promptTokens: number;
25
+ completionTokens: number;
26
+ cachedTokens: number;
27
+ }
28
+ /** 最终用量:token 数 + 哪些字段是估算的。 */
29
+ export interface Usage extends UsageTokens {
30
+ /** 输入是估算的(上游没给)。 */
31
+ inputEstimated: boolean;
32
+ /** 输出是估算的(上游没给)。 */
33
+ outputEstimated: boolean;
34
+ }
35
+ /**
36
+ * 从上游任意形态的 usage 对象提取归一用量。
37
+ *
38
+ * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
39
+ * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
40
+ * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
41
+ * 判别式就是「有没有 cache_read_input_tokens」。
42
+ *
43
+ * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
44
+ */
45
+ export declare function normalizeUsage(raw: unknown): UsageTokens | null;
46
+ /** 字段级 max 合并:后来的同名字段取大者(Claude 把 usage 拆在多个事件里)。 */
47
+ export declare function mergeUsage(prev: UsageTokens | null, next: UsageTokens | null): UsageTokens | null;
48
+ /**
49
+ * 从流式上游的一帧 JSON 里取 usage。
50
+ * 覆盖 OpenAI(Chat/Responses)、Claude、Gemini 三种事件形状,照 9router
51
+ * `extractUsage` 的形态判断(dsh-router 的上游多是 OpenAI SSE,但供应商
52
+ * 插件可能有直通的原生流,多认几种不亏)。
53
+ */
54
+ export declare function extractFrameUsage(chunk: unknown): UsageTokens | null;
55
+ /** 估算:~4 字符/token(照 9router 的 estimateInputTokens/estimateOutputTokens)。 */
56
+ export declare function estimateTokens(chars: number): number;
57
+ /**
58
+ * 非流式响应体取 usage。
59
+ *
60
+ * 正常是纯 JSON;但个别供应商客户端要 JSON 却回 SSE(核心会聚合成一次响应,
61
+ * 见 `aggregateSSE`),所以顺手扫尾部几帧——只扫尾部,不多做。
62
+ */
63
+ export declare function usageFromJsonBody(body: string): UsageTokens | null;
64
+ /**
65
+ * 归一用量 → DSH 的 `TokenUsage`(**必须**走这一步再交给 dsh-llm)。
66
+ *
67
+ * 为什么不能把上游 usage 原样透传:dsh-llm 的 `TokenUsage` 有两条硬性约定,
68
+ * 上游的 OpenAI 形态两条都踩:
69
+ * 1. 字段名是 `inputTokens` / `outputTokens`,不是 `prompt_tokens`;
70
+ * 名字对不上 → 下游读 `usage.inputTokens` 得 `undefined` → 累加出 `NaN`。
71
+ * 2. **DISJOINT 口径**:`inputTokens` 只算**未缓存**输入,缓存单列
72
+ * `cacheReadTokens`/`cacheWriteTokens`(计费输入 = 三者之和)。
73
+ * OpenAI 的 `prompt_tokens` 是**含**缓存的总量,直接当 inputTokens
74
+ * 就是重复计费。
75
+ *
76
+ * 后果不只是「数字不好看」:token-meter 的投影 schema 是
77
+ * `z.number().int().nonnegative()`,`NaN` 一进去校验就抛,整条
78
+ * `session.history` RPC 失败 —— 表现为「历史加载失败:history unavailable
79
+ * for session ... expected number, received NaN」。所以这里是**写入侧**
80
+ * 的防线:宁可丢掉这一帧 usage,也不能往会话日志里写 NaN。
81
+ *
82
+ * 天花板(刻意不做):不区分 cacheRead/cacheWrite。上游多数只报读不报写,
83
+ * 拆不开的字段硬拆只会更错;哪天上游稳定给 `cache_creation_input_tokens`
84
+ * 再补 `cacheWriteTokens`(升级路径就在这)。
85
+ *
86
+ * @returns DSH 契约用量;三个数全为 0 时返回 null(= 这一帧没真数据)
87
+ */
88
+ export declare function toTokenUsage(tokens: UsageTokens | null): TokenUsage | null;
89
+ /** 流结束后汇总出的东西。 */
90
+ export interface StreamUsage {
91
+ usage: UsageTokens | null;
92
+ /** 输出内容字符数(上游没给 completion_tokens 时用来估算)。 */
93
+ outputChars: number;
94
+ /** 首字节延迟(ms);一个字节都没收到时为 0。 */
95
+ ttfbMs: number;
96
+ }
97
+ /**
98
+ * 边透传边统计:原流**原样**出去,统计在旁边做。
99
+ *
100
+ * 为什么不用 `source.tee()`:tee 的两条分支互相牵制,慢的一边会把 buffer 憋住;
101
+ * 而统计这条分支得读完整个流才出得了 usage,客户端那边却要尽快拿到首字节。
102
+ * 手动转发是把上游 chunk **即刻**交给下游,统计只攒几个数字,零缓冲、
103
+ * 首字节延迟不受影响。
104
+ *
105
+ * @param source 上游流(插件已转成 OpenAI SSE)
106
+ * @param startedAt 请求开始时间(`Date.now()`),TTFB 以此为基准
107
+ */
108
+ export declare function tapStreamUsage(source: ReadableStream<Uint8Array>, startedAt: number): {
109
+ stream: ReadableStream<Uint8Array>;
110
+ done: () => Promise<StreamUsage>;
111
+ };
112
+ /**
113
+ * 补齐估算:上游没给的字段用字符数估,并打标记。
114
+ *
115
+ * `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
116
+ * 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
117
+ */
118
+ export declare function withEstimates(usage: UsageTokens | null, requestChars: number, outputChars: number): Usage;
@@ -11,7 +11,7 @@ export declare const ROUTER_VIEW_SELECTOR = "[data-dsh-router-view]";
11
11
  /** Active-state attribute on <html> that shows the panel. */
12
12
  export declare const ROUTER_ACTIVE_ATTR = "data-dsh-router-active";
13
13
  /** The panel's visible title (also the sidebar entry label). */
14
- export declare const ROUTER_TITLE = "\u8DEF\u7531\u7CFB\u7EDF";
14
+ export declare const ROUTER_TITLE = "\u8DEF\u7531";
15
15
  /** Same-origin API base the client panel fetches (host half serves it). */
16
16
  export declare const ROUTER_API_BASE = "/router/api";
17
17
  /** Bearer key env / credential reference protecting /v1/*. */
@@ -44,6 +44,22 @@ export interface RouterModelsResponse {
44
44
  context_length?: number;
45
45
  }>;
46
46
  }
47
+ /** 一键签到:单个供应商的汇总结果(`/suppliers/:id/checkin` 的响应体)。 */
48
+ export interface RouterCheckinResponse {
49
+ ok: boolean;
50
+ error?: string;
51
+ total: number;
52
+ /** 真正 claim 成功的链接数。 */
53
+ succeeded: number;
54
+ /** 今日已签到的链接数(幂等成功)。 */
55
+ already: number;
56
+ results?: Array<{
57
+ uid: string;
58
+ ok: boolean;
59
+ status?: string;
60
+ message?: string;
61
+ }>;
62
+ }
47
63
  /** `/router/api/health` response. */
48
64
  export interface RouterHealthResponse {
49
65
  ok: boolean;
@@ -136,3 +152,72 @@ export interface RouterLoginResponse {
136
152
  error?: string;
137
153
  loginUrl?: string;
138
154
  }
155
+ /** 面板周期。 */
156
+ export type RouterPeriod = 'today' | '24h' | '7d' | '30d';
157
+ /** 一条请求明细(面板「最近请求」)。 */
158
+ export interface RouterUsageRecord {
159
+ ts: number;
160
+ supplier: string;
161
+ model: string;
162
+ /** 客户端原始请求的 model(组合场景下就是组合名)。 */
163
+ requested: string;
164
+ ok: boolean;
165
+ promptTokens: number;
166
+ completionTokens: number;
167
+ cachedTokens: number;
168
+ /** 输入是估算的(上游没发 usage)。 */
169
+ inputEstimated: boolean;
170
+ /** 输出是估算的。 */
171
+ outputEstimated: boolean;
172
+ durationMs: number;
173
+ /** 首字节延迟(ms);非流式为 0。 */
174
+ ttfbMs: number;
175
+ error?: string;
176
+ }
177
+ /** Top 榜一行。 */
178
+ export interface RouterRankRow {
179
+ name: string;
180
+ requests: number;
181
+ ok: number;
182
+ failed: number;
183
+ promptTokens: number;
184
+ completionTokens: number;
185
+ lastTs: number;
186
+ }
187
+ /** `/router/api/stats` response. */
188
+ export interface RouterStatsResponse {
189
+ ok: boolean;
190
+ error?: string;
191
+ period?: RouterPeriod;
192
+ requests?: number;
193
+ okCount?: number;
194
+ failed?: number;
195
+ promptTokens?: number;
196
+ completionTokens?: number;
197
+ cachedTokens?: number;
198
+ /** 平均耗时(ms)。 */
199
+ avgDurationMs?: number;
200
+ /** 平均首字节延迟(ms)。 */
201
+ avgTtfbMs?: number;
202
+ /** 输入/输出为估算的请求数(面板据此提示)。 */
203
+ estimatedInputs?: number;
204
+ estimatedOutputs?: number;
205
+ /** 累计请求数(不受周期影响)。 */
206
+ lifetime?: number;
207
+ bySupplier?: RouterRankRow[];
208
+ byModel?: RouterRankRow[];
209
+ byRequested?: RouterRankRow[];
210
+ recent?: RouterUsageRecord[];
211
+ }
212
+ /** `/router/api/stats/chart` 一桶。 */
213
+ export interface RouterChartBucket {
214
+ label: string;
215
+ requests: number;
216
+ tokens: number;
217
+ }
218
+ /** `/router/api/stats/chart` response. */
219
+ export interface RouterChartResponse {
220
+ ok: boolean;
221
+ error?: string;
222
+ chart?: RouterChartBucket[];
223
+ }
@@ -10,10 +10,24 @@ export interface SupplierConfig {
10
10
  export declare class SupplierConfigStore {
11
11
  private fp;
12
12
  private bySupplier;
13
+ /** 全部供应商 id(加载时同步),用于别名唯一性校验。 */
14
+ private knownIds;
13
15
  constructor(stateFile: string);
16
+ /** 同步已知供应商 id 集合(加载供应商后调用)。 */
17
+ sync(supplierIds: string[]): void;
18
+ /** 各供应商当前生效的别名(未设置别名的用供应商 id)。 */
19
+ effectiveAliases(): Map<string, string>;
14
20
  /** 读取某供应商配置(不存在则返回默认并注册)。 */
15
21
  get(supplierId: string): SupplierConfig;
16
- setAlias(supplierId: string, alias: string): void;
22
+ /**
23
+ * 设置别名。别名必须唯一——它是对外模型全名的前缀(alias/model),
24
+ * 请求时用别名反查供应商,重复就会指错人。空别名表示用供应商 id(默认值)。
25
+ * @returns 冲突时返回占用的供应商 id
26
+ */
27
+ setAlias(supplierId: string, alias: string): {
28
+ ok: boolean;
29
+ conflictWith?: string;
30
+ };
17
31
  setModelEnabled(supplierId: string, modelId: string, enabled: boolean): void;
18
32
  setAllModelsEnabled(supplierId: string, enabled: boolean, allModelIds: string[]): void;
19
33
  addCustomModel(supplierId: string, modelId: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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": {
@@ -33,7 +33,10 @@
33
33
  "inject": [
34
34
  "react",
35
35
  "react-dom",
36
- "cordis"
36
+ "cordis",
37
+ "@deepseek-ai/dsh-client-runtime",
38
+ "@deepseek-ai/dsh-client-ui-slots",
39
+ "@deepseek-ai/dsh-client-locale"
37
40
  ],
38
41
  "platform": "web"
39
42
  }
@@ -45,20 +48,25 @@
45
48
  "lib/types/**/*.d.ts",
46
49
  "src",
47
50
  "cordis.patch.yml",
51
+ "docs",
48
52
  "README.md",
49
53
  "LICENSE"
50
54
  ],
51
55
  "scripts": {
52
- "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
56
+ "build": "node scripts/check-css.mjs && rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
53
57
  "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
58
  "typecheck": "tsc --noEmit",
55
59
  "test": "node --test \"src/**/*.test.ts\"",
56
60
  "bundle": "tsdown",
57
- "watch": "tsdown --watch"
61
+ "watch": "tsdown --watch",
62
+ "check:css": "node scripts/check-css.mjs"
58
63
  },
59
64
  "license": "MIT",
60
65
  "peerDependencies": {
61
66
  "@deepseek-ai/cordis": "^4.0.1",
67
+ "@deepseek-ai/dsh-client-locale": "*",
68
+ "@deepseek-ai/dsh-client-runtime": "*",
69
+ "@deepseek-ai/dsh-client-ui-slots": "*",
62
70
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
63
71
  "cordis": "^4.0.0-rc.7",
64
72
  "react": "^18.2.0",
@@ -67,6 +75,15 @@
67
75
  "peerDependenciesMeta": {
68
76
  "cordis": {
69
77
  "optional": true
78
+ },
79
+ "@deepseek-ai/dsh-client-locale": {
80
+ "optional": true
81
+ },
82
+ "@deepseek-ai/dsh-client-runtime": {
83
+ "optional": true
84
+ },
85
+ "@deepseek-ai/dsh-client-ui-slots": {
86
+ "optional": true
70
87
  }
71
88
  },
72
89
  "devDependencies": {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 概览的「签到」汇总卡:一键签到所有支持签到的供应商 + 显示今天点过没。
3
+ *
4
+ * **「今日已签到」的口径(天花板,刻意不撒谎)**:这里只知道「今天在这个浏览器
5
+ * 点过这个按钮没」,存在 localStorage,**不知道上游是不是真的签上了**。真凭据是
6
+ * 上游的 `checked_in`,但那得逐个链接查(traywork 侧实测:积分不可信、
7
+ * 只有 checked_in 算数),而当前契约里没有「查签到状态」这个能力。
8
+ * 所以卡片文案是「今日已点」而不是「已签到」,悬停说明里写清这一点。
9
+ *
10
+ * 要真状态的升级路径:给 supplier 契约加 `checkinStatus?()`,核心加一条
11
+ * `/suppliers/:id/checkin/status` 汇总路由,这里改成读它即可(卡片不用改)。
12
+ */
13
+ import { useEffect, useState } from 'react'
14
+ import {
15
+ ROUTER_API_BASE,
16
+ type RouterCheckinResponse,
17
+ type RouterHealthResponse,
18
+ } from '../shared.ts'
19
+
20
+ /* ---------------- SVG 图标 ---------------- */
21
+
22
+ function Icon({ d, size = 14 }: { d: string; size?: number }): JSX.Element {
23
+ return (
24
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
25
+ <path d={d} />
26
+ </svg>
27
+ )
28
+ }
29
+
30
+ const I = {
31
+ check: 'M4 12.5l5 5L20 6.5',
32
+ loading: 'M12 3a9 9 0 1 0 9 9',
33
+ }
34
+
35
+ /** localStorage key:值 = 本地日期 YYYY-MM-DD。 */
36
+ const CHECKIN_STAMP_KEY = 'dsh-router:checkin-stamp'
37
+
38
+ /** 本地日切(跟用量统计的 localDateKey 同口径,别用 UTC)。 */
39
+ function todayKey(ts = Date.now()): string {
40
+ const d = new Date(ts)
41
+ const p = (n: number): string => String(n).padStart(2, '0')
42
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
43
+ }
44
+
45
+ function readStamp(): string | null {
46
+ try {
47
+ return localStorage.getItem(CHECKIN_STAMP_KEY)
48
+ } catch {
49
+ // 隐私模式 / 存储被禁:退化为「不记得」,不影响签到本身能用
50
+ return null
51
+ }
52
+ }
53
+
54
+ function writeStamp(): void {
55
+ try {
56
+ localStorage.setItem(CHECKIN_STAMP_KEY, todayKey())
57
+ } catch { /* 存不了就只影响这个提示,签到照常 */ }
58
+ }
59
+
60
+ interface CheckinCardProps {
61
+ /** 供应商健康快照(用来筛出支持 checkinNow 的)。 */
62
+ health: RouterHealthResponse | null
63
+ /** 签完回调,让概览刷新快照(积分会变)。 */
64
+ onRefresh: () => void
65
+ }
66
+
67
+ /** 可签到的供应商(能力 checkinNow)。 */
68
+ function checkinable(health: RouterHealthResponse | null): Array<{ id: string; name: string }> {
69
+ return (health?.suppliers ?? [])
70
+ .filter((s) => (s.capabilities ?? []).includes('checkinNow'))
71
+ .map((s) => ({ id: s.id, name: s.name }))
72
+ }
73
+
74
+ export function CheckinCard({ health, onRefresh }: CheckinCardProps): JSX.Element {
75
+ const [busy, setBusy] = useState(false)
76
+ const [stamp, setStamp] = useState<string | null>(null)
77
+ const [result, setResult] = useState<string>('')
78
+ const [error, setError] = useState<string>('')
79
+
80
+ // 挂载时读一次;跨天自动失效因为存的是日期串
81
+ useEffect(() => { setStamp(readStamp()) }, [])
82
+
83
+ const targets = checkinable(health)
84
+ const clickedToday = stamp === todayKey()
85
+
86
+ const run = async (): Promise<void> => {
87
+ if (busy || targets.length === 0) return
88
+ setBusy(true)
89
+ setError('')
90
+ setResult('')
91
+ let okCount = 0
92
+ let alreadyCount = 0
93
+ let totalCount = 0
94
+ const failures: string[] = []
95
+ // 顺序发:签到是有副作用的上游调用,并发会把上游风控招来(见 traework 9074)
96
+ for (const t of targets) {
97
+ try {
98
+ const res = await fetch(`${ROUTER_API_BASE}/suppliers/${t.id}/checkin`, { method: 'POST', cache: 'no-store' })
99
+ const data = await res.json() as RouterCheckinResponse
100
+ if (data.error !== undefined) {
101
+ failures.push(`${t.name}:${data.error}`)
102
+ continue
103
+ }
104
+ okCount += data.succeeded
105
+ alreadyCount += data.already
106
+ totalCount += data.total
107
+ if (!data.ok) {
108
+ const first = (data.results ?? []).find((r) => r.status !== 'ok' && r.status !== 'already')
109
+ failures.push(`${t.name}:${first?.message ?? '有链接签到失败'}`)
110
+ }
111
+ } catch (err) {
112
+ failures.push(`${t.name}:${(err as Error).message}`)
113
+ }
114
+ }
115
+ // 只要发出去过就记「今日已点」——点过就是点过,成败另说(文案也这么说)
116
+ writeStamp()
117
+ setStamp(todayKey())
118
+ const parts = [`${okCount}/${totalCount} 成功`]
119
+ if (alreadyCount > 0) parts.push(`${alreadyCount} 今日已签`)
120
+ setResult(parts.join(' · '))
121
+ if (failures.length > 0) setError(failures.join(';'))
122
+ onRefresh()
123
+ setBusy(false)
124
+ }
125
+
126
+ /**
127
+ * 第三行文案:所有状态/结果都压在这一行里,行数恒定。
128
+ * 优先级:失败 > 本次结果 > 今日已点 > 默认供应商数。
129
+ * 失败时截断显示(全量挂 title),不换行。
130
+ */
131
+ const hintText = (): string => {
132
+ if (targets.length === 0) return '没有支持签到的供应商'
133
+ if (busy) return '签到中…'
134
+ if (error !== '') return `失败 · ${error}`
135
+ if (result !== '') return result
136
+ return clickedToday ? `今日已点 · ${targets.length} 个供应商` : `${targets.length} 个供应商`
137
+ }
138
+
139
+ /** 悬停说明:成功/失败都在这儿补全,正文只放得下一行 */
140
+ const hintTitle = (): string => {
141
+ if (targets.length === 0) return '没有供应商实现了 checkinNow 能力'
142
+ const lines = [`对 ${targets.length} 个支持签到的供应商逐个签到:${targets.map((t) => t.name).join('、')}`]
143
+ if (clickedToday) lines.push('今天在这个浏览器点过签到(不代表上游一定签上了)')
144
+ if (result !== '') lines.push(`上次结果:${result}`)
145
+ if (error !== '') lines.push(`失败:${error}`)
146
+ return lines.join('\n')
147
+ }
148
+
149
+ return (
150
+ <div
151
+ className={`dshr-statCard dshr-statCard-checkin${clickedToday ? ' dshr-statCard-done' : ''}`}
152
+ title={hintTitle()}
153
+ >
154
+ <span className="dshr-statLabel">签到</span>
155
+ {targets.length === 0
156
+ ? (
157
+ <span className="dshr-statValue">—</span>
158
+ )
159
+ : (
160
+ <button
161
+ type="button"
162
+ className={`dshr-checkinButton${busy ? ' dshr-checkinButton-busy' : ''}`}
163
+ onClick={() => { void run() }}
164
+ disabled={busy}
165
+ title={hintTitle()}
166
+ >
167
+ <span className={busy ? 'dshr-spin' : undefined}>
168
+ <Icon d={busy ? I.loading : I.check} size={14} />
169
+ </span>
170
+ {busy ? '签到中…' : '一键签到'}
171
+ </button>
172
+ )}
173
+ {/* 恒定第三行:状态/结果全塞进 hint,不成功后多出一行
174
+ —— 汇总卡是 3 行结构(label / value / hint),多一行就跟邻卡不齐 */}
175
+ <span className={`dshr-statHint${error !== '' ? ' dshr-statHint-err' : ''}`} title={error !== '' ? error : undefined}>
176
+ {hintText()}
177
+ </span>
178
+ </div>
179
+ )
180
+ }