free-coding-models 0.5.10 β 0.5.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/bin/free-coding-models.js +6 -1
- package/changelog/v0.5.11.md +56 -0
- package/changelog/v0.5.12.md +152 -0
- package/package.json +1 -1
- package/src/core/config.js +59 -1
- package/src/core/playground.js +502 -0
- package/src/core/router-daemon.js +861 -29
- package/src/core/utils.js +7 -0
- package/src/tui/app.js +9 -1
- package/src/tui/cli-help.js +2 -0
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +22 -0
- package/src/tui/overlays.js +11 -0
- package/src/tui/render-helpers.js +35 -0
- package/src/tui/render-table.js +22 -7
- package/src/tui/theme.js +3 -0
- package/web/dist/assets/index-Ce_pr2YF.js +39 -0
- package/web/dist/assets/index-H9JWDRIh.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +203 -1
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/ModelTable.jsx +9 -0
- package/web/src/components/dashboard/ModelTable.module.css +13 -0
- package/web/src/components/layout/Header.jsx +2 -1
- package/web/src/components/palette/CommandPalette.jsx +1 -0
- package/web/src/components/playground/PlaygroundView.jsx +528 -0
- package/web/src/components/playground/PlaygroundView.module.css +413 -0
- package/web/src/components/router/RouterView.jsx +519 -35
- package/web/src/components/router/RouterView.module.css +355 -0
- package/web/dist/assets/index-BrJgRhTA.css +0 -1
- package/web/dist/assets/index-BwLMt0bw.js +0 -39
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/router/RouterView.jsx
|
|
3
|
-
* @description Router Dashboard modal β daemon status, start/stop,
|
|
4
|
-
*
|
|
5
|
-
* π
|
|
3
|
+
* @description Router Dashboard modal β daemon status, start/stop, active set
|
|
4
|
+
* manager (add / remove / drag-and-drop), probe mode, quick-setup card.
|
|
5
|
+
* π M5: full set-management UI replacing the M4 read-only "Model Health" section.
|
|
6
6
|
*/
|
|
7
|
-
import { useEffect, useState, useCallback } from 'react'
|
|
7
|
+
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
|
8
8
|
import {
|
|
9
9
|
IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
|
|
10
10
|
IconCopy, IconCheck, IconChevronDown, IconChevronUp,
|
|
11
|
-
IconActivity, IconServer,
|
|
11
|
+
IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
|
|
12
|
+
IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
|
|
12
13
|
} from '@tabler/icons-react'
|
|
13
14
|
import styles from './RouterView.module.css'
|
|
14
15
|
|
|
@@ -38,6 +39,11 @@ function CircuitBadge({ state }) {
|
|
|
38
39
|
return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
const SAVE_STATUS_IDLE = { kind: 'idle' }
|
|
43
|
+
const SAVE_STATUS_SAVING = { kind: 'saving' }
|
|
44
|
+
const SAVE_STATUS_SAVED = { kind: 'saved' }
|
|
45
|
+
const SAVE_STATUS_ERROR = (message) => ({ kind: 'error', message })
|
|
46
|
+
|
|
41
47
|
export default function RouterView({ onClose, onToast }) {
|
|
42
48
|
const [status, setStatus] = useState(null)
|
|
43
49
|
const [stats, setStats] = useState(null)
|
|
@@ -45,6 +51,18 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
45
51
|
const [actionLoading, setActionLoading] = useState(false)
|
|
46
52
|
const [logExpanded, setLogExpanded] = useState(false)
|
|
47
53
|
const [copied, setCopied] = useState(null)
|
|
54
|
+
const [autoHealDismissed, setAutoHealDismissed] = useState(false)
|
|
55
|
+
|
|
56
|
+
// π Set management state β the active set, its model list (mutated
|
|
57
|
+
// π locally on every drag/remove/add), and the catalog of available
|
|
58
|
+
// π routeable models for the Add picker.
|
|
59
|
+
const [setsData, setSetsData] = useState({ activeSet: null, sets: {} })
|
|
60
|
+
const [catalog, setCatalog] = useState([]) // [{ key, provider, model, label, tier, ctx, hasKey }]
|
|
61
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
62
|
+
const [pickerSearch, setPickerSearch] = useState('')
|
|
63
|
+
const [pickerProvider, setPickerProvider] = useState('')
|
|
64
|
+
const [saveStatus, setSaveStatus] = useState(SAVE_STATUS_IDLE)
|
|
65
|
+
const saveTimerRef = useRef(null)
|
|
48
66
|
|
|
49
67
|
const fetchStatus = useCallback(async () => {
|
|
50
68
|
try {
|
|
@@ -59,12 +77,42 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
59
77
|
} catch {}
|
|
60
78
|
}, [])
|
|
61
79
|
|
|
80
|
+
const fetchSets = useCallback(async () => {
|
|
81
|
+
try {
|
|
82
|
+
const resp = await fetch('/api/router/sets')
|
|
83
|
+
const data = await resp.json()
|
|
84
|
+
if (data && data.sets) setSetsData(data)
|
|
85
|
+
} catch {}
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
const fetchCatalog = useCallback(async () => {
|
|
89
|
+
try {
|
|
90
|
+
const resp = await fetch('/api/router/catalog')
|
|
91
|
+
const data = await resp.json()
|
|
92
|
+
if (Array.isArray(data?.models)) setCatalog(data.models)
|
|
93
|
+
} catch {}
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
62
96
|
useEffect(() => {
|
|
63
97
|
void fetchStatus()
|
|
98
|
+
void fetchSets()
|
|
99
|
+
void fetchCatalog()
|
|
64
100
|
void fetch('/api/router/quick-setup').then(r => r.json()).then(setQuickSetup).catch(() => {})
|
|
65
|
-
const interval = setInterval(
|
|
101
|
+
const interval = setInterval(() => {
|
|
102
|
+
void fetchStatus()
|
|
103
|
+
void fetchSets()
|
|
104
|
+
}, 5000)
|
|
66
105
|
return () => clearInterval(interval)
|
|
67
|
-
}, [fetchStatus])
|
|
106
|
+
}, [fetchStatus, fetchSets, fetchCatalog])
|
|
107
|
+
|
|
108
|
+
// π Cleanup the "saved" indicator so it fades back to idle after 1.5s.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (saveStatus.kind !== 'saved') return undefined
|
|
111
|
+
saveTimerRef.current = setTimeout(() => setSaveStatus(SAVE_STATUS_IDLE), 1500)
|
|
112
|
+
return () => {
|
|
113
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
|
114
|
+
}
|
|
115
|
+
}, [saveStatus])
|
|
68
116
|
|
|
69
117
|
const handleStart = async () => {
|
|
70
118
|
setActionLoading(true)
|
|
@@ -74,6 +122,7 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
74
122
|
if (data.ok || data.alreadyRunning) {
|
|
75
123
|
onToast?.('Router daemon started.', 'success')
|
|
76
124
|
await fetchStatus()
|
|
125
|
+
await fetchSets()
|
|
77
126
|
} else {
|
|
78
127
|
onToast?.(`Failed to start: ${data.error || 'unknown'}`, 'error')
|
|
79
128
|
}
|
|
@@ -118,10 +167,250 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
118
167
|
} catch {}
|
|
119
168
|
}
|
|
120
169
|
|
|
170
|
+
// ββ Set management helpers ββββββββββββββββββββββββββββββββββββββββββββ
|
|
171
|
+
const activeSetName = setsData?.activeSet || status?.activeSet || 'fast-coding'
|
|
172
|
+
const activeSet = setsData?.sets?.[activeSetName] || { models: [] }
|
|
173
|
+
const models = Array.isArray(activeSet.models) ? activeSet.models : []
|
|
174
|
+
|
|
175
|
+
const setActiveSet = async (name) => {
|
|
176
|
+
try {
|
|
177
|
+
await fetch(`/api/router/sets/${encodeURIComponent(name)}/activate`, { method: 'POST' })
|
|
178
|
+
onToast?.(`Active set: ${name}`, 'info')
|
|
179
|
+
await fetchSets()
|
|
180
|
+
await fetchStatus()
|
|
181
|
+
} catch (err) {
|
|
182
|
+
onToast?.(`Failed to activate: ${err.message}`, 'error')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// π "Sync best" β re-run the probe pipeline against the user's actual
|
|
187
|
+
// π API keys and rebuild the set with only models that come back 2xx.
|
|
188
|
+
// π This is the one-click "default to working models" path for users
|
|
189
|
+
// π whose keys have changed since the last sync or who want a fresh
|
|
190
|
+
// π probe-driven ranking. The daemon shows probe progress to the UI
|
|
191
|
+
// π and returns the new model list.
|
|
192
|
+
const handleSyncBest = async () => {
|
|
193
|
+
if (!activeSetName) return
|
|
194
|
+
setSaveStatus(SAVE_STATUS_SAVING)
|
|
195
|
+
onToast?.('Probing models with your keysβ¦', 'info')
|
|
196
|
+
try {
|
|
197
|
+
const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/sync`, { method: 'POST' })
|
|
198
|
+
if (!resp.ok) {
|
|
199
|
+
const err = await resp.json().catch(() => ({}))
|
|
200
|
+
throw new Error(err.error || `HTTP ${resp.status}`)
|
|
201
|
+
}
|
|
202
|
+
const data = await resp.json()
|
|
203
|
+
const picked = data.selected?.length || 0
|
|
204
|
+
const probed = data.probeCount || 0
|
|
205
|
+
onToast?.(`Synced ${activeSetName}: ${picked} working model${picked === 1 ? '' : 's'} from ${probed} probes.`, 'success')
|
|
206
|
+
await fetchSets()
|
|
207
|
+
await fetchStatus()
|
|
208
|
+
setSaveStatus(SAVE_STATUS_SAVED)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
|
|
211
|
+
onToast?.(`Sync failed: ${err.message}`, 'error')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const persistReorder = useCallback(async (nextModels) => {
|
|
216
|
+
if (!activeSetName) return
|
|
217
|
+
setSaveStatus(SAVE_STATUS_SAVING)
|
|
218
|
+
try {
|
|
219
|
+
const order = nextModels.map((m) => `${m.provider}/${m.model}`)
|
|
220
|
+
const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/reorder`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({ order }),
|
|
224
|
+
})
|
|
225
|
+
if (!resp.ok) {
|
|
226
|
+
const err = await resp.json().catch(() => ({}))
|
|
227
|
+
throw new Error(err.error || `HTTP ${resp.status}`)
|
|
228
|
+
}
|
|
229
|
+
const data = await resp.json()
|
|
230
|
+
if (data?.sets?.[activeSetName]) {
|
|
231
|
+
setSetsData((prev) => ({ ...prev, sets: data.sets }))
|
|
232
|
+
} else {
|
|
233
|
+
await fetchSets()
|
|
234
|
+
}
|
|
235
|
+
setSaveStatus(SAVE_STATUS_SAVED)
|
|
236
|
+
} catch (err) {
|
|
237
|
+
setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
|
|
238
|
+
onToast?.(`Reorder failed: ${err.message}`, 'error')
|
|
239
|
+
}
|
|
240
|
+
}, [activeSetName, fetchSets, onToast])
|
|
241
|
+
|
|
242
|
+
const persistAdd = useCallback(async (provider, model) => {
|
|
243
|
+
if (!activeSetName) return
|
|
244
|
+
setSaveStatus(SAVE_STATUS_SAVING)
|
|
245
|
+
try {
|
|
246
|
+
const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ provider, model }),
|
|
250
|
+
})
|
|
251
|
+
if (!resp.ok) {
|
|
252
|
+
const err = await resp.json().catch(() => ({}))
|
|
253
|
+
throw new Error(err.error || `HTTP ${resp.status}`)
|
|
254
|
+
}
|
|
255
|
+
const data = await resp.json()
|
|
256
|
+
if (data?.sets?.[activeSetName]) {
|
|
257
|
+
setSetsData((prev) => ({ ...prev, sets: data.sets }))
|
|
258
|
+
} else {
|
|
259
|
+
await fetchSets()
|
|
260
|
+
}
|
|
261
|
+
setSaveStatus(SAVE_STATUS_SAVED)
|
|
262
|
+
onToast?.(`Added ${provider}/${model} to ${activeSetName}.`, 'success')
|
|
263
|
+
} catch (err) {
|
|
264
|
+
setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
|
|
265
|
+
onToast?.(`Add failed: ${err.message}`, 'error')
|
|
266
|
+
}
|
|
267
|
+
}, [activeSetName, fetchSets, onToast])
|
|
268
|
+
|
|
269
|
+
const persistRemove = useCallback(async (provider, model) => {
|
|
270
|
+
if (!activeSetName) return
|
|
271
|
+
setSaveStatus(SAVE_STATUS_SAVING)
|
|
272
|
+
try {
|
|
273
|
+
const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
|
|
274
|
+
method: 'DELETE',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ provider, model }),
|
|
277
|
+
})
|
|
278
|
+
if (!resp.ok) {
|
|
279
|
+
const err = await resp.json().catch(() => ({}))
|
|
280
|
+
throw new Error(err.error || `HTTP ${resp.status}`)
|
|
281
|
+
}
|
|
282
|
+
const data = await resp.json()
|
|
283
|
+
if (data?.sets?.[activeSetName]) {
|
|
284
|
+
setSetsData((prev) => ({ ...prev, sets: data.sets }))
|
|
285
|
+
} else {
|
|
286
|
+
await fetchSets()
|
|
287
|
+
}
|
|
288
|
+
setSaveStatus(SAVE_STATUS_SAVED)
|
|
289
|
+
} catch (err) {
|
|
290
|
+
setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
|
|
291
|
+
onToast?.(`Remove failed: ${err.message}`, 'error')
|
|
292
|
+
}
|
|
293
|
+
}, [activeSetName, fetchSets, onToast])
|
|
294
|
+
|
|
295
|
+
// ββ Drag and drop state βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
296
|
+
// We keep a local copy of `models` so the drag UX is instant β the
|
|
297
|
+
// server is updated only when the user actually drops the row.
|
|
298
|
+
const [localModels, setLocalModels] = useState(models)
|
|
299
|
+
useEffect(() => { setLocalModels(models) }, [models])
|
|
300
|
+
const [draggingKey, setDraggingKey] = useState(null)
|
|
301
|
+
const [dropPosition, setDropPosition] = useState(null) // { key, side: 'above' | 'below' } | null
|
|
302
|
+
|
|
303
|
+
const handleMove = useCallback(async (idx, direction) => {
|
|
304
|
+
const next = [...localModels]
|
|
305
|
+
const newIdx = direction === 'up' ? idx - 1 : idx + 1
|
|
306
|
+
if (newIdx < 0 || newIdx >= next.length) return
|
|
307
|
+
const [moved] = next.splice(idx, 1)
|
|
308
|
+
next.splice(newIdx, 0, moved)
|
|
309
|
+
setLocalModels(next)
|
|
310
|
+
await persistReorder(next)
|
|
311
|
+
}, [localModels, persistReorder])
|
|
312
|
+
|
|
313
|
+
const handleRemove = useCallback(async (idx) => {
|
|
314
|
+
const target = localModels[idx]
|
|
315
|
+
if (!target) return
|
|
316
|
+
// Optimistic update: drop the row immediately, send the DELETE after.
|
|
317
|
+
const next = localModels.filter((_, i) => i !== idx)
|
|
318
|
+
setLocalModels(next)
|
|
319
|
+
await persistRemove(target.provider, target.model)
|
|
320
|
+
}, [localModels, persistRemove])
|
|
321
|
+
|
|
322
|
+
const handleDragStart = (e, idx) => {
|
|
323
|
+
const target = localModels[idx]
|
|
324
|
+
if (!target) return
|
|
325
|
+
setDraggingKey(`${target.provider}/${target.model}`)
|
|
326
|
+
// π dataTransfer is required for Firefox to actually fire drag events.
|
|
327
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
328
|
+
e.dataTransfer.setData('text/plain', `${target.provider}/${target.model}`)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const handleDragOver = (e, idx) => {
|
|
332
|
+
if (draggingKey == null) return
|
|
333
|
+
e.preventDefault()
|
|
334
|
+
e.dataTransfer.dropEffect = 'move'
|
|
335
|
+
const target = localModels[idx]
|
|
336
|
+
if (!target) return
|
|
337
|
+
const key = `${target.provider}/${target.model}`
|
|
338
|
+
if (key === draggingKey) return
|
|
339
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
340
|
+
const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
|
|
341
|
+
setDropPosition({ key, side })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const handleDragLeave = (e) => {
|
|
345
|
+
// π Don't clear on every leave β only when we leave the list entirely.
|
|
346
|
+
if (e.currentTarget.contains(e.relatedTarget)) return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const handleDrop = async (e, idx) => {
|
|
350
|
+
e.preventDefault()
|
|
351
|
+
if (draggingKey == null) return
|
|
352
|
+
const dragIdx = localModels.findIndex((m) => `${m.provider}/${m.model}` === draggingKey)
|
|
353
|
+
if (dragIdx < 0) {
|
|
354
|
+
setDraggingKey(null)
|
|
355
|
+
setDropPosition(null)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const target = localModels[idx]
|
|
359
|
+
if (!target) {
|
|
360
|
+
setDraggingKey(null)
|
|
361
|
+
setDropPosition(null)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
365
|
+
const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
|
|
366
|
+
let insertAt = side === 'above' ? idx : idx + 1
|
|
367
|
+
if (dragIdx < insertAt) insertAt -= 1
|
|
368
|
+
if (insertAt === dragIdx) {
|
|
369
|
+
setDraggingKey(null)
|
|
370
|
+
setDropPosition(null)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const next = [...localModels]
|
|
374
|
+
const [moved] = next.splice(dragIdx, 1)
|
|
375
|
+
next.splice(insertAt, 0, moved)
|
|
376
|
+
setLocalModels(next)
|
|
377
|
+
setDraggingKey(null)
|
|
378
|
+
setDropPosition(null)
|
|
379
|
+
await persistReorder(next)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const handleDragEnd = () => {
|
|
383
|
+
setDraggingKey(null)
|
|
384
|
+
setDropPosition(null)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ββ Picker filter ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
388
|
+
const providers = useMemo(() => {
|
|
389
|
+
const set = new Set(catalog.map((m) => m.provider))
|
|
390
|
+
return Array.from(set).sort()
|
|
391
|
+
}, [catalog])
|
|
392
|
+
|
|
393
|
+
const filteredCatalog = useMemo(() => {
|
|
394
|
+
const q = pickerSearch.trim().toLowerCase()
|
|
395
|
+
return catalog.filter((m) => {
|
|
396
|
+
if (pickerProvider && m.provider !== pickerProvider) return false
|
|
397
|
+
if (!q) return true
|
|
398
|
+
return (
|
|
399
|
+
m.key.toLowerCase().includes(q)
|
|
400
|
+
|| (m.label || '').toLowerCase().includes(q)
|
|
401
|
+
|| m.provider.toLowerCase().includes(q)
|
|
402
|
+
)
|
|
403
|
+
}).slice(0, 200)
|
|
404
|
+
}, [catalog, pickerSearch, pickerProvider])
|
|
405
|
+
|
|
406
|
+
const modelKeyInSet = (provider, model) => localModels.some((m) => m.provider === provider && m.model === model)
|
|
407
|
+
|
|
121
408
|
const running = status?.ok
|
|
122
|
-
const models = stats?.models || []
|
|
123
|
-
const requestLog = stats?.requestLog || []
|
|
124
409
|
const circuitBreakers = stats?.circuitBreakers || {}
|
|
410
|
+
const requestLog = stats?.requestLog || []
|
|
411
|
+
|
|
412
|
+
const sets = setsData?.sets || {}
|
|
413
|
+
const setNames = Object.keys(sets).sort()
|
|
125
414
|
|
|
126
415
|
return (
|
|
127
416
|
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
@@ -135,6 +424,37 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
135
424
|
</div>
|
|
136
425
|
|
|
137
426
|
<div className={styles.body}>
|
|
427
|
+
{/* Auto-heal banner β shown when the daemon detected broken
|
|
428
|
+
models in the active set on startup. The banner disappears
|
|
429
|
+
once the user clicks "Sync best" or "Fix now" (which heals
|
|
430
|
+
the set and reloads the page state). */}
|
|
431
|
+
{running && status?.brokenModelCount > 0 && !autoHealDismissed && (
|
|
432
|
+
<div className={styles.autoHealBanner}>
|
|
433
|
+
<div className={styles.autoHealLeft}>
|
|
434
|
+
<span className={styles.autoHealIcon}>β </span>
|
|
435
|
+
<div>
|
|
436
|
+
<div className={styles.autoHealTitle}>
|
|
437
|
+
{status.brokenModelCount} model{status.brokenModelCount === 1 ? '' : 's'} in the active set are not responding
|
|
438
|
+
</div>
|
|
439
|
+
<div className={styles.autoHealHint}>
|
|
440
|
+
Auto-heal ran on startup but the replacement may also be broken.
|
|
441
|
+
Click <strong>Sync best</strong> below to re-probe with your current keys,
|
|
442
|
+
or click <strong>Fix now</strong> to manually replace the broken entries.
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
<div className={styles.autoHealActions}>
|
|
447
|
+
<button className={styles.smallBtn} onClick={handleSyncBest}>
|
|
448
|
+
<IconWand size={11} />
|
|
449
|
+
Fix now
|
|
450
|
+
</button>
|
|
451
|
+
<button className={styles.iconBtn} onClick={() => setAutoHealDismissed(true)} aria-label="Dismiss">
|
|
452
|
+
<IconX size={12} />
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
|
|
138
458
|
{/* Hero Card */}
|
|
139
459
|
<div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
|
|
140
460
|
<div className={styles.heroLeft}>
|
|
@@ -203,6 +523,173 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
203
523
|
</div>
|
|
204
524
|
)}
|
|
205
525
|
|
|
526
|
+
{/* Active Set Manager */}
|
|
527
|
+
{running && (
|
|
528
|
+
<div className={styles.section}>
|
|
529
|
+
<h3 className={styles.sectionTitle}>
|
|
530
|
+
<IconList size={14} />
|
|
531
|
+
Active Set ({localModels.length} models)
|
|
532
|
+
</h3>
|
|
533
|
+
|
|
534
|
+
<div className={styles.setMeta}>
|
|
535
|
+
<div className={styles.setActions}>
|
|
536
|
+
<span className={styles.setMetaName}>{activeSetName}</span>
|
|
537
|
+
{setNames.length > 1 && (
|
|
538
|
+
<select
|
|
539
|
+
className={styles.pickerSelect}
|
|
540
|
+
value={activeSetName}
|
|
541
|
+
onChange={(e) => setActiveSet(e.target.value)}
|
|
542
|
+
title="Switch the active set"
|
|
543
|
+
>
|
|
544
|
+
{setNames.map((n) => (
|
|
545
|
+
<option key={n} value={n}>{n}</option>
|
|
546
|
+
))}
|
|
547
|
+
</select>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
<div className={styles.setActions}>
|
|
551
|
+
<SaveBadge status={saveStatus} />
|
|
552
|
+
<button
|
|
553
|
+
className={styles.smallBtn}
|
|
554
|
+
onClick={handleSyncBest}
|
|
555
|
+
disabled={saveStatus.kind === 'saving'}
|
|
556
|
+
title="Probe your API keys and rebuild the set with only models that actually work"
|
|
557
|
+
>
|
|
558
|
+
<IconWand size={11} />
|
|
559
|
+
Sync best
|
|
560
|
+
</button>
|
|
561
|
+
<button
|
|
562
|
+
className={styles.primaryBtn}
|
|
563
|
+
onClick={() => setPickerOpen((v) => !v)}
|
|
564
|
+
disabled={saveStatus.kind === 'saving'}
|
|
565
|
+
>
|
|
566
|
+
{pickerOpen ? <IconX size={11} /> : <IconPlus size={11} />}
|
|
567
|
+
{pickerOpen ? 'Close' : 'Add model'}
|
|
568
|
+
</button>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
{localModels.length === 0 ? (
|
|
573
|
+
<div className={styles.setEmpty}>
|
|
574
|
+
The active set is empty. Add models with the button above to start routing.
|
|
575
|
+
</div>
|
|
576
|
+
) : (
|
|
577
|
+
<div className={styles.setList} onDragLeave={handleDragLeave}>
|
|
578
|
+
{localModels.map((m, idx) => {
|
|
579
|
+
const key = `${m.provider}/${m.model}`
|
|
580
|
+
const cb = circuitBreakers[key] || {}
|
|
581
|
+
const isDragging = draggingKey === key
|
|
582
|
+
const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
|
|
583
|
+
const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
|
|
584
|
+
return (
|
|
585
|
+
<div
|
|
586
|
+
key={key}
|
|
587
|
+
className={`${styles.setRow} ${isDragging ? styles.setRowDragging : ''} ${dropAbove ? `${styles.setRowDropTarget} ${styles.setRowDropTargetAbove}` : ''} ${dropBelow ? `${styles.setRowDropTarget} ${styles.setRowDropTargetBelow}` : ''}`}
|
|
588
|
+
draggable
|
|
589
|
+
onDragStart={(e) => handleDragStart(e, idx)}
|
|
590
|
+
onDragOver={(e) => handleDragOver(e, idx)}
|
|
591
|
+
onDrop={(e) => handleDrop(e, idx)}
|
|
592
|
+
onDragEnd={handleDragEnd}
|
|
593
|
+
title={key}
|
|
594
|
+
>
|
|
595
|
+
<span className={styles.setDragHandle} aria-hidden>
|
|
596
|
+
<IconGripVertical size={14} />
|
|
597
|
+
</span>
|
|
598
|
+
<span className={styles.setPriority}>#{idx + 1}</span>
|
|
599
|
+
<span className={styles.setKey}>{key}</span>
|
|
600
|
+
{m.tier && <span className={styles.setTier}>{m.tier}</span>}
|
|
601
|
+
<CircuitBadge state={cb.state || m.state} />
|
|
602
|
+
<div className={styles.setRowBtns}>
|
|
603
|
+
<button
|
|
604
|
+
className={styles.iconBtn}
|
|
605
|
+
onClick={() => handleMove(idx, 'up')}
|
|
606
|
+
disabled={idx === 0 || saveStatus.kind === 'saving'}
|
|
607
|
+
title="Move up"
|
|
608
|
+
aria-label={`Move ${key} up`}
|
|
609
|
+
>
|
|
610
|
+
<IconArrowUp size={12} />
|
|
611
|
+
</button>
|
|
612
|
+
<button
|
|
613
|
+
className={styles.iconBtn}
|
|
614
|
+
onClick={() => handleMove(idx, 'down')}
|
|
615
|
+
disabled={idx === localModels.length - 1 || saveStatus.kind === 'saving'}
|
|
616
|
+
title="Move down"
|
|
617
|
+
aria-label={`Move ${key} down`}
|
|
618
|
+
>
|
|
619
|
+
<IconArrowDown size={12} />
|
|
620
|
+
</button>
|
|
621
|
+
<button
|
|
622
|
+
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
|
623
|
+
onClick={() => handleRemove(idx)}
|
|
624
|
+
disabled={saveStatus.kind === 'saving'}
|
|
625
|
+
title="Remove from set"
|
|
626
|
+
aria-label={`Remove ${key}`}
|
|
627
|
+
>
|
|
628
|
+
<IconTrash size={12} />
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
)
|
|
633
|
+
})}
|
|
634
|
+
</div>
|
|
635
|
+
)}
|
|
636
|
+
|
|
637
|
+
{pickerOpen && (
|
|
638
|
+
<div className={styles.pickerPanel}>
|
|
639
|
+
<div className={styles.pickerHeader}>
|
|
640
|
+
<span>Add a model to <code>{activeSetName}</code></span>
|
|
641
|
+
<span style={{ color: 'var(--text-muted, #888)' }}>
|
|
642
|
+
{filteredCatalog.length} of {catalog.length}
|
|
643
|
+
</span>
|
|
644
|
+
</div>
|
|
645
|
+
<div className={styles.pickerSearch}>
|
|
646
|
+
<input
|
|
647
|
+
className={styles.pickerInput}
|
|
648
|
+
placeholder="Search by provider, model, or labelβ¦"
|
|
649
|
+
value={pickerSearch}
|
|
650
|
+
onChange={(e) => setPickerSearch(e.target.value)}
|
|
651
|
+
autoFocus
|
|
652
|
+
/>
|
|
653
|
+
<select
|
|
654
|
+
className={styles.pickerSelect}
|
|
655
|
+
value={pickerProvider}
|
|
656
|
+
onChange={(e) => setPickerProvider(e.target.value)}
|
|
657
|
+
>
|
|
658
|
+
<option value="">All providers</option>
|
|
659
|
+
{providers.map((p) => (
|
|
660
|
+
<option key={p} value={p}>{p}</option>
|
|
661
|
+
))}
|
|
662
|
+
</select>
|
|
663
|
+
</div>
|
|
664
|
+
<div className={styles.pickerList}>
|
|
665
|
+
{filteredCatalog.length === 0 ? (
|
|
666
|
+
<div className={styles.pickerEmpty}>No models match your filter.</div>
|
|
667
|
+
) : (
|
|
668
|
+
filteredCatalog.map((entry) => {
|
|
669
|
+
const inSet = modelKeyInSet(entry.provider, entry.model)
|
|
670
|
+
return (
|
|
671
|
+
<div
|
|
672
|
+
key={entry.key}
|
|
673
|
+
className={`${styles.pickerItem} ${inSet ? styles.pickerItemAdded : ''}`}
|
|
674
|
+
onClick={() => { if (!inSet) void persistAdd(entry.provider, entry.model) }}
|
|
675
|
+
title={inSet ? 'Already in set' : `Add ${entry.key}`}
|
|
676
|
+
>
|
|
677
|
+
<span className={styles.pickerProvider}>{entry.provider}</span>
|
|
678
|
+
<span className={styles.pickerModel}>{entry.label || entry.model}</span>
|
|
679
|
+
{entry.tier && <span className={styles.setTier}>{entry.tier}</span>}
|
|
680
|
+
{entry.hasKey
|
|
681
|
+
? <span className={`${styles.pickerBadge} ${styles.pickerBadgeOk}`}>key</span>
|
|
682
|
+
: <span className={styles.pickerBadge}>no key</span>}
|
|
683
|
+
</div>
|
|
684
|
+
)
|
|
685
|
+
})
|
|
686
|
+
)}
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
|
|
206
693
|
{/* Probe Mode */}
|
|
207
694
|
{running && (
|
|
208
695
|
<div className={styles.section}>
|
|
@@ -224,32 +711,6 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
224
711
|
</div>
|
|
225
712
|
)}
|
|
226
713
|
|
|
227
|
-
{/* Model Health */}
|
|
228
|
-
{running && models.length > 0 && (
|
|
229
|
-
<div className={styles.section}>
|
|
230
|
-
<h3 className={styles.sectionTitle}>
|
|
231
|
-
<IconServer size={14} />
|
|
232
|
-
Model Health ({models.length})
|
|
233
|
-
</h3>
|
|
234
|
-
<div className={styles.modelList}>
|
|
235
|
-
{models.map((m) => {
|
|
236
|
-
const cb = circuitBreakers[m.key] || {}
|
|
237
|
-
return (
|
|
238
|
-
<div key={m.key} className={styles.modelRow}>
|
|
239
|
-
<span className={styles.modelPriority}>#{m.priority}</span>
|
|
240
|
-
<span className={styles.modelName} title={m.key}>{m.key}</span>
|
|
241
|
-
<CircuitBadge state={cb.state || m.state} />
|
|
242
|
-
<span className={styles.modelScore}>{m.score?.toFixed(2) || 'β'}</span>
|
|
243
|
-
<span className={styles.modelLatency}>
|
|
244
|
-
{m.last_latency_ms != null ? `${m.last_latency_ms}ms` : 'β'}
|
|
245
|
-
</span>
|
|
246
|
-
</div>
|
|
247
|
-
)
|
|
248
|
-
})}
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
251
|
-
)}
|
|
252
|
-
|
|
253
714
|
{/* Request Log */}
|
|
254
715
|
{running && requestLog.length > 0 && (
|
|
255
716
|
<div className={styles.section}>
|
|
@@ -279,8 +740,31 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
279
740
|
)}
|
|
280
741
|
</div>
|
|
281
742
|
)}
|
|
743
|
+
|
|
744
|
+
{/* Server health (small chip at the bottom for visibility) */}
|
|
745
|
+
<div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
|
|
746
|
+
<span className={styles.saveStatus}>
|
|
747
|
+
{stats?.tokenStats ? `${formatNumber(stats.tokenStats.all_time?.total_tokens || 0)} tokens routed lifetime` : ''}
|
|
748
|
+
</span>
|
|
749
|
+
</div>
|
|
282
750
|
</div>
|
|
283
751
|
</div>
|
|
284
752
|
</div>
|
|
285
753
|
)
|
|
286
754
|
}
|
|
755
|
+
|
|
756
|
+
function SaveBadge({ status }) {
|
|
757
|
+
if (!status || status.kind === 'idle') return null
|
|
758
|
+
if (status.kind === 'saving') {
|
|
759
|
+
return <span className={styles.saveStatus}>savingβ¦</span>
|
|
760
|
+
}
|
|
761
|
+
if (status.kind === 'saved') {
|
|
762
|
+
return <span className={`${styles.saveStatus} ${styles.saveStatusOk}`}>β saved</span>
|
|
763
|
+
}
|
|
764
|
+
if (status.kind === 'error') {
|
|
765
|
+
return <span className={`${styles.saveStatus} ${styles.saveStatusErr}`} title={status.message}>
|
|
766
|
+
β {status.message?.slice(0, 40) || 'error'}
|
|
767
|
+
</span>
|
|
768
|
+
}
|
|
769
|
+
return null
|
|
770
|
+
}
|