dsh-router-core 0.1.0 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +58 -13
  2. package/lib/client-registry.js +824 -153
  3. package/lib/client.js +824 -153
  4. package/lib/index.js +1606 -227
  5. package/lib/types/llm/adapter.d.ts +2 -0
  6. package/lib/types/router/account-pool.d.ts +41 -0
  7. package/lib/types/router/index.d.ts +72 -4
  8. package/lib/types/router/types.d.ts +12 -10
  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 +69 -0
  12. package/lib/types/supplier-config.d.ts +15 -4
  13. package/lib/types/suppliers/contract.d.ts +70 -21
  14. package/lib/types/suppliers/registry.d.ts +4 -3
  15. package/package.json +2 -1
  16. package/src/client/CombosTab.tsx +28 -16
  17. package/src/client/RouterView.tsx +15 -2
  18. package/src/client/StatsTab.tsx +521 -0
  19. package/src/client/SupplierDetail.tsx +58 -34
  20. package/src/client/router.css +406 -0
  21. package/src/client/workspace-mount.tsx +2 -9
  22. package/src/index.ts +52 -4
  23. package/src/llm/adapter.test.ts +108 -0
  24. package/src/llm/adapter.ts +18 -6
  25. package/src/router/account-pool.test.ts +120 -0
  26. package/src/router/account-pool.ts +161 -0
  27. package/src/router/index.ts +652 -26
  28. package/src/router/models-cache.test.ts +455 -0
  29. package/src/router/precise-dispatch.test.ts +198 -0
  30. package/src/router/types.ts +12 -7
  31. package/src/router/usage-store.ts +499 -0
  32. package/src/router/usage-tokens.ts +327 -0
  33. package/src/router/usage.test.ts +558 -0
  34. package/src/shared.ts +77 -0
  35. package/src/supplier-config.ts +31 -14
  36. package/src/suppliers/contract.ts +71 -8
  37. package/src/suppliers/loader.ts +21 -13
  38. package/src/suppliers/nvidia/plugin.ts +161 -119
  39. package/src/suppliers/opencode/plugin.ts +47 -95
  40. package/src/suppliers/openrouter/plugin.ts +67 -122
  41. package/src/suppliers/registry.test.ts +194 -0
  42. package/src/suppliers/registry.ts +79 -59
@@ -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;
@@ -2094,3 +2103,400 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
2094
2103
  width: 1px;
2095
2104
  background: var(--rs-line);
2096
2105
  }
2106
+
2107
+ /* ============================================================
2108
+ dsh-router — 概览(用量看板,9router Overview 风格)
2109
+ ============================================================ */
2110
+
2111
+ /* 周期切换 + 操作条 */
2112
+ .dshr-statsBar {
2113
+ display: flex;
2114
+ flex: none;
2115
+ flex-wrap: wrap;
2116
+ align-items: center;
2117
+ gap: 10px;
2118
+ }
2119
+
2120
+ .dshr-periods {
2121
+ display: inline-flex;
2122
+ gap: 2px;
2123
+ padding: 2px;
2124
+ border: 1px solid var(--rs-line);
2125
+ border-radius: 8px;
2126
+ background: var(--rs-layer-2);
2127
+ }
2128
+
2129
+ /* 周期按钮三态。特异性阶梯(类/伪类数 , 元素数):
2130
+ 文件顶部有 `.dshr-shell button { color: inherit; font: inherit }` = (1,1),
2131
+ 单类的 .dshr-period (1,0) 会被它打死(白字、600 字重全丢),所以下面每条
2132
+ 都必须带 .dshr-shell 抬到 (2,0) 以上。
2133
+ 两种 hover 用 :not() 做成互斥,谁赢跟源码顺序无关(免得以后有人挪块就翻车)。 */
2134
+ .dshr-shell .dshr-period {
2135
+ min-width: 46px;
2136
+ height: 26px;
2137
+ padding: 0 10px;
2138
+ border: none;
2139
+ border-radius: 6px;
2140
+ color: var(--rs-muted);
2141
+ background: transparent;
2142
+ font-size: 12px;
2143
+ font-weight: 500;
2144
+ cursor: pointer;
2145
+ transition: color .12s ease, background .12s ease, filter .12s ease;
2146
+ }
2147
+
2148
+ /* 未激活悬停:跟 .dshr-miniButton / .dshr-iconButton 一个套路(主文字色 + --rs-hover 底) */
2149
+ .dshr-shell .dshr-period:not(.dshr-period-on):hover {
2150
+ color: var(--rs-text);
2151
+ background: var(--rs-hover);
2152
+ }
2153
+
2154
+ /* 激活:强调色块 + 白字。底色用 layer-1 时跟卡片同色,根本看不出选中了哪个 */
2155
+ .dshr-shell .dshr-period-on {
2156
+ color: #fff;
2157
+ background: var(--rs-accent);
2158
+ font-weight: 600;
2159
+ box-shadow: 0 1px 2px rgb(0 0 0 / .12);
2160
+ }
2161
+
2162
+ /* 激活态被悬停:跟 .dshr-primaryButton 一样提亮,表示「它还是能点的」 */
2163
+ .dshr-shell .dshr-period-on:hover {
2164
+ color: #fff;
2165
+ background: var(--rs-accent);
2166
+ filter: brightness(1.08);
2167
+ }
2168
+
2169
+ .dshr-statsBarRight {
2170
+ display: flex;
2171
+ align-items: center;
2172
+ gap: 6px;
2173
+ margin-left: auto;
2174
+ }
2175
+
2176
+ .dshr-lifetime {
2177
+ font-size: 11px;
2178
+ white-space: nowrap;
2179
+ }
2180
+
2181
+ /* 汇总卡(照 9router OverviewCards 的自适应五宫格) */
2182
+ .dshr-statGrid {
2183
+ display: grid;
2184
+ flex: none;
2185
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
2186
+ gap: 10px;
2187
+ }
2188
+
2189
+ .dshr-statCard {
2190
+ display: flex;
2191
+ min-width: 0;
2192
+ flex-direction: column;
2193
+ gap: 3px;
2194
+ padding: 12px 14px;
2195
+ border: 1px solid var(--rs-line);
2196
+ border-radius: 10px;
2197
+ background: var(--rs-layer-1);
2198
+ box-shadow: 0 1px 2px rgb(0 0 0 / .04);
2199
+ }
2200
+
2201
+ .dshr-statLabel {
2202
+ overflow: hidden;
2203
+ color: var(--rs-faint);
2204
+ font-size: 11px;
2205
+ font-weight: 600;
2206
+ letter-spacing: .03em;
2207
+ text-overflow: ellipsis;
2208
+ text-transform: uppercase;
2209
+ white-space: nowrap;
2210
+ }
2211
+
2212
+ .dshr-statValue {
2213
+ overflow: hidden;
2214
+ font-size: 22px;
2215
+ font-weight: 700;
2216
+ letter-spacing: -.01em;
2217
+ line-height: 1.2;
2218
+ text-overflow: ellipsis;
2219
+ }
2220
+
2221
+ .dshr-statHint {
2222
+ color: var(--rs-faint);
2223
+ font-size: 11px;
2224
+ }
2225
+
2226
+ .dshr-statCard-primary .dshr-statValue { color: var(--rs-accent); }
2227
+ .dshr-statCard-ok .dshr-statValue { color: var(--rs-success); }
2228
+ .dshr-statCard-warn .dshr-statValue { color: var(--rs-warn); }
2229
+ .dshr-statCard-danger .dshr-statValue { color: var(--rs-danger); }
2230
+
2231
+ .dshr-estimateNote {
2232
+ margin: 0;
2233
+ color: var(--rs-faint);
2234
+ font-size: 11px;
2235
+ line-height: 1.6;
2236
+ }
2237
+
2238
+ .dshr-estimateNote code {
2239
+ padding: 0 3px;
2240
+ border-radius: 3px;
2241
+ background: var(--rs-layer-2);
2242
+ font-family: var(--rs-mono);
2243
+ }
2244
+
2245
+ /* 趋势折线图(纯 SVG + HTML 叠加层,不引图表库) */
2246
+ .dshr-trendWrap {
2247
+ position: relative;
2248
+ padding: 14px 14px 10px;
2249
+ }
2250
+
2251
+ .dshr-trendBusy {
2252
+ position: absolute;
2253
+ inset: 0;
2254
+ background: color-mix(in srgb, var(--rs-layer-1) 55%, transparent);
2255
+ pointer-events: none;
2256
+ }
2257
+
2258
+ /* 绘图区:高度跟 SVG 视口一致,叠加层用百分比定位才对得上 */
2259
+ .dshr-linePlot {
2260
+ position: relative;
2261
+ height: 150px;
2262
+ cursor: crosshair;
2263
+ }
2264
+
2265
+ /* 键盘可达(←/→ 逐格):聚焦环跟面板其它控件一致 */
2266
+ .dshr-linePlot:focus-visible {
2267
+ outline: 2px solid var(--rs-accent);
2268
+ outline-offset: 2px;
2269
+ }
2270
+
2271
+ /* 峰值:给读数一个量级参照;半透明底是怕压在折线上看不清 */
2272
+ .dshr-linePeak {
2273
+ position: absolute;
2274
+ top: 0;
2275
+ right: 0;
2276
+ padding: 0 4px;
2277
+ border-radius: 3px;
2278
+ color: var(--rs-faint);
2279
+ background: color-mix(in srgb, var(--rs-layer-1) 72%, transparent);
2280
+ font-size: 9px;
2281
+ font-variant-numeric: tabular-nums;
2282
+ pointer-events: none;
2283
+ }
2284
+
2285
+ .dshr-lineSvg {
2286
+ position: absolute;
2287
+ display: block;
2288
+ width: 100%;
2289
+ height: 100%;
2290
+ inset: 0;
2291
+ overflow: visible;
2292
+ }
2293
+
2294
+ .dshr-lineGrid {
2295
+ stroke: var(--rs-line);
2296
+ stroke-dasharray: 3 3;
2297
+ }
2298
+
2299
+ .dshr-lineArea {
2300
+ fill: color-mix(in srgb, var(--rs-accent) 14%, transparent);
2301
+ stroke: none;
2302
+ }
2303
+
2304
+ .dshr-linePath {
2305
+ fill: none;
2306
+ stroke: var(--rs-accent);
2307
+ stroke-width: 2;
2308
+ stroke-linejoin: round;
2309
+ stroke-linecap: round;
2310
+ }
2311
+
2312
+ /* 数值点:空心小圆(SVG 会被横向拉伸,所以画在 HTML 层) */
2313
+ .dshr-linePoint {
2314
+ position: absolute;
2315
+ width: 5px;
2316
+ height: 5px;
2317
+ border: 1.5px solid var(--rs-accent);
2318
+ border-radius: 50%;
2319
+ background: var(--rs-layer-1);
2320
+ transform: translate(-50%, -50%);
2321
+ pointer-events: none;
2322
+ }
2323
+
2324
+ .dshr-lineGuide {
2325
+ position: absolute;
2326
+ top: 0;
2327
+ bottom: 0;
2328
+ width: 1px;
2329
+ background: var(--rs-line-strong);
2330
+ pointer-events: none;
2331
+ }
2332
+
2333
+ .dshr-lineDot {
2334
+ position: absolute;
2335
+ width: 9px;
2336
+ height: 9px;
2337
+ border: 2px solid var(--rs-layer-1);
2338
+ border-radius: 50%;
2339
+ background: var(--rs-accent);
2340
+ box-shadow: 0 0 0 1px var(--rs-accent);
2341
+ transform: translate(-50%, -50%);
2342
+ pointer-events: none;
2343
+ }
2344
+
2345
+ .dshr-lineLabels {
2346
+ position: relative;
2347
+ height: 14px;
2348
+ margin-top: 5px;
2349
+ }
2350
+
2351
+ .dshr-lineLabel {
2352
+ position: absolute;
2353
+ top: 0;
2354
+ overflow: visible;
2355
+ color: var(--rs-faint);
2356
+ font-size: 9px;
2357
+ font-variant-numeric: tabular-nums;
2358
+ white-space: nowrap;
2359
+ transform: translateX(-50%);
2360
+ }
2361
+
2362
+ /* 选中的时段:标签跟着高亮,不用抬头对读数才知道看的是哪一段 */
2363
+ .dshr-lineLabel-on {
2364
+ color: var(--rs-accent);
2365
+ font-weight: 700;
2366
+ }
2367
+
2368
+ .dshr-trendReadout {
2369
+ display: flex;
2370
+ align-items: center;
2371
+ gap: 2px;
2372
+ min-height: 18px;
2373
+ padding-top: 6px;
2374
+ font-size: 11px;
2375
+ }
2376
+
2377
+ .dshr-trendEmpty {
2378
+ display: flex;
2379
+ height: 150px;
2380
+ flex-direction: column;
2381
+ align-items: center;
2382
+ justify-content: center;
2383
+ gap: 6px;
2384
+ color: var(--rs-faint);
2385
+ font-size: 12px;
2386
+ }
2387
+
2388
+ .dshr-trendEmptyIcon {
2389
+ display: inline-flex;
2390
+ align-items: center;
2391
+ justify-content: center;
2392
+ width: 44px;
2393
+ height: 44px;
2394
+ border-radius: 50%;
2395
+ color: var(--rs-accent);
2396
+ background: color-mix(in srgb, var(--rs-accent) 10%, transparent);
2397
+ }
2398
+
2399
+ /* Top 榜(供应商 / 模型并排,窄屏堆叠) */
2400
+ .dshr-rankPair {
2401
+ display: grid;
2402
+ flex: none;
2403
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
2404
+ gap: 12px;
2405
+ align-items: start;
2406
+ }
2407
+
2408
+ .dshr-rankList,
2409
+ .dshr-recentList {
2410
+ display: flex;
2411
+ flex-direction: column;
2412
+ padding: 6px 14px 12px;
2413
+ }
2414
+
2415
+ .dshr-rankRow,
2416
+ .dshr-recentRow {
2417
+ display: flex;
2418
+ align-items: center;
2419
+ gap: 10px;
2420
+ min-width: 0;
2421
+ padding: 7px 0;
2422
+ border-bottom: 1px solid var(--rs-line);
2423
+ font-size: 12px;
2424
+ }
2425
+
2426
+ .dshr-rankRow:last-child,
2427
+ .dshr-recentRow:last-child { border-bottom: none; }
2428
+
2429
+ /* 表头:不参与 hover,且不画分隔线(视觉上属于卡片头) */
2430
+ .dshr-rankHead {
2431
+ border-bottom: 1px solid var(--rs-line);
2432
+ color: var(--rs-faint);
2433
+ font-size: 11px;
2434
+ font-weight: 600;
2435
+ letter-spacing: .03em;
2436
+ text-transform: uppercase;
2437
+ }
2438
+
2439
+ .dshr-rankRow:not(.dshr-rankHead):hover { background: var(--rs-hover); }
2440
+
2441
+ .dshr-rankName {
2442
+ min-width: 0;
2443
+ flex: 1 1 auto;
2444
+ overflow: hidden;
2445
+ text-overflow: ellipsis;
2446
+ white-space: nowrap;
2447
+ }
2448
+
2449
+ .dshr-rankNum {
2450
+ flex: none;
2451
+ min-width: 52px;
2452
+ text-align: right;
2453
+ font-variant-numeric: tabular-nums;
2454
+ white-space: nowrap;
2455
+ }
2456
+
2457
+ .dshr-rankFail { color: var(--rs-danger); }
2458
+
2459
+ .dshr-tokenIn { color: var(--rs-accent); }
2460
+ .dshr-tokenOut { color: var(--rs-success); }
2461
+
2462
+ /* 最近请求列宽(比 Top 榜宽,要放得下模型名) */
2463
+ .dshr-recentTime {
2464
+ display: flex;
2465
+ flex: none;
2466
+ align-items: center;
2467
+ gap: 5px;
2468
+ width: 78px;
2469
+ color: var(--rs-muted);
2470
+ font-variant-numeric: tabular-nums;
2471
+ }
2472
+
2473
+ .dshr-recentModel {
2474
+ min-width: 0;
2475
+ flex: 1 1 auto;
2476
+ overflow: hidden;
2477
+ text-overflow: ellipsis;
2478
+ white-space: nowrap;
2479
+ }
2480
+
2481
+ .dshr-recentSupplier {
2482
+ flex: none;
2483
+ width: 92px;
2484
+ overflow: hidden;
2485
+ color: var(--rs-muted);
2486
+ text-overflow: ellipsis;
2487
+ white-space: nowrap;
2488
+ }
2489
+
2490
+ .dshr-recentRow-fail .dshr-recentModel { color: var(--rs-danger); }
2491
+
2492
+ /* 状态点 */
2493
+ .dshr-dot {
2494
+ display: inline-block;
2495
+ flex: none;
2496
+ width: 6px;
2497
+ height: 6px;
2498
+ border-radius: 50%;
2499
+ }
2500
+
2501
+ .dshr-dot-ok { background: var(--rs-success); }
2502
+ .dshr-dot-fail { background: var(--rs-danger); }
@@ -5,9 +5,8 @@
5
5
  * other sidebar panels (taskboard / ssh / mnemon) through the shared
6
6
  * `dsh-panel-activate` event.
7
7
  */
8
- import { useEffect } from 'react'
9
8
  import { createRoot, type Root } from 'react-dom/client'
10
- import { ROUTER_ACTIVE_ATTR, ROUTER_TITLE, ROUTER_VIEW_SELECTOR } from '../shared.ts'
9
+ import { ROUTER_ACTIVE_ATTR } from '../shared.ts'
11
10
  import { RouterView } from './RouterView.tsx'
12
11
  import { mountRouterSidebarEntry } from './sidebar-entry.ts'
13
12
  import { RouterWorkspaceController } from './workspace-controller.ts'
@@ -24,13 +23,7 @@ function conversationColumn(): HTMLElement | undefined {
24
23
 
25
24
  function mountPanel(controller: RouterWorkspaceController): () => void {
26
25
  function Panel(): JSX.Element {
27
- useEffect(() => {
28
- // Keep the panel title in the browser tab for orientation, like the
29
- // official panels do.
30
- const original = document.title
31
- document.title = ROUTER_TITLE
32
- return () => { document.title = original }
33
- }, [])
26
+ // 不改 document.title:这是宿主页面(会话)的标题,插件不该动它。
34
27
  return <RouterView onBack={() => controller.close()} />
35
28
  }
36
29