free-coding-models 0.5.86 β 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.
- package/README.md +110 -881
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.87.md +48 -0
- package/package.json +8 -5
- package/sources.js +4 -4
- package/src/core/cache.js +3 -1
- package/src/core/config.js +43 -5
- package/src/core/endpoint-installer.js +23 -12
- package/src/core/installed-models-manager.js +43 -20
- package/src/core/kilo-config.js +51 -5
- package/src/core/kilo.js +5 -3
- package/src/core/legacy-proxy-cleanup.js +22 -5
- package/src/core/model-merger.js +4 -0
- package/src/core/models-drift.js +9 -0
- package/src/core/opencode-config.js +57 -5
- package/src/core/opencode.js +67 -41
- package/src/core/playground.js +7 -0
- package/src/core/probe-cache.js +35 -4
- package/src/core/provider-key-tester.js +10 -2
- package/src/core/provider-metadata.js +10 -0
- package/src/core/provider-quota-fetchers.js +14 -7
- package/src/core/router-daemon.js +208 -26
- package/src/core/runtime-telemetry.js +27 -1
- package/src/core/shared-helpers.js +12 -1
- package/src/core/shell-env.js +3 -1
- package/src/core/telemetry.js +18 -5
- package/src/core/tool-launchers.js +9 -6
- package/src/core/updater.js +28 -2
- package/src/core/utils.js +76 -71
- package/src/tui/app.js +38 -14
- package/src/tui/command-palette.js +22 -2
- package/src/tui/key-handler.js +123 -179
- package/src/tui/overlays.js +21 -71
- package/src/tui/render-helpers.js +62 -4
- package/src/tui/render-table.js +34 -3
- package/src/tui/theme.js +36 -5
- package/src/tui/tui-filters.js +28 -8
- package/src/tui/tui-state.js +22 -6
- package/web/dist/assets/index-BpTpPS0g.js +44 -0
- package/web/dist/assets/index-CAzFIt8P.css +1 -0
- package/web/dist/index.html +5 -9
- package/web/index.html +3 -7
- package/web/server.js +97 -23
- package/web/vite.config.js +8 -0
- package/web/app.legacy.js +0 -900
- package/web/dist/assets/index-C5-Ywvv3.css +0 -1
- package/web/dist/assets/index-Dm-Xfby9.js +0 -40
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +0 -12
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +0 -25
- package/web/src/App.jsx +0 -521
- package/web/src/components/analytics/AnalyticsView.jsx +0 -147
- package/web/src/components/analytics/AnalyticsView.module.css +0 -190
- package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
- package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
- package/web/src/components/atoms/AILatencyCell.jsx +0 -38
- package/web/src/components/atoms/AILatencyCell.module.css +0 -51
- package/web/src/components/atoms/HealthCell.jsx +0 -143
- package/web/src/components/atoms/HealthCell.module.css +0 -60
- package/web/src/components/atoms/LastPingCell.jsx +0 -35
- package/web/src/components/atoms/LastPingCell.module.css +0 -45
- package/web/src/components/atoms/MoodCell.jsx +0 -25
- package/web/src/components/atoms/MoodCell.module.css +0 -6
- package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
- package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
- package/web/src/components/atoms/ProviderLogo.jsx +0 -187
- package/web/src/components/atoms/ProviderLogo.module.css +0 -103
- package/web/src/components/atoms/RankCell.jsx +0 -9
- package/web/src/components/atoms/RankCell.module.css +0 -9
- package/web/src/components/atoms/Sparkline.jsx +0 -63
- package/web/src/components/atoms/StabilityCell.jsx +0 -18
- package/web/src/components/atoms/StabilityCell.module.css +0 -13
- package/web/src/components/atoms/StatusDot.jsx +0 -13
- package/web/src/components/atoms/StatusDot.module.css +0 -29
- package/web/src/components/atoms/TPSCell.jsx +0 -36
- package/web/src/components/atoms/TPSCell.module.css +0 -44
- package/web/src/components/atoms/TierBadge.jsx +0 -10
- package/web/src/components/atoms/TierBadge.module.css +0 -29
- package/web/src/components/atoms/Toast.jsx +0 -25
- package/web/src/components/atoms/Toast.module.css +0 -41
- package/web/src/components/atoms/ToastContainer.jsx +0 -16
- package/web/src/components/atoms/ToastContainer.module.css +0 -10
- package/web/src/components/atoms/VerdictBadge.jsx +0 -36
- package/web/src/components/atoms/VerdictBadge.module.css +0 -40
- package/web/src/components/changelog/ChangelogView.jsx +0 -135
- package/web/src/components/changelog/ChangelogView.module.css +0 -159
- package/web/src/components/dashboard/DetailPanel.jsx +0 -217
- package/web/src/components/dashboard/DetailPanel.module.css +0 -260
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
- package/web/src/components/dashboard/ExportModal.jsx +0 -84
- package/web/src/components/dashboard/ExportModal.module.css +0 -99
- package/web/src/components/dashboard/FilterBar.jsx +0 -338
- package/web/src/components/dashboard/FilterBar.module.css +0 -342
- package/web/src/components/dashboard/ModelTable.jsx +0 -672
- package/web/src/components/dashboard/ModelTable.module.css +0 -446
- package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
- package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
- package/web/src/components/help/HelpView.jsx +0 -201
- package/web/src/components/help/HelpView.module.css +0 -156
- package/web/src/components/install/InstallEndpointsView.jsx +0 -309
- package/web/src/components/install/InstallEndpointsView.module.css +0 -427
- package/web/src/components/installed/InstalledModelsView.jsx +0 -89
- package/web/src/components/installed/InstalledModelsView.module.css +0 -200
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
- package/web/src/components/launch/LaunchButton.jsx +0 -30
- package/web/src/components/launch/LaunchButton.module.css +0 -35
- package/web/src/components/launch/LaunchModal.module.css +0 -125
- package/web/src/components/layout/Footer.jsx +0 -19
- package/web/src/components/layout/Footer.module.css +0 -10
- package/web/src/components/layout/Header.jsx +0 -265
- package/web/src/components/layout/Header.module.css +0 -357
- package/web/src/components/palette/CommandPalette.jsx +0 -301
- package/web/src/components/palette/CommandPalette.module.css +0 -121
- package/web/src/components/playground/PlaygroundChat.jsx +0 -527
- package/web/src/components/playground/PlaygroundChat.module.css +0 -382
- package/web/src/components/playground/PlaygroundView.jsx +0 -371
- package/web/src/components/playground/PlaygroundView.module.css +0 -504
- package/web/src/components/recommend/RecommendView.jsx +0 -121
- package/web/src/components/recommend/RecommendView.module.css +0 -57
- package/web/src/components/router/RouterView.jsx +0 -1212
- package/web/src/components/router/RouterView.module.css +0 -1289
- package/web/src/components/settings/SettingsView.jsx +0 -555
- package/web/src/components/settings/SettingsView.module.css +0 -550
- package/web/src/components/tools/ToolPicker.jsx +0 -86
- package/web/src/components/tools/ToolPicker.module.css +0 -97
- package/web/src/components/update/UpdateChip.jsx +0 -104
- package/web/src/components/update/UpdateChip.module.css +0 -146
- package/web/src/global.css +0 -298
- package/web/src/hooks/urlState.constants.js +0 -28
- package/web/src/hooks/useChangelog.js +0 -51
- package/web/src/hooks/useColumnSizing.js +0 -211
- package/web/src/hooks/useFavorites.js +0 -150
- package/web/src/hooks/useFilter.js +0 -310
- package/web/src/hooks/useInstalledModels.js +0 -42
- package/web/src/hooks/useRecommend.js +0 -67
- package/web/src/hooks/useRouterDashboard.js +0 -133
- package/web/src/hooks/useSSE.js +0 -73
- package/web/src/hooks/useSocket.js +0 -203
- package/web/src/hooks/useTheme.js +0 -83
- package/web/src/hooks/useTokenUsage.js +0 -103
- package/web/src/hooks/useToolMode.js +0 -77
- package/web/src/hooks/useUpdateChecker.js +0 -91
- package/web/src/hooks/useUrlState.js +0 -170
- package/web/src/main.jsx +0 -15
- package/web/src/utils/download.js +0 -15
- package/web/src/utils/format.js +0 -42
- package/web/src/utils/m3.js +0 -57
- package/web/src/utils/ranks.js +0 -37
- 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
|
-
}
|