dsh-model-rotation 0.0.0-stage → 0.8.2
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/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/client.js +1543 -0
- package/cordis.patch.yml +9 -0
- package/index.js +2113 -0
- package/package.json +93 -4
package/client.js
ADDED
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-model-rotation —— client 上半(Web 设置页)
|
|
3
|
+
*
|
|
4
|
+
* 在「设置」里注册一整页「模型轮询」,直接可视化管理轮询链:
|
|
5
|
+
* - 手动排序(↑ / ↓ / 拖位)+ 自动排序(偏好:速度 / 稳定 / 均衡)
|
|
6
|
+
* - 每档的 provider / model / 思考强度(inherit / low / medium / high / xhigh / none)
|
|
7
|
+
* - 预设的保存、应用、删除
|
|
8
|
+
* - 实时看健康度、冷却、调用与切换统计,一键探测 / 重置
|
|
9
|
+
*
|
|
10
|
+
* 数据通道:host 半注册的 3 条 HTTP 路由
|
|
11
|
+
* GET /plugins/model-rotation/state
|
|
12
|
+
* POST /plugins/model-rotation/command { argv: string[] }
|
|
13
|
+
* GET /plugins/model-rotation/catalog (?provider=&model= 时可查该模型的思考强度)
|
|
14
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-model-rotation',
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} }
|
|
19
|
+
var exports = module.exports
|
|
20
|
+
|
|
21
|
+
const React = require('react')
|
|
22
|
+
const h = React.createElement
|
|
23
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
24
|
+
|
|
25
|
+
const BASE = (typeof window !== 'undefined' && window.location && window.location.origin) || ''
|
|
26
|
+
const STATE_URL = '/plugins/model-rotation/state'
|
|
27
|
+
const COMMAND_URL = '/plugins/model-rotation/command'
|
|
28
|
+
const CATALOG_URL = '/plugins/model-rotation/catalog'
|
|
29
|
+
const POLL_MS = 5000
|
|
30
|
+
|
|
31
|
+
/* ---------------- 颜色 / 样式(跟随主题) ---------------- */
|
|
32
|
+
|
|
33
|
+
const C = {
|
|
34
|
+
border: 'color-mix(in oklab, currentColor 16%, transparent)',
|
|
35
|
+
borderStrong: 'color-mix(in oklab, currentColor 30%, transparent)',
|
|
36
|
+
soft: 'color-mix(in oklab, currentColor 7%, transparent)',
|
|
37
|
+
soft2: 'color-mix(in oklab, currentColor 12%, transparent)',
|
|
38
|
+
muted: 'color-mix(in oklab, currentColor 62%, transparent)',
|
|
39
|
+
faint: 'color-mix(in oklab, currentColor 45%, transparent)',
|
|
40
|
+
accent: 'color-mix(in oklab, #4f7cff 82%, currentColor)',
|
|
41
|
+
ok: 'color-mix(in oklab, #22a06b 88%, currentColor)',
|
|
42
|
+
warn: 'color-mix(in oklab, #d98324 90%, currentColor)',
|
|
43
|
+
bad: 'color-mix(in oklab, #d94a4a 90%, currentColor)',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const S = {
|
|
47
|
+
page: { padding: '20px 22px 40px', display: 'flex', flexDirection: 'column', gap: '18px', fontSize: '13px' },
|
|
48
|
+
h1: { fontSize: '17px', fontWeight: 600, margin: 0 },
|
|
49
|
+
sub: { color: C.muted, fontSize: '12px', lineHeight: 1.55 },
|
|
50
|
+
card: { border: `1px solid ${C.border}`, borderRadius: '10px', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '12px' },
|
|
51
|
+
cardTitle: { fontSize: '13px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' },
|
|
52
|
+
row: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
|
53
|
+
chainRow: {
|
|
54
|
+
display: 'flex',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
gap: '8px',
|
|
57
|
+
padding: '8px 10px',
|
|
58
|
+
border: `1px solid ${C.border}`,
|
|
59
|
+
borderRadius: '8px',
|
|
60
|
+
background: C.soft,
|
|
61
|
+
flexWrap: 'wrap',
|
|
62
|
+
},
|
|
63
|
+
idx: { width: '24px', textAlign: 'center', color: C.faint, fontVariantNumeric: 'tabular-nums', fontSize: '12px' },
|
|
64
|
+
btn: {
|
|
65
|
+
border: `1px solid ${C.borderStrong}`,
|
|
66
|
+
background: 'transparent',
|
|
67
|
+
color: 'inherit',
|
|
68
|
+
borderRadius: '6px',
|
|
69
|
+
padding: '3px 9px',
|
|
70
|
+
fontSize: '12px',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
lineHeight: 1.5,
|
|
73
|
+
},
|
|
74
|
+
btnPrimary: {
|
|
75
|
+
border: `1px solid ${C.accent}`,
|
|
76
|
+
background: C.accent,
|
|
77
|
+
color: '#fff',
|
|
78
|
+
borderRadius: '6px',
|
|
79
|
+
padding: '3px 9px',
|
|
80
|
+
fontSize: '12px',
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
lineHeight: 1.5,
|
|
83
|
+
},
|
|
84
|
+
btnTiny: {
|
|
85
|
+
border: `1px solid ${C.border}`,
|
|
86
|
+
background: 'transparent',
|
|
87
|
+
color: C.muted,
|
|
88
|
+
borderRadius: '5px',
|
|
89
|
+
padding: '1px 6px',
|
|
90
|
+
fontSize: '11px',
|
|
91
|
+
cursor: 'pointer',
|
|
92
|
+
lineHeight: 1.6,
|
|
93
|
+
},
|
|
94
|
+
seg: { display: 'inline-flex', border: `1px solid ${C.border}`, borderRadius: '7px', overflow: 'hidden' },
|
|
95
|
+
segBtn: { border: 0, background: 'transparent', color: C.muted, padding: '4px 12px', fontSize: '12px', cursor: 'pointer' },
|
|
96
|
+
segBtnOn: { border: 0, background: C.soft2, color: 'inherit', padding: '4px 12px', fontSize: '12px', cursor: 'pointer', fontWeight: 600 },
|
|
97
|
+
select: {
|
|
98
|
+
border: `1px solid ${C.border}`,
|
|
99
|
+
background: 'transparent',
|
|
100
|
+
color: 'inherit',
|
|
101
|
+
borderRadius: '6px',
|
|
102
|
+
padding: '3px 6px',
|
|
103
|
+
fontSize: '12px',
|
|
104
|
+
maxWidth: '260px',
|
|
105
|
+
},
|
|
106
|
+
input: {
|
|
107
|
+
border: `1px solid ${C.border}`,
|
|
108
|
+
background: 'transparent',
|
|
109
|
+
color: 'inherit',
|
|
110
|
+
borderRadius: '6px',
|
|
111
|
+
padding: '3px 8px',
|
|
112
|
+
fontSize: '12px',
|
|
113
|
+
width: '130px',
|
|
114
|
+
},
|
|
115
|
+
badge: { borderRadius: '999px', padding: '1px 8px', fontSize: '11px', border: `1px solid ${C.border}`, whiteSpace: 'nowrap' },
|
|
116
|
+
name: { fontWeight: 600, fontSize: '12px' },
|
|
117
|
+
muted: { color: C.muted, fontSize: '12px' },
|
|
118
|
+
log: {
|
|
119
|
+
whiteSpace: 'pre-wrap',
|
|
120
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
121
|
+
fontSize: '11.5px',
|
|
122
|
+
lineHeight: 1.6,
|
|
123
|
+
background: C.soft,
|
|
124
|
+
border: `1px solid ${C.border}`,
|
|
125
|
+
borderRadius: '8px',
|
|
126
|
+
padding: '10px 12px',
|
|
127
|
+
maxHeight: '260px',
|
|
128
|
+
overflow: 'auto',
|
|
129
|
+
},
|
|
130
|
+
dim: { color: C.faint, fontSize: '11.5px' },
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------------- 工具 ---------------- */
|
|
134
|
+
|
|
135
|
+
async function getJson(path) {
|
|
136
|
+
const res = await fetch(BASE + path, { headers: { Accept: 'application/json' } })
|
|
137
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
|
138
|
+
return await res.json()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function postJson(path, body) {
|
|
142
|
+
const res = await fetch(BASE + path, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
})
|
|
147
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
|
148
|
+
return await res.json()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 浏览器侧诊断上报:客户端 console 不进 host 日志,座位注册/渲染出问题时
|
|
153
|
+
* 只能靠这条通道把现场送到 /plugins/model-rotation/state 的 clientReports 里。
|
|
154
|
+
*/
|
|
155
|
+
const REPORT_URL = '/plugins/model-rotation/client-report'
|
|
156
|
+
const REPORTED = new Set()
|
|
157
|
+
function report(event, detail, once) {
|
|
158
|
+
try {
|
|
159
|
+
const key = String(event)
|
|
160
|
+
if (once === true) {
|
|
161
|
+
if (REPORTED.has(key)) return
|
|
162
|
+
REPORTED.add(key)
|
|
163
|
+
}
|
|
164
|
+
const payload = {
|
|
165
|
+
event: key,
|
|
166
|
+
detail: detail === undefined ? null : detail,
|
|
167
|
+
page: typeof window !== 'undefined' && window.location ? String(window.location.pathname || '') : '',
|
|
168
|
+
}
|
|
169
|
+
void fetch(BASE + REPORT_URL, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify(payload),
|
|
173
|
+
keepalive: true,
|
|
174
|
+
}).catch(() => {})
|
|
175
|
+
} catch {
|
|
176
|
+
/* 诊断失败绝不影响主流程 */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function errorDetail(error, extra) {
|
|
181
|
+
const base = {
|
|
182
|
+
name: error && error.name ? String(error.name) : 'Error',
|
|
183
|
+
message: error && error.message ? String(error.message) : String(error),
|
|
184
|
+
stack: error && error.stack ? String(error.stack).split('\n').slice(0, 6).join('\n') : '',
|
|
185
|
+
}
|
|
186
|
+
if (extra && typeof extra === 'object') {
|
|
187
|
+
for (const key of Object.keys(extra)) base[key] = extra[key]
|
|
188
|
+
}
|
|
189
|
+
return base
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function fmtRemaining(ms) {
|
|
193
|
+
if (!ms || ms <= 0) return ''
|
|
194
|
+
const total = Math.round(ms / 1000)
|
|
195
|
+
const m = Math.floor(total / 60)
|
|
196
|
+
const s = total % 60
|
|
197
|
+
if (m >= 60) return `${Math.floor(m / 60)}h${m % 60}m`
|
|
198
|
+
return m > 0 ? `${m}m${s}s` : `${s}s`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function effortText(value) {
|
|
202
|
+
if (!value) return 'inherit'
|
|
203
|
+
if (value === 'none') return '不带强度'
|
|
204
|
+
return value
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function keyOf(row) {
|
|
208
|
+
return `${row.provider}/${row.model}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ---------------- 设置页 ---------------- */
|
|
212
|
+
|
|
213
|
+
function ModelRotationSection() {
|
|
214
|
+
const [snap, setSnap] = useState(null)
|
|
215
|
+
const [error, setError] = useState('')
|
|
216
|
+
const [catalog, setCatalog] = useState(null)
|
|
217
|
+
const [efforts, setEfforts] = useState({}) // key -> {ids:[], names:{}}
|
|
218
|
+
const [busy, setBusy] = useState('')
|
|
219
|
+
const [log, setLog] = useState('')
|
|
220
|
+
const [picker, setPicker] = useState({ provider: '', model: '', effort: '', filter: '' })
|
|
221
|
+
const [presetName, setPresetName] = useState('')
|
|
222
|
+
const [confirmReset, setConfirmReset] = useState(false)
|
|
223
|
+
const mounted = useRef(true)
|
|
224
|
+
const effortsRef = useRef({})
|
|
225
|
+
effortsRef.current = efforts
|
|
226
|
+
|
|
227
|
+
const applySnapshot = useCallback((data) => {
|
|
228
|
+
if (!mounted.current || !data) return
|
|
229
|
+
setSnap(data)
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
const refresh = useCallback(async () => {
|
|
233
|
+
try {
|
|
234
|
+
const data = await getJson(STATE_URL)
|
|
235
|
+
applySnapshot(data)
|
|
236
|
+
setError('')
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (mounted.current) setError(`读取插件状态失败:${e.message || e}`)
|
|
239
|
+
}
|
|
240
|
+
}, [applySnapshot])
|
|
241
|
+
|
|
242
|
+
const run = useCallback(
|
|
243
|
+
async (argv, label) => {
|
|
244
|
+
setBusy(label || argv[0] || 'run')
|
|
245
|
+
try {
|
|
246
|
+
const data = await postJson(COMMAND_URL, { argv })
|
|
247
|
+
applySnapshot(data)
|
|
248
|
+
if (mounted.current) setLog(String(data.text || '(无输出)'))
|
|
249
|
+
if (!data.ok && mounted.current) setError(String(data.text || '命令执行失败'))
|
|
250
|
+
else if (mounted.current) setError('')
|
|
251
|
+
return data
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (mounted.current) setError(`命令失败:${e.message || e}`)
|
|
254
|
+
return null
|
|
255
|
+
} finally {
|
|
256
|
+
if (mounted.current) setBusy('')
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[applySnapshot],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// 首次加载 + 轮询状态
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
mounted.current = true
|
|
265
|
+
void refresh()
|
|
266
|
+
const timer = setInterval(() => {
|
|
267
|
+
void refresh()
|
|
268
|
+
}, POLL_MS)
|
|
269
|
+
return () => {
|
|
270
|
+
mounted.current = false
|
|
271
|
+
clearInterval(timer)
|
|
272
|
+
}
|
|
273
|
+
}, [refresh])
|
|
274
|
+
|
|
275
|
+
// 拉一次模型目录(按分组)
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
let cancelled = false
|
|
278
|
+
getJson(CATALOG_URL)
|
|
279
|
+
.then((data) => {
|
|
280
|
+
if (cancelled) return
|
|
281
|
+
const groups = data && Array.isArray(data.groups) ? data.groups : []
|
|
282
|
+
setCatalog({ groups, providers: data && Array.isArray(data.providers) ? data.providers : [] })
|
|
283
|
+
// 目录里已经带了每个模型的思考强度,直接铺进 efforts 表,链上的下拉就能用
|
|
284
|
+
const built = {}
|
|
285
|
+
for (const group of groups) {
|
|
286
|
+
for (const provider of group.providers || []) {
|
|
287
|
+
for (const model of provider.models || []) {
|
|
288
|
+
const ids = Array.isArray(model.efforts) ? model.efforts : []
|
|
289
|
+
const names = {}
|
|
290
|
+
for (const id of ids) names[id] = id
|
|
291
|
+
built[`${provider.id}/${model.id}`] = { ids, names }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
setEfforts((prev) => ({ ...built, ...prev }))
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
if (!cancelled) setCatalog({ groups: [], providers: [] })
|
|
299
|
+
})
|
|
300
|
+
return () => {
|
|
301
|
+
cancelled = true
|
|
302
|
+
}
|
|
303
|
+
}, [])
|
|
304
|
+
|
|
305
|
+
const loadEfforts = useCallback(async (provider, model) => {
|
|
306
|
+
const key = `${provider}/${model}`
|
|
307
|
+
if (!provider || !model) return
|
|
308
|
+
if (effortsRef.current[key]) return
|
|
309
|
+
try {
|
|
310
|
+
const data = await getJson(
|
|
311
|
+
`${CATALOG_URL}?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`,
|
|
312
|
+
)
|
|
313
|
+
if (!mounted.current) return
|
|
314
|
+
const ids = (data && data.efforts ? data.efforts : []).map((item) => item.id)
|
|
315
|
+
const names = {}
|
|
316
|
+
for (const item of data && data.efforts ? data.efforts : []) names[item.id] = item.name || item.id
|
|
317
|
+
setEfforts((prev) => (prev[key] ? prev : { ...prev, [key]: { ids, names } }))
|
|
318
|
+
} catch {
|
|
319
|
+
if (mounted.current) setEfforts((prev) => (prev[key] ? prev : { ...prev, [key]: { ids: [], names: {} } }))
|
|
320
|
+
}
|
|
321
|
+
}, [])
|
|
322
|
+
|
|
323
|
+
// 给链里每个模型补拉强度列表
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (!snap || !Array.isArray(snap.chain)) return
|
|
326
|
+
for (const row of snap.chain) void loadEfforts(row.provider, row.model)
|
|
327
|
+
}, [snap, loadEfforts])
|
|
328
|
+
|
|
329
|
+
const modelOptions = useMemo(() => {
|
|
330
|
+
const list = []
|
|
331
|
+
const groups = (catalog && catalog.groups) || []
|
|
332
|
+
for (const group of groups) {
|
|
333
|
+
for (const provider of group.providers || []) {
|
|
334
|
+
for (const model of provider.models || []) {
|
|
335
|
+
list.push({
|
|
336
|
+
group: group.id,
|
|
337
|
+
groupLabel: group.label || group.id,
|
|
338
|
+
provider: provider.id,
|
|
339
|
+
providerName: provider.name,
|
|
340
|
+
model: model.id,
|
|
341
|
+
modelName: model.name,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return list
|
|
347
|
+
}, [catalog])
|
|
348
|
+
|
|
349
|
+
const pickerGroup = useMemo(() => {
|
|
350
|
+
const groups = (catalog && catalog.groups) || []
|
|
351
|
+
return groups.find((group) => group.id === picker.provider) || null
|
|
352
|
+
}, [catalog, picker.provider])
|
|
353
|
+
|
|
354
|
+
const pickerModels = useMemo(() => {
|
|
355
|
+
if (!pickerGroup) return []
|
|
356
|
+
const keyword = (picker.filter || '').trim().toLowerCase()
|
|
357
|
+
const list = []
|
|
358
|
+
for (const provider of pickerGroup.providers || []) {
|
|
359
|
+
for (const model of provider.models || []) {
|
|
360
|
+
const haystack = `${provider.id}/${model.id} ${model.name || ''}`.toLowerCase()
|
|
361
|
+
if (keyword && !haystack.includes(keyword)) continue
|
|
362
|
+
list.push({ ...model, provider: provider.id })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return list
|
|
366
|
+
}, [pickerGroup, picker.filter])
|
|
367
|
+
|
|
368
|
+
// 添加候选:默认选第一个分组的第一个模型
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (!picker.provider && catalog && catalog.groups && catalog.groups.length) {
|
|
371
|
+
const first = catalog.groups[0]
|
|
372
|
+
const firstProvider = (first.providers || [])[0]
|
|
373
|
+
setPicker({
|
|
374
|
+
provider: first.id,
|
|
375
|
+
model: firstProvider && firstProvider.models && firstProvider.models[0] ? firstProvider.models[0].id : '',
|
|
376
|
+
effort: '',
|
|
377
|
+
filter: '',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}, [catalog, picker.provider])
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (picker.provider && picker.model) void loadEfforts(picker.provider, picker.model)
|
|
384
|
+
}, [picker.provider, picker.model, loadEfforts])
|
|
385
|
+
|
|
386
|
+
const chain = snap && Array.isArray(snap.chain) ? snap.chain : []
|
|
387
|
+
const activeCount = chain.filter((row) => row.enabled).length
|
|
388
|
+
const isAuto = snap && snap.sortMode === 'auto'
|
|
389
|
+
const pickerEfforts = picker.provider && picker.model ? (efforts[`${picker.provider}/${picker.model}`] || { ids: [] }) : { ids: [] }
|
|
390
|
+
|
|
391
|
+
const rowMove = (row, delta) => {
|
|
392
|
+
const target = row.index + delta
|
|
393
|
+
if (target < 0 || target >= chain.length) return
|
|
394
|
+
void run(['move', String(row.index + 1), String(target + 1)], 'move')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return h(
|
|
398
|
+
'div',
|
|
399
|
+
{ style: S.page },
|
|
400
|
+
// 头部
|
|
401
|
+
h(
|
|
402
|
+
'div',
|
|
403
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
404
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } }, h('h1', { style: S.h1 }, '模型轮询'),
|
|
405
|
+
h('span', { style: { ...S.badge, borderColor: C.border } }, snap ? `provider: ${snap.virtual.provider}` : '加载中…'),
|
|
406
|
+
h('span', { style: { ...S.badge } }, snap ? `模型: ${snap.virtual.model}` : '—'),
|
|
407
|
+
snap
|
|
408
|
+
? h('span', { style: { ...S.badge, color: C.muted } },
|
|
409
|
+
`调用 ${snap.stats.calls} 次 · 切换 ${snap.stats.switches} 次 · 启用 ${activeCount}/${chain.length} 档`)
|
|
410
|
+
: null,
|
|
411
|
+
snap && chain.some((row) => row.cooling)
|
|
412
|
+
? h('span', {
|
|
413
|
+
style: { ...S.badge, color: C.warn, borderColor: C.warn },
|
|
414
|
+
title: '这些档刚失败过,正在冷却,暂时会被跳过(冷却结束自动恢复可用)',
|
|
415
|
+
}, `冷却中 ${chain.filter((row) => row.cooling).length} 档`)
|
|
416
|
+
: null,
|
|
417
|
+
snap && snap.lastAnswer && snap.lastAnswer.key
|
|
418
|
+
? h('span', {
|
|
419
|
+
style: { ...S.badge, color: C.accent, borderColor: C.accent },
|
|
420
|
+
title: `最近一次回答:${snap.lastAnswer.key}\n分组:${snap.lastAnswer.groupLabel || snap.lastAnswer.group || ''}\n耗时:${Math.round((snap.lastAnswer.latencyMs || 0))}ms${snap.lastAnswer.switched ? `(前面 ${snap.lastAnswer.switched} 档失败后切换)` : ''}`,
|
|
421
|
+
}, `上次回答:${snap.lastAnswer.label || snap.lastAnswer.model}`)
|
|
422
|
+
: null,
|
|
423
|
+
),
|
|
424
|
+
h('div', { style: S.sub },
|
|
425
|
+
'按顺序尝试,前档额度用尽 / 报错会自动切到下一档;已输出内容后不再切换,避免半截回复。',
|
|
426
|
+
h('br'),
|
|
427
|
+
'在模型选择器里选「Model Rotation / Auto」即走这条链;也可以直接用会话命令 /rotation。',
|
|
428
|
+
),
|
|
429
|
+
),
|
|
430
|
+
error ? h('div', { style: { ...S.card, borderColor: C.bad, color: C.bad } }, error) : null,
|
|
431
|
+
// 三步上手(新手引导)
|
|
432
|
+
h(
|
|
433
|
+
'div',
|
|
434
|
+
{ style: { ...S.card, borderColor: C.accent } },
|
|
435
|
+
h('div', { style: S.cardTitle }, '三步上手'),
|
|
436
|
+
h(
|
|
437
|
+
'div',
|
|
438
|
+
{ style: S.sub },
|
|
439
|
+
h('span', {}),
|
|
440
|
+
'① 在输入框右下角的模型选择器里选「⚡ 自动轮询」——之后每次回答都走下面这条链;',
|
|
441
|
+
h('br'),
|
|
442
|
+
'② 「轮询链」就是尝试顺序:靠前的先用,额度用尽 / 报错自动换下一档,还会自动把你这一步重新发一遍;',
|
|
443
|
+
h('br'),
|
|
444
|
+
'③ 不想逐项配置,直接点下面的「一键:推荐链」,再按需微调即可。',
|
|
445
|
+
),
|
|
446
|
+
h(
|
|
447
|
+
'div',
|
|
448
|
+
{ style: S.row },
|
|
449
|
+
h('button', {
|
|
450
|
+
style: S.btnPrimary,
|
|
451
|
+
title: '载入内置 example 预设:Gemini 3.8 Flash → Codex 6.1 Sol → xiao-DeepSeek → 内置 DeepSeek',
|
|
452
|
+
disabled: busy !== '',
|
|
453
|
+
onClick: () => void run(['preset', 'use', 'example'], 'onboard-preset'),
|
|
454
|
+
}, '一键:推荐链'),
|
|
455
|
+
h('button', {
|
|
456
|
+
style: S.btn,
|
|
457
|
+
title: '按「健康度 + 延迟」自动排序(均衡),失败多的档自动往后排',
|
|
458
|
+
disabled: busy !== '',
|
|
459
|
+
onClick: () => void run(['sort', 'auto', 'balanced'], 'onboard-auto'),
|
|
460
|
+
}, '一键:自动排序'),
|
|
461
|
+
h('button', {
|
|
462
|
+
style: S.btn,
|
|
463
|
+
title: '逐个直连探测每个档,检查账号/额度/地区是否可用(会消耗少量额度)',
|
|
464
|
+
disabled: busy !== '',
|
|
465
|
+
onClick: () => void run(['probe'], 'onboard-probe'),
|
|
466
|
+
}, '一键:探测全部'),
|
|
467
|
+
h('button', {
|
|
468
|
+
style: S.btn,
|
|
469
|
+
title: '查看全部命令与参数说明',
|
|
470
|
+
disabled: busy !== '',
|
|
471
|
+
onClick: () => void run(['help'], 'onboard-help'),
|
|
472
|
+
}, '命令速查'),
|
|
473
|
+
),
|
|
474
|
+
h('div', { style: S.dim },
|
|
475
|
+
'这些按钮等价于在会话里敲 /rotation preset use example、/rotation sort auto balanced 等命令。'),
|
|
476
|
+
),
|
|
477
|
+
// 排序方式
|
|
478
|
+
h(
|
|
479
|
+
'div',
|
|
480
|
+
{ style: S.card },
|
|
481
|
+
h('div', { style: S.cardTitle }, '① 排序方式(手动 / 自动)'),
|
|
482
|
+
h(
|
|
483
|
+
'div',
|
|
484
|
+
{ style: S.row },
|
|
485
|
+
h(
|
|
486
|
+
'div',
|
|
487
|
+
{ style: S.seg },
|
|
488
|
+
h(
|
|
489
|
+
'button',
|
|
490
|
+
{
|
|
491
|
+
style: isAuto ? S.segBtn : S.segBtnOn,
|
|
492
|
+
disabled: !snap || busy !== '' || !isAuto,
|
|
493
|
+
onClick: () => void run(['manual'], 'manual'),
|
|
494
|
+
},
|
|
495
|
+
'手动排序',
|
|
496
|
+
),
|
|
497
|
+
h(
|
|
498
|
+
'button',
|
|
499
|
+
{
|
|
500
|
+
style: isAuto ? S.segBtnOn : S.segBtn,
|
|
501
|
+
disabled: !snap || busy !== '' || isAuto,
|
|
502
|
+
onClick: () => void run(['sort', 'auto'], 'auto'),
|
|
503
|
+
},
|
|
504
|
+
'自动排序',
|
|
505
|
+
),
|
|
506
|
+
),
|
|
507
|
+
isAuto
|
|
508
|
+
? h(
|
|
509
|
+
'div',
|
|
510
|
+
{ style: S.row },
|
|
511
|
+
h('span', { style: S.muted }, '偏好'),
|
|
512
|
+
h(
|
|
513
|
+
'select',
|
|
514
|
+
{
|
|
515
|
+
style: S.select,
|
|
516
|
+
value: snap.autoPrefer || 'balanced',
|
|
517
|
+
disabled: busy !== '',
|
|
518
|
+
onChange: (e) => void run(['sort', 'auto', e.target.value], 'prefer'),
|
|
519
|
+
},
|
|
520
|
+
h('option', { value: 'balanced' }, '均衡(健康度 50% + 速度 50%)'),
|
|
521
|
+
h('option', { value: 'speed' }, '速度优先(10% 健康度 + 90% 速度)'),
|
|
522
|
+
h('option', { value: 'reliability' }, '稳定优先(85% 健康度 + 15% 速度)'),
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
: h('span', { style: S.muted }, '手动模式严格按下面的顺序尝试,不会自己调整。'),
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
// 轮询链
|
|
529
|
+
h(
|
|
530
|
+
'div',
|
|
531
|
+
{ style: S.card },
|
|
532
|
+
h('div', { style: { ...S.cardTitle, justifyContent: 'space-between' } },
|
|
533
|
+
h('div', { style: S.row },
|
|
534
|
+
'② 轮询链(靠前的先用)',
|
|
535
|
+
h('span', { style: { ...S.badge, color: C.muted } },
|
|
536
|
+
snap && snap.activePreset ? `当前预设:${snap.activePreset}` : '当前:手动链'),
|
|
537
|
+
),
|
|
538
|
+
h('div', { style: S.row },
|
|
539
|
+
busy === 'probe' ? h('span', { style: S.muted }, '探测中…') : null,
|
|
540
|
+
h('button', { style: S.btn, disabled: busy !== '', onClick: () => void run(['probe'], 'probe') }, '探测全部'),
|
|
541
|
+
h('button', { style: S.btn, disabled: busy !== '', onClick: () => void run(['test'], 'test') }, '端到端测试'),
|
|
542
|
+
),
|
|
543
|
+
),
|
|
544
|
+
chain.length === 0
|
|
545
|
+
? h('div', { style: S.muted }, '链是空的,请在下面添加候选。')
|
|
546
|
+
: chain.map((row) => {
|
|
547
|
+
const effortIds = (efforts[keyOf(row)] || { ids: [] }).ids
|
|
548
|
+
const effortChoices = ['', ...effortIds.filter((id) => id !== ''), 'none']
|
|
549
|
+
const unique = []
|
|
550
|
+
for (const id of effortChoices) if (!unique.includes(id)) unique.push(id)
|
|
551
|
+
return h(
|
|
552
|
+
'div',
|
|
553
|
+
{ key: `${row.index}:${row.key}`, style: S.chainRow },
|
|
554
|
+
h('span', { style: S.idx }, `#${row.index + 1}`),
|
|
555
|
+
h(
|
|
556
|
+
'span',
|
|
557
|
+
{ style: { ...S.badge, color: C.muted, borderColor: C.border, maxWidth: '132px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
558
|
+
row.groupShort || row.group || row.provider,
|
|
559
|
+
),
|
|
560
|
+
h(
|
|
561
|
+
'div',
|
|
562
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '1px' } },
|
|
563
|
+
h('button', { style: S.btnTiny, disabled: row.index === 0 || busy !== '', onClick: () => rowMove(row, -1) }, '↑'),
|
|
564
|
+
h('button', { style: S.btnTiny, disabled: row.index >= chain.length - 1 || busy !== '', onClick: () => rowMove(row, 1) }, '↓'),
|
|
565
|
+
),
|
|
566
|
+
h('label', { style: { ...S.row, gap: '4px', cursor: 'pointer' } },
|
|
567
|
+
h('input', {
|
|
568
|
+
type: 'checkbox',
|
|
569
|
+
checked: row.enabled !== false,
|
|
570
|
+
disabled: busy !== '',
|
|
571
|
+
onChange: (e) => void run([e.target.checked ? 'enable' : 'disable', String(row.index + 1)], 'toggle'),
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
h(
|
|
575
|
+
'div',
|
|
576
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '220px' } },
|
|
577
|
+
row.label
|
|
578
|
+
? h('span', { style: S.name }, row.label)
|
|
579
|
+
: h(
|
|
580
|
+
'select',
|
|
581
|
+
{
|
|
582
|
+
style: S.select,
|
|
583
|
+
value: `${row.provider}/${row.model}`,
|
|
584
|
+
disabled: busy !== '' || modelOptions.length === 0,
|
|
585
|
+
onChange: (e) => {
|
|
586
|
+
const value = String(e.target.value || '')
|
|
587
|
+
const slash = value.indexOf('/')
|
|
588
|
+
if (slash <= 0) return
|
|
589
|
+
void run(
|
|
590
|
+
['set', String(row.index + 1), value, row.reasoningEffort || 'inherit'],
|
|
591
|
+
'set',
|
|
592
|
+
)
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
modelOptions.length === 0
|
|
596
|
+
? h('option', { value: `${row.provider}/${row.model}` }, `${row.provider}/${row.model}`)
|
|
597
|
+
: null,
|
|
598
|
+
((catalog && catalog.groups) || []).map((group) =>
|
|
599
|
+
h(
|
|
600
|
+
'optgroup',
|
|
601
|
+
{ key: group.id, label: group.label || group.id },
|
|
602
|
+
(group.providers || []).flatMap((provider) =>
|
|
603
|
+
(provider.models || []).map((model) =>
|
|
604
|
+
h(
|
|
605
|
+
'option',
|
|
606
|
+
{ key: `${provider.id}/${model.id}`, value: `${provider.id}/${model.id}` },
|
|
607
|
+
`${model.name || model.id}(${provider.id}/${model.id})`,
|
|
608
|
+
),
|
|
609
|
+
),
|
|
610
|
+
),
|
|
611
|
+
),
|
|
612
|
+
),
|
|
613
|
+
),
|
|
614
|
+
h('span', { style: S.dim }, row.label ? `${row.provider}/${row.model}` : row.key),
|
|
615
|
+
),
|
|
616
|
+
h(
|
|
617
|
+
'select',
|
|
618
|
+
{
|
|
619
|
+
style: S.select,
|
|
620
|
+
value: row.reasoningEffort || '',
|
|
621
|
+
disabled: busy !== '',
|
|
622
|
+
onChange: (e) => void run(['effort', String(row.index + 1), e.target.value || 'inherit'], 'effort'),
|
|
623
|
+
},
|
|
624
|
+
unique.map((id) =>
|
|
625
|
+
h('option', { key: id || 'inherit', value: id }, id === '' ? '强度 inherit' : id === 'none' ? '不带强度' : `强度 ${id}`),
|
|
626
|
+
),
|
|
627
|
+
),
|
|
628
|
+
row.cooling
|
|
629
|
+
? h('span', { style: { ...S.badge, color: C.warn, borderColor: C.warn } },
|
|
630
|
+
`冷却 ${fmtRemaining(row.cooldownRemainingMs)}${row.cooldownCode ? `(${row.cooldownCode})` : ''}`)
|
|
631
|
+
: row.health && row.health.attempts > 0
|
|
632
|
+
? h('span', { style: { ...S.badge, color: row.health.successRate >= 60 ? C.ok : C.warn } },
|
|
633
|
+
`成功 ${row.health.successRate}% · ${row.health.avgLatencyMs}ms`)
|
|
634
|
+
: h('span', { style: { ...S.badge, color: C.faint } }, '未测'),
|
|
635
|
+
h('span', { style: { flex: 1 } }),
|
|
636
|
+
h('button', { style: S.btnTiny, disabled: busy !== '', onClick: () => void run(['probe', String(row.index + 1)], 'probe1') }, '探测'),
|
|
637
|
+
h('button', { style: S.btnTiny, disabled: busy !== '', onClick: () => void run(['up', String(row.index + 1)], 'up') }, '上移'),
|
|
638
|
+
h('button', { style: S.btnTiny, disabled: busy !== '', onClick: () => void run(['down', String(row.index + 1)], 'down') }, '下移'),
|
|
639
|
+
h('button', {
|
|
640
|
+
style: { ...S.btnTiny, color: C.bad },
|
|
641
|
+
disabled: busy !== '' || chain.length <= 1,
|
|
642
|
+
onClick: () => void run(['remove', String(row.index + 1)], 'remove'),
|
|
643
|
+
}, '删除'),
|
|
644
|
+
)
|
|
645
|
+
}),
|
|
646
|
+
// 添加候选(先选分组,再在组内筛选模型)
|
|
647
|
+
h(
|
|
648
|
+
'div',
|
|
649
|
+
{ style: { ...S.row, borderTop: `1px dashed ${C.border}`, paddingTop: '10px', flexWrap: 'wrap' } },
|
|
650
|
+
h('span', { style: S.muted }, '③ 添加候选(先选分组,再在组内筛选)'),
|
|
651
|
+
h(
|
|
652
|
+
'select',
|
|
653
|
+
{
|
|
654
|
+
style: { ...S.select, maxWidth: '260px' },
|
|
655
|
+
value: picker.provider,
|
|
656
|
+
disabled: busy !== '',
|
|
657
|
+
onChange: (e) => {
|
|
658
|
+
const groupId = String(e.target.value || '')
|
|
659
|
+
const group = ((catalog && catalog.groups) || []).find((item) => item.id === groupId)
|
|
660
|
+
const firstProvider = group && group.providers ? group.providers[0] : null
|
|
661
|
+
setPicker({
|
|
662
|
+
provider: groupId,
|
|
663
|
+
model: firstProvider && firstProvider.models && firstProvider.models[0] ? firstProvider.models[0].id : '',
|
|
664
|
+
effort: '',
|
|
665
|
+
filter: '',
|
|
666
|
+
})
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
((catalog && catalog.groups) || []).map((group) =>
|
|
670
|
+
h('option', { key: group.id, value: group.id }, `${group.label || group.id}(${group.id})`),
|
|
671
|
+
),
|
|
672
|
+
),
|
|
673
|
+
h('input', {
|
|
674
|
+
style: { ...S.input, maxWidth: '150px' },
|
|
675
|
+
placeholder: '筛选模型…',
|
|
676
|
+
value: picker.filter,
|
|
677
|
+
disabled: busy !== '',
|
|
678
|
+
onChange: (e) => setPicker((prev) => ({ ...prev, filter: e.target.value })),
|
|
679
|
+
}),
|
|
680
|
+
h(
|
|
681
|
+
'select',
|
|
682
|
+
{
|
|
683
|
+
style: { ...S.select, maxWidth: '280px' },
|
|
684
|
+
value: picker.model,
|
|
685
|
+
disabled: busy !== '',
|
|
686
|
+
onChange: (e) => setPicker((prev) => ({ ...prev, model: String(e.target.value || ''), effort: '' })),
|
|
687
|
+
},
|
|
688
|
+
pickerModels.length === 0
|
|
689
|
+
? h('option', { value: '' }, picker.filter ? '(该分组里没有匹配的模型)' : '(该分组没有模型)')
|
|
690
|
+
: pickerModels.map((model) =>
|
|
691
|
+
h(
|
|
692
|
+
'option',
|
|
693
|
+
{ key: `${model.provider}/${model.id}`, value: model.id },
|
|
694
|
+
`${model.name && model.name !== model.id ? `${model.name}(${model.id})` : model.id}`,
|
|
695
|
+
),
|
|
696
|
+
),
|
|
697
|
+
),
|
|
698
|
+
h(
|
|
699
|
+
'select',
|
|
700
|
+
{
|
|
701
|
+
style: S.select,
|
|
702
|
+
value: picker.effort,
|
|
703
|
+
disabled: busy !== '',
|
|
704
|
+
onChange: (e) => setPicker((prev) => ({ ...prev, effort: String(e.target.value || '') })),
|
|
705
|
+
},
|
|
706
|
+
h('option', { value: '' }, '强度 inherit'),
|
|
707
|
+
(pickerEfforts.ids || []).map((id) => h('option', { key: id, value: id }, `强度 ${id}`)),
|
|
708
|
+
h('option', { value: 'none' }, '不带强度'),
|
|
709
|
+
),
|
|
710
|
+
h('button', {
|
|
711
|
+
style: S.btnPrimary,
|
|
712
|
+
disabled: busy !== '' || !picker.provider || !picker.model,
|
|
713
|
+
onClick: () =>
|
|
714
|
+
void run(
|
|
715
|
+
['add', `${picker.provider}/${picker.model}`, picker.effort || 'inherit'],
|
|
716
|
+
'add',
|
|
717
|
+
),
|
|
718
|
+
}, '添加'),
|
|
719
|
+
pickerGroup && pickerGroup.label
|
|
720
|
+
? h('span', { style: S.dim }, `分组 ${pickerGroup.label} · ${pickerModels.length} 个模型`)
|
|
721
|
+
: null,
|
|
722
|
+
),
|
|
723
|
+
),
|
|
724
|
+
// 预设
|
|
725
|
+
h(
|
|
726
|
+
'div',
|
|
727
|
+
{ style: S.card },
|
|
728
|
+
h('div', { style: S.cardTitle }, '④ 预设(模型 + 思考强度,一键切换)'),
|
|
729
|
+
h(
|
|
730
|
+
'div',
|
|
731
|
+
{ style: S.row },
|
|
732
|
+
h(
|
|
733
|
+
'select',
|
|
734
|
+
{
|
|
735
|
+
style: { ...S.select, maxWidth: '320px' },
|
|
736
|
+
value: (snap && snap.activePreset) || '',
|
|
737
|
+
disabled: busy !== '' || !snap,
|
|
738
|
+
onChange: (e) => void run(['preset', 'use', String(e.target.value || 'chain')], 'preset'),
|
|
739
|
+
},
|
|
740
|
+
h('option', { value: '' }, '不使用预设(用当前手动链)'),
|
|
741
|
+
(snap && snap.presets ? snap.presets : []).map((preset) =>
|
|
742
|
+
h('option', { key: preset.id, value: preset.id },
|
|
743
|
+
`${preset.label ? `${preset.label}(${preset.id})` : preset.id}${preset.builtin ? ' · 内置' : ''}`),
|
|
744
|
+
),
|
|
745
|
+
),
|
|
746
|
+
h('input', {
|
|
747
|
+
style: S.input,
|
|
748
|
+
placeholder: '新预设 id',
|
|
749
|
+
value: presetName,
|
|
750
|
+
disabled: busy !== '',
|
|
751
|
+
onChange: (e) => setPresetName(e.target.value),
|
|
752
|
+
}),
|
|
753
|
+
h('button', {
|
|
754
|
+
style: S.btn,
|
|
755
|
+
disabled: busy !== '' || !presetName.trim(),
|
|
756
|
+
onClick: async () => {
|
|
757
|
+
await run(['preset', 'save', presetName.trim()], 'preset-save')
|
|
758
|
+
setPresetName('')
|
|
759
|
+
},
|
|
760
|
+
}, '存为预设'),
|
|
761
|
+
snap && snap.activePreset
|
|
762
|
+
? h('button', {
|
|
763
|
+
style: { ...S.btn, color: C.bad },
|
|
764
|
+
disabled: busy !== '',
|
|
765
|
+
onClick: () => void run(['preset', 'delete', snap.activePreset], 'preset-delete'),
|
|
766
|
+
}, '删除当前预设')
|
|
767
|
+
: null,
|
|
768
|
+
),
|
|
769
|
+
snap && snap.presets && snap.presets.length
|
|
770
|
+
? h(
|
|
771
|
+
'div',
|
|
772
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
773
|
+
snap.presets.map((preset) =>
|
|
774
|
+
h('div', { key: preset.id, style: { ...S.dim, display: 'flex', gap: '8px' } },
|
|
775
|
+
h('span', { style: { ...S.badge, color: preset.active ? C.accent : C.faint, borderColor: preset.active ? C.accent : C.border } },
|
|
776
|
+
preset.active ? '使用中' : preset.builtin ? '内置' : '自定义'),
|
|
777
|
+
h('span', { style: { color: C.muted } }, preset.summary || '(空)'),
|
|
778
|
+
),
|
|
779
|
+
),
|
|
780
|
+
)
|
|
781
|
+
: null,
|
|
782
|
+
),
|
|
783
|
+
// 状态 / 维护
|
|
784
|
+
h(
|
|
785
|
+
'div',
|
|
786
|
+
{ style: S.card },
|
|
787
|
+
h('div', { style: { ...S.cardTitle, justifyContent: 'space-between' } },
|
|
788
|
+
'⑤ 状态与维护',
|
|
789
|
+
h('div', { style: S.row },
|
|
790
|
+
h('button', { style: S.btn, disabled: busy !== '', onClick: () => void refresh() }, '刷新'),
|
|
791
|
+
h('button', { style: S.btn, disabled: busy !== '', onClick: () => void run(['reset'], 'reset') }, '重置冷却与统计'),
|
|
792
|
+
h(
|
|
793
|
+
'button',
|
|
794
|
+
{
|
|
795
|
+
style: { ...S.btn, color: confirmReset ? C.bad : C.muted, borderColor: confirmReset ? C.bad : C.border },
|
|
796
|
+
disabled: busy !== '',
|
|
797
|
+
onClick: async () => {
|
|
798
|
+
if (!confirmReset) {
|
|
799
|
+
setConfirmReset(true)
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
setConfirmReset(false)
|
|
803
|
+
await run(['reset-all'], 'reset-all')
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
confirmReset ? '再点一次:恢复默认' : '恢复默认设置',
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
),
|
|
810
|
+
h('div', { style: S.dim },
|
|
811
|
+
snap
|
|
812
|
+
? `冷却策略:额度类 ${Math.round(snap.cooldownConfig.quotaMs / 1000)}s · 其它错误 ${Math.round(snap.cooldownConfig.errorMs / 1000)}s | 尝试顺序:${(snap.order || []).join(' → ') || '—'}`
|
|
813
|
+
: '加载中…',
|
|
814
|
+
),
|
|
815
|
+
log ? h('pre', { style: S.log }, log) : null,
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/* ======================================================================
|
|
821
|
+
右下角模型选择器:把「自动轮询(Auto)」置顶
|
|
822
|
+
----------------------------------------------------------------------
|
|
823
|
+
官方选择器(@deepseek-ai/dsh-client-ui-model-selection)占用的是 single 槽
|
|
824
|
+
`conversation.input.model`,没有可以追加子项的口子,所以这里整体接管这个
|
|
825
|
+
座位:数据仍然来自同一个 per-session `ModelDirectory`(目录 / 提交都与官方
|
|
826
|
+
一致),只是菜单第一行固定是「自动轮询」,选中即把会话切到本插件的虚拟
|
|
827
|
+
provider(rotation/auto),之后每次调用都按设置页里的链逐档尝试。
|
|
828
|
+
====================================================================== */
|
|
829
|
+
|
|
830
|
+
const AUTO_PLACEHOLDER = { provider: 'rotation', model: 'auto' }
|
|
831
|
+
/** 虚拟 provider/模型可由设置覆盖,这里读一次宿主状态 */
|
|
832
|
+
const autoTarget = { provider: AUTO_PLACEHOLDER.provider, model: AUTO_PLACEHOLDER.model }
|
|
833
|
+
let autoTargetRequested = false
|
|
834
|
+
|
|
835
|
+
function ensureAutoTarget() {
|
|
836
|
+
if (autoTargetRequested) return
|
|
837
|
+
autoTargetRequested = true
|
|
838
|
+
getJson(STATE_URL)
|
|
839
|
+
.then((data) => {
|
|
840
|
+
const provider = data && data.virtual ? data.virtual.provider : undefined
|
|
841
|
+
const model = data && data.virtual ? data.virtual.model : undefined
|
|
842
|
+
if (typeof provider === 'string' && provider && typeof model === 'string' && model) {
|
|
843
|
+
autoTarget.provider = provider
|
|
844
|
+
autoTarget.model = model
|
|
845
|
+
}
|
|
846
|
+
})
|
|
847
|
+
.catch(() => {})
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** 各 provider 的显示名:优先用宿主 /catalog 里 provider 自报的名字(不写死任何厂家/型号) */
|
|
851
|
+
const LABELS = { data: {}, at: 0, inflight: null }
|
|
852
|
+
/**
|
|
853
|
+
* /catalog 的形状可能是新版(顶层 labels 映射)或旧版(只有 groups[].providers[]),
|
|
854
|
+
* 这里两种都认,避免宿主版本不同就丢标签 —— 标签只做兜底,不参与任何逻辑。
|
|
855
|
+
*/
|
|
856
|
+
const labelsFromCatalog = (data) => {
|
|
857
|
+
const out = {}
|
|
858
|
+
if (!data || typeof data !== 'object') return out
|
|
859
|
+
if (data.labels && typeof data.labels === 'object') {
|
|
860
|
+
for (const id of Object.keys(data.labels)) {
|
|
861
|
+
const name = asStr(data.labels[id])
|
|
862
|
+
if (id && name) out[id] = name
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const groups = Array.isArray(data.groups) ? data.groups : []
|
|
866
|
+
for (const group of groups) {
|
|
867
|
+
if (!group || typeof group !== 'object') continue
|
|
868
|
+
const providers = Array.isArray(group.providers) ? group.providers : []
|
|
869
|
+
for (const provider of providers) {
|
|
870
|
+
if (!provider || typeof provider !== 'object') continue
|
|
871
|
+
const id = asStr(provider.id)
|
|
872
|
+
if (!id || out[id]) continue
|
|
873
|
+
const name = asStr(provider.name) || asStr(provider.displayName)
|
|
874
|
+
if (name) out[id] = name
|
|
875
|
+
}
|
|
876
|
+
const groupId = asStr(group.id)
|
|
877
|
+
if (groupId && !out[groupId]) {
|
|
878
|
+
const groupName = asStr(group.label) || asStr(group.name)
|
|
879
|
+
if (groupName) out[groupId] = groupName
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return out
|
|
883
|
+
}
|
|
884
|
+
function loadProviderLabels() {
|
|
885
|
+
if (LABELS.inflight) return
|
|
886
|
+
if (Date.now() - LABELS.at < 60000 && Object.keys(LABELS.data).length > 0) return
|
|
887
|
+
LABELS.inflight = getJson(CATALOG_URL)
|
|
888
|
+
.then((data) => {
|
|
889
|
+
const map = labelsFromCatalog(data)
|
|
890
|
+
LABELS.data = map
|
|
891
|
+
LABELS.at = Date.now()
|
|
892
|
+
})
|
|
893
|
+
.catch(() => {})
|
|
894
|
+
.finally(() => {
|
|
895
|
+
LABELS.inflight = null
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
const providerLabel = (id) => (id && LABELS.data[id]) || ''
|
|
899
|
+
|
|
900
|
+
/** 目录里的 group 形状可能随宿主版本变化,这里只做宽松归一(取不到就留空,不做假设) */
|
|
901
|
+
const asStr = (value) => (typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value))
|
|
902
|
+
const normalizeModel = (model) => ({
|
|
903
|
+
id: asStr(model && (model.id ?? model.model)),
|
|
904
|
+
name: asStr(model && (model.name ?? model.label)),
|
|
905
|
+
reasoning: (model && model.reasoning) || undefined,
|
|
906
|
+
})
|
|
907
|
+
const normalizeGroup = (group) => {
|
|
908
|
+
const id = asStr(group && (group.id ?? group.provider ?? group.key))
|
|
909
|
+
const name = asStr(group && (group.name ?? group.label ?? group.title))
|
|
910
|
+
const models = Array.isArray(group && group.models)
|
|
911
|
+
? group.models
|
|
912
|
+
: Array.isArray(group && group.items)
|
|
913
|
+
? group.items
|
|
914
|
+
: []
|
|
915
|
+
return { id, name: name || providerLabel(id) || id, models: models.map(normalizeModel) }
|
|
916
|
+
}
|
|
917
|
+
const normalizeGroups = (groups) => (Array.isArray(groups) ? groups.map(normalizeGroup) : [])
|
|
918
|
+
|
|
919
|
+
const matchModels = (models, query) => {
|
|
920
|
+
const q = String(query == null ? '' : query).trim().toLowerCase()
|
|
921
|
+
if (!q) return models
|
|
922
|
+
return models.filter((model) => `${model.name || ''} ${model.id}`.toLowerCase().includes(q))
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const COMPOSER_CSS = [
|
|
926
|
+
'.mrot-root{position:relative;min-width:0}',
|
|
927
|
+
'.mrot-trigger{display:flex;align-items:center;gap:4px;height:28px;min-width:0;max-width:min(360px,45cqw);padding:0 6px 0 8px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer}',
|
|
928
|
+
'.mrot-trigger:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
|
|
929
|
+
'.mrot-trigger:disabled{opacity:.55;cursor:default}',
|
|
930
|
+
'.mrot-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}',
|
|
931
|
+
'.mrot-label-auto{color:var(--dsw-alias-brand-primary);font-weight:600}',
|
|
932
|
+
'.mrot-effort{color:var(--dsw-alias-label-secondary);opacity:.75;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}',
|
|
933
|
+
'.mrot-chevron{flex:none;opacity:.6;transition:transform .12s}',
|
|
934
|
+
'.mrot-chevron-open{transform:rotate(180deg)}',
|
|
935
|
+
'.mrot-menu{position:fixed;z-index:1100;display:flex;flex-direction:column;min-width:250px;max-width:min(430px,100vw - 32px);max-height:min(390px,100vh - 96px);padding:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-overlay);box-shadow:0 14px 34px rgba(0,0,0,.2)}',
|
|
936
|
+
'.mrot-search{display:flex;padding:2px 3px 6px}',
|
|
937
|
+
'.mrot-search input{flex:1;min-width:0;padding:5px 7px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;outline:none}',
|
|
938
|
+
'.mrot-list{display:flex;flex-direction:column;overflow-y:auto;min-height:0}',
|
|
939
|
+
'.mrot-group{padding:8px 7px 3px;font-size:11px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-primary);opacity:.62;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
940
|
+
'.mrot-row{display:flex;align-items:center;gap:6px;min-height:32px;padding:4px 7px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:13px;text-align:left;cursor:pointer}',
|
|
941
|
+
'.mrot-row:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
|
|
942
|
+
'.mrot-row[data-on="true"]{background:var(--dsw-alias-bg-layer-2)}',
|
|
943
|
+
'.mrot-row:disabled{opacity:.5;cursor:default}',
|
|
944
|
+
'.mrot-row-main{display:flex;flex-direction:column;flex:1;min-width:0}',
|
|
945
|
+
'.mrot-row-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
946
|
+
'.mrot-row-hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
947
|
+
'.mrot-check{flex:0 0 14px;display:grid;place-items:center;font-size:12px}',
|
|
948
|
+
'.mrot-sep{height:1px;margin:4px 2px;background:var(--dsw-alias-border-l1)}',
|
|
949
|
+
'.mrot-cell{display:flex;align-items:center;gap:6px;min-height:32px;padding:0 7px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:13px;text-align:left;cursor:pointer}',
|
|
950
|
+
'.mrot-cell:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
|
|
951
|
+
'.mrot-cell-label{flex:none}',
|
|
952
|
+
'.mrot-cell-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;color:var(--dsw-alias-label-secondary)}',
|
|
953
|
+
'.mrot-cell-chevron{flex:none;opacity:.55}',
|
|
954
|
+
'.mrot-error{margin:4px 2px 0;padding:5px 7px;border-radius:6px;background:color-mix(in oklab, var(--dsw-alias-state-error-primary) 12%, transparent);color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px}',
|
|
955
|
+
].join('')
|
|
956
|
+
|
|
957
|
+
function ensureComposerCss() {
|
|
958
|
+
if (typeof document === 'undefined') return
|
|
959
|
+
if (document.getElementById('mrot-composer-css')) return
|
|
960
|
+
const el = document.createElement('style')
|
|
961
|
+
el.id = 'mrot-composer-css'
|
|
962
|
+
el.textContent = COMPOSER_CSS
|
|
963
|
+
document.head.appendChild(el)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* 接管模型座位:props 由槽的 inject 注入(与官方一致的目录与提交通道)。
|
|
968
|
+
*/
|
|
969
|
+
function ModelSeat(props) {
|
|
970
|
+
const available = props.available
|
|
971
|
+
const locked = props.locked === true
|
|
972
|
+
const directory = props.directory
|
|
973
|
+
const load = props.load
|
|
974
|
+
const select = props.select
|
|
975
|
+
|
|
976
|
+
const state = React.useSyncExternalStore(
|
|
977
|
+
(notify) => directory.subscribe(notify),
|
|
978
|
+
() => directory.getSnapshot(),
|
|
979
|
+
)
|
|
980
|
+
const [open, setOpen] = useState(false)
|
|
981
|
+
const [pane, setPane] = useState('root')
|
|
982
|
+
const [query, setQuery] = useState('')
|
|
983
|
+
const [error, setError] = useState('')
|
|
984
|
+
const [pos, setPos] = useState(null)
|
|
985
|
+
const rootRef = useRef(null)
|
|
986
|
+
const triggerRef = useRef(null)
|
|
987
|
+
const menuRef = useRef(null)
|
|
988
|
+
|
|
989
|
+
const groups = useMemo(() => normalizeGroups(state && state.groups), [state])
|
|
990
|
+
const current = (state && state.current) || null
|
|
991
|
+
const pending = state ? state.pending : null
|
|
992
|
+
const busy = pending !== null && pending !== undefined
|
|
993
|
+
const isAuto = current !== null && current.provider === autoTarget.provider && current.model === autoTarget.model
|
|
994
|
+
const currentGroup = current === null ? undefined : groups.find((group) => group.id === current.provider)
|
|
995
|
+
const currentModel =
|
|
996
|
+
currentGroup === undefined || current === null ? undefined : currentGroup.models.find((model) => model.id === current.model)
|
|
997
|
+
const reasoning = currentModel ? currentModel.reasoning : undefined
|
|
998
|
+
const effectiveEffort = current && current.reasoningEffort !== undefined ? current.reasoningEffort : reasoning ? reasoning.defaultEffort : undefined
|
|
999
|
+
const effortName =
|
|
1000
|
+
reasoning === undefined
|
|
1001
|
+
? ''
|
|
1002
|
+
: effectiveEffort === undefined
|
|
1003
|
+
? ''
|
|
1004
|
+
: (reasoning.efforts.find((effort) => effort.id === effectiveEffort) || {}).name || effectiveEffort
|
|
1005
|
+
const totalModels = useMemo(() => groups.reduce((sum, group) => sum + group.models.length, 0), [groups])
|
|
1006
|
+
const showSearch = totalModels > 4
|
|
1007
|
+
const filteredGroups = useMemo(
|
|
1008
|
+
() =>
|
|
1009
|
+
groups
|
|
1010
|
+
.map((group) => ({ ...group, models: matchModels(group.models, showSearch ? query : '') }))
|
|
1011
|
+
.filter((group) => group.models.length > 0),
|
|
1012
|
+
[groups, query, showSearch],
|
|
1013
|
+
)
|
|
1014
|
+
const effortRows = useMemo(() => {
|
|
1015
|
+
if (reasoning === undefined) return []
|
|
1016
|
+
const rows = reasoning.defaultEffort === undefined ? [{ id: '', name: '(跟随提供方默认)' }] : []
|
|
1017
|
+
return rows.concat(reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name })))
|
|
1018
|
+
}, [reasoning])
|
|
1019
|
+
|
|
1020
|
+
// 贴住触发按钮上方定位(视口内夹紧)
|
|
1021
|
+
React.useLayoutEffect(() => {
|
|
1022
|
+
if (!open) {
|
|
1023
|
+
setPos(null)
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
const place = () => {
|
|
1027
|
+
const rect = triggerRef.current ? triggerRef.current.getBoundingClientRect() : null
|
|
1028
|
+
if (rect === null) return
|
|
1029
|
+
const MARGIN = 12
|
|
1030
|
+
const width = menuRef.current ? menuRef.current.offsetWidth : 0
|
|
1031
|
+
const height = menuRef.current ? menuRef.current.offsetHeight : 0
|
|
1032
|
+
let left = rect.right - width
|
|
1033
|
+
let top = rect.top - 8 - height
|
|
1034
|
+
if (width > 0) left = Math.min(Math.max(left, MARGIN), window.innerWidth - width - MARGIN)
|
|
1035
|
+
if (height > 0) top = Math.min(Math.max(top, MARGIN), window.innerHeight - height - MARGIN)
|
|
1036
|
+
setPos({ left, top })
|
|
1037
|
+
}
|
|
1038
|
+
place()
|
|
1039
|
+
window.addEventListener('scroll', place, true)
|
|
1040
|
+
window.addEventListener('resize', place)
|
|
1041
|
+
return () => {
|
|
1042
|
+
window.removeEventListener('scroll', place, true)
|
|
1043
|
+
window.removeEventListener('resize', place)
|
|
1044
|
+
}
|
|
1045
|
+
}, [open, pane, query, state])
|
|
1046
|
+
|
|
1047
|
+
// 点外面 / Esc 关闭
|
|
1048
|
+
useEffect(() => {
|
|
1049
|
+
if (!open) return
|
|
1050
|
+
const onDown = (event) => {
|
|
1051
|
+
if (menuRef.current && menuRef.current.contains(event.target)) return
|
|
1052
|
+
if (rootRef.current && rootRef.current.contains(event.target)) return
|
|
1053
|
+
setOpen(false)
|
|
1054
|
+
}
|
|
1055
|
+
const onKey = (event) => {
|
|
1056
|
+
if (event.key === 'Escape') setOpen(false)
|
|
1057
|
+
}
|
|
1058
|
+
document.addEventListener('mousedown', onDown)
|
|
1059
|
+
document.addEventListener('keydown', onKey)
|
|
1060
|
+
return () => {
|
|
1061
|
+
document.removeEventListener('mousedown', onDown)
|
|
1062
|
+
document.removeEventListener('keydown', onKey)
|
|
1063
|
+
}
|
|
1064
|
+
}, [open])
|
|
1065
|
+
|
|
1066
|
+
if (!available) return null
|
|
1067
|
+
|
|
1068
|
+
const showMenu = () => {
|
|
1069
|
+
loadProviderLabels()
|
|
1070
|
+
setError('')
|
|
1071
|
+
setQuery('')
|
|
1072
|
+
setPane('root')
|
|
1073
|
+
setOpen(true)
|
|
1074
|
+
if (typeof load === 'function') load()
|
|
1075
|
+
}
|
|
1076
|
+
const closeMenu = () => setOpen(false)
|
|
1077
|
+
const choose = async (selection) => {
|
|
1078
|
+
setError('')
|
|
1079
|
+
let result
|
|
1080
|
+
try {
|
|
1081
|
+
result = await select(selection)
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
setError(err && err.message ? String(err.message) : '切换失败,请重试')
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
if (result && result.ok === false) {
|
|
1087
|
+
setError(result.error && result.error.message ? String(result.error.message) : '切换失败,请重试')
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
setOpen(false)
|
|
1091
|
+
}
|
|
1092
|
+
const chooseAuto = () => choose({ provider: autoTarget.provider, model: autoTarget.model })
|
|
1093
|
+
const chooseModel = (group, model) => {
|
|
1094
|
+
const selection = { provider: group.id, model: model.id }
|
|
1095
|
+
if (model.reasoning && model.reasoning.defaultEffort !== undefined) selection.reasoningEffort = model.reasoning.defaultEffort
|
|
1096
|
+
return choose(selection)
|
|
1097
|
+
}
|
|
1098
|
+
const chooseEffort = (effortId) => {
|
|
1099
|
+
if (current === null) return undefined
|
|
1100
|
+
const selection = { provider: current.provider, model: current.model }
|
|
1101
|
+
if (effortId) selection.reasoningEffort = effortId
|
|
1102
|
+
return choose(selection)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const triggerLabel = current === null ? '选择模型' : isAuto ? '自动轮询' : currentModel ? currentModel.name || current.model : current.model
|
|
1106
|
+
|
|
1107
|
+
const trigger = h(
|
|
1108
|
+
'button',
|
|
1109
|
+
{
|
|
1110
|
+
key: 'trigger',
|
|
1111
|
+
ref: triggerRef,
|
|
1112
|
+
type: 'button',
|
|
1113
|
+
className: 'mrot-trigger',
|
|
1114
|
+
'aria-haspopup': 'menu',
|
|
1115
|
+
'aria-expanded': open,
|
|
1116
|
+
disabled: locked || busy,
|
|
1117
|
+
title: isAuto
|
|
1118
|
+
? '当前:自动轮询(Model Rotation)— 每次调用按设置里的链逐档尝试'
|
|
1119
|
+
: `当前模型:${currentGroup ? currentGroup.name : ''}${triggerLabel ? ' / ' + triggerLabel : ''}`,
|
|
1120
|
+
onClick: () => (open ? closeMenu() : showMenu()),
|
|
1121
|
+
},
|
|
1122
|
+
h('span', { className: isAuto ? 'mrot-label mrot-label-auto' : 'mrot-label' }, isAuto ? '⚡ ' + triggerLabel : triggerLabel),
|
|
1123
|
+
effortName && !isAuto ? h('span', { className: 'mrot-effort' }, effortName) : null,
|
|
1124
|
+
h('span', { className: 'mrot-chevron' + (open ? ' mrot-chevron-open' : '') }, '⌄'),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
let menu = null
|
|
1128
|
+
if (open) {
|
|
1129
|
+
const rows = []
|
|
1130
|
+
if (pane === 'root') {
|
|
1131
|
+
rows.push(
|
|
1132
|
+
h(
|
|
1133
|
+
'button',
|
|
1134
|
+
{
|
|
1135
|
+
key: 'auto',
|
|
1136
|
+
type: 'button',
|
|
1137
|
+
role: 'menuitemradio',
|
|
1138
|
+
'aria-checked': isAuto,
|
|
1139
|
+
className: 'mrot-row',
|
|
1140
|
+
'data-on': isAuto ? 'true' : 'false',
|
|
1141
|
+
disabled: busy,
|
|
1142
|
+
onClick: chooseAuto,
|
|
1143
|
+
},
|
|
1144
|
+
h(
|
|
1145
|
+
'span',
|
|
1146
|
+
{ className: 'mrot-row-main' },
|
|
1147
|
+
h('span', { className: 'mrot-row-name' }, '⚡ 自动轮询(Auto)'),
|
|
1148
|
+
h('span', { className: 'mrot-row-hint' }, '按模型轮询设置里的链逐档尝试,失败自动换下一档'),
|
|
1149
|
+
),
|
|
1150
|
+
h('span', { className: 'mrot-check' }, isAuto ? '✓' : ''),
|
|
1151
|
+
),
|
|
1152
|
+
)
|
|
1153
|
+
rows.push(h('div', { key: 'sep', className: 'mrot-sep' }))
|
|
1154
|
+
rows.push(
|
|
1155
|
+
h(
|
|
1156
|
+
'button',
|
|
1157
|
+
{
|
|
1158
|
+
key: 'model-cell',
|
|
1159
|
+
type: 'button',
|
|
1160
|
+
className: 'mrot-cell',
|
|
1161
|
+
onClick: () => {
|
|
1162
|
+
setQuery('')
|
|
1163
|
+
setPane('model')
|
|
1164
|
+
},
|
|
1165
|
+
},
|
|
1166
|
+
h('span', { className: 'mrot-cell-label' }, '模型'),
|
|
1167
|
+
h('span', { className: 'mrot-cell-value' }, triggerLabel),
|
|
1168
|
+
h('span', { className: 'mrot-cell-chevron' }, '›'),
|
|
1169
|
+
),
|
|
1170
|
+
)
|
|
1171
|
+
if (effortRows.length > 0) {
|
|
1172
|
+
rows.push(
|
|
1173
|
+
h(
|
|
1174
|
+
'button',
|
|
1175
|
+
{
|
|
1176
|
+
key: 'effort-cell',
|
|
1177
|
+
type: 'button',
|
|
1178
|
+
className: 'mrot-cell',
|
|
1179
|
+
onClick: () => setPane('effort'),
|
|
1180
|
+
},
|
|
1181
|
+
h('span', { className: 'mrot-cell-label' }, '思考强度'),
|
|
1182
|
+
h('span', { className: 'mrot-cell-value' }, effortName || '提供方默认'),
|
|
1183
|
+
h('span', { className: 'mrot-cell-chevron' }, '›'),
|
|
1184
|
+
),
|
|
1185
|
+
)
|
|
1186
|
+
}
|
|
1187
|
+
} else if (pane === 'model') {
|
|
1188
|
+
rows.push(
|
|
1189
|
+
h(
|
|
1190
|
+
'button',
|
|
1191
|
+
{ key: 'back', type: 'button', className: 'mrot-cell', onClick: () => setPane('root') },
|
|
1192
|
+
h('span', { className: 'mrot-cell-chevron' }, '‹'),
|
|
1193
|
+
h('span', { className: 'mrot-cell-label' }, '返回'),
|
|
1194
|
+
),
|
|
1195
|
+
)
|
|
1196
|
+
if (showSearch) {
|
|
1197
|
+
rows.push(
|
|
1198
|
+
h(
|
|
1199
|
+
'div',
|
|
1200
|
+
{ key: 'search', className: 'mrot-search' },
|
|
1201
|
+
h('input', {
|
|
1202
|
+
value: query,
|
|
1203
|
+
placeholder: '搜索模型(支持模糊包含)',
|
|
1204
|
+
onChange: (event) => setQuery(event.target.value),
|
|
1205
|
+
}),
|
|
1206
|
+
),
|
|
1207
|
+
)
|
|
1208
|
+
}
|
|
1209
|
+
for (const group of filteredGroups) {
|
|
1210
|
+
const groupText = group.name || providerLabel(group.id) || group.id || '模型'
|
|
1211
|
+
rows.push(h('div', { key: 'g:' + group.id, className: 'mrot-group', title: groupText }, groupText))
|
|
1212
|
+
for (const model of group.models) {
|
|
1213
|
+
const on = current !== null && current.provider === group.id && current.model === model.id
|
|
1214
|
+
rows.push(
|
|
1215
|
+
h(
|
|
1216
|
+
'button',
|
|
1217
|
+
{
|
|
1218
|
+
key: group.id + '/' + model.id,
|
|
1219
|
+
type: 'button',
|
|
1220
|
+
role: 'menuitemradio',
|
|
1221
|
+
'aria-checked': on,
|
|
1222
|
+
className: 'mrot-row',
|
|
1223
|
+
'data-on': on ? 'true' : 'false',
|
|
1224
|
+
disabled: busy,
|
|
1225
|
+
onClick: () => chooseModel(group, model),
|
|
1226
|
+
},
|
|
1227
|
+
h('span', { className: 'mrot-row-main' }, h('span', { className: 'mrot-row-name' }, model.name || model.id)),
|
|
1228
|
+
h('span', { className: 'mrot-check' }, on ? '✓' : ''),
|
|
1229
|
+
),
|
|
1230
|
+
)
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (filteredGroups.length === 0) {
|
|
1234
|
+
rows.push(h('div', { key: 'empty', className: 'mrot-group', style: { padding: '10px 7px' } }, '没有匹配的模型'))
|
|
1235
|
+
}
|
|
1236
|
+
} else if (pane === 'effort') {
|
|
1237
|
+
rows.push(
|
|
1238
|
+
h(
|
|
1239
|
+
'button',
|
|
1240
|
+
{ key: 'back', type: 'button', className: 'mrot-cell', onClick: () => setPane('root') },
|
|
1241
|
+
h('span', { className: 'mrot-cell-chevron' }, '‹'),
|
|
1242
|
+
h('span', { className: 'mrot-cell-label' }, '返回'),
|
|
1243
|
+
),
|
|
1244
|
+
)
|
|
1245
|
+
for (const row of effortRows) {
|
|
1246
|
+
const on =
|
|
1247
|
+
row.id === '' ? effectiveEffort === undefined : effectiveEffort !== undefined && effectiveEffort === row.id
|
|
1248
|
+
rows.push(
|
|
1249
|
+
h(
|
|
1250
|
+
'button',
|
|
1251
|
+
{
|
|
1252
|
+
key: 'e:' + (row.id || 'default'),
|
|
1253
|
+
type: 'button',
|
|
1254
|
+
role: 'menuitemradio',
|
|
1255
|
+
'aria-checked': on,
|
|
1256
|
+
className: 'mrot-row',
|
|
1257
|
+
'data-on': on ? 'true' : 'false',
|
|
1258
|
+
disabled: busy,
|
|
1259
|
+
onClick: () => chooseEffort(row.id),
|
|
1260
|
+
},
|
|
1261
|
+
h('span', { className: 'mrot-row-main' }, h('span', { className: 'mrot-row-name' }, row.name)),
|
|
1262
|
+
h('span', { className: 'mrot-check' }, on ? '✓' : ''),
|
|
1263
|
+
),
|
|
1264
|
+
)
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
menu = h(
|
|
1269
|
+
'div',
|
|
1270
|
+
{
|
|
1271
|
+
key: 'menu',
|
|
1272
|
+
ref: menuRef,
|
|
1273
|
+
className: 'mrot-menu',
|
|
1274
|
+
role: 'menu',
|
|
1275
|
+
style: pos === null ? { left: 0, top: 0, visibility: 'hidden' } : { left: pos.left + 'px', top: pos.top + 'px' },
|
|
1276
|
+
},
|
|
1277
|
+
h('div', { className: 'mrot-list' }, rows),
|
|
1278
|
+
error ? h('div', { className: 'mrot-error' }, error) : null,
|
|
1279
|
+
)
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return h('div', { ref: rootRef, className: 'mrot-root' }, trigger, menu)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/* ======================================================================
|
|
1286
|
+
座位失败兜底:渲染器对 single 槽的条目有「崩了就 abdicate、回退官方」的
|
|
1287
|
+
机制(客户端 console 我们看不到)。这里自己再包一层错误边界:
|
|
1288
|
+
- 崩了就把错误上报到 host(/plugins/model-rotation/client-report)
|
|
1289
|
+
- 并渲染一个极简可用的「⚡自动」按钮,保证用户仍然能切到轮询模式
|
|
1290
|
+
====================================================================== */
|
|
1291
|
+
|
|
1292
|
+
class SeatBoundary extends React.Component {
|
|
1293
|
+
constructor(props) {
|
|
1294
|
+
super(props)
|
|
1295
|
+
this.state = { error: null }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
static getDerivedStateFromError(error) {
|
|
1299
|
+
return { error }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
componentDidCatch(error, info) {
|
|
1303
|
+
report('seat-crash', errorDetail(error, { componentStack: info && info.componentStack ? String(info.componentStack).split('\n').slice(0, 8).join('\n') : '' }))
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
render() {
|
|
1307
|
+
if (this.state.error) {
|
|
1308
|
+
const props = this.props || {}
|
|
1309
|
+
const select = props.select
|
|
1310
|
+
const target = autoTarget
|
|
1311
|
+
return h(
|
|
1312
|
+
'button',
|
|
1313
|
+
{
|
|
1314
|
+
type: 'button',
|
|
1315
|
+
className: 'mrot-fallback',
|
|
1316
|
+
title: '模型轮询面板渲染失败,点击切到自动轮询(详情已上报)',
|
|
1317
|
+
onClick: () => {
|
|
1318
|
+
try {
|
|
1319
|
+
const pending = select && select({ provider: target.provider, model: target.model })
|
|
1320
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
report('seat-fallback-select-failed', errorDetail(error))
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
},
|
|
1326
|
+
'⚡ 自动轮询',
|
|
1327
|
+
)
|
|
1328
|
+
}
|
|
1329
|
+
return h(ModelSeat, this.props)
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/* ======================================================================
|
|
1334
|
+
「这次回答是哪个模型」:会话头徽章
|
|
1335
|
+
----------------------------------------------------------------------
|
|
1336
|
+
槽位 `conversation.session.header.actions`(list, scope session)是会话
|
|
1337
|
+
标题旁的 action 区,agy-link 的状态徽章也挂在这里。host 半在每次模型调用
|
|
1338
|
+
成功后记录 lastAnswer(provider/model/label/耗时/切换轨迹),这里轮询
|
|
1339
|
+
状态接口展示;点开 tooltip 能看到这次回答前面失败过哪些档。
|
|
1340
|
+
====================================================================== */
|
|
1341
|
+
|
|
1342
|
+
const ANSWER_CSS = `
|
|
1343
|
+
.mrot-answer { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 999px;
|
|
1344
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.28)); background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.08));
|
|
1345
|
+
color: var(--dsw-alias-label-secondary, inherit); font-size: 11.5px; line-height: 1; white-space: nowrap; cursor: default; user-select: none; }
|
|
1346
|
+
.mrot-answer-bolt { color: var(--dsw-alias-brand-primary, #4f7cff); font-size: 12px; }
|
|
1347
|
+
.mrot-answer-model { color: var(--dsw-alias-label-primary, inherit); font-weight: 600; }
|
|
1348
|
+
.mrot-answer-meta { color: var(--dsw-alias-label-secondary, inherit); opacity: 0.85; }
|
|
1349
|
+
.mrot-answer-switch { color: var(--dsw-alias-state-warn-primary, #d98324); }
|
|
1350
|
+
.mrot-answer-live { width: 6px; height: 6px; border-radius: 999px; background: var(--dsw-alias-state-success-primary, #22a06b); }
|
|
1351
|
+
.mrot-fallback { height: 28px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.28));
|
|
1352
|
+
background: transparent; color: var(--dsw-alias-label-primary, inherit); font-size: 12px; cursor: pointer; }
|
|
1353
|
+
`
|
|
1354
|
+
|
|
1355
|
+
let answerCssInstalled = false
|
|
1356
|
+
function ensureAnswerCss() {
|
|
1357
|
+
if (answerCssInstalled) return
|
|
1358
|
+
answerCssInstalled = true
|
|
1359
|
+
try {
|
|
1360
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
1361
|
+
if (document.getElementById('mrot-answer-css')) return
|
|
1362
|
+
const style = document.createElement('style')
|
|
1363
|
+
style.id = 'mrot-answer-css'
|
|
1364
|
+
style.textContent = ANSWER_CSS
|
|
1365
|
+
document.head.appendChild(style)
|
|
1366
|
+
} catch {
|
|
1367
|
+
/* 样式失败不影响功能 */
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const answerCache = { data: null, at: 0, inflight: false }
|
|
1372
|
+
|
|
1373
|
+
function HeaderAnswerBadge(props) {
|
|
1374
|
+
const sessionId = props && props.sessionId !== undefined ? String(props.sessionId) : ''
|
|
1375
|
+
const [snapshot, setSnapshot] = useState(null)
|
|
1376
|
+
|
|
1377
|
+
useEffect(() => {
|
|
1378
|
+
let stopped = false
|
|
1379
|
+
let timer = null
|
|
1380
|
+
const tick = async () => {
|
|
1381
|
+
if (stopped) return
|
|
1382
|
+
const now = Date.now()
|
|
1383
|
+
if (!answerCache.inflight && now - answerCache.at > 2000) {
|
|
1384
|
+
answerCache.inflight = true
|
|
1385
|
+
try {
|
|
1386
|
+
const data = await getJson(STATE_URL)
|
|
1387
|
+
answerCache.data = data
|
|
1388
|
+
answerCache.at = Date.now()
|
|
1389
|
+
} catch {
|
|
1390
|
+
/* 忽略:host 半可能还没加载完 */
|
|
1391
|
+
} finally {
|
|
1392
|
+
answerCache.inflight = false
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (!stopped) setSnapshot(answerCache.data)
|
|
1396
|
+
timer = setTimeout(tick, 4000)
|
|
1397
|
+
}
|
|
1398
|
+
void tick()
|
|
1399
|
+
return () => {
|
|
1400
|
+
stopped = true
|
|
1401
|
+
if (timer) clearTimeout(timer)
|
|
1402
|
+
}
|
|
1403
|
+
}, [])
|
|
1404
|
+
|
|
1405
|
+
const answer = sessionId && snapshot && snapshot.answersBySession
|
|
1406
|
+
? snapshot.answersBySession[sessionId]
|
|
1407
|
+
: snapshot && snapshot.lastAnswer
|
|
1408
|
+
if (!answer || !sessionId || String(answer.sessionId || '') !== sessionId) return null
|
|
1409
|
+
|
|
1410
|
+
const label = answer.label || answer.model || '未知模型'
|
|
1411
|
+
const isDirect = answer.mode === 'direct'
|
|
1412
|
+
const effort = answer.reasoningEffort && answer.reasoningEffort !== 'none' ? answer.reasoningEffort : ''
|
|
1413
|
+
const latency = answer.latencyMs ? `${(answer.latencyMs / 1000).toFixed(1)}s` : ''
|
|
1414
|
+
const switched = Number(answer.switched) || 0
|
|
1415
|
+
const trail = Array.isArray(answer.attempts) ? answer.attempts : []
|
|
1416
|
+
const trailText = trail
|
|
1417
|
+
.map((item) => {
|
|
1418
|
+
const mark = item.ok ? '✅' : `❌ ${item.code || ''}`
|
|
1419
|
+
const ms = item.totalMs ? ` ${(item.totalMs / 1000).toFixed(1)}s` : ''
|
|
1420
|
+
return `${mark} ${item.group ? `[${item.group}] ` : ''}${item.key || item.model || ''}${ms}`
|
|
1421
|
+
})
|
|
1422
|
+
.join('\n')
|
|
1423
|
+
const hint = switched > 0 ? `(前面 ${switched} 档失败后切到本档)` : ''
|
|
1424
|
+
const headline = isDirect
|
|
1425
|
+
? `本会话最近一次回答(直连):${answer.provider}/${answer.model}`
|
|
1426
|
+
: `本会话最近一次轮询回答:${label}`
|
|
1427
|
+
const title =
|
|
1428
|
+
`${headline}${effort ? `(思考强度 ${effort})` : ''}${isDirect ? '' : ` · ${snapshot.virtual ? `${snapshot.virtual.provider}/${snapshot.virtual.model}` : '旋转链'}`}\n` +
|
|
1429
|
+
`分组:${answer.groupLabel || answer.group || '—'}\n` +
|
|
1430
|
+
`耗时:${latency || '—'}${hint}\n` +
|
|
1431
|
+
(trailText ? `\n尝试轨迹:\n${trailText}` : '')
|
|
1432
|
+
|
|
1433
|
+
return h(
|
|
1434
|
+
'div',
|
|
1435
|
+
{ className: isDirect ? 'mrot-answer mrot-answer-direct' : 'mrot-answer', title, 'data-session': sessionId, 'aria-label': `最近回答模型:${label}` },
|
|
1436
|
+
h('span', { className: 'mrot-answer-bolt' }, isDirect ? '◆' : '⚡'),
|
|
1437
|
+
h('span', { className: 'mrot-answer-model' }, label),
|
|
1438
|
+
effort ? h('span', { className: 'mrot-answer-meta' }, `· ${effort}`) : null,
|
|
1439
|
+
latency ? h('span', { className: 'mrot-answer-meta' }, `· ${latency}`) : null,
|
|
1440
|
+
switched > 0 ? h('span', { className: 'mrot-answer-switch', title: '发生过自动切换' }, `↻${switched}`) : null,
|
|
1441
|
+
isDirect ? h('span', { className: 'mrot-answer-meta' }, '直连') : null,
|
|
1442
|
+
)
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/* ---------------- 插件入口 ---------------- */
|
|
1446
|
+
|
|
1447
|
+
const name = 'dsh-model-rotation-client'
|
|
1448
|
+
const inject = ['slots']
|
|
1449
|
+
|
|
1450
|
+
function apply(ctx) {
|
|
1451
|
+
ensureComposerCss()
|
|
1452
|
+
ensureAnswerCss()
|
|
1453
|
+
ensureAutoTarget()
|
|
1454
|
+
loadProviderLabels()
|
|
1455
|
+
report('apply', {
|
|
1456
|
+
hasSlots: !!(ctx && ctx.slots),
|
|
1457
|
+
hasInject: typeof ctx?.inject === 'function',
|
|
1458
|
+
href: typeof window !== 'undefined' && window.location ? String(window.location.href || '').slice(0, 200) : '',
|
|
1459
|
+
}, true)
|
|
1460
|
+
|
|
1461
|
+
ctx.slots.inject('settings.section', () =>
|
|
1462
|
+
ctx.slots.register(
|
|
1463
|
+
{
|
|
1464
|
+
name: 'settings.section',
|
|
1465
|
+
id: 'model-rotation',
|
|
1466
|
+
order: 26,
|
|
1467
|
+
label: () => '模型轮询',
|
|
1468
|
+
},
|
|
1469
|
+
ModelRotationSection,
|
|
1470
|
+
),
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
// 接管 composer 模型座位:官方选择器是 single 槽,只能整体替换(置顶 Auto 的唯一做法)。
|
|
1474
|
+
// single 槽同 priority 注册会抛错,所以用更低的 priority 遮蔽官方那一份(槽的实现里「最低 priority 渲染」)。
|
|
1475
|
+
ctx.inject(['slots', 'modelDirectories', 'sessions'], (scope) => {
|
|
1476
|
+
try {
|
|
1477
|
+
const models = scope && scope.modelDirectories
|
|
1478
|
+
if (!models || typeof models.directoryFor !== 'function') {
|
|
1479
|
+
report('seat-未注册', { reason: 'modelDirectories 服务不可用', hasScope: !!scope }, true)
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
const sessions = scope.sessions
|
|
1483
|
+
const slots = scope.slots || ctx.slots
|
|
1484
|
+
slots.inject('conversation.input.model', () =>
|
|
1485
|
+
slots.register(
|
|
1486
|
+
{
|
|
1487
|
+
name: 'conversation.input.model',
|
|
1488
|
+
priority: -20,
|
|
1489
|
+
registrant: 'dsh-model-rotation',
|
|
1490
|
+
inject: (sessionId) => {
|
|
1491
|
+
try {
|
|
1492
|
+
const directory = models.directoryFor(sessionId)
|
|
1493
|
+
let available = true
|
|
1494
|
+
if (sessions && typeof sessions.subagentAddress === 'function') {
|
|
1495
|
+
available = sessions.subagentAddress(sessionId) === undefined
|
|
1496
|
+
}
|
|
1497
|
+
report('seat-inject', { sessionId: String(sessionId ?? ''), available, target: autoTarget }, true)
|
|
1498
|
+
return {
|
|
1499
|
+
available,
|
|
1500
|
+
directory: directory.store,
|
|
1501
|
+
load: () => {
|
|
1502
|
+
if (!available || typeof directory.load !== 'function') return
|
|
1503
|
+
const pending = directory.load()
|
|
1504
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
1505
|
+
},
|
|
1506
|
+
select: (selection) => (available ? directory.select(selection) : Promise.resolve(undefined)),
|
|
1507
|
+
}
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
report('seat-inject-failed', errorDetail(error, { sessionId: String(sessionId ?? '') }))
|
|
1510
|
+
return { available: false, directory: null, load: () => {}, select: () => Promise.resolve(undefined) }
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
SeatBoundary,
|
|
1515
|
+
),
|
|
1516
|
+
)
|
|
1517
|
+
report('seat-registered', { priority: -20, boundary: true }, true)
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
report('seat-register-failed', errorDetail(err))
|
|
1520
|
+
console.warn('[dsh-model-rotation] 接管模型选择器失败:' + (err && err.message ? err.message : String(err)))
|
|
1521
|
+
}
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
// 「这次回答是哪个模型」徽章:挂在会话标题旁的 action 区。
|
|
1525
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
1526
|
+
ctx.slots.register(
|
|
1527
|
+
{
|
|
1528
|
+
name: 'conversation.session.header.actions',
|
|
1529
|
+
id: 'model-rotation-last-answer',
|
|
1530
|
+
order: 6,
|
|
1531
|
+
inject: (sessionId) => ({ sessionId }),
|
|
1532
|
+
},
|
|
1533
|
+
HeaderAnswerBadge,
|
|
1534
|
+
),
|
|
1535
|
+
)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
exports.apply = apply
|
|
1539
|
+
exports.inject = inject
|
|
1540
|
+
exports.name = name
|
|
1541
|
+
return module.exports
|
|
1542
|
+
},
|
|
1543
|
+
})
|