free-coding-models 0.5.28 β 0.5.30
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 +1 -1
- package/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.29.md +19 -0
- package/changelog/v0.5.30.md +15 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +1 -14
- package/src/core/endpoint-installer.js +5 -9
- package/src/core/legacy-proxy-cleanup.js +2 -0
- package/src/core/model-merger.js +4 -13
- package/src/core/playground.js +6 -4
- package/src/core/router-daemon.js +101 -43
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +11 -24
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +2 -2
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +77 -20
- package/src/tui/render-table.js +3 -3
- package/web/dist/assets/index-CsFt3qt5.css +1 -0
- package/web/dist/assets/index-I6N91rH7.js +40 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/index.html +2 -2
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- 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/server.js +34 -4
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
- package/web/src/components/playground/PlaygroundChat.jsx +527 -0
- package/web/src/components/playground/PlaygroundChat.module.css +382 -0
- package/web/src/components/playground/PlaygroundView.jsx +104 -442
- package/web/src/components/playground/PlaygroundView.module.css +10 -0
- package/web/src/components/router/RouterView.jsx +268 -56
- package/web/src/components/router/RouterView.module.css +416 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-BRFowJv5.css +0 -1
- package/web/dist/assets/index-Pr3waI0-.js +0 -41
|
@@ -166,6 +166,16 @@
|
|
|
166
166
|
min-height: 320px;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/* π Flex container that fills the modal body and hosts either the router
|
|
170
|
+
daemon start/starting panels or the shared <PlaygroundChat> (which itself is
|
|
171
|
+
a column flex that scrolls internally and pins the input at the bottom). */
|
|
172
|
+
.chatBody {
|
|
173
|
+
flex: 1;
|
|
174
|
+
min-height: 320px;
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
.empty {
|
|
170
180
|
flex: 1;
|
|
171
181
|
display: flex;
|
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
|
|
10
10
|
IconCopy, IconCheck, IconChevronDown, IconChevronUp,
|
|
11
11
|
IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
|
|
12
|
-
IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
|
|
12
|
+
IconArrowRight, IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
|
|
13
|
+
IconSend, IconBolt,
|
|
13
14
|
} from '@tabler/icons-react'
|
|
14
15
|
import styles from './RouterView.module.css'
|
|
16
|
+
import PlaygroundChat from '../playground/PlaygroundChat.jsx'
|
|
15
17
|
|
|
16
18
|
function formatUptime(seconds) {
|
|
17
19
|
if (!seconds || seconds <= 0) return 'β'
|
|
@@ -72,6 +74,13 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
72
74
|
const [pickerProvider, setPickerProvider] = useState('')
|
|
73
75
|
const [saveStatus, setSaveStatus] = useState(SAVE_STATUS_IDLE)
|
|
74
76
|
const saveTimerRef = useRef(null)
|
|
77
|
+
const hasAutoExpandedLog = useRef(false)
|
|
78
|
+
|
|
79
|
+
// π Probe state β AI Latency benchmarks launched on the active set. Progress
|
|
80
|
+
// π comes from /stats.globalBenchmark (polled), results land on
|
|
81
|
+
// π /stats.models[].benchmark. `probePolling` speeds up polling while running
|
|
82
|
+
// π so the progress bar + per-model latencies update live.
|
|
83
|
+
const [probePolling, setProbePolling] = useState(false)
|
|
75
84
|
|
|
76
85
|
const fetchStatus = useCallback(async () => {
|
|
77
86
|
try {
|
|
@@ -114,6 +123,23 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
114
123
|
return () => clearInterval(interval)
|
|
115
124
|
}, [fetchStatus, fetchSets, fetchCatalog])
|
|
116
125
|
|
|
126
|
+
// π Fast-poll /stats while a probe is running so the progress bar and
|
|
127
|
+
// π per-model AI latencies stream in live (every 1.2s instead of 5s).
|
|
128
|
+
// π Stops the moment the daemon reports the global benchmark as done.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!probePolling) return undefined
|
|
131
|
+
const interval = setInterval(() => { void fetchStatus() }, 1200)
|
|
132
|
+
return () => clearInterval(interval)
|
|
133
|
+
}, [probePolling, fetchStatus])
|
|
134
|
+
|
|
135
|
+
// π Keep probePolling in sync with the daemon's real state: if it says the
|
|
136
|
+
// π global benchmark stopped, drop our fast-poll flag so we go back to 5s.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (probePolling && stats?.globalBenchmark && !stats.globalBenchmark.running) {
|
|
139
|
+
setProbePolling(false)
|
|
140
|
+
}
|
|
141
|
+
}, [stats?.globalBenchmark, probePolling])
|
|
142
|
+
|
|
117
143
|
// π Cleanup the "saved" indicator so it fades back to idle after 1.5s.
|
|
118
144
|
useEffect(() => {
|
|
119
145
|
if (saveStatus.kind !== 'saved') return undefined
|
|
@@ -133,10 +159,15 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
133
159
|
await fetchStatus()
|
|
134
160
|
await fetchSets()
|
|
135
161
|
} else {
|
|
136
|
-
onToast?.(`Failed to start: ${data.error || 'unknown'}`, 'error')
|
|
162
|
+
onToast?.(`Failed to start: ${data.error || data.message || 'unknown'}`, 'error')
|
|
137
163
|
}
|
|
138
164
|
} catch (err) {
|
|
139
|
-
|
|
165
|
+
// π Distinguish between "daemon not running" (fetch itself fails) and
|
|
166
|
+
// π other network errors so the user gets an actionable error message.
|
|
167
|
+
const msg = err.name === 'TypeError' && err.message?.includes('fetch')
|
|
168
|
+
? 'Cannot reach daemon β it may not be installed or the port is blocked'
|
|
169
|
+
: err.message
|
|
170
|
+
onToast?.(`Start failed: ${msg}`, 'error')
|
|
140
171
|
} finally { setActionLoading(false) }
|
|
141
172
|
}
|
|
142
173
|
|
|
@@ -301,6 +332,44 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
301
332
|
}
|
|
302
333
|
}, [activeSetName, fetchSets, onToast])
|
|
303
334
|
|
|
335
|
+
// π handleProbeAll β launch AI Latency benchmarks on every model in the
|
|
336
|
+
// π active set, inside the DAEMON. Progress + results stream back through
|
|
337
|
+
// π /stats (globalBenchmark + per-model benchmark), which the fast-poll
|
|
338
|
+
// π effect above picks up every 1.2s. Disabled while already running.
|
|
339
|
+
const handleProbeAll = async () => {
|
|
340
|
+
if (!activeSetName || probePolling) return
|
|
341
|
+
if (localModels.length === 0) {
|
|
342
|
+
onToast?.('Add models to the set first, then probe.', 'info')
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const probeModels = localModels.map((m) => ({ providerKey: m.provider, modelId: m.model }))
|
|
347
|
+
const resp = await fetch('/api/router/probe-all', {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
350
|
+
body: JSON.stringify({ models: probeModels }),
|
|
351
|
+
})
|
|
352
|
+
const data = await resp.json().catch(() => null)
|
|
353
|
+
if (!resp.ok || !resp.status || resp.status === 409) {
|
|
354
|
+
throw new Error(data?.error || 'Probe already running')
|
|
355
|
+
}
|
|
356
|
+
setProbePolling(true)
|
|
357
|
+
const total = data?.total ?? localModels.length
|
|
358
|
+
onToast?.(`Probing ${total} model${total === 1 ? '' : 's'} for AI Latencyβ¦`, 'info')
|
|
359
|
+
await fetchStatus()
|
|
360
|
+
} catch (err) {
|
|
361
|
+
onToast?.(`Probe failed: ${err.message}`, 'error')
|
|
362
|
+
setProbePolling(false)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// π The "Test Router" mini playground is now rendered by the shared
|
|
367
|
+
// π <PlaygroundChat> component (model="fcm"), which streams the reply
|
|
368
|
+
// π through /api/playground/chat and shows the harmonized metadata row
|
|
369
|
+
// π (served model Β· ms Β· tok Β· t/s) under every reply. The router's
|
|
370
|
+
// π Primary pick is passed as `targetLabel` so the user sees which model
|
|
371
|
+
// π the priority-first router will try first under their user bubble.
|
|
372
|
+
|
|
304
373
|
// ββ Drag and drop state βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
305
374
|
// We keep a local copy of `models` so the drag UX is instant β the
|
|
306
375
|
// server is updated only when the user actually drops the row.
|
|
@@ -418,6 +487,38 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
418
487
|
const circuitBreakers = stats?.circuitBreakers || {}
|
|
419
488
|
const requestLog = stats?.requestLog || []
|
|
420
489
|
|
|
490
|
+
// π routingOrder β the exact attempt order the daemon will use for the next
|
|
491
|
+
// π request (priority-first among healthy models). routingOrder[0] is the
|
|
492
|
+
// π model that will serve the next chat. Used to mark the "next" row and
|
|
493
|
+
// π to label Primary vs Fallback semantics. Falls back to set order when the
|
|
494
|
+
// π daemon isn't running or hasn't reported yet (so stopped state still shows
|
|
495
|
+
// π a sensible priority chain). See issue #120.
|
|
496
|
+
const routingOrder = stats?.routingOrder || []
|
|
497
|
+
const nextToServeKey = routingOrder.length > 0 ? routingOrder[0].key : null
|
|
498
|
+
|
|
499
|
+
// π Benchmark results keyed by "provider/model" so each set row can show
|
|
500
|
+
// π its live AI Latency + TPS after a probe. Built from /stats.models.
|
|
501
|
+
const benchmarkByKey = new Map()
|
|
502
|
+
for (const m of (stats?.models || [])) {
|
|
503
|
+
if (m && typeof m.key === 'string') benchmarkByKey.set(m.key, m)
|
|
504
|
+
}
|
|
505
|
+
const globalBenchmark = stats?.globalBenchmark || null
|
|
506
|
+
const probeActive = Boolean(globalBenchmark?.running) || probePolling
|
|
507
|
+
|
|
508
|
+
// π Auto-expand the request log the first time requests appear.
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
if (requestLog.length > 0 && !hasAutoExpandedLog.current) {
|
|
511
|
+
hasAutoExpandedLog.current = true
|
|
512
|
+
setLogExpanded(true)
|
|
513
|
+
}
|
|
514
|
+
}, [requestLog.length])
|
|
515
|
+
|
|
516
|
+
// π Computed quick-setup display values: actual when running, defaults when stopped.
|
|
517
|
+
const qsBaseUrl = running && quickSetup?.baseUrl ? quickSetup.baseUrl : 'http://localhost:19280/v1'
|
|
518
|
+
const qsModel = quickSetup?.model || 'fcm'
|
|
519
|
+
const qsApiKey = 'fcm-local'
|
|
520
|
+
const qsAllText = `Base URL: ${qsBaseUrl}\nModel: ${qsModel}\nAPI Key: ${qsApiKey}`
|
|
521
|
+
|
|
421
522
|
const sets = setsData?.sets || {}
|
|
422
523
|
const setNames = Object.keys(sets).sort()
|
|
423
524
|
|
|
@@ -464,6 +565,41 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
464
565
|
</div>
|
|
465
566
|
)}
|
|
466
567
|
|
|
568
|
+
{/* Quick Setup β ALWAYS visible, front and center */}
|
|
569
|
+
<div className={`${styles.quickSetup} ${running ? styles.quickSetupHero : ''}`}>
|
|
570
|
+
<h3 className={styles.sectionTitle}>
|
|
571
|
+
<IconCopy size={14} />
|
|
572
|
+
Quick Setup
|
|
573
|
+
<button className={styles.copyAllBtn} onClick={() => handleCopy(qsAllText, 'all')}>
|
|
574
|
+
{copied === 'all' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
575
|
+
{copied === 'all' ? 'Copied!' : 'Copy all'}
|
|
576
|
+
</button>
|
|
577
|
+
</h3>
|
|
578
|
+
<div className={styles.quickRows}>
|
|
579
|
+
<div className={styles.quickRow}>
|
|
580
|
+
<span className={styles.quickLabel}>Base URL</span>
|
|
581
|
+
<code className={styles.quickValue}>{qsBaseUrl}</code>
|
|
582
|
+
<button className={styles.copyBtn} onClick={() => handleCopy(qsBaseUrl, 'url')}>
|
|
583
|
+
{copied === 'url' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
<div className={styles.quickRow}>
|
|
587
|
+
<span className={styles.quickLabel}>Model</span>
|
|
588
|
+
<code className={styles.quickValue}>{qsModel}</code>
|
|
589
|
+
<button className={styles.copyBtn} onClick={() => handleCopy(qsModel, 'model')}>
|
|
590
|
+
{copied === 'model' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
591
|
+
</button>
|
|
592
|
+
</div>
|
|
593
|
+
<div className={styles.quickRow}>
|
|
594
|
+
<span className={styles.quickLabel}>API Key</span>
|
|
595
|
+
<code className={styles.quickValue}>{qsApiKey}</code>
|
|
596
|
+
<button className={styles.copyBtn} onClick={() => handleCopy(qsApiKey, 'key')}>
|
|
597
|
+
{copied === 'key' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
598
|
+
</button>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
467
603
|
{/* Hero Card */}
|
|
468
604
|
<div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
|
|
469
605
|
<div className={styles.heroLeft}>
|
|
@@ -488,8 +624,8 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
488
624
|
</div>
|
|
489
625
|
<div className={styles.heroActions}>
|
|
490
626
|
{!running ? (
|
|
491
|
-
<button className={styles.startBtn} onClick={handleStart} disabled={actionLoading}>
|
|
492
|
-
<IconPlayerPlay size={
|
|
627
|
+
<button className={`${styles.startBtn} ${styles.startBtnBig}`} onClick={handleStart} disabled={actionLoading}>
|
|
628
|
+
<IconPlayerPlay size={16} />
|
|
493
629
|
{actionLoading ? 'Startingβ¦' : 'Start Router'}
|
|
494
630
|
</button>
|
|
495
631
|
) : (
|
|
@@ -504,34 +640,6 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
504
640
|
</div>
|
|
505
641
|
</div>
|
|
506
642
|
|
|
507
|
-
{/* Quick Setup */}
|
|
508
|
-
{running && quickSetup && (
|
|
509
|
-
<div className={styles.quickSetup}>
|
|
510
|
-
<h3 className={styles.sectionTitle}>
|
|
511
|
-
<IconCopy size={14} />
|
|
512
|
-
Quick Setup
|
|
513
|
-
</h3>
|
|
514
|
-
<div className={styles.quickRows}>
|
|
515
|
-
{quickSetup.baseUrl && (
|
|
516
|
-
<div className={styles.quickRow}>
|
|
517
|
-
<span className={styles.quickLabel}>Base URL</span>
|
|
518
|
-
<code className={styles.quickValue}>{quickSetup.baseUrl}</code>
|
|
519
|
-
<button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.baseUrl, 'url')}>
|
|
520
|
-
{copied === 'url' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
521
|
-
</button>
|
|
522
|
-
</div>
|
|
523
|
-
)}
|
|
524
|
-
<div className={styles.quickRow}>
|
|
525
|
-
<span className={styles.quickLabel}>Model</span>
|
|
526
|
-
<code className={styles.quickValue}>{quickSetup.model}</code>
|
|
527
|
-
<button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.model, 'model')}>
|
|
528
|
-
{copied === 'model' ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
|
529
|
-
</button>
|
|
530
|
-
</div>
|
|
531
|
-
</div>
|
|
532
|
-
</div>
|
|
533
|
-
)}
|
|
534
|
-
|
|
535
643
|
{/* Active Set Manager */}
|
|
536
644
|
{running && (
|
|
537
645
|
<div className={styles.section}>
|
|
@@ -567,6 +675,17 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
567
675
|
<IconWand size={11} />
|
|
568
676
|
Sync best
|
|
569
677
|
</button>
|
|
678
|
+
{/* π Probe all β run AI Latency benchmarks on every model in the
|
|
679
|
+
set. Results stream into the rows below (AI Lat column). */}
|
|
680
|
+
<button
|
|
681
|
+
className={`${styles.smallBtn} ${probeActive ? styles.probeBtnActive : ''}`}
|
|
682
|
+
onClick={handleProbeAll}
|
|
683
|
+
disabled={probeActive || saveStatus.kind === 'saving' || localModels.length === 0}
|
|
684
|
+
title="Benchmark AI Latency + TPS on every model in this set"
|
|
685
|
+
>
|
|
686
|
+
<IconBolt size={11} />
|
|
687
|
+
{probeActive ? 'Probingβ¦' : 'Probe all'}
|
|
688
|
+
</button>
|
|
570
689
|
<button
|
|
571
690
|
className={styles.primaryBtn}
|
|
572
691
|
onClick={() => setPickerOpen((v) => !v)}
|
|
@@ -578,22 +697,69 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
578
697
|
</div>
|
|
579
698
|
</div>
|
|
580
699
|
|
|
700
|
+
{/* π Probe progress bar β shown while AI Latency benchmarks run
|
|
701
|
+
across the set. Reads /stats.globalBenchmark (fast-polled). */}
|
|
702
|
+
{probeActive && globalBenchmark && (
|
|
703
|
+
<div className={styles.probeProgress}>
|
|
704
|
+
<div className={styles.probeProgressLabel}>
|
|
705
|
+
<IconBolt size={11} />
|
|
706
|
+
AI Latency probe
|
|
707
|
+
<span className={styles.probeProgressCount}>
|
|
708
|
+
{globalBenchmark.completed} / {globalBenchmark.total || localModels.length}
|
|
709
|
+
</span>
|
|
710
|
+
</div>
|
|
711
|
+
<div className={styles.probeProgressBar}>
|
|
712
|
+
<div
|
|
713
|
+
className={styles.probeProgressFill}
|
|
714
|
+
style={{ width: `${Math.min(100, ((globalBenchmark.completed || 0) / Math.max(1, globalBenchmark.total || localModels.length)) * 100)}%` }}
|
|
715
|
+
/>
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
)}
|
|
719
|
+
|
|
581
720
|
{localModels.length === 0 ? (
|
|
582
721
|
<div className={styles.setEmpty}>
|
|
583
|
-
|
|
722
|
+
<div className={styles.setEmptyTitle}>No models in the active set</div>
|
|
723
|
+
<div className={styles.setEmptyHint}>
|
|
724
|
+
Add models from the picker below, or click <strong>Sync best</strong> above to auto-pick working models.
|
|
725
|
+
</div>
|
|
584
726
|
</div>
|
|
585
727
|
) : (
|
|
586
|
-
|
|
728
|
+
<>
|
|
729
|
+
{/* π Priority legend β explains the fallback chain so users
|
|
730
|
+
understand WHY the top model serves every request (issue #120).
|
|
731
|
+
Higher priority = tried first; the rest are failover targets. */}
|
|
732
|
+
<div className={styles.priorityLegend}>
|
|
733
|
+
<span className={styles.legendPrimary}><IconArrowRight size={11} /> Primary</span>
|
|
734
|
+
<span className={styles.legendSeparator}>tries first</span>
|
|
735
|
+
<span className={styles.legendFallback}>Fallback</span>
|
|
736
|
+
<span className={styles.legendSeparator}>on failure / rate-limit</span>
|
|
737
|
+
{running && nextToServeKey && (
|
|
738
|
+
<span className={styles.legendNext}>Next up: <code>{nextToServeKey}</code></span>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
<div className={styles.setList} onDragLeave={handleDragLeave}>
|
|
587
742
|
{localModels.map((m, idx) => {
|
|
588
743
|
const key = `${m.provider}/${m.model}`
|
|
589
744
|
const cb = circuitBreakers[key] || {}
|
|
745
|
+
// π Per-model AI Latency from the latest probe. `bm` is the
|
|
746
|
+
// π raw benchmark result; `bmLoading` is true while it runs.
|
|
747
|
+
const bmEntry = benchmarkByKey.get(key)
|
|
748
|
+
const bm = bmEntry?.benchmark || null
|
|
749
|
+
const bmLoading = Boolean(bmEntry?.isBenchmarking)
|
|
590
750
|
const isDragging = draggingKey === key
|
|
591
751
|
const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
|
|
592
752
|
const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
|
|
753
|
+
// π Priority semantics for the UI: #1 is the Primary model the
|
|
754
|
+
// π router tries first; everyone else is a Fallback. `isNext` is
|
|
755
|
+
// π true for the exact model the daemon will serve next request β
|
|
756
|
+
// π derived from /stats.routingOrder (priority-first, see #120).
|
|
757
|
+
const isPrimary = idx === 0
|
|
758
|
+
const isNext = running && nextToServeKey === key
|
|
593
759
|
return (
|
|
594
760
|
<div
|
|
595
761
|
key={key}
|
|
596
|
-
className={`${styles.setRow} ${isDragging ? styles.setRowDragging : ''} ${dropAbove ? `${styles.setRowDropTarget} ${styles.setRowDropTargetAbove}` : ''} ${dropBelow ? `${styles.setRowDropTarget} ${styles.setRowDropTargetBelow}` : ''}`}
|
|
762
|
+
className={`${styles.setRow} ${isDragging ? styles.setRowDragging : ''} ${isNext ? styles.setRowNext : ''} ${dropAbove ? `${styles.setRowDropTarget} ${styles.setRowDropTargetAbove}` : ''} ${dropBelow ? `${styles.setRowDropTarget} ${styles.setRowDropTargetBelow}` : ''}`}
|
|
597
763
|
draggable
|
|
598
764
|
onDragStart={(e) => handleDragStart(e, idx)}
|
|
599
765
|
onDragOver={(e) => handleDragOver(e, idx)}
|
|
@@ -604,10 +770,27 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
604
770
|
<span className={styles.setDragHandle} aria-hidden>
|
|
605
771
|
<IconGripVertical size={14} />
|
|
606
772
|
</span>
|
|
607
|
-
<span
|
|
773
|
+
<span
|
|
774
|
+
className={`${styles.setPriority} ${isPrimary ? styles.setPriorityPrimary : styles.setPriorityFallback}`}
|
|
775
|
+
title={isPrimary ? 'Primary model β tried first on every request' : `Fallback #${idx + 1} β used when higher-priority models fail or rate-limit`}
|
|
776
|
+
>
|
|
777
|
+
{isPrimary ? 'Primary' : `#${idx + 1}`}
|
|
778
|
+
</span>
|
|
608
779
|
<span className={styles.setKey}>{key}</span>
|
|
609
780
|
{m.tier && <span className={styles.setTier}>{m.tier}</span>}
|
|
610
781
|
<CircuitBadge state={cb.state || m.state} />
|
|
782
|
+
{/* π AI Latency β populated by "Probe all". Shows a spinner
|
|
783
|
+
while benchmarking, the latency+TPS once done, or a dim
|
|
784
|
+
placeholder before the first probe. */}
|
|
785
|
+
<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'}>
|
|
786
|
+
{bmLoading
|
|
787
|
+
? <span className={styles.aiLatencySpin}>Β·Β·Β·</span>
|
|
788
|
+
: bm?.ok
|
|
789
|
+
? <><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>}</>
|
|
790
|
+
: bm && !bm.ok
|
|
791
|
+
? <span className={styles.aiLatencyErr}>fail</span>
|
|
792
|
+
: <span className={styles.aiLatencyNone}>β</span>}
|
|
793
|
+
</span>
|
|
611
794
|
<div className={styles.setRowBtns}>
|
|
612
795
|
<button
|
|
613
796
|
className={styles.iconBtn}
|
|
@@ -641,6 +824,7 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
641
824
|
)
|
|
642
825
|
})}
|
|
643
826
|
</div>
|
|
827
|
+
</>
|
|
644
828
|
)}
|
|
645
829
|
|
|
646
830
|
{pickerOpen && (
|
|
@@ -720,8 +904,8 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
720
904
|
</div>
|
|
721
905
|
)}
|
|
722
906
|
|
|
723
|
-
{/* Request Log */}
|
|
724
|
-
{running &&
|
|
907
|
+
{/* Request Log β always visible when running */}
|
|
908
|
+
{running && (
|
|
725
909
|
<div className={styles.section}>
|
|
726
910
|
<h3 className={styles.sectionTitle} onClick={() => setLogExpanded(!logExpanded)} style={{ cursor: 'pointer' }}>
|
|
727
911
|
<IconActivity size={14} />
|
|
@@ -729,27 +913,55 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
729
913
|
{logExpanded ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
|
|
730
914
|
</h3>
|
|
731
915
|
{logExpanded && (
|
|
732
|
-
|
|
733
|
-
{
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
916
|
+
requestLog.length === 0 ? (
|
|
917
|
+
<div className={styles.logEmpty}>No requests yet. Start coding to see traffic here.</div>
|
|
918
|
+
) : (
|
|
919
|
+
<div className={styles.logList}>
|
|
920
|
+
{requestLog.map((entry, i) => (
|
|
921
|
+
<div key={i} className={styles.logRow}>
|
|
922
|
+
<span className={entry.error ? styles.logErr : styles.logOk}>
|
|
923
|
+
{entry.status || 'β'}
|
|
924
|
+
</span>
|
|
925
|
+
<span className={styles.logModel}>{entry.model}</span>
|
|
926
|
+
<span className={styles.logLatency}>
|
|
927
|
+
{entry.latency_ms != null ? `${entry.latency_ms}ms` : 'β'}
|
|
928
|
+
</span>
|
|
929
|
+
<span className={styles.logTokens}>
|
|
930
|
+
{entry.tokens > 0 ? formatNumber(entry.tokens) + ' tok' : ''}
|
|
931
|
+
</span>
|
|
932
|
+
{entry.failover && <span className={styles.logFailover}>failover</span>}
|
|
933
|
+
</div>
|
|
934
|
+
))}
|
|
935
|
+
</div>
|
|
936
|
+
)
|
|
749
937
|
)}
|
|
750
938
|
</div>
|
|
751
939
|
)}
|
|
752
940
|
|
|
941
|
+
{/* π Test Router β the shared PlaygroundChat core, routed through
|
|
942
|
+
`fcm` so the user can sanity-check the priority-first router right
|
|
943
|
+
from this view. The router's Primary pick is passed as targetLabel
|
|
944
|
+
so it appears under the user bubble; the served model (+ ms / tok /
|
|
945
|
+
t/s) appears under the reply and reveals any failover. Only shown
|
|
946
|
+
when running (needs the daemon). */}
|
|
947
|
+
{running && (
|
|
948
|
+
<div className={styles.section}>
|
|
949
|
+
<h3 className={styles.sectionTitle}>
|
|
950
|
+
<IconSend size={14} />
|
|
951
|
+
Test Router
|
|
952
|
+
<span className={styles.miniPgHint}>routes through <code>fcm</code> β try the fallback chain</span>
|
|
953
|
+
</h3>
|
|
954
|
+
<PlaygroundChat
|
|
955
|
+
model="fcm"
|
|
956
|
+
variant="mini"
|
|
957
|
+
disabled={!running}
|
|
958
|
+
targetLabel={nextToServeKey || 'fcm'}
|
|
959
|
+
placeholder="Test the router⦠(e.g. write a haiku about TypeScript)"
|
|
960
|
+
emptyHint="Send a message to see which model the router picks, with latency + TPS."
|
|
961
|
+
/>
|
|
962
|
+
</div>
|
|
963
|
+
)}
|
|
964
|
+
|
|
753
965
|
{/* Server health (small chip at the bottom for visibility) */}
|
|
754
966
|
<div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
|
|
755
967
|
<span className={styles.saveStatus}>
|