free-coding-models 0.5.85 β†’ 0.5.87

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.
Files changed (164) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.86.md +10 -0
  4. package/changelog/v0.5.87.md +48 -0
  5. package/package.json +8 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/shared-helpers.js +12 -1
  26. package/src/core/shell-env.js +3 -1
  27. package/src/core/telemetry.js +18 -5
  28. package/src/core/tool-launchers.js +9 -6
  29. package/src/core/updater.js +28 -2
  30. package/src/core/utils.js +76 -71
  31. package/src/tui/app.js +47 -16
  32. package/src/tui/command-palette.js +22 -2
  33. package/src/tui/key-handler.js +123 -179
  34. package/src/tui/overlays.js +35 -75
  35. package/src/tui/render-helpers.js +62 -4
  36. package/src/tui/render-table.js +46 -5
  37. package/src/tui/theme.js +36 -5
  38. package/src/tui/tui-filters.js +28 -8
  39. package/src/tui/tui-state.js +22 -6
  40. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/index.html +5 -9
  43. package/web/index.html +3 -7
  44. package/web/server.js +97 -23
  45. package/web/vite.config.js +8 -0
  46. package/web/app.legacy.js +0 -900
  47. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  48. package/web/dist/assets/index-DWUely84.js +0 -40
  49. package/web/public/favicon.ico +0 -0
  50. package/web/public/favicons/apple-touch-icon.png +0 -0
  51. package/web/public/favicons/browserconfig.xml +0 -12
  52. package/web/public/favicons/favicon-16x16.png +0 -0
  53. package/web/public/favicons/favicon-192x192.png +0 -0
  54. package/web/public/favicons/favicon-32x32.png +0 -0
  55. package/web/public/favicons/favicon-48x48.png +0 -0
  56. package/web/public/favicons/favicon-512x512.png +0 -0
  57. package/web/public/favicons/favicon-96x96.png +0 -0
  58. package/web/public/favicons/favicon.ico +0 -0
  59. package/web/public/favicons/mstile-150x150.png +0 -0
  60. package/web/public/favicons/mstile-310x150.png +0 -0
  61. package/web/public/favicons/mstile-310x310.png +0 -0
  62. package/web/public/favicons/mstile-512x512.png +0 -0
  63. package/web/public/favicons/mstile-70x70.png +0 -0
  64. package/web/public/favicons/site.webmanifest +0 -25
  65. package/web/src/App.jsx +0 -521
  66. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  67. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  68. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  69. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  70. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  71. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  72. package/web/src/components/atoms/HealthCell.jsx +0 -143
  73. package/web/src/components/atoms/HealthCell.module.css +0 -60
  74. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  75. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  76. package/web/src/components/atoms/MoodCell.jsx +0 -25
  77. package/web/src/components/atoms/MoodCell.module.css +0 -6
  78. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  79. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  80. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  81. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  82. package/web/src/components/atoms/RankCell.jsx +0 -9
  83. package/web/src/components/atoms/RankCell.module.css +0 -9
  84. package/web/src/components/atoms/Sparkline.jsx +0 -63
  85. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  86. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  87. package/web/src/components/atoms/StatusDot.jsx +0 -13
  88. package/web/src/components/atoms/StatusDot.module.css +0 -29
  89. package/web/src/components/atoms/TPSCell.jsx +0 -36
  90. package/web/src/components/atoms/TPSCell.module.css +0 -44
  91. package/web/src/components/atoms/TierBadge.jsx +0 -10
  92. package/web/src/components/atoms/TierBadge.module.css +0 -29
  93. package/web/src/components/atoms/Toast.jsx +0 -25
  94. package/web/src/components/atoms/Toast.module.css +0 -41
  95. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  96. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  97. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  98. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  99. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  100. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  101. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  102. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  103. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  104. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  105. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  106. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  107. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  108. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  109. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  110. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  111. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  112. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  113. package/web/src/components/help/HelpView.jsx +0 -201
  114. package/web/src/components/help/HelpView.module.css +0 -156
  115. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  116. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  117. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  118. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  119. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  120. package/web/src/components/launch/LaunchButton.jsx +0 -30
  121. package/web/src/components/launch/LaunchButton.module.css +0 -35
  122. package/web/src/components/launch/LaunchModal.module.css +0 -125
  123. package/web/src/components/layout/Footer.jsx +0 -19
  124. package/web/src/components/layout/Footer.module.css +0 -10
  125. package/web/src/components/layout/Header.jsx +0 -265
  126. package/web/src/components/layout/Header.module.css +0 -357
  127. package/web/src/components/palette/CommandPalette.jsx +0 -301
  128. package/web/src/components/palette/CommandPalette.module.css +0 -121
  129. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  130. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  131. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  132. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  133. package/web/src/components/recommend/RecommendView.jsx +0 -121
  134. package/web/src/components/recommend/RecommendView.module.css +0 -57
  135. package/web/src/components/router/RouterView.jsx +0 -1212
  136. package/web/src/components/router/RouterView.module.css +0 -1289
  137. package/web/src/components/settings/SettingsView.jsx +0 -555
  138. package/web/src/components/settings/SettingsView.module.css +0 -550
  139. package/web/src/components/tools/ToolPicker.jsx +0 -86
  140. package/web/src/components/tools/ToolPicker.module.css +0 -97
  141. package/web/src/components/update/UpdateChip.jsx +0 -104
  142. package/web/src/components/update/UpdateChip.module.css +0 -146
  143. package/web/src/global.css +0 -298
  144. package/web/src/hooks/urlState.constants.js +0 -28
  145. package/web/src/hooks/useChangelog.js +0 -51
  146. package/web/src/hooks/useColumnSizing.js +0 -211
  147. package/web/src/hooks/useFavorites.js +0 -150
  148. package/web/src/hooks/useFilter.js +0 -310
  149. package/web/src/hooks/useInstalledModels.js +0 -42
  150. package/web/src/hooks/useRecommend.js +0 -67
  151. package/web/src/hooks/useRouterDashboard.js +0 -133
  152. package/web/src/hooks/useSSE.js +0 -73
  153. package/web/src/hooks/useSocket.js +0 -203
  154. package/web/src/hooks/useTheme.js +0 -83
  155. package/web/src/hooks/useTokenUsage.js +0 -103
  156. package/web/src/hooks/useToolMode.js +0 -77
  157. package/web/src/hooks/useUpdateChecker.js +0 -91
  158. package/web/src/hooks/useUrlState.js +0 -170
  159. package/web/src/main.jsx +0 -15
  160. package/web/src/utils/download.js +0 -15
  161. package/web/src/utils/format.js +0 -42
  162. package/web/src/utils/m3.js +0 -57
  163. package/web/src/utils/ranks.js +0 -37
  164. package/web/styles.legacy.css +0 -963
@@ -1,1212 +0,0 @@
1
- /**
2
- * @file web/src/components/router/RouterView.jsx
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
- */
7
- import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
8
- import {
9
- IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
10
- IconCopy, IconCheck, IconChevronDown, IconChevronUp,
11
- IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
12
- IconArrowRight, IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
13
- IconSend, IconBolt,
14
- } from '@tabler/icons-react'
15
- import styles from './RouterView.module.css'
16
- import PlaygroundChat from '../playground/PlaygroundChat.jsx'
17
-
18
- function formatUptime(seconds) {
19
- if (!seconds || seconds <= 0) return 'β€”'
20
- const h = Math.floor(seconds / 3600)
21
- const m = Math.floor((seconds % 3600) / 60)
22
- const s = seconds % 60
23
- if (h > 0) return `${h}h ${m}m`
24
- if (m > 0) return `${m}m ${s}s`
25
- return `${s}s`
26
- }
27
-
28
- function formatTime(isoString) {
29
- if (!isoString) return 'β€”'
30
- try {
31
- const d = new Date(isoString)
32
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
33
- } catch {
34
- return 'β€”'
35
- }
36
- }
37
-
38
- function formatNumber(n) {
39
- if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
40
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
41
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
42
- return String(n)
43
- }
44
-
45
- // πŸ“– Friendly labels for the circuit breaker states. The raw names
46
- // πŸ“– (CLOSED/OPEN/HALF_OPEN/AUTH_ERROR) are jargon β€” translate them
47
- // πŸ“– to words a normal developer can scan in <1 second.
48
- const CIRCUIT_STATE_LABELS = {
49
- CLOSED: { label: 'Healthy', cls: 'circuitClosed' },
50
- OPEN: { label: 'Down', cls: 'circuitOpen' },
51
- HALF_OPEN: { label: 'Recovering', cls: 'circuitHalfOpen' },
52
- AUTH_ERROR: { label: 'Auth error', cls: 'circuitAuth' },
53
- STALE: { label: 'Deprecated', cls: 'circuitUnknown' },
54
- UNSUPPORTED:{ label: 'Unsupported',cls: 'circuitUnknown' },
55
- UNKNOWN: { label: 'Unknown', cls: 'circuitUnknown' },
56
- }
57
-
58
- function CircuitBadge({ state }) {
59
- const entry = CIRCUIT_STATE_LABELS[state] || CIRCUIT_STATE_LABELS.UNKNOWN
60
- return <span className={`${styles.circuitBadge} ${styles[entry.cls]}`}>{entry.label}</span>
61
- }
62
-
63
- const SAVE_STATUS_IDLE = { kind: 'idle' }
64
- const SAVE_STATUS_SAVING = { kind: 'saving' }
65
- const SAVE_STATUS_SAVED = { kind: 'saved' }
66
- const SAVE_STATUS_ERROR = (message) => ({ kind: 'error', message })
67
-
68
- export default function RouterView({ onClose, onToast, favorites }) {
69
- const [status, setStatus] = useState(null)
70
- const [stats, setStats] = useState(null)
71
- const [quickSetup, setQuickSetup] = useState(null)
72
- const [actionLoading, setActionLoading] = useState(false)
73
- const [logExpanded, setLogExpanded] = useState(false)
74
- const [copied, setCopied] = useState(null)
75
- const [autoHealDismissed, setAutoHealDismissed] = useState(false)
76
-
77
- // πŸ“– Set management state β€” the active set, its model list (mutated
78
- // πŸ“– locally on every drag/remove/add), and the catalog of available
79
- // πŸ“– routeable models for the Add picker.
80
- const [setsData, setSetsData] = useState({ activeSet: null, sets: {} })
81
- const [catalog, setCatalog] = useState([]) // [{ key, provider, model, label, tier, ctx, hasKey }]
82
- const [pickerOpen, setPickerOpen] = useState(false)
83
- const [pickerSearch, setPickerSearch] = useState('')
84
- const [pickerProvider, setPickerProvider] = useState('')
85
- const [saveStatus, setSaveStatus] = useState(SAVE_STATUS_IDLE)
86
- const saveTimerRef = useRef(null)
87
- const hasAutoExpandedLog = useRef(false)
88
-
89
- // πŸ“– Probe state β€” AI Latency benchmarks launched on the active set. Progress
90
- // πŸ“– comes from /stats.globalBenchmark (polled), results land on
91
- // πŸ“– /stats.models[].benchmark. `probePolling` speeds up polling while running
92
- // πŸ“– so the progress bar + per-model latencies update live.
93
- const [probePolling, setProbePolling] = useState(false)
94
-
95
- const fetchStatus = useCallback(async () => {
96
- try {
97
- const resp = await fetch('/api/router/status')
98
- const data = await resp.json()
99
- setStatus(data)
100
- if (data?.ok) {
101
- const statsResp = await fetch('/api/router/stats')
102
- const statsData = await statsResp.json()
103
- if (statsData.ok) setStats(statsData)
104
- }
105
- } catch {}
106
- }, [])
107
-
108
- const fetchSets = useCallback(async () => {
109
- try {
110
- const resp = await fetch('/api/router/sets')
111
- const data = await resp.json()
112
- if (data && data.sets) setSetsData(data)
113
- } catch {}
114
- }, [])
115
-
116
- const fetchCatalog = useCallback(async () => {
117
- try {
118
- const resp = await fetch('/api/router/catalog')
119
- const data = await resp.json()
120
- if (Array.isArray(data?.models)) setCatalog(data.models)
121
- } catch {}
122
- }, [])
123
-
124
- useEffect(() => {
125
- void fetchStatus()
126
- void fetchSets()
127
- void fetchCatalog()
128
- void fetch('/api/router/quick-setup').then(r => r.json()).then(setQuickSetup).catch(() => {})
129
- const interval = setInterval(() => {
130
- void fetchStatus()
131
- void fetchSets()
132
- }, 2000)
133
- return () => clearInterval(interval)
134
- }, [fetchStatus, fetchSets, fetchCatalog])
135
-
136
- // πŸ“– Fast-poll /stats while a probe is running so the progress bar and
137
- // πŸ“– per-model AI latencies stream in live (every 1.2s instead of 5s).
138
- // πŸ“– Stops the moment the daemon reports the global benchmark as done.
139
- useEffect(() => {
140
- if (!probePolling) return undefined
141
- const interval = setInterval(() => { void fetchStatus() }, 1200)
142
- return () => clearInterval(interval)
143
- }, [probePolling, fetchStatus])
144
-
145
- // πŸ“– Keep probePolling in sync with the daemon's real state: if it says the
146
- // πŸ“– global benchmark stopped, drop our fast-poll flag so we go back to 5s.
147
- useEffect(() => {
148
- if (probePolling && stats?.globalBenchmark && !stats.globalBenchmark.running) {
149
- setProbePolling(false)
150
- }
151
- }, [stats?.globalBenchmark, probePolling])
152
-
153
- // πŸ“– Cleanup the "saved" indicator so it fades back to idle after 1.5s.
154
- useEffect(() => {
155
- if (saveStatus.kind !== 'saved') return undefined
156
- saveTimerRef.current = setTimeout(() => setSaveStatus(SAVE_STATUS_IDLE), 1500)
157
- return () => {
158
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
159
- }
160
- }, [saveStatus])
161
-
162
- const handleStart = async () => {
163
- setActionLoading(true)
164
- try {
165
- const resp = await fetch('/api/router/start', { method: 'POST' })
166
- const data = await resp.json()
167
- if (data.ok || data.alreadyRunning) {
168
- onToast?.('Router daemon started.', 'success')
169
- await fetchStatus()
170
- await fetchSets()
171
- } else {
172
- onToast?.(`Failed to start: ${data.error || data.message || 'unknown'}`, 'error')
173
- }
174
- } catch (err) {
175
- // πŸ“– Distinguish between "daemon not running" (fetch itself fails) and
176
- // πŸ“– other network errors so the user gets an actionable error message.
177
- const msg = err.name === 'TypeError' && err.message?.includes('fetch')
178
- ? 'Cannot reach daemon β€” it may not be installed or the port is blocked'
179
- : err.message
180
- onToast?.(`Start failed: ${msg}`, 'error')
181
- } finally { setActionLoading(false) }
182
- }
183
-
184
- const handleStop = async () => {
185
- setActionLoading(true)
186
- try {
187
- const resp = await fetch('/api/router/stop', { method: 'POST' })
188
- const data = await resp.json()
189
- if (data.ok) {
190
- onToast?.('Router daemon stopped.', 'success')
191
- setStatus({ ok: false, running: false })
192
- setStats(null)
193
- } else {
194
- onToast?.(`Failed to stop: ${data.error || 'unknown'}`, 'error')
195
- }
196
- } catch (err) {
197
- onToast?.(`Stop failed: ${err.message}`, 'error')
198
- } finally { setActionLoading(false) }
199
- }
200
-
201
- const handleCopy = (text, label) => {
202
- navigator.clipboard.writeText(text).then(() => {
203
- setCopied(label)
204
- setTimeout(() => setCopied(null), 1500)
205
- })
206
- }
207
-
208
- const handleSetProbeMode = async (mode) => {
209
- try {
210
- await fetch('/api/router/probe-mode', {
211
- method: 'POST',
212
- headers: { 'Content-Type': 'application/json' },
213
- body: JSON.stringify({ probeMode: mode }),
214
- })
215
- onToast?.(`Probe mode set to ${mode}.`, 'info')
216
- await fetchStatus()
217
- } catch {}
218
- }
219
-
220
- // ── Set management helpers ────────────────────────────────────────────
221
- const activeSetName = setsData?.activeSet || status?.activeSet || 'fast-coding'
222
- const activeSet = setsData?.sets?.[activeSetName] || { models: [] }
223
- const models = Array.isArray(activeSet.models) ? activeSet.models : []
224
-
225
- const setActiveSet = async (name) => {
226
- try {
227
- await fetch(`/api/router/sets/${encodeURIComponent(name)}/activate`, { method: 'POST' })
228
- onToast?.(`Active set: ${name}`, 'info')
229
- await fetchSets()
230
- await fetchStatus()
231
- } catch (err) {
232
- onToast?.(`Failed to activate: ${err.message}`, 'error')
233
- }
234
- }
235
-
236
- // πŸ“– "Sync best" β€” re-run the probe pipeline against the user's actual
237
- // πŸ“– API keys and rebuild the set with only models that come back 2xx.
238
- // πŸ“– This is the one-click "default to working models" path for users
239
- // πŸ“– whose keys have changed since the last sync or who want a fresh
240
- // πŸ“– probe-driven ranking. The daemon shows probe progress to the UI
241
- // πŸ“– and returns the new model list.
242
- const handleSyncBest = async () => {
243
- if (!activeSetName) return
244
- setSaveStatus(SAVE_STATUS_SAVING)
245
- onToast?.('Probing models with your keys…', 'info')
246
- try {
247
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/sync`, { method: 'POST' })
248
- if (!resp.ok) {
249
- const err = await resp.json().catch(() => ({}))
250
- throw new Error(err.error || `HTTP ${resp.status}`)
251
- }
252
- const data = await resp.json()
253
- const picked = data.selected?.length || 0
254
- const probed = data.probeCount || 0
255
- onToast?.(`Synced ${activeSetName}: ${picked} working model${picked === 1 ? '' : 's'} from ${probed} probes.`, 'success')
256
- await fetchSets()
257
- await fetchStatus()
258
- setSaveStatus(SAVE_STATUS_SAVED)
259
- } catch (err) {
260
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
261
- onToast?.(`Sync failed: ${err.message}`, 'error')
262
- }
263
- }
264
-
265
- // πŸ“– Replace active set models with favorites
266
- const handleUseFavorites = async () => {
267
- if (!activeSetName) return
268
- const favList = favorites?.favorites || []
269
- if (favList.length === 0) {
270
- onToast?.('You do not have any favorite models yet. Star some models first!', 'info')
271
- return
272
- }
273
-
274
- const nextModels = favList.map((key, idx) => {
275
- const parts = key.split('/')
276
- const provider = parts[0]
277
- const model = parts.slice(1).join('/')
278
- return { provider, model, priority: idx + 1 }
279
- })
280
-
281
- setSaveStatus(SAVE_STATUS_SAVING)
282
- try {
283
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}`, {
284
- method: 'PUT',
285
- headers: { 'Content-Type': 'application/json' },
286
- body: JSON.stringify({ models: nextModels }),
287
- })
288
- if (!resp.ok) {
289
- const err = await resp.json().catch(() => ({}))
290
- throw new Error(err.error || `HTTP ${resp.status}`)
291
- }
292
- const data = await resp.json()
293
- if (data?.sets?.[activeSetName]) {
294
- setSetsData((prev) => ({ ...prev, sets: data.sets }))
295
- } else {
296
- await fetchSets()
297
- }
298
- setSaveStatus(SAVE_STATUS_SAVED)
299
- onToast?.(`Replaced active set with ${favList.length} favorite model${favList.length === 1 ? '' : 's'}.`, 'success')
300
- await fetchStatus()
301
- } catch (err) {
302
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
303
- onToast?.(`Failed to replace set: ${err.message}`, 'error')
304
- }
305
- }
306
-
307
- // πŸ“– t8 - family failover keeps the user inside the same model family when a
308
- // request fails (e.g. DeepSeek on NIM -> DeepSeek on Together) instead of
309
- // hopping to a different family. Per-set toggle, persisted via PUT /sets/:name.
310
- const handleToggleFamilyFailover = async () => {
311
- if (!activeSetName) return
312
- const next = !(activeSet?.familyFailover !== false)
313
- setSaveStatus(SAVE_STATUS_SAVING)
314
- try {
315
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}`, {
316
- method: 'PUT',
317
- headers: { 'Content-Type': 'application/json' },
318
- body: JSON.stringify({ familyFailover: next }),
319
- })
320
- if (!resp.ok) {
321
- const err = await resp.json().catch(() => ({}))
322
- throw new Error(err.error || `HTTP ${resp.status}`)
323
- }
324
- setSaveStatus(SAVE_STATUS_SAVED)
325
- onToast?.(`Family failover ${next ? 'enabled' : 'disabled'} for ${activeSetName}.`, 'info')
326
- await fetchSets()
327
- } catch (err) {
328
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
329
- onToast?.(`Failed to toggle family failover: ${err.message}`, 'error')
330
- }
331
- }
332
-
333
- const persistReorder = useCallback(async (nextModels) => {
334
- if (!activeSetName) return
335
- setSaveStatus(SAVE_STATUS_SAVING)
336
- try {
337
- const order = nextModels.map((m) => `${m.provider}/${m.model}`)
338
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/reorder`, {
339
- method: 'POST',
340
- headers: { 'Content-Type': 'application/json' },
341
- body: JSON.stringify({ order }),
342
- })
343
- if (!resp.ok) {
344
- const err = await resp.json().catch(() => ({}))
345
- throw new Error(err.error || `HTTP ${resp.status}`)
346
- }
347
- const data = await resp.json()
348
- if (data?.sets?.[activeSetName]) {
349
- setSetsData((prev) => ({ ...prev, sets: data.sets }))
350
- } else {
351
- await fetchSets()
352
- }
353
- setSaveStatus(SAVE_STATUS_SAVED)
354
- } catch (err) {
355
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
356
- onToast?.(`Reorder failed: ${err.message}`, 'error')
357
- }
358
- }, [activeSetName, fetchSets, onToast])
359
-
360
- const persistAdd = useCallback(async (provider, model) => {
361
- if (!activeSetName) return
362
- setSaveStatus(SAVE_STATUS_SAVING)
363
- try {
364
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
365
- method: 'POST',
366
- headers: { 'Content-Type': 'application/json' },
367
- body: JSON.stringify({ provider, model }),
368
- })
369
- if (!resp.ok) {
370
- const err = await resp.json().catch(() => ({}))
371
- throw new Error(err.error || `HTTP ${resp.status}`)
372
- }
373
- const data = await resp.json()
374
- if (data?.sets?.[activeSetName]) {
375
- setSetsData((prev) => ({ ...prev, sets: data.sets }))
376
- } else {
377
- await fetchSets()
378
- }
379
- setSaveStatus(SAVE_STATUS_SAVED)
380
- onToast?.(`Added ${provider}/${model} to ${activeSetName}.`, 'success')
381
- } catch (err) {
382
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
383
- onToast?.(`Add failed: ${err.message}`, 'error')
384
- }
385
- }, [activeSetName, fetchSets, onToast])
386
-
387
- const persistRemove = useCallback(async (provider, model) => {
388
- if (!activeSetName) return
389
- setSaveStatus(SAVE_STATUS_SAVING)
390
- try {
391
- const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
392
- method: 'DELETE',
393
- headers: { 'Content-Type': 'application/json' },
394
- body: JSON.stringify({ provider, model }),
395
- })
396
- if (!resp.ok) {
397
- const err = await resp.json().catch(() => ({}))
398
- throw new Error(err.error || `HTTP ${resp.status}`)
399
- }
400
- const data = await resp.json()
401
- if (data?.sets?.[activeSetName]) {
402
- setSetsData((prev) => ({ ...prev, sets: data.sets }))
403
- } else {
404
- await fetchSets()
405
- }
406
- setSaveStatus(SAVE_STATUS_SAVED)
407
- } catch (err) {
408
- setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
409
- onToast?.(`Remove failed: ${err.message}`, 'error')
410
- }
411
- }, [activeSetName, fetchSets, onToast])
412
-
413
- // πŸ“– handleProbeAll β€” launch AI Latency benchmarks on every model in the
414
- // πŸ“– active set, inside the DAEMON. Progress + results stream back through
415
- // πŸ“– /stats (globalBenchmark + per-model benchmark), which the fast-poll
416
- // πŸ“– effect above picks up every 1.2s. Disabled while already running.
417
- const handleProbeAll = async () => {
418
- if (!activeSetName || probePolling) return
419
- if (localModels.length === 0) {
420
- onToast?.('Add models to the set first, then probe.', 'info')
421
- return
422
- }
423
- try {
424
- const probeModels = localModels.map((m) => ({ providerKey: m.provider, modelId: m.model }))
425
- const resp = await fetch('/api/router/probe-all', {
426
- method: 'POST',
427
- headers: { 'Content-Type': 'application/json' },
428
- body: JSON.stringify({ models: probeModels }),
429
- })
430
- const data = await resp.json().catch(() => null)
431
- if (!resp.ok || !resp.status || resp.status === 409) {
432
- throw new Error(data?.error || 'Probe already running')
433
- }
434
- setProbePolling(true)
435
- const total = data?.total ?? localModels.length
436
- onToast?.(`Probing ${total} model${total === 1 ? '' : 's'} for AI Latency…`, 'info')
437
- await fetchStatus()
438
- } catch (err) {
439
- onToast?.(`Probe failed: ${err.message}`, 'error')
440
- setProbePolling(false)
441
- }
442
- }
443
-
444
- // πŸ“– The "Test Router" mini playground is now rendered by the shared
445
- // πŸ“– <PlaygroundChat> component (model="fcm"), which streams the reply
446
- // πŸ“– through /api/playground/chat and shows the harmonized metadata row
447
- // πŸ“– (served model Β· ms Β· tok Β· t/s) under every reply. The router's
448
- // πŸ“– Primary pick is passed as `targetLabel` so the user sees which model
449
- // πŸ“– the priority-first router will try first under their user bubble.
450
-
451
- // ── Drag and drop state ───────────────────────────────────────────────
452
- // We keep a local copy of `models` so the drag UX is instant β€” the
453
- // server is updated only when the user actually drops the row.
454
- const [localModels, setLocalModels] = useState(models)
455
- useEffect(() => { setLocalModels(models) }, [models])
456
- const [draggingKey, setDraggingKey] = useState(null)
457
- const [dropPosition, setDropPosition] = useState(null) // { key, side: 'above' | 'below' } | null
458
-
459
- const handleMove = useCallback(async (idx, direction) => {
460
- const next = [...localModels]
461
- const newIdx = direction === 'up' ? idx - 1 : idx + 1
462
- if (newIdx < 0 || newIdx >= next.length) return
463
- const [moved] = next.splice(idx, 1)
464
- next.splice(newIdx, 0, moved)
465
- setLocalModels(next)
466
- await persistReorder(next)
467
- }, [localModels, persistReorder])
468
-
469
- const handleRemove = useCallback(async (idx) => {
470
- const target = localModels[idx]
471
- if (!target) return
472
- // Optimistic update: drop the row immediately, send the DELETE after.
473
- const next = localModels.filter((_, i) => i !== idx)
474
- setLocalModels(next)
475
- await persistRemove(target.provider, target.model)
476
- }, [localModels, persistRemove])
477
-
478
- const handleDragStart = (e, idx) => {
479
- const target = localModels[idx]
480
- if (!target) return
481
- setDraggingKey(`${target.provider}/${target.model}`)
482
- // πŸ“– dataTransfer is required for Firefox to actually fire drag events.
483
- e.dataTransfer.effectAllowed = 'move'
484
- e.dataTransfer.setData('text/plain', `${target.provider}/${target.model}`)
485
- }
486
-
487
- const handleDragOver = (e, idx) => {
488
- if (draggingKey == null) return
489
- e.preventDefault()
490
- e.dataTransfer.dropEffect = 'move'
491
- const target = localModels[idx]
492
- if (!target) return
493
- const key = `${target.provider}/${target.model}`
494
- if (key === draggingKey) return
495
- const rect = e.currentTarget.getBoundingClientRect()
496
- const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
497
- setDropPosition({ key, side })
498
- }
499
-
500
- const handleDragLeave = (e) => {
501
- // πŸ“– Don't clear on every leave β€” only when we leave the list entirely.
502
- if (e.currentTarget.contains(e.relatedTarget)) return
503
- }
504
-
505
- const handleDrop = async (e, idx) => {
506
- e.preventDefault()
507
- if (draggingKey == null) return
508
- const dragIdx = localModels.findIndex((m) => `${m.provider}/${m.model}` === draggingKey)
509
- if (dragIdx < 0) {
510
- setDraggingKey(null)
511
- setDropPosition(null)
512
- return
513
- }
514
- const target = localModels[idx]
515
- if (!target) {
516
- setDraggingKey(null)
517
- setDropPosition(null)
518
- return
519
- }
520
- const rect = e.currentTarget.getBoundingClientRect()
521
- const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
522
- let insertAt = side === 'above' ? idx : idx + 1
523
- if (dragIdx < insertAt) insertAt -= 1
524
- if (insertAt === dragIdx) {
525
- setDraggingKey(null)
526
- setDropPosition(null)
527
- return
528
- }
529
- const next = [...localModels]
530
- const [moved] = next.splice(dragIdx, 1)
531
- next.splice(insertAt, 0, moved)
532
- setLocalModels(next)
533
- setDraggingKey(null)
534
- setDropPosition(null)
535
- await persistReorder(next)
536
- }
537
-
538
- const handleDragEnd = () => {
539
- setDraggingKey(null)
540
- setDropPosition(null)
541
- }
542
-
543
- // ── Picker filter ────────────────────────────────────────────────────
544
- const providers = useMemo(() => {
545
- const set = new Set(catalog.map((m) => m.provider))
546
- return Array.from(set).sort()
547
- }, [catalog])
548
-
549
- const filteredCatalog = useMemo(() => {
550
- const q = pickerSearch.trim().toLowerCase()
551
- return catalog.filter((m) => {
552
- if (pickerProvider && m.provider !== pickerProvider) return false
553
- if (!q) return true
554
- return (
555
- m.key.toLowerCase().includes(q)
556
- || (m.label || '').toLowerCase().includes(q)
557
- || m.provider.toLowerCase().includes(q)
558
- )
559
- }).slice(0, 200)
560
- }, [catalog, pickerSearch, pickerProvider])
561
-
562
- const modelKeyInSet = (provider, model) => localModels.some((m) => m.provider === provider && m.model === model)
563
-
564
- const running = status?.ok
565
- const circuitBreakers = stats?.circuitBreakers || {}
566
- const requestLog = stats?.requestLog || []
567
- const activeRequests = status?.activeRequests || []
568
-
569
- // πŸ“– routingOrder β€” the exact attempt order the daemon will use for the next
570
- // πŸ“– request (priority-first among healthy models). routingOrder[0] is the
571
- // πŸ“– model that will serve the next chat. Used to mark the "next" row and
572
- // πŸ“– to label Primary vs Fallback semantics. Falls back to set order when the
573
- // πŸ“– daemon isn't running or hasn't reported yet (so stopped state still shows
574
- // πŸ“– a sensible priority chain). See issue #120.
575
- const routingOrder = stats?.routingOrder || []
576
- const nextToServeKey = routingOrder.length > 0 ? routingOrder[0].key : null
577
-
578
- // πŸ“– Benchmark results keyed by "provider/model" so each set row can show
579
- // πŸ“– its live AI Latency + TPS after a probe. Built from /stats.models.
580
- const benchmarkByKey = new Map()
581
- for (const m of (stats?.models || [])) {
582
- if (m && typeof m.key === 'string') benchmarkByKey.set(m.key, m)
583
- }
584
- const globalBenchmark = stats?.globalBenchmark || null
585
- const probeActive = Boolean(globalBenchmark?.running) || probePolling
586
-
587
- // πŸ“– Auto-expand the request log the first time requests appear.
588
- useEffect(() => {
589
- if (requestLog.length > 0 && !hasAutoExpandedLog.current) {
590
- hasAutoExpandedLog.current = true
591
- setLogExpanded(true)
592
- }
593
- }, [requestLog.length])
594
-
595
- // πŸ“– Computed quick-setup display values: actual when running, defaults when stopped.
596
- const qsBaseUrl = running && quickSetup?.baseUrl ? quickSetup.baseUrl : 'http://localhost:19280/v1'
597
- const qsModel = quickSetup?.model || 'fcm'
598
- const qsApiKey = 'fcm-local'
599
- const qsAllText = `Base URL: ${qsBaseUrl}\nModel: ${qsModel}\nAPI Key: ${qsApiKey}`
600
-
601
- const sets = setsData?.sets || {}
602
- const setNames = Object.keys(sets).sort()
603
-
604
- return (
605
- <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
606
- <div className={styles.modal}>
607
- <div className={styles.header}>
608
- <h2 className={styles.title}>
609
- <IconRoute size={20} stroke={1.5} />
610
- Router Dashboard
611
- </h2>
612
- <button className={styles.closeBtn} onClick={onClose} aria-label="Close">βœ•</button>
613
- </div>
614
-
615
- <div className={styles.body}>
616
- {/* Auto-heal banner β€” shown when the daemon detected broken
617
- models in the active set on startup. The banner disappears
618
- once the user clicks "Sync best" or "Fix now" (which heals
619
- the set and reloads the page state). */}
620
- {running && status?.brokenModelCount > 0 && !autoHealDismissed && (
621
- <div className={styles.autoHealBanner}>
622
- <div className={styles.autoHealLeft}>
623
- <span className={styles.autoHealIcon}>⚠</span>
624
- <div>
625
- <div className={styles.autoHealTitle}>
626
- {status.brokenModelCount} model{status.brokenModelCount === 1 ? '' : 's'} in the active set are not responding
627
- </div>
628
- <div className={styles.autoHealHint}>
629
- Auto-heal ran on startup but the replacement may also be broken.
630
- Click <strong>Sync best</strong> below to re-probe with your current keys,
631
- or click <strong>Fix now</strong> to manually replace the broken entries.
632
- </div>
633
- </div>
634
- </div>
635
- <div className={styles.autoHealActions}>
636
- <button className={styles.smallBtn} onClick={handleSyncBest}>
637
- <IconWand size={11} />
638
- Fix now
639
- </button>
640
- <button className={styles.iconBtn} onClick={() => setAutoHealDismissed(true)} aria-label="Dismiss">
641
- <IconX size={12} />
642
- </button>
643
- </div>
644
- </div>
645
- )}
646
-
647
- {/* Quick Setup β€” ALWAYS visible, front and center */}
648
- <div className={`${styles.quickSetup} ${running ? styles.quickSetupHero : ''}`}>
649
- <h3 className={styles.sectionTitle}>
650
- <IconCopy size={14} />
651
- Quick Setup
652
- <button className={styles.copyAllBtn} onClick={() => handleCopy(qsAllText, 'all')}>
653
- {copied === 'all' ? <IconCheck size={12} /> : <IconCopy size={12} />}
654
- {copied === 'all' ? 'Copied!' : 'Copy all'}
655
- </button>
656
- </h3>
657
- <div className={styles.quickRows}>
658
- <div className={styles.quickRow}>
659
- <span className={styles.quickLabel}>Base URL</span>
660
- <code className={styles.quickValue}>{qsBaseUrl}</code>
661
- <button className={styles.copyBtn} onClick={() => handleCopy(qsBaseUrl, 'url')}>
662
- {copied === 'url' ? <IconCheck size={12} /> : <IconCopy size={12} />}
663
- </button>
664
- </div>
665
- <div className={styles.quickRow}>
666
- <span className={styles.quickLabel}>Model</span>
667
- <code className={styles.quickValue}>{qsModel}</code>
668
- <button className={styles.copyBtn} onClick={() => handleCopy(qsModel, 'model')}>
669
- {copied === 'model' ? <IconCheck size={12} /> : <IconCopy size={12} />}
670
- </button>
671
- </div>
672
- <div className={styles.quickRow}>
673
- <span className={styles.quickLabel}>API Key</span>
674
- <code className={styles.quickValue}>{qsApiKey}</code>
675
- <button className={styles.copyBtn} onClick={() => handleCopy(qsApiKey, 'key')}>
676
- {copied === 'key' ? <IconCheck size={12} /> : <IconCopy size={12} />}
677
- </button>
678
- </div>
679
- </div>
680
- </div>
681
-
682
- {/* Hero Card */}
683
- <div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
684
- <div className={styles.heroLeft}>
685
- <div className={styles.heroStatus}>
686
- <span className={`${styles.statusDot} ${running ? styles.dotGreen : styles.dotGray}`} />
687
- <span className={styles.heroLabel}>{running ? 'Running' : 'Stopped'}</span>
688
- </div>
689
- {running && (
690
- <div className={styles.heroMeta}>
691
- <span>Port {status.port}</span>
692
- <span>Β·</span>
693
- <span>Uptime {formatUptime(status.uptimeSeconds)}</span>
694
- <span>Β·</span>
695
- <span>{status.requestsRouted} requests</span>
696
- </div>
697
- )}
698
- {!running && (
699
- <div className={styles.heroMeta}>
700
- Smart failover router β€” start to route requests to the healthiest model.
701
- </div>
702
- )}
703
- </div>
704
- <div className={styles.heroActions}>
705
- {!running ? (
706
- <button className={`${styles.startBtn} ${styles.startBtnBig}`} onClick={handleStart} disabled={actionLoading}>
707
- <IconPlayerPlay size={16} />
708
- {actionLoading ? 'Starting…' : 'Start Router'}
709
- </button>
710
- ) : (
711
- <button className={styles.stopBtn} onClick={handleStop} disabled={actionLoading}>
712
- <IconPlayerStop size={14} />
713
- {actionLoading ? 'Stopping…' : 'Stop'}
714
- </button>
715
- )}
716
- <button className={styles.refreshBtn} onClick={fetchStatus} title="Refresh">
717
- <IconRefresh size={14} />
718
- </button>
719
- </div>
720
- </div>
721
-
722
- {/* Active Set Manager */}
723
- {running && (
724
- <div className={styles.section}>
725
- <h3 className={styles.sectionTitle}>
726
- <IconList size={14} />
727
- Active Set ({localModels.length} models)
728
- </h3>
729
-
730
- <div className={styles.setMeta}>
731
- <div className={styles.setActions}>
732
- <span className={styles.setMetaName}>{activeSetName}</span>
733
- {setNames.length > 1 && (
734
- <select
735
- className={styles.pickerSelect}
736
- value={activeSetName}
737
- onChange={(e) => setActiveSet(e.target.value)}
738
- title="Switch the active set"
739
- >
740
- {setNames.map((n) => (
741
- <option key={n} value={n}>{n}</option>
742
- ))}
743
- </select>
744
- )}
745
- <button
746
- className={`${styles.smallBtn} ${(activeSet?.familyFailover !== false) ? styles.probeBtnActive : ''}`}
747
- onClick={handleToggleFamilyFailover}
748
- disabled={saveStatus.kind === 'saving'}
749
- title="When a model fails, first retry the same family on another provider (e.g. DeepSeek on NIM -> DeepSeek on Together) before falling back to set order"
750
- >
751
- <IconRoute size={11} />
752
- Family failover: {activeSet?.familyFailover !== false ? 'on' : 'off'}
753
- </button>
754
- </div>
755
- <div className={styles.setActions}>
756
- <SaveBadge status={saveStatus} />
757
- <button
758
- className={styles.smallBtn}
759
- onClick={handleSyncBest}
760
- disabled={saveStatus.kind === 'saving'}
761
- title="Probe your API keys and rebuild the set with only models that actually work"
762
- >
763
- <IconWand size={11} />
764
- Sync best
765
- </button>
766
- {favorites && (
767
- <button
768
- className={styles.smallBtn}
769
- onClick={handleUseFavorites}
770
- disabled={saveStatus.kind === 'saving'}
771
- title="Replace current router models with your favorite models"
772
- >
773
- <IconList size={11} />
774
- Use favorites
775
- </button>
776
- )}
777
- {/* πŸ“– Probe all β€” run AI Latency benchmarks on every model in the
778
- set. Results stream into the rows below (AI Lat column). */}
779
- <button
780
- className={`${styles.smallBtn} ${probeActive ? styles.probeBtnActive : ''}`}
781
- onClick={handleProbeAll}
782
- disabled={probeActive || saveStatus.kind === 'saving' || localModels.length === 0}
783
- title="Benchmark AI Latency + TPS on every model in this set"
784
- >
785
- <IconBolt size={11} />
786
- {probeActive ? 'Probing…' : 'Probe all'}
787
- </button>
788
- <button
789
- className={styles.primaryBtn}
790
- onClick={() => setPickerOpen((v) => !v)}
791
- disabled={saveStatus.kind === 'saving'}
792
- >
793
- {pickerOpen ? <IconX size={11} /> : <IconPlus size={11} />}
794
- {pickerOpen ? 'Close' : 'Add model'}
795
- </button>
796
- </div>
797
- </div>
798
-
799
- {/* πŸ“– Probe progress bar β€” shown while AI Latency benchmarks run
800
- across the set. Reads /stats.globalBenchmark (fast-polled). */}
801
- {probeActive && globalBenchmark && (
802
- <div className={styles.probeProgress}>
803
- <div className={styles.probeProgressLabel}>
804
- <IconBolt size={11} />
805
- AI Latency probe
806
- <span className={styles.probeProgressCount}>
807
- {globalBenchmark.completed} / {globalBenchmark.total || localModels.length}
808
- </span>
809
- </div>
810
- <div className={styles.probeProgressBar}>
811
- <div
812
- className={styles.probeProgressFill}
813
- style={{ width: `${Math.min(100, ((globalBenchmark.completed || 0) / Math.max(1, globalBenchmark.total || localModels.length)) * 100)}%` }}
814
- />
815
- </div>
816
- </div>
817
- )}
818
-
819
- {localModels.length === 0 ? (
820
- <div className={styles.setEmpty}>
821
- <div className={styles.setEmptyTitle}>No models in the active set</div>
822
- <div className={styles.setEmptyHint}>
823
- Add models from the picker below, or click <strong>Sync best</strong> above to auto-pick working models.
824
- </div>
825
- </div>
826
- ) : (
827
- <>
828
- {/* πŸ“– Priority legend β€” explains the fallback chain so users
829
- understand WHY the top model serves every request (issue #120).
830
- Higher priority = tried first; the rest are failover targets. */}
831
- <div className={styles.priorityLegend}>
832
- <span className={styles.legendPrimary}><IconArrowRight size={11} /> Primary</span>
833
- <span className={styles.legendSeparator}>tries first</span>
834
- <span className={styles.legendFallback}>Fallback</span>
835
- <span className={styles.legendSeparator}>on failure / rate-limit</span>
836
- {running && nextToServeKey && (
837
- <span className={styles.legendNext}>Next up: <code>{nextToServeKey}</code></span>
838
- )}
839
- </div>
840
- <div className={styles.setList} onDragLeave={handleDragLeave}>
841
- {localModels.map((m, idx) => {
842
- const key = `${m.provider}/${m.model}`
843
- const cb = circuitBreakers[key] || {}
844
- // πŸ“– Per-model AI Latency from the latest probe. `bm` is the
845
- // πŸ“– raw benchmark result; `bmLoading` is true while it runs.
846
- const bmEntry = benchmarkByKey.get(key)
847
- const bm = bmEntry?.benchmark || null
848
- const bmLoading = Boolean(bmEntry?.isBenchmarking)
849
- const isDragging = draggingKey === key
850
- const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
851
- const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
852
- // πŸ“– Priority semantics for the UI: #1 is the Primary model the
853
- // πŸ“– router tries first; everyone else is a Fallback. `isNext` is
854
- // πŸ“– true for the exact model the daemon will serve next request β€”
855
- // πŸ“– derived from /stats.routingOrder (priority-first, see #120).
856
- const isPrimary = idx === 0
857
- const isNext = running && nextToServeKey === key
858
- return (
859
- <div
860
- key={key}
861
- className={`${styles.setRow} ${isDragging ? styles.setRowDragging : ''} ${isNext ? styles.setRowNext : ''} ${dropAbove ? `${styles.setRowDropTarget} ${styles.setRowDropTargetAbove}` : ''} ${dropBelow ? `${styles.setRowDropTarget} ${styles.setRowDropTargetBelow}` : ''}`}
862
- draggable
863
- onDragStart={(e) => handleDragStart(e, idx)}
864
- onDragOver={(e) => handleDragOver(e, idx)}
865
- onDrop={(e) => handleDrop(e, idx)}
866
- onDragEnd={handleDragEnd}
867
- title={key}
868
- >
869
- <span className={styles.setDragHandle} aria-hidden>
870
- <IconGripVertical size={14} />
871
- </span>
872
- <span
873
- className={`${styles.setPriority} ${isPrimary ? styles.setPriorityPrimary : styles.setPriorityFallback}`}
874
- title={isPrimary ? 'Primary model β€” tried first on every request' : `Fallback #${idx + 1} β€” used when higher-priority models fail or rate-limit`}
875
- >
876
- {isPrimary ? 'Primary' : `#${idx + 1}`}
877
- </span>
878
- <span className={styles.setKey}>{key}</span>
879
- {m.tier && <span className={styles.setTier}>{m.tier}</span>}
880
- <CircuitBadge state={cb.state || m.state} />
881
- {/* πŸ“– AI Latency β€” populated by "Probe all". Shows a spinner
882
- while benchmarking, the latency+TPS once done, or a dim
883
- placeholder before the first probe. */}
884
- <span className={styles.aiLatencyCell} title={bm ? `AI Latency: ${Math.round(bm.totalMs)}ms Β· TPS: ${(bm.tokensPerSecond ?? 0).toFixed(1)}` : 'Run Probe all to measure AI Latency'}>
885
- {bmLoading
886
- ? <span className={styles.aiLatencySpin}>Β·Β·Β·</span>
887
- : bm?.ok
888
- ? <><span className={styles.aiLatencyMs}>{Math.round(bm.totalMs)}ms</span>{bm.tokensPerSecond != null && bm.tokensPerSecond > 0 && <span className={styles.aiLatencyTps}>{bm.tokensPerSecond.toFixed(1)} t/s</span>}</>
889
- : bm && !bm.ok
890
- ? <span className={styles.aiLatencyErr}>fail</span>
891
- : <span className={styles.aiLatencyNone}>β€”</span>}
892
- </span>
893
- <div className={styles.setRowBtns}>
894
- <button
895
- className={styles.iconBtn}
896
- onClick={() => handleMove(idx, 'up')}
897
- disabled={idx === 0 || saveStatus.kind === 'saving'}
898
- title="Move up"
899
- aria-label={`Move ${key} up`}
900
- >
901
- <IconArrowUp size={12} />
902
- </button>
903
- <button
904
- className={styles.iconBtn}
905
- onClick={() => handleMove(idx, 'down')}
906
- disabled={idx === localModels.length - 1 || saveStatus.kind === 'saving'}
907
- title="Move down"
908
- aria-label={`Move ${key} down`}
909
- >
910
- <IconArrowDown size={12} />
911
- </button>
912
- <button
913
- className={`${styles.iconBtn} ${styles.removeBtn}`}
914
- onClick={() => handleRemove(idx)}
915
- disabled={saveStatus.kind === 'saving'}
916
- title="Remove from set"
917
- aria-label={`Remove ${key}`}
918
- >
919
- <IconTrash size={12} />
920
- </button>
921
- </div>
922
- </div>
923
- )
924
- })}
925
- </div>
926
- </>
927
- )}
928
-
929
- {pickerOpen && (
930
- <div className={styles.pickerPanel}>
931
- <div className={styles.pickerHeader}>
932
- <span>Add a model to <code>{activeSetName}</code></span>
933
- <span style={{ color: 'var(--text-muted, #888)' }}>
934
- {filteredCatalog.length} of {catalog.length}
935
- </span>
936
- </div>
937
- <div className={styles.pickerSearch}>
938
- <input
939
- className={styles.pickerInput}
940
- placeholder="Search by provider, model, or label…"
941
- value={pickerSearch}
942
- onChange={(e) => setPickerSearch(e.target.value)}
943
- autoFocus
944
- />
945
- <select
946
- className={styles.pickerSelect}
947
- value={pickerProvider}
948
- onChange={(e) => setPickerProvider(e.target.value)}
949
- >
950
- <option value="">All providers</option>
951
- {providers.map((p) => (
952
- <option key={p} value={p}>{p}</option>
953
- ))}
954
- </select>
955
- </div>
956
- <div className={styles.pickerList}>
957
- {filteredCatalog.length === 0 ? (
958
- <div className={styles.pickerEmpty}>No models match your filter.</div>
959
- ) : (
960
- filteredCatalog.map((entry) => {
961
- const inSet = modelKeyInSet(entry.provider, entry.model)
962
- return (
963
- <div
964
- key={entry.key}
965
- className={`${styles.pickerItem} ${inSet ? styles.pickerItemAdded : ''}`}
966
- onClick={() => { if (!inSet) void persistAdd(entry.provider, entry.model) }}
967
- title={inSet ? 'Already in set' : `Add ${entry.key}`}
968
- >
969
- <span className={styles.pickerProvider}>{entry.provider}</span>
970
- <span className={styles.pickerModel}>{entry.label || entry.model}</span>
971
- {entry.tier && <span className={styles.setTier}>{entry.tier}</span>}
972
- {entry.hasKey
973
- ? <span className={`${styles.pickerBadge} ${styles.pickerBadgeOk}`}>key</span>
974
- : <span className={styles.pickerBadge}>no key</span>}
975
- </div>
976
- )
977
- })
978
- )}
979
- </div>
980
- </div>
981
- )}
982
- </div>
983
- )}
984
-
985
- {/* Probe Mode */}
986
- {running && (
987
- <div className={styles.section}>
988
- <h3 className={styles.sectionTitle}>
989
- <IconActivity size={14} />
990
- Probe Mode
991
- </h3>
992
- <div className={styles.probeModes}>
993
- {['eco', 'balanced', 'aggressive'].map((mode) => (
994
- <button
995
- key={mode}
996
- className={`${styles.probeBtn} ${status?.probeMode === mode ? styles.probeActive : ''}`}
997
- onClick={() => handleSetProbeMode(mode)}
998
- >
999
- {mode}
1000
- </button>
1001
- ))}
1002
- </div>
1003
- </div>
1004
- )}
1005
-
1006
- {/* Active Requests */}
1007
- {running && (
1008
- <div className={styles.section}>
1009
- <h3 className={styles.sectionTitle}>
1010
- <IconBolt size={14} className={activeRequests.length > 0 ? styles.pulse : ''} />
1011
- Active Requests ({activeRequests.length})
1012
- </h3>
1013
- {activeRequests.length > 0 ? (
1014
- <div className={styles.activeList}>
1015
- {activeRequests.map((req) => (
1016
- <div key={req.request_id} className={styles.activeRow}>
1017
- <div className={styles.activeMain}>
1018
- <span className={styles.activeModel}>
1019
- <span className={styles.activeId} title={`Request ID: ${req.request_id}`}>
1020
- {req.request_id.slice(-4)}
1021
- </span>
1022
- {req.model}
1023
- </span>
1024
- <span className={styles.activeStatus}>
1025
- {req.stalled ? (
1026
- <span className={styles.stalledText}>Stalled?</span>
1027
- ) : (
1028
- <span className={styles.processingText}>Processing...</span>
1029
- )}
1030
- </span>
1031
- </div>
1032
- <div className={styles.activeDetails}>
1033
- <span className={styles.activeCurrentModel}>{req.current_model || 'Routing...'}</span>
1034
- {req.stream && <span className={styles.activeTokens}>{req.tokens} tokens</span>}
1035
- <span className={styles.activeUptime}>{formatUptime(Math.floor((Date.now() - req.started_at) / 1000))}</span>
1036
- </div>
1037
- </div>
1038
- ))}
1039
- </div>
1040
- ) : (
1041
- <div className={styles.logEmpty}>No active requests.</div>
1042
- )}
1043
- </div>
1044
- )}
1045
-
1046
- {/* Request Log β€” always visible when running */}
1047
- {running && (
1048
- <div className={styles.section}>
1049
- <h3 className={styles.sectionTitle} onClick={() => setLogExpanded(!logExpanded)} style={{ cursor: 'pointer' }}>
1050
- <IconActivity size={14} />
1051
- Request Log ({requestLog.length})
1052
- {logExpanded ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
1053
- </h3>
1054
- {logExpanded && (
1055
- requestLog.length === 0 ? (
1056
- <div className={styles.logEmpty}>No requests yet. Start coding to see traffic here.</div>
1057
- ) : (
1058
- <div className={styles.logList}>
1059
- {requestLog.map((entry, i) => (
1060
- <div key={i} className={styles.logRow}>
1061
- <span className={styles.logId} title={`Request ID: ${entry.request_id || 'β€”'}`}>
1062
- {entry.request_id ? entry.request_id.slice(-4) : 'β€”'}
1063
- </span>
1064
- <span className={styles.logTime}>
1065
- {formatTime(entry.at)}
1066
- </span>
1067
- <span className={entry.error ? styles.logErr : styles.logOk}>
1068
- {entry.status || 'β€”'}
1069
- </span>
1070
- <span className={styles.logModel}>{entry.model}</span>
1071
- <span className={styles.logLatency}>
1072
- {entry.latency_ms != null ? `${entry.latency_ms}ms` : 'β€”'}
1073
- </span>
1074
- <span className={styles.logTokens}>
1075
- {entry.tokens > 0 ? formatNumber(entry.tokens) + ' tok' : ''}
1076
- </span>
1077
- <span className={styles.logDetail}>
1078
- {[
1079
- // πŸ“– t8 - a family hop stays in the same model family
1080
- // (e.g. DeepSeek on another host) and is tagged as such.
1081
- entry.failover ? (entry.failover_reason === 'family_failover' ? 'family' : 'failover') : '',
1082
- entry.stream ? 'stream' : '',
1083
- entry.error || '',
1084
- ].filter(Boolean).join(', ')}
1085
- </span>
1086
- </div>
1087
- ))}
1088
- </div>
1089
- )
1090
- )}
1091
- </div>
1092
- )}
1093
-
1094
- {/* πŸ“– Test Router β€” the shared PlaygroundChat core, routed through
1095
- `fcm` so the user can sanity-check the priority-first router right
1096
- from this view. The router's Primary pick is passed as targetLabel
1097
- so it appears under the user bubble; the served model (+ ms / tok /
1098
- t/s) appears under the reply and reveals any failover. Only shown
1099
- when running (needs the daemon). */}
1100
- {running && (
1101
- <div className={styles.section}>
1102
- <h3 className={styles.sectionTitle}>
1103
- <IconSend size={14} />
1104
- Test Router
1105
- <span className={styles.miniPgHint}>routes through <code>fcm</code> β€” try the fallback chain</span>
1106
- </h3>
1107
- <PlaygroundChat
1108
- model="fcm"
1109
- variant="mini"
1110
- disabled={!running}
1111
- targetLabel={nextToServeKey || 'fcm'}
1112
- placeholder="Test the router… (e.g. write a haiku about TypeScript)"
1113
- emptyHint="Send a message to see which model the router picks, with latency + TPS."
1114
- />
1115
- </div>
1116
- )}
1117
-
1118
- {/* Runtime telemetry (t3): per-model real-world success rate + throughput.
1119
- πŸ“– Read from /stats.runtime (same poll, 5s refresh). Models with
1120
- πŸ“– insufficient data (< 5 calls) are hidden β€” no penalty for new
1121
- πŸ“– models. */}
1122
- {stats?.runtimeTelemetry?.stats?.modelsTracked > 0 && (
1123
- <div className={styles.section} style={{ marginTop: 12 }}>
1124
- <h3 className={styles.sectionTitle}>
1125
- πŸ“ˆ Runtime Telemetry
1126
- <span className={styles.miniPgHint}>{stats.runtimeTelemetry.stats.totalCalls} calls tracked Β· local-only</span>
1127
- </h3>
1128
- <div className={styles.quotaGrid}>
1129
- {Object.entries(stats.runtimeTelemetry.models)
1130
- .filter(([, m]) => m.totalCalls >= 5)
1131
- .sort((a, b) => (b[1]?.successRate ?? 0) - (a[1]?.successRate ?? 0))
1132
- .slice(0, 6)
1133
- .map(([key, m]) => {
1134
- const srPct = Math.round((m.successRate ?? 0) * 100)
1135
- const tps = m.avgTokensPerSecond || 0
1136
- const srColor = srPct <= 50 ? '#dc2626' : srPct <= 80 ? '#f59e0b' : '#16a34a'
1137
- return (
1138
- <div key={key} className={styles.quotaCell} title={`${key}: ${m.totalCalls} calls, ${m.successCalls} ok, ${m.errorCalls} err, ${tps.toFixed(1)} tok/s avg`}>
1139
- <span className={styles.quotaSource}>πŸ“ˆ</span>
1140
- <span className={styles.quotaName}>{key.split('/')[1] || key}</span>
1141
- <div className={styles.quotaBar}>
1142
- <div className={styles.quotaFill} style={{ width: `${srPct}%`, background: srColor }} />
1143
- </div>
1144
- <span className={styles.quotaPct} style={{ color: srColor }}>{srPct}%</span>
1145
- </div>
1146
- )
1147
- })}
1148
- </div>
1149
- </div>
1150
- )}
1151
-
1152
- {/* Passive quota (t2): per-provider live quota from response headers.
1153
- πŸ“– Updated every /stats poll (5s). See provider-quota-fetchers.js. */}
1154
- {stats?.quota && Object.keys(stats.quota).length > 0 && (
1155
- <div className={styles.section} style={{ marginTop: 12 }}>
1156
- <h3 className={styles.sectionTitle}>
1157
- πŸ“Š Provider Quota
1158
- <span className={styles.miniPgHint}>live from response headers (no extra requests)</span>
1159
- </h3>
1160
- <div className={styles.quotaGrid}>
1161
- {Object.entries(stats.quota)
1162
- .sort((a, b) => (a[1]?.percent ?? 100) - (b[1]?.percent ?? 100))
1163
- .map(([providerKey, snap]) => {
1164
- const pct = snap?.percent ?? 0
1165
- const color = pct <= 10 ? '#dc2626' : pct <= 25 ? '#f59e0b' : '#16a34a'
1166
- const window = snap?.windowType && snap.windowType !== 'unknown' ? snap.windowType : ''
1167
- const source = snap?.source === 'header' ? 'πŸ›°οΈ' : 'πŸ”Œ'
1168
- return (
1169
- <div key={providerKey} className={styles.quotaCell} title={`${providerKey}: ${snap?.remaining ?? '?'}/${snap?.limit ?? '?'} (${pct}%) ${window ? `[${window}]` : ''}`}>
1170
- <span className={styles.quotaSource}>{source}</span>
1171
- <span className={styles.quotaName}>{providerKey}</span>
1172
- <div className={styles.quotaBar}>
1173
- <div
1174
- className={styles.quotaFill}
1175
- style={{ width: `${pct}%`, background: color }}
1176
- />
1177
- </div>
1178
- <span className={styles.quotaPct} style={{ color }}>{pct}%</span>
1179
- </div>
1180
- )
1181
- })}
1182
- </div>
1183
- </div>
1184
- )}
1185
-
1186
- {/* Server health (small chip at the bottom for visibility) */}
1187
- <div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
1188
- <span className={styles.saveStatus}>
1189
- {stats?.tokenStats ? `${formatNumber(stats.tokenStats.all_time?.total_tokens || 0)} tokens routed lifetime` : ''}
1190
- </span>
1191
- </div>
1192
- </div>
1193
- </div>
1194
- </div>
1195
- )
1196
- }
1197
-
1198
- function SaveBadge({ status }) {
1199
- if (!status || status.kind === 'idle') return null
1200
- if (status.kind === 'saving') {
1201
- return <span className={styles.saveStatus}>saving…</span>
1202
- }
1203
- if (status.kind === 'saved') {
1204
- return <span className={`${styles.saveStatus} ${styles.saveStatusOk}`}>βœ“ saved</span>
1205
- }
1206
- if (status.kind === 'error') {
1207
- return <span className={`${styles.saveStatus} ${styles.saveStatusErr}`} title={status.message}>
1208
- ⚠ {status.message?.slice(0, 40) || 'error'}
1209
- </span>
1210
- }
1211
- return null
1212
- }