dsh-router-core 0.3.21 → 0.4.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.
- package/README.md +77 -34
- package/docs/pool-sticky-block.md +158 -0
- package/lib/client-registry.js +351 -407
- package/lib/client.js +351 -407
- package/lib/index.js +383 -170
- package/lib/types/ext/contract.d.ts +0 -7
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +4 -7
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +13 -21
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +2 -2
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +12 -13
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +15 -178
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/index.tsx +45 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router.css +2106 -1630
- package/src/ext/contract.ts +0 -7
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +133 -59
- package/src/llm/adapter.test.ts +74 -0
- package/src/llm/adapter.ts +33 -0
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/index.ts +22 -28
- package/src/router/models-cache.test.ts +9 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +13 -24
- package/src/supplier-config.ts +2 -13
- package/src/suppliers/contract.ts +2 -2
- package/src/suppliers/loader.test.ts +2 -2
- package/src/suppliers/registry.ts +1 -20
- package/src/types.d.ts +2 -1
package/src/client/ExtDetail.tsx
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 扩展详情页 —— 从设置 → 路由 → 扩展 的卡片点进来。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 「复制 kill 命令」两个剪贴板动作方便用户去终端处理。
|
|
8
|
-
* 进程数据走后端 `/router/api/ext/:id/processes`;`ps` 瞬时失败时保留上一帧,别刷成
|
|
9
|
-
* 整屏错误。
|
|
4
|
+
* 布局同供应商详情:返回链接 + 图标 + 名称/副行,头部右侧是启用开关;下面跟扩展说明
|
|
5
|
+
* 卡与未就绪横幅。有 `api`() 支持的自定义扩展可在未来按需加独立面板,此处不内置任何
|
|
6
|
+
* 具体扩展的专属视图。
|
|
10
7
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
ROUTER_API_BASE,
|
|
14
|
-
type RouterExtItem,
|
|
15
|
-
type RouterWatchProcess,
|
|
16
|
-
type RouterWatchResponse,
|
|
17
|
-
} from '../shared.ts'
|
|
8
|
+
import type { RouterExtItem } from '../shared.ts'
|
|
18
9
|
|
|
19
10
|
function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
20
11
|
return (
|
|
@@ -26,32 +17,7 @@ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
|
26
17
|
|
|
27
18
|
const I = {
|
|
28
19
|
back: 'M19 12H5M12 19l-7-7 7-7',
|
|
29
|
-
refresh: 'M20.5 12a8.5 8.5 0 1 1-2.6-6.1M20.5 4v5h-5',
|
|
30
|
-
node: 'M8 5.5L12 3l4 2.5v5L12 13l-4-2.5v-5zM4 9v5l4 2.5M12 13v5l4-2.5M20 9v5l-4 2.5',
|
|
31
20
|
bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
|
|
32
|
-
copy: 'M8 8h11v11H8zM5 14H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
|
|
33
|
-
check: 'M4 12.5l5 5L20 6.5',
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 刷新按钮(带禁用中旋转)。 */
|
|
37
|
-
function RefreshBtn({ onClick, busy }: { onClick: () => void; busy: boolean }): JSX.Element {
|
|
38
|
-
return (
|
|
39
|
-
<button
|
|
40
|
-
type="button"
|
|
41
|
-
className="dshr-iconButton"
|
|
42
|
-
onClick={onClick}
|
|
43
|
-
disabled={busy}
|
|
44
|
-
title="刷新进程"
|
|
45
|
-
aria-label="刷新进程"
|
|
46
|
-
>
|
|
47
|
-
<span className={busy ? 'dshr-spin' : undefined}><Icon d={I.refresh} size={16} /></span>
|
|
48
|
-
</button>
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function fmtPct(v: number | undefined): string {
|
|
53
|
-
const n = v ?? 0
|
|
54
|
-
return `${n.toFixed(1)}%`
|
|
55
21
|
}
|
|
56
22
|
|
|
57
23
|
interface ExtDetailProps {
|
|
@@ -72,7 +38,17 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
|
|
|
72
38
|
</button>
|
|
73
39
|
<div className="dshr-providerTitleRow">
|
|
74
40
|
<div className="dshr-providerIcon" style={{ color: 'var(--rs-faint)', background: 'var(--rs-layer-2)' }}>
|
|
75
|
-
|
|
41
|
+
{item.icon !== undefined
|
|
42
|
+
? (
|
|
43
|
+
<img
|
|
44
|
+
className="dshr-providerImg"
|
|
45
|
+
src={item.icon}
|
|
46
|
+
alt=""
|
|
47
|
+
// 同上:失败就藏,露出备用图标位置(不显示碎图)
|
|
48
|
+
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
: <Icon d={I.bolt} size={22} />}
|
|
76
52
|
</div>
|
|
77
53
|
<div className="dshr-providerMeta">
|
|
78
54
|
<h1 className="dshr-providerName">{item.name}</h1>
|
|
@@ -113,145 +89,6 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
|
|
|
113
89
|
<span>{item.detail !== undefined && item.detail !== '' ? item.detail : '当前不可用'}</span>
|
|
114
90
|
</div>
|
|
115
91
|
)}
|
|
116
|
-
|
|
117
|
-
{item.id === 'watch' ? <WatchPanel /> : null}
|
|
118
92
|
</div>
|
|
119
93
|
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** watch 的进程监控面板:轮询采样(只读)+ 复制 PID / 复制 kill 命令。 */
|
|
123
|
-
function WatchPanel(): JSX.Element {
|
|
124
|
-
const [processes, setProcesses] = useState<RouterWatchProcess[]>([])
|
|
125
|
-
const [error, setError] = useState('')
|
|
126
|
-
const [loading, setLoading] = useState(true)
|
|
127
|
-
// 刚复制成的那条(pid + 文本),显示「已复制」提示,1.2s 后清除。
|
|
128
|
-
const [copied, setCopied] = useState<{ pid: number; text: string } | null>(null)
|
|
129
|
-
const alive = useRef(true)
|
|
130
|
-
const now = useRef(Date.now())
|
|
131
|
-
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
alive.current = true
|
|
134
|
-
return () => { alive.current = false }
|
|
135
|
-
}, [])
|
|
136
|
-
|
|
137
|
-
const load = async (): Promise<void> => {
|
|
138
|
-
try {
|
|
139
|
-
const res = await fetch(`${ROUTER_API_BASE}/ext/watch/processes`, { cache: 'no-store' })
|
|
140
|
-
if (!res.ok) {
|
|
141
|
-
// 尽量把后端的原因露出来(如「extension not found」/「extension api not found」),
|
|
142
|
-
// 别只剩 `HTTP 404`——它分不清是扩展没装、还是 api 没命中。
|
|
143
|
-
let reason = ''
|
|
144
|
-
try {
|
|
145
|
-
const err = await res.json() as { error?: string }
|
|
146
|
-
if (err.error) reason = ` — ${err.error}`
|
|
147
|
-
} catch { /* 非 JSON 响应体,忽略 */ }
|
|
148
|
-
if (alive.current) setError(`HTTP ${res.status}${reason}`)
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
// 响应体非 JSON(历史上 404 空 body 会在这里抛 Unexpected end of JSON input)。
|
|
152
|
-
// 分开 try,别把"非 JSON"混进网络错误的文案里。
|
|
153
|
-
let data: RouterWatchResponse
|
|
154
|
-
try {
|
|
155
|
-
data = await res.json() as RouterWatchResponse
|
|
156
|
-
} catch {
|
|
157
|
-
if (alive.current) setError('响应不是 JSON')
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
if (!alive.current) return
|
|
161
|
-
setError(data.ok ? '' : (data.error ?? '加载进程失败'))
|
|
162
|
-
if (data.ok && data.processes !== undefined) setProcesses(data.processes) // ?: 失败保留上一帧
|
|
163
|
-
} catch (err) {
|
|
164
|
-
if (alive.current) setError((err as Error).message) // 失败保留上一帧
|
|
165
|
-
} finally {
|
|
166
|
-
if (alive.current) setLoading(false)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// 进入面板后轮询采样(3s;离开卸载)。
|
|
171
|
-
useEffect(() => {
|
|
172
|
-
void load()
|
|
173
|
-
const timer = setInterval(() => { void load() }, 3000)
|
|
174
|
-
return () => clearInterval(timer)
|
|
175
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
-
}, [])
|
|
177
|
-
|
|
178
|
-
const copy = (pid: number, text: string): void => {
|
|
179
|
-
const stamp = Date.now()
|
|
180
|
-
now.current = stamp
|
|
181
|
-
void navigator.clipboard.writeText(text)
|
|
182
|
-
.then(() => {
|
|
183
|
-
if (alive.current && now.current === stamp) {
|
|
184
|
-
setCopied({ pid, text })
|
|
185
|
-
setTimeout(() => { if (alive.current) setCopied((c) => c?.pid === pid ? null : c) }, 1200)
|
|
186
|
-
}
|
|
187
|
-
})
|
|
188
|
-
.catch(() => { if (alive.current) setError('复制失败') })
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<section className="dshr-card">
|
|
193
|
-
<div className="dshr-cardHead">
|
|
194
|
-
<span className="dshr-cardIcon"><Icon d={I.node} /></span>
|
|
195
|
-
<div className="dshr-cardTitle">运行进程</div>
|
|
196
|
-
<span className="dshr-cardMeta">agent 启动 · 只读 · 3s 采样</span>
|
|
197
|
-
<RefreshBtn onClick={() => void load()} busy={loading} />
|
|
198
|
-
</div>
|
|
199
|
-
|
|
200
|
-
{error !== '' && (
|
|
201
|
-
<div className="dshr-alert"><strong>采样失败</strong><span>{error}(保留上一帧)</span></div>
|
|
202
|
-
)}
|
|
203
|
-
|
|
204
|
-
{!loading && processes.length === 0 ? (
|
|
205
|
-
<div className="dshr-empty">
|
|
206
|
-
暂无会话中 agent 启动的进程。运行诸如 <code>node server.js</code> 的长驻命令后
|
|
207
|
-
会出现在这里。
|
|
208
|
-
</div>
|
|
209
|
-
) : (
|
|
210
|
-
<div className="dshr-rankList">
|
|
211
|
-
<div className="dshr-rankRow dshr-rankHead">
|
|
212
|
-
<span className="dshr-rankName">PID / 命令</span>
|
|
213
|
-
<span className="dshr-rankNum">CPU</span>
|
|
214
|
-
<span className="dshr-rankNum">内存</span>
|
|
215
|
-
<span className="dshr-rankNum" />
|
|
216
|
-
</div>
|
|
217
|
-
{processes.map((p) => {
|
|
218
|
-
const done = copied?.pid === p.pid
|
|
219
|
-
return (
|
|
220
|
-
<div key={p.pid} className="dshr-rankRow">
|
|
221
|
-
<span className="dshr-rankName dshr-mono" title={p.command}>
|
|
222
|
-
<span className="dshr-muted">{p.pid}</span> {p.command}
|
|
223
|
-
</span>
|
|
224
|
-
<span className="dshr-rankNum">{fmtPct(p.cpu)}</span>
|
|
225
|
-
<span className="dshr-rankNum">{fmtPct(p.mem)}</span>
|
|
226
|
-
<span className="dshr-rankNum dshr-procOps">
|
|
227
|
-
{done
|
|
228
|
-
? <span className="dshr-muted"><Icon d={I.check} size={13} /> 已复制</span>
|
|
229
|
-
: (
|
|
230
|
-
<>
|
|
231
|
-
<button
|
|
232
|
-
type="button"
|
|
233
|
-
className="dshr-miniButton"
|
|
234
|
-
onClick={() => copy(p.pid, String(p.pid))}
|
|
235
|
-
title={`复制 PID ${p.pid}`}
|
|
236
|
-
>
|
|
237
|
-
<Icon d={I.copy} size={13} /> PID
|
|
238
|
-
</button>
|
|
239
|
-
<button
|
|
240
|
-
type="button"
|
|
241
|
-
className="dshr-miniButton"
|
|
242
|
-
onClick={() => copy(p.pid, `kill -TERM ${p.pid}`)}
|
|
243
|
-
title={`复制 kill -TERM ${p.pid}`}
|
|
244
|
-
>
|
|
245
|
-
<Icon d={I.copy} size={13} /> kill
|
|
246
|
-
</button>
|
|
247
|
-
</>
|
|
248
|
-
)}
|
|
249
|
-
</span>
|
|
250
|
-
</div>
|
|
251
|
-
)
|
|
252
|
-
})}
|
|
253
|
-
</div>
|
|
254
|
-
)}
|
|
255
|
-
</section>
|
|
256
|
-
)
|
|
257
94
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入框上方的「最近命中」徽章 —— 走 DSH 官方 `conversation.input.dock` 座位
|
|
3
|
+
* (scope: session,见 dsh-client-ui-conversation 的 slots 契约)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么用 slot 而不是认领 DOM:
|
|
6
|
+
* - slot 的 session scope 会**注入 `sessionId`**(SessionStandardProps),
|
|
7
|
+
* 于是能按当前会话取「最近命中」—— team 多会话下不会显示别的成员;
|
|
8
|
+
* - slot 是稳定契约,宿主 hash 类名(如之前的 `.JObwrW_root`)随版本会变。
|
|
9
|
+
* 参照实现:@deepseek-ai/dsh-client-ui-goal 的 GoalDock 同挂这个座位,
|
|
10
|
+
* 组件 props 由宿主注入,`inject(sessionId)` 拿会话身份。
|
|
11
|
+
*
|
|
12
|
+
* 数据源:核心 `/router/api/last-hit?session=`(按会话取一条轻记录)。
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from 'react'
|
|
15
|
+
import { ROUTER_API_BASE } from '../shared.ts'
|
|
16
|
+
import { getLastHit, subscribeLastHit, type LastHit } from './last-hit-store.ts'
|
|
17
|
+
|
|
18
|
+
/** 路由字形(纯 currentColor 描边,14px 与相邻控件对齐)。 */
|
|
19
|
+
const ICON = (
|
|
20
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
21
|
+
<circle cx="5" cy="5" r="2.4" />
|
|
22
|
+
<circle cx="19" cy="5" r="2.4" />
|
|
23
|
+
<circle cx="12" cy="19" r="2.4" />
|
|
24
|
+
<path d="M7.4 5h9.2" />
|
|
25
|
+
<path d="M5 7.4v6.2c0 2.6 3 3.4 4.6 3.8" />
|
|
26
|
+
<path d="M19 7.4v6.2c0 2.6-3 3.4-4.6 3.8" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
/** 账号名:别名 → uid → 「无连接」。 */
|
|
31
|
+
function connectionName(hit: LastHit): string {
|
|
32
|
+
const name = hit.account !== undefined && hit.account !== '' ? hit.account : hit.uid
|
|
33
|
+
return name === '' ? '无连接' : name
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 积分展示。**未知就是未知**:`-1` / 缺省一律显示「未知」,
|
|
38
|
+
* 绝不编一个 0 —— 沿用 store 那条「不用 0 冒充未知」的纪律。
|
|
39
|
+
*/
|
|
40
|
+
function creditsText(hit: LastHit): string {
|
|
41
|
+
return hit.credits === undefined || hit.credits < 0 ? '未知' : `${Math.round(hit.credits)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 模型短名(去掉 `prefix/`;前缀在「服务商」行已单独列出)。 */
|
|
45
|
+
function modelShort(hit: LastHit): string {
|
|
46
|
+
const full = hit.model === '' ? hit.supplier : hit.model
|
|
47
|
+
const slash = full.lastIndexOf('/')
|
|
48
|
+
return slash > 0 ? full.slice(slash + 1) : full
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 宿主注入的 props(只声明用到的)。 */
|
|
52
|
+
interface LastHitDockProps {
|
|
53
|
+
/** 宿主注入的会话身份;宿主未注入时为 undefined。 */
|
|
54
|
+
sessionId?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 输入框上方的「最近命中」徽章:图标 + 模型短名;点击展开连接/积分卡片。
|
|
59
|
+
*
|
|
60
|
+
* @param props 宿主注入的会话身份
|
|
61
|
+
*/
|
|
62
|
+
export function LastHitDock({ sessionId }: LastHitDockProps): JSX.Element {
|
|
63
|
+
const [hit, setHit] = useState<LastHit | null>(() => getLastHit(sessionId))
|
|
64
|
+
const [open, setOpen] = useState(false)
|
|
65
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
// 诊断:确认 slot 真挂上了、以及宿主是否注入了 sessionId
|
|
69
|
+
console.info('[dsh-router] last-hit dock mounted, sessionId =', sessionId)
|
|
70
|
+
return subscribeLastHit(sessionId, setHit)
|
|
71
|
+
}, [sessionId])
|
|
72
|
+
|
|
73
|
+
// 点外部收起:对齐宿主原生 popover 的关闭语义(primitives.Menu /
|
|
74
|
+
// useDismissOnOutsidePointer)——document 级 `pointerdown` 冒泡相位(早于
|
|
75
|
+
// click,避免"先选中后关闭")、根节点含自身则忽略;另补 Escape 关闭。
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return
|
|
78
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
79
|
+
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
|
|
80
|
+
setOpen(false)
|
|
81
|
+
}
|
|
82
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
83
|
+
if (e.key === 'Escape') setOpen(false)
|
|
84
|
+
}
|
|
85
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
86
|
+
document.addEventListener('keydown', onKeyDown)
|
|
87
|
+
return () => {
|
|
88
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
89
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
90
|
+
}
|
|
91
|
+
}, [open])
|
|
92
|
+
|
|
93
|
+
// 无数据时**仍渲染占位**(不返回 null):否则「挂载成功但暂无记录」看起来
|
|
94
|
+
// 就像"整个控件不见了",无法与"根本没挂上"区分。
|
|
95
|
+
const rows: Array<[string, string]> = hit === null
|
|
96
|
+
? []
|
|
97
|
+
: [
|
|
98
|
+
['服务商', hit.supplier],
|
|
99
|
+
['模型', modelShort(hit)],
|
|
100
|
+
['连接', connectionName(hit)],
|
|
101
|
+
['积分', creditsText(hit)],
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="dshr-lastHit" ref={rootRef}>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className={`dshr-lastHit-btn${hit !== null && !hit.ok ? ' dshr-lastHit-fail' : ''}`}
|
|
109
|
+
aria-label="最近一次命中"
|
|
110
|
+
aria-expanded={open}
|
|
111
|
+
title={hit === null ? '暂无命中记录' : `${hit.model === '' ? hit.supplier : hit.model} · ${connectionName(hit)}`}
|
|
112
|
+
onClick={() => setOpen((v) => !v)}
|
|
113
|
+
>
|
|
114
|
+
{ICON}
|
|
115
|
+
<span className="dshr-lastHit-label">{hit === null ? '暂无' : modelShort(hit)}</span>
|
|
116
|
+
</button>
|
|
117
|
+
{open && (
|
|
118
|
+
<div className="dshr-lastHit-card">
|
|
119
|
+
<div className="dshr-lastHit-title">
|
|
120
|
+
<span className="dshr-lastHit-titleLabel">
|
|
121
|
+
{ICON}
|
|
122
|
+
<span>路由</span>
|
|
123
|
+
</span>
|
|
124
|
+
<span className="dshr-lastHit-titleValue" title={hit?.requested}>
|
|
125
|
+
{hit === null || hit.requested === '' ? '—' : hit.requested}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="dshr-lastHit-titleRule" />
|
|
129
|
+
{rows.length === 0
|
|
130
|
+
? <div className="dshr-lastHit-row"><span className="dshr-lastHit-key">暂无命中记录</span></div>
|
|
131
|
+
: rows.map(([k, v]) => (
|
|
132
|
+
<div key={k} className="dshr-lastHit-row">
|
|
133
|
+
<span className="dshr-lastHit-key">{k}</span>
|
|
134
|
+
<span className="dshr-lastHit-val">{v}</span>
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
package/src/client/StatsTab.tsx
CHANGED
|
@@ -333,9 +333,9 @@ function RecentTable({ rows }: { rows: RouterUsageRecord[] }): JSX.Element {
|
|
|
333
333
|
<div className="dshr-recentRow dshr-rankHead">
|
|
334
334
|
<span className="dshr-recentTime">时间</span>
|
|
335
335
|
<span className="dshr-recentModel">模型</span>
|
|
336
|
-
<span className="dshr-recentSupplier"
|
|
337
|
-
<span className="dshr-
|
|
338
|
-
<span className="dshr-
|
|
336
|
+
<span className="dshr-recentSupplier">连接</span>
|
|
337
|
+
<span className="dshr-recentTokens">In / Out</span>
|
|
338
|
+
<span className="dshr-recentDuration">耗时</span>
|
|
339
339
|
</div>
|
|
340
340
|
{rows.map((r, i) => (
|
|
341
341
|
// 明细没有 id;同毫秒两条请求是可能的,索引用 ts+模型+序号兜底
|
|
@@ -347,13 +347,15 @@ function RecentTable({ rows }: { rows: RouterUsageRecord[] }): JSX.Element {
|
|
|
347
347
|
<span className="dshr-recentModel dshr-mono" title={`请求 ${r.requested}${r.error === undefined ? '' : ` · ${r.error}`}`}>
|
|
348
348
|
{r.model === '' ? r.requested : r.model}
|
|
349
349
|
</span>
|
|
350
|
-
<span className="dshr-recentSupplier"
|
|
351
|
-
|
|
350
|
+
<span className="dshr-recentSupplier" title={r.uid ?? ''}>
|
|
351
|
+
{r.connection === undefined || r.connection === '' ? '—' : r.connection}
|
|
352
|
+
</span>
|
|
353
|
+
<span className="dshr-recentTokens">
|
|
352
354
|
<span className="dshr-tokenIn">{fmtShort(r.promptTokens)}{r.inputEstimated ? '~' : ''}</span>
|
|
353
355
|
{' / '}
|
|
354
356
|
<span className="dshr-tokenOut">{fmtShort(r.completionTokens)}{r.outputEstimated ? '~' : ''}</span>
|
|
355
357
|
</span>
|
|
356
|
-
<span className="dshr-
|
|
358
|
+
<span className="dshr-recentDuration dshr-muted">{fmtDuration(r.durationMs)}</span>
|
|
357
359
|
</div>
|
|
358
360
|
))}
|
|
359
361
|
</div>
|
|
@@ -106,7 +106,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
106
106
|
const [modelQuery, setModelQuery] = useState('')
|
|
107
107
|
const [fetchingModels, setFetchingModels] = useState(false)
|
|
108
108
|
const [removeModelTarget, setRemoveModelTarget] = useState<string | null>(null)
|
|
109
|
-
const [poolStrategy, setPoolStrategy] = useState<'fallback' | 'round-robin'>('fallback')
|
|
110
109
|
const [poolOrder, setPoolOrder] = useState<string[]>([])
|
|
111
110
|
const [checkingIn, setCheckingIn] = useState(false)
|
|
112
111
|
const [refreshing, setRefreshing] = useState(false)
|
|
@@ -146,16 +145,9 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
146
145
|
|
|
147
146
|
const loadPoolConfig = useCallback(async (): Promise<void> => {
|
|
148
147
|
try {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' }),
|
|
152
|
-
])
|
|
153
|
-
const [strategyData, orderData] = await Promise.all([strategyRes.json(), orderRes.json()]) as [
|
|
154
|
-
{ ok: boolean; strategy?: 'fallback' | 'round-robin' },
|
|
155
|
-
{ ok: boolean; order?: string[] },
|
|
156
|
-
]
|
|
148
|
+
const orderRes = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' })
|
|
149
|
+
const orderData = await orderRes.json() as { ok: boolean; order?: string[] }
|
|
157
150
|
if (mounted.current) {
|
|
158
|
-
if (strategyData.ok && strategyData.strategy) setPoolStrategy(strategyData.strategy)
|
|
159
151
|
if (orderData.ok && Array.isArray(orderData.order)) setPoolOrder(orderData.order)
|
|
160
152
|
}
|
|
161
153
|
} catch {
|
|
@@ -535,17 +527,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
535
527
|
const data = await response.json() as { ok: boolean; error?: string }
|
|
536
528
|
if (data.ok) {
|
|
537
529
|
setAlias(aliasDraft.trim())
|
|
538
|
-
// 同时保存连接池策略
|
|
539
|
-
try {
|
|
540
|
-
await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, {
|
|
541
|
-
method: 'POST',
|
|
542
|
-
headers: { 'Content-Type': 'application/json' },
|
|
543
|
-
body: JSON.stringify({ strategy: poolStrategy }),
|
|
544
|
-
cache: 'no-store',
|
|
545
|
-
})
|
|
546
|
-
} catch {
|
|
547
|
-
// 策略保存失败不阻断前缀保存
|
|
548
|
-
}
|
|
549
530
|
setShowAlias(false)
|
|
550
531
|
} else {
|
|
551
532
|
setAliasError(data.error ?? '保存失败')
|
|
@@ -603,7 +584,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
603
584
|
<h1 className="dshr-providerName">{supplier.name}</h1>
|
|
604
585
|
<p className="dshr-providerCount">
|
|
605
586
|
{supplierAccounts.length} 链接
|
|
606
|
-
{canPool && ` · ${poolStrategy === 'round-robin' ? '轮询' : '回退'}`}
|
|
607
587
|
{canEdit && ` · 前缀 ${alias}`}
|
|
608
588
|
</p>
|
|
609
589
|
</div>
|
|
@@ -905,18 +885,6 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
905
885
|
onChange={(e) => setAliasDraft(e.target.value)}
|
|
906
886
|
/>
|
|
907
887
|
</>
|
|
908
|
-
<>
|
|
909
|
-
<label className="dshr-fieldLabel">连接池策略</label>
|
|
910
|
-
<p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。</p>
|
|
911
|
-
<select
|
|
912
|
-
className="dshr-select dshr-input"
|
|
913
|
-
value={poolStrategy}
|
|
914
|
-
onChange={(e) => setPoolStrategy(e.target.value as 'fallback' | 'round-robin')}
|
|
915
|
-
>
|
|
916
|
-
<option value="fallback">回退</option>
|
|
917
|
-
<option value="round-robin">轮询</option>
|
|
918
|
-
</select>
|
|
919
|
-
</>
|
|
920
888
|
{aliasError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{aliasError}</span></div>}
|
|
921
889
|
<div className="dshr-modalActions">
|
|
922
890
|
<button type="button" className="dshr-miniButton" onClick={() => setShowAlias(false)}>取消</button>
|
package/src/client/index.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import { mountRouterWorkspace } from './workspace-mount.tsx'
|
|
|
12
12
|
import { RouterSettingsSection } from './settings-section.tsx'
|
|
13
13
|
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
14
14
|
import { rewriteRouterModelHint } from './model-hint-copy.ts'
|
|
15
|
+
import { LastHitDock } from './LastHitDock.tsx'
|
|
16
|
+
import { ROUTER_API_BASE } from '../shared.ts'
|
|
15
17
|
import './router.css'
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -37,6 +39,42 @@ interface Ctx {
|
|
|
37
39
|
effect: (fn: () => (() => void) | void, label?: string) => void
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 挂载输入框底部的「最近命中」徽章:注册到官方 `conversation.composer.dock`
|
|
44
|
+
* (kind: list, scope: session —— 即宿主 composer 卡片底部操作行
|
|
45
|
+
* `.uV2eYG_dock` 里、原生上下文环旁边的位置)。
|
|
46
|
+
* scope 为 session 时宿主给组件注入 `sessionId`,据此按**当前会话**取命中。
|
|
47
|
+
*
|
|
48
|
+
* **仅宿主 >= 0.1.7 才挂**:composer.dock 这个座位在 0.1.5 也在,但渲染在
|
|
49
|
+
* InputBar 之后的独立块(位置不对),只有 0.1.7 才放进底部操作行。座位名相同
|
|
50
|
+
* 没法靠契约区分,故先问核心 `/router/api/health` 的 `lastHitDock` 支持位。
|
|
51
|
+
* @returns 清理函数;不支持或宿主无 slots 时为空操作
|
|
52
|
+
*/
|
|
53
|
+
function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
|
|
54
|
+
const slots = ctx.slots
|
|
55
|
+
if (slots === undefined || !supported) return () => {}
|
|
56
|
+
return slots.inject('conversation.composer.dock', () => slots.register({
|
|
57
|
+
name: 'conversation.composer.dock',
|
|
58
|
+
id: 'dsh-router-last-hit',
|
|
59
|
+
order: 10,
|
|
60
|
+
}, LastHitDock))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 问核心是否支持「最近命中」徽章(宿主 >= 0.1.7)。
|
|
65
|
+
* 网络/解析失败一律当**不支持**——宁可不显示,也不在 0.1.5 上摆错位置。
|
|
66
|
+
*/
|
|
67
|
+
async function fetchLastHitDockSupport(): Promise<boolean> {
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${ROUTER_API_BASE}/health`)
|
|
70
|
+
if (!res.ok) return false
|
|
71
|
+
const body = (await res.json()) as { lastHitDock?: unknown }
|
|
72
|
+
return body.lastHitDock === true
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
40
78
|
/** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
|
|
41
79
|
function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
|
|
42
80
|
const slots = ctx.slots
|
|
@@ -56,6 +94,13 @@ function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
|
|
|
56
94
|
}
|
|
57
95
|
|
|
58
96
|
export function apply(ctx: Ctx): void {
|
|
97
|
+
// 「最近命中」徽章:走官方 conversation.composer.dock(scope: session → 组件
|
|
98
|
+
// props 注入 sessionId,按当前会话取命中)。**仅宿主 >= 0.1.7 才挂**——
|
|
99
|
+
// composer.dock 在 0.1.5 渲染位置不对,先问核心拿支持位,避免摆错位置。
|
|
100
|
+
// 查询是异步的,拿到结果(无论成败)再决定注册与否。
|
|
101
|
+
void fetchLastHitDockSupport().then((supported) => {
|
|
102
|
+
ctx.effect(() => mountLastHitDock(ctx, supported), 'dsh-router: last-hit dock')
|
|
103
|
+
})
|
|
59
104
|
const disposeSettings = mountSettingsSection(ctx)
|
|
60
105
|
if (disposeSettings === undefined) {
|
|
61
106
|
// 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「最近命中」的会话级数据源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这层:徽章组件可能因宿主重渲染而卸载/重建,但**轮询不该跟着重建**
|
|
5
|
+
* (每个会话一个轮询器,多会话各自独立;组件只订阅结果)。这里做两件事:
|
|
6
|
+
* - 按 sessionId 维护「最近一次命中」的最新值 + 订阅者集合;
|
|
7
|
+
* - 每个会话一个定时器,只在有订阅者时开、无订阅者时停。
|
|
8
|
+
*
|
|
9
|
+
* 数据源:核心 `/router/api/last-hit?session=`(轻端点,不打 status)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DIGEST_API = '/router/api/last-hit'
|
|
13
|
+
/** 刷新间隔:命中信息跟着请求走,轮询取一个不打扰的频率。 */
|
|
14
|
+
const POLL_MS = 3000
|
|
15
|
+
|
|
16
|
+
/** 一次命中的快照(后端 `/last-hit` 的 `hit` 字段,只声明用到的)。 */
|
|
17
|
+
export interface LastHit {
|
|
18
|
+
supplier: string
|
|
19
|
+
model: string
|
|
20
|
+
requested: string
|
|
21
|
+
uid: string
|
|
22
|
+
account?: string
|
|
23
|
+
credits?: number
|
|
24
|
+
ok: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Entry {
|
|
28
|
+
value: LastHit | null
|
|
29
|
+
listeners: Set<(v: LastHit | null) => void>
|
|
30
|
+
timer: number | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const entries = new Map<string, Entry>()
|
|
34
|
+
|
|
35
|
+
function entryOf(key: string): Entry {
|
|
36
|
+
let e = entries.get(key)
|
|
37
|
+
if (e === undefined) {
|
|
38
|
+
e = { value: null, listeners: new Set(), timer: null }
|
|
39
|
+
entries.set(key, e)
|
|
40
|
+
}
|
|
41
|
+
return e
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emit(e: Entry): void {
|
|
45
|
+
for (const fn of e.listeners) fn(e.value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function refresh(key: string): Promise<void> {
|
|
49
|
+
const e = entryOf(key)
|
|
50
|
+
try {
|
|
51
|
+
// key 为空(宿主未注入 sessionId)时不带 session 参数 → 后端回退全局最近。
|
|
52
|
+
const qs = key === '' ? '' : `?session=${encodeURIComponent(key)}`
|
|
53
|
+
const res = await fetch(`${DIGEST_API}${qs}`, { cache: 'no-store' })
|
|
54
|
+
const data = await res.json() as { ok: boolean; hit?: LastHit | null }
|
|
55
|
+
const next = data.ok && data.hit ? data.hit : null
|
|
56
|
+
e.value = next
|
|
57
|
+
} catch {
|
|
58
|
+
// 网络抖动不该清空徽章(保留上次值)
|
|
59
|
+
}
|
|
60
|
+
emit(e)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 读该会话当前的最近命中(同步快照)。 */
|
|
64
|
+
export function getLastHit(sessionId?: string): LastHit | null {
|
|
65
|
+
return entryOf(sessionId ?? '').value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 订阅该会话的最近命中。首次订阅即启动轮询,最后一个退订时停。
|
|
70
|
+
* @param sessionId 宿主会话身份(可能为 undefined,此时键为空串 = 全局)
|
|
71
|
+
* @param listener 值变化回调
|
|
72
|
+
* @returns 退订函数
|
|
73
|
+
*/
|
|
74
|
+
export function subscribeLastHit(sessionId: string | undefined, listener: (v: LastHit | null) => void): () => void {
|
|
75
|
+
const key = sessionId ?? ''
|
|
76
|
+
const e = entryOf(key)
|
|
77
|
+
e.listeners.add(listener)
|
|
78
|
+
if (e.timer === null) {
|
|
79
|
+
void refresh(key)
|
|
80
|
+
e.timer = window.setInterval(() => void refresh(key), POLL_MS)
|
|
81
|
+
}
|
|
82
|
+
return () => {
|
|
83
|
+
e.listeners.delete(listener)
|
|
84
|
+
if (e.listeners.size === 0 && e.timer !== null) {
|
|
85
|
+
window.clearInterval(e.timer)
|
|
86
|
+
e.timer = null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -10,8 +10,8 @@ import assert from 'node:assert/strict'
|
|
|
10
10
|
import { routerHintRewrite, ROUTER_MODEL_HINT_COPY } from './model-hint-copy.ts'
|
|
11
11
|
|
|
12
12
|
test('认出宿主提示(中/英两种文案 + 命名空间指纹)', () => {
|
|
13
|
-
const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (
|
|
14
|
-
const en = 'Other fields live in settings.yaml; edit that section directly. (
|
|
13
|
+
const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (dsh-router)'
|
|
14
|
+
const en = 'Other fields live in settings.yaml; edit that section directly. (dsh-router)'
|
|
15
15
|
assert.equal(routerHintRewrite(zh), ROUTER_MODEL_HINT_COPY)
|
|
16
16
|
assert.equal(routerHintRewrite(en), ROUTER_MODEL_HINT_COPY)
|
|
17
17
|
})
|
|
@@ -21,6 +21,11 @@ test('别的 provider 的同类提示不动(指纹只属于 Router)', () =>
|
|
|
21
21
|
assert.equal(routerHintRewrite(deepseek), undefined)
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
test('旧命名空间指纹不再认领 —— 0.1.7 起宿主渲染的是行 id', () => {
|
|
25
|
+
const legacy = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)'
|
|
26
|
+
assert.equal(routerHintRewrite(legacy), undefined)
|
|
27
|
+
})
|
|
28
|
+
|
|
24
29
|
test('已是目标文案时早退 —— 观察器不自激', () => {
|
|
25
30
|
assert.equal(routerHintRewrite(ROUTER_MODEL_HINT_COPY), undefined)
|
|
26
31
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
|
|
5
5
|
* llm-deepseek / llm-pi-ai 两套表单),那句
|
|
6
|
-
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (
|
|
6
|
+
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (dsh-router)」
|
|
7
7
|
* 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
|
|
8
8
|
* 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
|
|
9
9
|
* 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
|
|
19
|
-
const FINGERPRINT = '(
|
|
19
|
+
const FINGERPRINT = '(dsh-router)'
|
|
20
20
|
|
|
21
21
|
/** 正确说法:对齐 README「组合即模型」。 */
|
|
22
22
|
export const ROUTER_MODEL_HINT_COPY = '组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。'
|