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.
- package/README.md +40 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +1119 -213
- package/lib/client.js +1119 -213
- package/lib/index.js +1066 -50
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/index.d.ts +34 -5
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +86 -1
- package/lib/types/supplier-config.d.ts +15 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/CombosTab.tsx +28 -18
- package/src/client/RouterView.tsx +44 -27
- package/src/client/StatsTab.tsx +531 -0
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +688 -35
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +51 -3
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/index.ts +461 -49
- package/src/router/models-cache.test.ts +269 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +90 -1
- package/src/supplier-config.ts +30 -2
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +2 -3
- package/src/suppliers/opencode/plugin.ts +2 -3
- package/src/suppliers/openrouter/plugin.ts +2 -3
- package/src/suppliers/registry.ts +7 -2
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 概览 —— 仿 9router `Dashboard → Usage → Overview` 的统计看板。
|
|
3
|
+
*
|
|
4
|
+
* 布局/交互跟其它 tab 一致(同一套 `.dshr-card` / `.dshr-tabBody`):
|
|
5
|
+
* 周期切换 → 汇总卡 → 趋势折线图 → Top 榜 → 最近请求。
|
|
6
|
+
*
|
|
7
|
+
* 两个 9router 有而我们刻意不做的(天花板,非遗漏):
|
|
8
|
+
* - 成本估算:9router 有 pricingRepo 全表,dsh-router 没有价格数据,
|
|
9
|
+
* 硬造一个只会显示假钱。宁可不显示。
|
|
10
|
+
* - 实时推送:9router 有 `/api/usage/stream` 的 SSE 看板。这里刷新才更新
|
|
11
|
+
* (面板本身有刷新按钮)。要实时就加一条 SSE 路由,看板不用改。
|
|
12
|
+
*
|
|
13
|
+
* token 口径见 `router/usage-tokens.ts`:上游不发 usage 时按字符数估算,
|
|
14
|
+
* 估算过的字段标 `~`,鼠标悬停会说明。
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
21
|
+
type PointerEvent as ReactPointerEvent,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from 'react'
|
|
24
|
+
import {
|
|
25
|
+
ROUTER_API_BASE,
|
|
26
|
+
type RouterChartBucket,
|
|
27
|
+
type RouterChartResponse,
|
|
28
|
+
type RouterHealthResponse,
|
|
29
|
+
type RouterPeriod,
|
|
30
|
+
type RouterRankRow,
|
|
31
|
+
type RouterStatsResponse,
|
|
32
|
+
type RouterUsageRecord,
|
|
33
|
+
} from '../shared.ts'
|
|
34
|
+
import { CheckinCard } from './CheckinCard.tsx'
|
|
35
|
+
|
|
36
|
+
/* ---------------- SVG 图标 ---------------- */
|
|
37
|
+
|
|
38
|
+
function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
41
|
+
<path d={d} />
|
|
42
|
+
</svg>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const I = {
|
|
47
|
+
chart: 'M3 3v18h18M7 15l4-5 3 3 5-7',
|
|
48
|
+
bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
|
|
49
|
+
refresh: 'M21 12a9 9 0 1 1-3-6.7M21 4v5h-5',
|
|
50
|
+
empty: 'M4 4h16v16H4zM8 12h8M12 8v8',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 周期选项(9router 的 PERIODS)。
|
|
55
|
+
* `value` 是 `/stats?period=` 的参数值,别为了显示去动它;
|
|
56
|
+
* 显示文案统一中文(跟「今日」一致)。
|
|
57
|
+
*/
|
|
58
|
+
const PERIODS: Array<{ value: RouterPeriod; label: string }> = [
|
|
59
|
+
{ value: 'today', label: '今日' },
|
|
60
|
+
{ value: '24h', label: '24 小时' },
|
|
61
|
+
{ value: '7d', label: '7 天' },
|
|
62
|
+
{ value: '30d', label: '30 天' },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/** 「最近请求」显示条数(后端给了 20 条,面板只看最近的这些)。 */
|
|
66
|
+
const RECENT_LIMIT = 10
|
|
67
|
+
|
|
68
|
+
/** 千分位。 */
|
|
69
|
+
function fmt(n: number | undefined): string {
|
|
70
|
+
return new Intl.NumberFormat().format(n ?? 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 大数缩写:1.2K / 3.4M(卡片和柱图上放不下完整数字时用)。 */
|
|
74
|
+
function fmtShort(n: number | undefined): string {
|
|
75
|
+
const v = n ?? 0
|
|
76
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
|
77
|
+
if (v >= 10_000) return `${(v / 1000).toFixed(1)}K`
|
|
78
|
+
return fmt(v)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fmtDuration(ms: number | undefined): string {
|
|
82
|
+
const v = ms ?? 0
|
|
83
|
+
if (v <= 0) return '—'
|
|
84
|
+
if (v < 1000) return `${v}ms`
|
|
85
|
+
return `${(v / 1000).toFixed(1)}s`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function timeAgo(ts: number): string {
|
|
89
|
+
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
|
90
|
+
if (diff < 60) return `${diff}秒前`
|
|
91
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
|
92
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
|
93
|
+
return `${Math.floor(diff / 86400)}天前`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clockTime(ts: number): string {
|
|
97
|
+
const d = new Date(ts)
|
|
98
|
+
const p = (n: number): string => String(n).padStart(2, '0')
|
|
99
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---------------- 汇总卡 ---------------- */
|
|
103
|
+
|
|
104
|
+
interface StatCardProps {
|
|
105
|
+
label: string
|
|
106
|
+
value: string
|
|
107
|
+
/** 右下角补充(如「估算」提示)。 */
|
|
108
|
+
hint?: ReactNode
|
|
109
|
+
/** 强调色(用现有语义色变量)。 */
|
|
110
|
+
tone?: 'default' | 'primary' | 'ok' | 'warn' | 'danger'
|
|
111
|
+
title?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function StatCard({ label, value, hint, tone = 'default', title }: StatCardProps): JSX.Element {
|
|
115
|
+
return (
|
|
116
|
+
<div className={`dshr-statCard dshr-statCard-${tone}`} title={title}>
|
|
117
|
+
<span className="dshr-statLabel">{label}</span>
|
|
118
|
+
<span className="dshr-statValue">{value}</span>
|
|
119
|
+
{hint !== undefined && <span className="dshr-statHint">{hint}</span>}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------------- 趋势折线图(纯 SVG,不引图表库) ---------------- */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 视口坐标固定,SVG 用 `preserveAspectRatio="none"` 横向拉满容器:
|
|
128
|
+
* 线宽靠 `vector-effect="non-scaling-stroke"` 抵消拉伸,数值点/悬停辅助线
|
|
129
|
+
* 用 HTML 绝对定位叠在 SVG 上(否则圆点会被拉成椭圆)。
|
|
130
|
+
*
|
|
131
|
+
* 交互:指针(鼠标/触摸/笔)取最近桶,键盘 ←/→/Home/End 逐格走,Esc 取消。
|
|
132
|
+
* 天花板:只画一条 tokens 折线(桶里没有输入/输出分项),要分线得先改
|
|
133
|
+
* `/stats/chart` 的桶结构。
|
|
134
|
+
*/
|
|
135
|
+
const TREND_W = 640
|
|
136
|
+
const TREND_H = 150
|
|
137
|
+
const TREND_PAD_X = 12
|
|
138
|
+
const TREND_PAD_T = 12
|
|
139
|
+
const TREND_PAD_B = 8
|
|
140
|
+
|
|
141
|
+
function LineChart({ buckets, busy }: { buckets: RouterChartBucket[]; busy: boolean }): JSX.Element {
|
|
142
|
+
const [hover, setHover] = useState<number | null>(null)
|
|
143
|
+
const n = buckets.length
|
|
144
|
+
const max = Math.max(1, ...buckets.map((b) => b.tokens))
|
|
145
|
+
const hasData = buckets.some((b) => b.requests > 0 || b.tokens > 0)
|
|
146
|
+
const active = hover === null ? undefined : buckets[hover]
|
|
147
|
+
|
|
148
|
+
const innerW = TREND_W - TREND_PAD_X * 2
|
|
149
|
+
const innerH = TREND_H - TREND_PAD_T - TREND_PAD_B
|
|
150
|
+
const xOf = (i: number): number => TREND_PAD_X + (n <= 1 ? innerW / 2 : (i * innerW) / (n - 1))
|
|
151
|
+
const yOf = (v: number): number => TREND_PAD_T + (1 - v / max) * innerH
|
|
152
|
+
const pctX = (i: number): string => `${(xOf(i) / TREND_W) * 100}%`
|
|
153
|
+
const pctY = (v: number): string => `${(yOf(v) / TREND_H) * 100}%`
|
|
154
|
+
|
|
155
|
+
const pts = buckets.map((b, i) => `${xOf(i)},${yOf(b.tokens)}`)
|
|
156
|
+
const linePath = pts.length > 0 ? `M${pts.join('L')}` : ''
|
|
157
|
+
const areaPath = pts.length > 0
|
|
158
|
+
? `${linePath}L${xOf(n - 1)},${TREND_H - TREND_PAD_B}L${xOf(0)},${TREND_H - TREND_PAD_B}Z`
|
|
159
|
+
: ''
|
|
160
|
+
// 标签最多 ~8 个,最后一个一定显示(7 天周期全显示,24 小时每 3 小时一个);
|
|
161
|
+
// 末尾那个离倒数第二格太近时会叠字,这时让位给最后一格
|
|
162
|
+
const labelStep = Math.max(1, Math.ceil(n / 8))
|
|
163
|
+
const showLabel = (i: number): boolean => i === n - 1 || (i % labelStep === 0 && n - 1 - i >= labelStep / 2)
|
|
164
|
+
|
|
165
|
+
/** 取离指针最近的桶:比给每桶铺一个透明 rect 省 DOM,30 天也只有一层。 */
|
|
166
|
+
const pick = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
167
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
168
|
+
const x = ((e.clientX - rect.left) / rect.width) * TREND_W
|
|
169
|
+
const i = n <= 1 ? 0 : Math.round(((x - TREND_PAD_X) / innerW) * (n - 1))
|
|
170
|
+
setHover(Math.min(n - 1, Math.max(0, i)))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 键盘逐格走:函数式更新,连按不会读到过期的 hover。 */
|
|
174
|
+
const step = (delta: number): void => {
|
|
175
|
+
setHover((cur) => (n === 0 ? null : Math.min(n - 1, Math.max(0, (cur ?? n - 1) + delta))))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
179
|
+
const actions: Record<string, () => void> = {
|
|
180
|
+
ArrowLeft: () => step(-1),
|
|
181
|
+
ArrowRight: () => step(1),
|
|
182
|
+
Home: () => setHover(0),
|
|
183
|
+
End: () => setHover(n - 1),
|
|
184
|
+
Escape: () => setHover(null),
|
|
185
|
+
}
|
|
186
|
+
const act = actions[e.key]
|
|
187
|
+
if (act === undefined) return
|
|
188
|
+
e.preventDefault()
|
|
189
|
+
act()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="dshr-trendWrap">
|
|
194
|
+
{busy && n > 0 && <div className="dshr-trendBusy" aria-hidden="true" />}
|
|
195
|
+
{!hasData
|
|
196
|
+
? (
|
|
197
|
+
<div className="dshr-trendEmpty">
|
|
198
|
+
<span className="dshr-trendEmptyIcon"><Icon d={I.empty} size={26} /></span>
|
|
199
|
+
这个周期还没有请求
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
: (
|
|
203
|
+
<>
|
|
204
|
+
<div
|
|
205
|
+
className="dshr-linePlot"
|
|
206
|
+
role="img"
|
|
207
|
+
aria-label={`Token 趋势:${n} 个时段,峰值 ${fmtShort(max)} tokens`}
|
|
208
|
+
tabIndex={0}
|
|
209
|
+
onPointerMove={pick}
|
|
210
|
+
onPointerDown={pick}
|
|
211
|
+
// 触摸抬起也会触发 pointerleave:清掉读数等于白点一下,所以只认鼠标
|
|
212
|
+
onPointerLeave={(e) => { if (e.pointerType === 'mouse') setHover(null) }}
|
|
213
|
+
onBlur={() => setHover(null)}
|
|
214
|
+
onKeyDown={onKeyDown}
|
|
215
|
+
>
|
|
216
|
+
<svg className="dshr-lineSvg" viewBox={`0 0 ${TREND_W} ${TREND_H}`} preserveAspectRatio="none" aria-hidden="true">
|
|
217
|
+
{/* 三条水平参照线:只给刻度感,读数看悬停(峰值标在右上角) */}
|
|
218
|
+
{[0, 0.5, 1].map((f) => (
|
|
219
|
+
<line
|
|
220
|
+
key={f}
|
|
221
|
+
className="dshr-lineGrid"
|
|
222
|
+
x1={0}
|
|
223
|
+
x2={TREND_W}
|
|
224
|
+
y1={yOf(max * f)}
|
|
225
|
+
y2={yOf(max * f)}
|
|
226
|
+
vectorEffect="non-scaling-stroke"
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
<path className="dshr-lineArea" d={areaPath} />
|
|
230
|
+
<path className="dshr-linePath" d={linePath} vectorEffect="non-scaling-stroke" />
|
|
231
|
+
</svg>
|
|
232
|
+
{/* 峰值:给读数一个量级参照,否则「45.2K」看不出高不高 */}
|
|
233
|
+
<span className="dshr-linePeak">峰值 {fmtShort(max)}</span>
|
|
234
|
+
{/* 空心点只画在桶少时:30 个点会糊成一条 */}
|
|
235
|
+
{n <= 24 && buckets.map((b, i) => (
|
|
236
|
+
// 索引作 key:桶是按时间顺序固定位置的,本来就没有稳定 id
|
|
237
|
+
<span key={i} className="dshr-linePoint" style={{ left: pctX(i), top: pctY(b.tokens) }} />
|
|
238
|
+
))}
|
|
239
|
+
{hover !== null && active !== undefined && (
|
|
240
|
+
<>
|
|
241
|
+
<span className="dshr-lineGuide" style={{ left: pctX(hover) }} />
|
|
242
|
+
<span className="dshr-lineDot" style={{ left: pctX(hover), top: pctY(active.tokens) }} />
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
<div className="dshr-lineLabels">
|
|
247
|
+
{buckets.map((b, i) => (showLabel(i) && (
|
|
248
|
+
<span
|
|
249
|
+
key={i}
|
|
250
|
+
className={`dshr-lineLabel${hover === i ? ' dshr-lineLabel-on' : ''}`}
|
|
251
|
+
style={{ left: pctX(i) }}
|
|
252
|
+
>
|
|
253
|
+
{b.label}
|
|
254
|
+
</span>
|
|
255
|
+
)))}
|
|
256
|
+
</div>
|
|
257
|
+
<div
|
|
258
|
+
className="dshr-trendReadout"
|
|
259
|
+
// 缩写会丢精度,精确值放在 title 里
|
|
260
|
+
title={active === undefined ? undefined : `${fmt(active.requests)} 请求 · ${fmt(active.tokens)} tokens`}
|
|
261
|
+
>
|
|
262
|
+
{active === undefined
|
|
263
|
+
? <span className="dshr-muted">悬停或按 ←/→ 看每个时段</span>
|
|
264
|
+
: (
|
|
265
|
+
<>
|
|
266
|
+
<strong>{active.label}</strong>
|
|
267
|
+
<span className="dshr-muted"> · {fmtShort(active.requests)} 请求</span>
|
|
268
|
+
<span className="dshr-muted"> · {fmtShort(active.tokens)} tokens</span>
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ---------------- Top 榜 ---------------- */
|
|
279
|
+
|
|
280
|
+
function RankTable({ title, rows, empty }: { title: string; rows: RouterRankRow[]; empty: string }): JSX.Element {
|
|
281
|
+
return (
|
|
282
|
+
<section className="dshr-card">
|
|
283
|
+
<div className="dshr-cardHead">
|
|
284
|
+
<span className="dshr-cardIcon"><Icon d={I.chart} /></span>
|
|
285
|
+
<div className="dshr-cardTitle">{title}</div>
|
|
286
|
+
<span className="dshr-cardMeta">{rows.length > 0 ? `Top ${rows.length}` : ''}</span>
|
|
287
|
+
</div>
|
|
288
|
+
{rows.length === 0
|
|
289
|
+
? <div className="dshr-empty">{empty}</div>
|
|
290
|
+
: (
|
|
291
|
+
<div className="dshr-rankList">
|
|
292
|
+
<div className="dshr-rankRow dshr-rankHead">
|
|
293
|
+
<span className="dshr-rankName">名称</span>
|
|
294
|
+
<span className="dshr-rankNum">请求</span>
|
|
295
|
+
<span className="dshr-rankNum">输入</span>
|
|
296
|
+
<span className="dshr-rankNum">输出</span>
|
|
297
|
+
<span className="dshr-rankNum">最近</span>
|
|
298
|
+
</div>
|
|
299
|
+
{rows.map((r) => (
|
|
300
|
+
<div key={r.name} className="dshr-rankRow">
|
|
301
|
+
<span className="dshr-rankName dshr-mono" title={r.name}>{r.name}</span>
|
|
302
|
+
<span className="dshr-rankNum">
|
|
303
|
+
{fmt(r.requests)}
|
|
304
|
+
{r.failed > 0 && <span className="dshr-rankFail" title={`${r.failed} 次失败`}> ({r.failed})</span>}
|
|
305
|
+
</span>
|
|
306
|
+
<span className="dshr-rankNum">{fmtShort(r.promptTokens)}</span>
|
|
307
|
+
<span className="dshr-rankNum">{fmtShort(r.completionTokens)}</span>
|
|
308
|
+
<span className="dshr-rankNum dshr-muted">{r.lastTs > 0 ? timeAgo(r.lastTs) : '—'}</span>
|
|
309
|
+
</div>
|
|
310
|
+
))}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</section>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---------------- 最近请求 ---------------- */
|
|
318
|
+
|
|
319
|
+
function RecentTable({ rows }: { rows: RouterUsageRecord[] }): JSX.Element {
|
|
320
|
+
return (
|
|
321
|
+
<section className="dshr-card">
|
|
322
|
+
<div className="dshr-cardHead">
|
|
323
|
+
<span className="dshr-cardIcon"><Icon d={I.bolt} /></span>
|
|
324
|
+
<div className="dshr-cardTitle">最近请求</div>
|
|
325
|
+
<span className="dshr-cardMeta">最近 {rows.length} 条</span>
|
|
326
|
+
</div>
|
|
327
|
+
{rows.length === 0
|
|
328
|
+
? <div className="dshr-empty">还没有请求记录</div>
|
|
329
|
+
: (
|
|
330
|
+
<div className="dshr-recentList">
|
|
331
|
+
<div className="dshr-recentRow dshr-rankHead">
|
|
332
|
+
<span className="dshr-recentTime">时间</span>
|
|
333
|
+
<span className="dshr-recentModel">模型</span>
|
|
334
|
+
<span className="dshr-recentSupplier">供应商</span>
|
|
335
|
+
<span className="dshr-rankNum">In / Out</span>
|
|
336
|
+
<span className="dshr-rankNum">耗时</span>
|
|
337
|
+
</div>
|
|
338
|
+
{rows.map((r, i) => (
|
|
339
|
+
// 明细没有 id;同毫秒两条请求是可能的,索引用 ts+模型+序号兜底
|
|
340
|
+
<div key={`${r.ts}-${r.model}-${i}`} className={`dshr-recentRow${r.ok ? '' : ' dshr-recentRow-fail'}`}>
|
|
341
|
+
<span className="dshr-recentTime" title={new Date(r.ts).toLocaleString()}>
|
|
342
|
+
<span className={`dshr-dot ${r.ok ? 'dshr-dot-ok' : 'dshr-dot-fail'}`} />
|
|
343
|
+
{clockTime(r.ts)}
|
|
344
|
+
</span>
|
|
345
|
+
<span className="dshr-recentModel dshr-mono" title={`请求 ${r.requested}${r.error === undefined ? '' : ` · ${r.error}`}`}>
|
|
346
|
+
{r.model === '' ? r.requested : r.model}
|
|
347
|
+
</span>
|
|
348
|
+
<span className="dshr-recentSupplier">{r.supplier === '' ? '—' : r.supplier}</span>
|
|
349
|
+
<span className="dshr-rankNum">
|
|
350
|
+
<span className="dshr-tokenIn">{fmtShort(r.promptTokens)}{r.inputEstimated ? '~' : ''}</span>
|
|
351
|
+
{' / '}
|
|
352
|
+
<span className="dshr-tokenOut">{fmtShort(r.completionTokens)}{r.outputEstimated ? '~' : ''}</span>
|
|
353
|
+
</span>
|
|
354
|
+
<span className="dshr-rankNum dshr-muted">{fmtDuration(r.durationMs)}</span>
|
|
355
|
+
</div>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</section>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ---------------- 主面板 ---------------- */
|
|
364
|
+
|
|
365
|
+
interface StatsTabProps {
|
|
366
|
+
/** 顶部刷新按钮触发(父组件统一拉快照)。 */
|
|
367
|
+
onRefresh: () => void
|
|
368
|
+
refreshing: boolean
|
|
369
|
+
/** 掩码:面板是否可见(不可见时不做周期轮询)。 */
|
|
370
|
+
active: boolean
|
|
371
|
+
/**
|
|
372
|
+
* 供应商健康快照:签到卡要按 `checkinNow` 能力筛出可签到的供应商。
|
|
373
|
+
* 不传就不显示签到卡(老的中心栏挂载方式下 RouterView 也会传,这里留可选)。
|
|
374
|
+
*/
|
|
375
|
+
health?: RouterHealthResponse | null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProps): JSX.Element {
|
|
379
|
+
const [period, setPeriod] = useState<RouterPeriod>('today')
|
|
380
|
+
const [stats, setStats] = useState<RouterStatsResponse | null>(null)
|
|
381
|
+
const [chart, setChart] = useState<RouterChartBucket[]>([])
|
|
382
|
+
const [loading, setLoading] = useState(true)
|
|
383
|
+
const [error, setError] = useState('')
|
|
384
|
+
const mounted = useRef(true)
|
|
385
|
+
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
mounted.current = true
|
|
388
|
+
return () => { mounted.current = false }
|
|
389
|
+
}, [])
|
|
390
|
+
|
|
391
|
+
const load = async (p: RouterPeriod): Promise<void> => {
|
|
392
|
+
try {
|
|
393
|
+
const [sRes, cRes] = await Promise.all([
|
|
394
|
+
fetch(`${ROUTER_API_BASE}/stats?period=${p}`, { cache: 'no-store' }),
|
|
395
|
+
fetch(`${ROUTER_API_BASE}/stats/chart?period=${p}`, { cache: 'no-store' }),
|
|
396
|
+
])
|
|
397
|
+
const s = await sRes.json() as RouterStatsResponse
|
|
398
|
+
const c = await cRes.json() as RouterChartResponse
|
|
399
|
+
if (!mounted.current) return
|
|
400
|
+
setStats(s.ok ? s : null)
|
|
401
|
+
setChart(c.ok ? c.chart ?? [] : [])
|
|
402
|
+
setError(s.ok ? (c.ok ? '' : (c.error ?? '')) : (s.error ?? '加载统计失败'))
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (mounted.current) setError((err as Error).message)
|
|
405
|
+
} finally {
|
|
406
|
+
if (mounted.current) setLoading(false)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 切周期/变可见时重新拉。父组件的「刷新」走 refreshToken。
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (!active) return
|
|
413
|
+
setLoading(true)
|
|
414
|
+
void load(period)
|
|
415
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
416
|
+
}, [period, active])
|
|
417
|
+
|
|
418
|
+
const s = stats
|
|
419
|
+
const successRate = s !== null && s.requests !== undefined && s.requests > 0
|
|
420
|
+
? Math.round(((s.okCount ?? 0) / s.requests) * 100)
|
|
421
|
+
: null
|
|
422
|
+
// 估算提示:只有真有估算时才显示,别没事吓唬人
|
|
423
|
+
const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0)
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<div className="dshr-tabBody">
|
|
427
|
+
{/* 周期切换 + 刷新 */}
|
|
428
|
+
<div className="dshr-statsBar">
|
|
429
|
+
<div className="dshr-periods" role="tablist" aria-label="统计周期">
|
|
430
|
+
{PERIODS.map((p) => (
|
|
431
|
+
<button
|
|
432
|
+
key={p.value}
|
|
433
|
+
type="button"
|
|
434
|
+
role="tab"
|
|
435
|
+
aria-selected={period === p.value}
|
|
436
|
+
className={`dshr-period${period === p.value ? ' dshr-period-on' : ''}`}
|
|
437
|
+
onClick={() => setPeriod(p.value)}
|
|
438
|
+
>
|
|
439
|
+
{p.label}
|
|
440
|
+
</button>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
<div className="dshr-statsBarRight">
|
|
444
|
+
{s?.lifetime !== undefined && s.lifetime > 0 && (
|
|
445
|
+
<span className="dshr-muted dshr-lifetime" title="累计请求数(不受周期影响)">
|
|
446
|
+
累计 {fmt(s.lifetime)}
|
|
447
|
+
</span>
|
|
448
|
+
)}
|
|
449
|
+
{/* 纯图标:文字「刷新中…」在窄屏会把周期条挤换行。
|
|
450
|
+
禁用态必须看得出来(见 .dshr-iconBtn:disabled 的注释)。 */}
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
className="dshr-iconBtn"
|
|
454
|
+
onClick={() => { void load(period); onRefresh() }}
|
|
455
|
+
disabled={refreshing}
|
|
456
|
+
title={refreshing ? '刷新中…' : '刷新统计'}
|
|
457
|
+
aria-label={refreshing ? '刷新中' : '刷新统计'}
|
|
458
|
+
>
|
|
459
|
+
<span className={refreshing ? 'dshr-spin' : undefined}><Icon d={I.refresh} size={16} /></span>
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{error !== '' && (
|
|
465
|
+
<div className="dshr-alert">
|
|
466
|
+
<strong>统计加载失败</strong>
|
|
467
|
+
<span>{error}</span>
|
|
468
|
+
</div>
|
|
469
|
+
)}
|
|
470
|
+
|
|
471
|
+
{loading && s === null
|
|
472
|
+
? <div className="dshr-empty">加载中…</div>
|
|
473
|
+
: (
|
|
474
|
+
<>
|
|
475
|
+
{/* 汇总卡(照 9router OverviewCards 的五宫格) */}
|
|
476
|
+
<div className="dshr-statGrid">
|
|
477
|
+
<StatCard label="总请求" value={fmt(s?.requests)} hint={successRate === null ? undefined : `成功率 ${successRate}%`} tone="default" />
|
|
478
|
+
<StatCard
|
|
479
|
+
label="输入 Tokens"
|
|
480
|
+
value={fmtShort(s?.promptTokens)}
|
|
481
|
+
hint={(s?.estimatedInputs ?? 0) > 0 ? '~ 含估算' : undefined}
|
|
482
|
+
tone="primary"
|
|
483
|
+
title="包含缓存命中的输入"
|
|
484
|
+
/>
|
|
485
|
+
<StatCard
|
|
486
|
+
label="输出 Tokens"
|
|
487
|
+
value={fmtShort(s?.completionTokens)}
|
|
488
|
+
hint={(s?.estimatedOutputs ?? 0) > 0 ? '~ 含估算' : undefined}
|
|
489
|
+
tone="ok"
|
|
490
|
+
/>
|
|
491
|
+
{/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token */}
|
|
492
|
+
<StatCard label="缓存 Tokens" value={fmtShort(s?.cachedTokens)} hint="命中缓存的输入部分" tone="default" />
|
|
493
|
+
<StatCard
|
|
494
|
+
label="平均耗时"
|
|
495
|
+
value={fmtDuration(s?.avgDurationMs)}
|
|
496
|
+
hint={(s?.avgTtfbMs ?? 0) > 0 ? `首字节 ${fmtDuration(s?.avgTtfbMs)}` : undefined}
|
|
497
|
+
tone="warn"
|
|
498
|
+
/>
|
|
499
|
+
{/* 签到卡放在最后:它是操作(按钮)不是指标,但跟指标同宽同高度,
|
|
500
|
+
排在统计卡后面不会让人误以为是又一个 token 计数 */}
|
|
501
|
+
{health !== undefined && <CheckinCard health={health} onRefresh={onRefresh} />}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
{estimated && (
|
|
505
|
+
<p className="dshr-estimateNote">
|
|
506
|
+
带 <code>~</code> 的数字为估算:部分上游不返回用量,按 ~4 字符/token 推算。请求数与耗时不受影响。
|
|
507
|
+
</p>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
{/* 趋势折线图 */}
|
|
511
|
+
<section className="dshr-card">
|
|
512
|
+
<div className="dshr-cardHead">
|
|
513
|
+
<span className="dshr-cardIcon"><Icon d={I.chart} /></span>
|
|
514
|
+
<div className="dshr-cardTitle">Token 趋势</div>
|
|
515
|
+
<span className="dshr-cardMeta">{PERIODS.find((p) => p.value === period)?.label}</span>
|
|
516
|
+
</div>
|
|
517
|
+
<LineChart buckets={chart} busy={loading} />
|
|
518
|
+
</section>
|
|
519
|
+
|
|
520
|
+
{/* Top 榜 */}
|
|
521
|
+
<div className="dshr-rankPair">
|
|
522
|
+
<RankTable title="按供应商" rows={s?.bySupplier ?? []} empty="这个周期还没有请求" />
|
|
523
|
+
<RankTable title="按模型" rows={s?.byModel ?? []} empty="这个周期还没有请求" />
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
<RecentTable rows={(s?.recent ?? []).slice(0, RECENT_LIMIT)} />
|
|
527
|
+
</>
|
|
528
|
+
)}
|
|
529
|
+
</div>
|
|
530
|
+
)
|
|
531
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,18 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client half of dsh-router
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Client half of dsh-router.
|
|
3
|
+
*
|
|
4
|
+
* 挂载点是「设置 → 路由」(官方 `settings.section` 座位),不再是侧边栏
|
|
5
|
+
* DOM 注入 + 中心栏劫持。`slots` / `locale` 由宿主运行时提供(profile 的
|
|
6
|
+
* node_modules 里 @deepseek-ai 是空的:这些包不入 node_modules,按
|
|
7
|
+
* package.json 的 dsh.client.inject 声明即可)。
|
|
8
|
+
*
|
|
9
|
+
* 拿不到 slots 时(老宿主)回退到原来的中心栏面板,不至于整个插件失效。
|
|
5
10
|
*/
|
|
6
11
|
import { mountRouterWorkspace } from './workspace-mount.tsx'
|
|
12
|
+
import { RouterSettingsSection } from './settings-section.tsx'
|
|
13
|
+
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
7
14
|
import './router.css'
|
|
8
15
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 设置导航里这一页的显示名。同时是注册 label 和「认领图标」时匹配的文案,
|
|
18
|
+
* 两处必须一致 —— 认领逻辑按可见文案找自己那一行(见 settings-nav-icon.ts)。
|
|
19
|
+
*/
|
|
20
|
+
const SECTION_LABEL = '路由'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 必须声明:Cordis 的 ctx 是 Proxy,未声明就访问 `ctx.slots` 会直接抛
|
|
24
|
+
* `cannot get property "slots" without inject`(实测错误,不是理论风险)——
|
|
25
|
+
* 连 `ctx.slots === undefined` 这种防御性判断都跑不到,getter 先炸。
|
|
26
|
+
*/
|
|
27
|
+
export const inject: string[] = ['slots']
|
|
28
|
+
|
|
29
|
+
interface SlotsFace {
|
|
30
|
+
inject: (key: string, cb: () => unknown) => () => void
|
|
31
|
+
register: (reg: Record<string, unknown>, component: unknown) => unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Ctx {
|
|
35
|
+
slots?: SlotsFace
|
|
36
|
+
effect: (fn: () => (() => void) | void, label?: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
|
|
40
|
+
function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
|
|
41
|
+
const slots = ctx.slots
|
|
42
|
+
if (slots === undefined) return undefined
|
|
43
|
+
// order 10 = 与「模型」「远程访问」同值,靠注册顺序定先后,实测排成
|
|
44
|
+
// 模型 → 路由 → 远程访问(正是要的位置)。
|
|
45
|
+
// 排序规则(宿主实现):priority ?? 0 优先,再 order ?? 0,数值升序,
|
|
46
|
+
// 同值保持注册顺序。所以想插在两个同 order 的条目中间是做不到的——
|
|
47
|
+
// 试过 9.99(跑到模型上面)、10.001/10.05/11(都跑到远程访问后面)。
|
|
48
|
+
// 只能取同值,让注册顺序决定。
|
|
49
|
+
return slots.inject('settings.section', () => slots.register({
|
|
50
|
+
name: 'settings.section',
|
|
51
|
+
id: 'dsh-router',
|
|
52
|
+
order: 10,
|
|
53
|
+
label: SECTION_LABEL,
|
|
54
|
+
}, RouterSettingsSection))
|
|
55
|
+
}
|
|
11
56
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'dsh-router
|
|
17
|
-
|
|
57
|
+
export function apply(ctx: Ctx): void {
|
|
58
|
+
const disposeSettings = mountSettingsSection(ctx)
|
|
59
|
+
if (disposeSettings === undefined) {
|
|
60
|
+
// 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板
|
|
61
|
+
console.warn('[dsh-router] 宿主未提供 slots,回退到侧边栏面板')
|
|
62
|
+
ctx.effect(() => mountRouterWorkspace(), 'dsh-router: routing system workspace')
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
ctx.effect(() => disposeSettings, 'dsh-router: settings section')
|
|
66
|
+
// 换掉宿主的默认齿轮(契约没有 icon 字段,只能注册后认领自己的行)
|
|
67
|
+
ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), 'dsh-router: settings nav icon')
|
|
18
68
|
}
|