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/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
+ })