free-coding-models 0.5.29 → 0.5.31
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/changelog/v0.5.30.md +15 -0
- package/changelog/v0.5.31.md +6 -0
- package/package.json +1 -1
- package/src/core/playground.js +6 -4
- package/src/core/router-daemon.js +25 -2
- package/src/core/sync-set.js +5 -3
- package/web/dist/assets/index-CsFt3qt5.css +1 -0
- package/web/dist/assets/index-D2n0DqkV.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 +73 -4
- package/web/src/App.jsx +1 -0
- 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 +198 -1
- package/web/src/components/router/RouterView.module.css +281 -0
- package/web/dist/assets/index-BMU58Jju.js +0 -41
- package/web/dist/assets/index-Dd1jOGEn.css +0 -1
|
@@ -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;
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
IconCopy, IconCheck, IconChevronDown, IconChevronUp,
|
|
11
11
|
IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
|
|
12
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 '—'
|
|
@@ -53,7 +55,7 @@ const SAVE_STATUS_SAVING = { kind: 'saving' }
|
|
|
53
55
|
const SAVE_STATUS_SAVED = { kind: 'saved' }
|
|
54
56
|
const SAVE_STATUS_ERROR = (message) => ({ kind: 'error', message })
|
|
55
57
|
|
|
56
|
-
export default function RouterView({ onClose, onToast }) {
|
|
58
|
+
export default function RouterView({ onClose, onToast, favorites }) {
|
|
57
59
|
const [status, setStatus] = useState(null)
|
|
58
60
|
const [stats, setStats] = useState(null)
|
|
59
61
|
const [quickSetup, setQuickSetup] = useState(null)
|
|
@@ -74,6 +76,12 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
74
76
|
const saveTimerRef = useRef(null)
|
|
75
77
|
const hasAutoExpandedLog = useRef(false)
|
|
76
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)
|
|
84
|
+
|
|
77
85
|
const fetchStatus = useCallback(async () => {
|
|
78
86
|
try {
|
|
79
87
|
const resp = await fetch('/api/router/status')
|
|
@@ -115,6 +123,23 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
115
123
|
return () => clearInterval(interval)
|
|
116
124
|
}, [fetchStatus, fetchSets, fetchCatalog])
|
|
117
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
|
+
|
|
118
143
|
// 📖 Cleanup the "saved" indicator so it fades back to idle after 1.5s.
|
|
119
144
|
useEffect(() => {
|
|
120
145
|
if (saveStatus.kind !== 'saved') return undefined
|
|
@@ -227,6 +252,48 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
227
252
|
}
|
|
228
253
|
}
|
|
229
254
|
|
|
255
|
+
// 📖 Replace active set models with favorites
|
|
256
|
+
const handleUseFavorites = async () => {
|
|
257
|
+
if (!activeSetName) return
|
|
258
|
+
const favList = favorites?.favorites || []
|
|
259
|
+
if (favList.length === 0) {
|
|
260
|
+
onToast?.('You do not have any favorite models yet. Star some models first!', 'info')
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const nextModels = favList.map((key, idx) => {
|
|
265
|
+
const parts = key.split('/')
|
|
266
|
+
const provider = parts[0]
|
|
267
|
+
const model = parts.slice(1).join('/')
|
|
268
|
+
return { provider, model, priority: idx + 1 }
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
setSaveStatus(SAVE_STATUS_SAVING)
|
|
272
|
+
try {
|
|
273
|
+
const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}`, {
|
|
274
|
+
method: 'PUT',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ models: nextModels }),
|
|
277
|
+
})
|
|
278
|
+
if (!resp.ok) {
|
|
279
|
+
const err = await resp.json().catch(() => ({}))
|
|
280
|
+
throw new Error(err.error || `HTTP ${resp.status}`)
|
|
281
|
+
}
|
|
282
|
+
const data = await resp.json()
|
|
283
|
+
if (data?.sets?.[activeSetName]) {
|
|
284
|
+
setSetsData((prev) => ({ ...prev, sets: data.sets }))
|
|
285
|
+
} else {
|
|
286
|
+
await fetchSets()
|
|
287
|
+
}
|
|
288
|
+
setSaveStatus(SAVE_STATUS_SAVED)
|
|
289
|
+
onToast?.(`Replaced active set with ${favList.length} favorite model${favList.length === 1 ? '' : 's'}.`, 'success')
|
|
290
|
+
await fetchStatus()
|
|
291
|
+
} catch (err) {
|
|
292
|
+
setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
|
|
293
|
+
onToast?.(`Failed to replace set: ${err.message}`, 'error')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
const persistReorder = useCallback(async (nextModels) => {
|
|
231
298
|
if (!activeSetName) return
|
|
232
299
|
setSaveStatus(SAVE_STATUS_SAVING)
|
|
@@ -307,6 +374,44 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
307
374
|
}
|
|
308
375
|
}, [activeSetName, fetchSets, onToast])
|
|
309
376
|
|
|
377
|
+
// 📖 handleProbeAll — launch AI Latency benchmarks on every model in the
|
|
378
|
+
// 📖 active set, inside the DAEMON. Progress + results stream back through
|
|
379
|
+
// 📖 /stats (globalBenchmark + per-model benchmark), which the fast-poll
|
|
380
|
+
// 📖 effect above picks up every 1.2s. Disabled while already running.
|
|
381
|
+
const handleProbeAll = async () => {
|
|
382
|
+
if (!activeSetName || probePolling) return
|
|
383
|
+
if (localModels.length === 0) {
|
|
384
|
+
onToast?.('Add models to the set first, then probe.', 'info')
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const probeModels = localModels.map((m) => ({ providerKey: m.provider, modelId: m.model }))
|
|
389
|
+
const resp = await fetch('/api/router/probe-all', {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { 'Content-Type': 'application/json' },
|
|
392
|
+
body: JSON.stringify({ models: probeModels }),
|
|
393
|
+
})
|
|
394
|
+
const data = await resp.json().catch(() => null)
|
|
395
|
+
if (!resp.ok || !resp.status || resp.status === 409) {
|
|
396
|
+
throw new Error(data?.error || 'Probe already running')
|
|
397
|
+
}
|
|
398
|
+
setProbePolling(true)
|
|
399
|
+
const total = data?.total ?? localModels.length
|
|
400
|
+
onToast?.(`Probing ${total} model${total === 1 ? '' : 's'} for AI Latency…`, 'info')
|
|
401
|
+
await fetchStatus()
|
|
402
|
+
} catch (err) {
|
|
403
|
+
onToast?.(`Probe failed: ${err.message}`, 'error')
|
|
404
|
+
setProbePolling(false)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 📖 The "Test Router" mini playground is now rendered by the shared
|
|
409
|
+
// 📖 <PlaygroundChat> component (model="fcm"), which streams the reply
|
|
410
|
+
// 📖 through /api/playground/chat and shows the harmonized metadata row
|
|
411
|
+
// 📖 (served model · ms · tok · t/s) under every reply. The router's
|
|
412
|
+
// 📖 Primary pick is passed as `targetLabel` so the user sees which model
|
|
413
|
+
// 📖 the priority-first router will try first under their user bubble.
|
|
414
|
+
|
|
310
415
|
// ── Drag and drop state ───────────────────────────────────────────────
|
|
311
416
|
// We keep a local copy of `models` so the drag UX is instant — the
|
|
312
417
|
// server is updated only when the user actually drops the row.
|
|
@@ -433,6 +538,15 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
433
538
|
const routingOrder = stats?.routingOrder || []
|
|
434
539
|
const nextToServeKey = routingOrder.length > 0 ? routingOrder[0].key : null
|
|
435
540
|
|
|
541
|
+
// 📖 Benchmark results keyed by "provider/model" so each set row can show
|
|
542
|
+
// 📖 its live AI Latency + TPS after a probe. Built from /stats.models.
|
|
543
|
+
const benchmarkByKey = new Map()
|
|
544
|
+
for (const m of (stats?.models || [])) {
|
|
545
|
+
if (m && typeof m.key === 'string') benchmarkByKey.set(m.key, m)
|
|
546
|
+
}
|
|
547
|
+
const globalBenchmark = stats?.globalBenchmark || null
|
|
548
|
+
const probeActive = Boolean(globalBenchmark?.running) || probePolling
|
|
549
|
+
|
|
436
550
|
// 📖 Auto-expand the request log the first time requests appear.
|
|
437
551
|
useEffect(() => {
|
|
438
552
|
if (requestLog.length > 0 && !hasAutoExpandedLog.current) {
|
|
@@ -603,6 +717,28 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
603
717
|
<IconWand size={11} />
|
|
604
718
|
Sync best
|
|
605
719
|
</button>
|
|
720
|
+
{favorites && (
|
|
721
|
+
<button
|
|
722
|
+
className={styles.smallBtn}
|
|
723
|
+
onClick={handleUseFavorites}
|
|
724
|
+
disabled={saveStatus.kind === 'saving'}
|
|
725
|
+
title="Replace current router models with your favorite models"
|
|
726
|
+
>
|
|
727
|
+
<IconList size={11} />
|
|
728
|
+
Use favorites
|
|
729
|
+
</button>
|
|
730
|
+
)}
|
|
731
|
+
{/* 📖 Probe all — run AI Latency benchmarks on every model in the
|
|
732
|
+
set. Results stream into the rows below (AI Lat column). */}
|
|
733
|
+
<button
|
|
734
|
+
className={`${styles.smallBtn} ${probeActive ? styles.probeBtnActive : ''}`}
|
|
735
|
+
onClick={handleProbeAll}
|
|
736
|
+
disabled={probeActive || saveStatus.kind === 'saving' || localModels.length === 0}
|
|
737
|
+
title="Benchmark AI Latency + TPS on every model in this set"
|
|
738
|
+
>
|
|
739
|
+
<IconBolt size={11} />
|
|
740
|
+
{probeActive ? 'Probing…' : 'Probe all'}
|
|
741
|
+
</button>
|
|
606
742
|
<button
|
|
607
743
|
className={styles.primaryBtn}
|
|
608
744
|
onClick={() => setPickerOpen((v) => !v)}
|
|
@@ -614,6 +750,26 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
614
750
|
</div>
|
|
615
751
|
</div>
|
|
616
752
|
|
|
753
|
+
{/* 📖 Probe progress bar — shown while AI Latency benchmarks run
|
|
754
|
+
across the set. Reads /stats.globalBenchmark (fast-polled). */}
|
|
755
|
+
{probeActive && globalBenchmark && (
|
|
756
|
+
<div className={styles.probeProgress}>
|
|
757
|
+
<div className={styles.probeProgressLabel}>
|
|
758
|
+
<IconBolt size={11} />
|
|
759
|
+
AI Latency probe
|
|
760
|
+
<span className={styles.probeProgressCount}>
|
|
761
|
+
{globalBenchmark.completed} / {globalBenchmark.total || localModels.length}
|
|
762
|
+
</span>
|
|
763
|
+
</div>
|
|
764
|
+
<div className={styles.probeProgressBar}>
|
|
765
|
+
<div
|
|
766
|
+
className={styles.probeProgressFill}
|
|
767
|
+
style={{ width: `${Math.min(100, ((globalBenchmark.completed || 0) / Math.max(1, globalBenchmark.total || localModels.length)) * 100)}%` }}
|
|
768
|
+
/>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
)}
|
|
772
|
+
|
|
617
773
|
{localModels.length === 0 ? (
|
|
618
774
|
<div className={styles.setEmpty}>
|
|
619
775
|
<div className={styles.setEmptyTitle}>No models in the active set</div>
|
|
@@ -639,6 +795,11 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
639
795
|
{localModels.map((m, idx) => {
|
|
640
796
|
const key = `${m.provider}/${m.model}`
|
|
641
797
|
const cb = circuitBreakers[key] || {}
|
|
798
|
+
// 📖 Per-model AI Latency from the latest probe. `bm` is the
|
|
799
|
+
// 📖 raw benchmark result; `bmLoading` is true while it runs.
|
|
800
|
+
const bmEntry = benchmarkByKey.get(key)
|
|
801
|
+
const bm = bmEntry?.benchmark || null
|
|
802
|
+
const bmLoading = Boolean(bmEntry?.isBenchmarking)
|
|
642
803
|
const isDragging = draggingKey === key
|
|
643
804
|
const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
|
|
644
805
|
const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
|
|
@@ -671,6 +832,18 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
671
832
|
<span className={styles.setKey}>{key}</span>
|
|
672
833
|
{m.tier && <span className={styles.setTier}>{m.tier}</span>}
|
|
673
834
|
<CircuitBadge state={cb.state || m.state} />
|
|
835
|
+
{/* 📖 AI Latency — populated by "Probe all". Shows a spinner
|
|
836
|
+
while benchmarking, the latency+TPS once done, or a dim
|
|
837
|
+
placeholder before the first probe. */}
|
|
838
|
+
<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'}>
|
|
839
|
+
{bmLoading
|
|
840
|
+
? <span className={styles.aiLatencySpin}>···</span>
|
|
841
|
+
: bm?.ok
|
|
842
|
+
? <><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>}</>
|
|
843
|
+
: bm && !bm.ok
|
|
844
|
+
? <span className={styles.aiLatencyErr}>fail</span>
|
|
845
|
+
: <span className={styles.aiLatencyNone}>—</span>}
|
|
846
|
+
</span>
|
|
674
847
|
<div className={styles.setRowBtns}>
|
|
675
848
|
<button
|
|
676
849
|
className={styles.iconBtn}
|
|
@@ -818,6 +991,30 @@ export default function RouterView({ onClose, onToast }) {
|
|
|
818
991
|
</div>
|
|
819
992
|
)}
|
|
820
993
|
|
|
994
|
+
{/* 📖 Test Router — the shared PlaygroundChat core, routed through
|
|
995
|
+
`fcm` so the user can sanity-check the priority-first router right
|
|
996
|
+
from this view. The router's Primary pick is passed as targetLabel
|
|
997
|
+
so it appears under the user bubble; the served model (+ ms / tok /
|
|
998
|
+
t/s) appears under the reply and reveals any failover. Only shown
|
|
999
|
+
when running (needs the daemon). */}
|
|
1000
|
+
{running && (
|
|
1001
|
+
<div className={styles.section}>
|
|
1002
|
+
<h3 className={styles.sectionTitle}>
|
|
1003
|
+
<IconSend size={14} />
|
|
1004
|
+
Test Router
|
|
1005
|
+
<span className={styles.miniPgHint}>routes through <code>fcm</code> — try the fallback chain</span>
|
|
1006
|
+
</h3>
|
|
1007
|
+
<PlaygroundChat
|
|
1008
|
+
model="fcm"
|
|
1009
|
+
variant="mini"
|
|
1010
|
+
disabled={!running}
|
|
1011
|
+
targetLabel={nextToServeKey || 'fcm'}
|
|
1012
|
+
placeholder="Test the router… (e.g. write a haiku about TypeScript)"
|
|
1013
|
+
emptyHint="Send a message to see which model the router picks, with latency + TPS."
|
|
1014
|
+
/>
|
|
1015
|
+
</div>
|
|
1016
|
+
)}
|
|
1017
|
+
|
|
821
1018
|
{/* Server health (small chip at the bottom for visibility) */}
|
|
822
1019
|
<div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
|
|
823
1020
|
<span className={styles.saveStatus}>
|
|
@@ -844,3 +844,284 @@
|
|
|
844
844
|
flex-shrink: 0;
|
|
845
845
|
}
|
|
846
846
|
|
|
847
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
848
|
+
📖 Probe-all button + progress bar (AI Latency benchmarks on the set).
|
|
849
|
+
════════════════════════════════════════════════════════════════════════ */
|
|
850
|
+
|
|
851
|
+
/* 📖 Active state for the Probe button while benchmarks run. */
|
|
852
|
+
.probeBtnActive {
|
|
853
|
+
border-color: var(--accent, #00c864);
|
|
854
|
+
color: var(--accent, #00c864);
|
|
855
|
+
cursor: progress;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/* 📖 Progress card shown below the set actions while probing. */
|
|
859
|
+
.probeProgress {
|
|
860
|
+
display: flex;
|
|
861
|
+
flex-direction: column;
|
|
862
|
+
gap: 6px;
|
|
863
|
+
padding: 8px 10px;
|
|
864
|
+
margin-bottom: 8px;
|
|
865
|
+
background: var(--bg-secondary, #14141f);
|
|
866
|
+
border: 1px solid rgba(0, 200, 100, 0.2);
|
|
867
|
+
border-radius: 6px;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.probeProgressLabel {
|
|
871
|
+
display: flex;
|
|
872
|
+
align-items: center;
|
|
873
|
+
gap: 5px;
|
|
874
|
+
font-size: 11px;
|
|
875
|
+
color: var(--accent, #00c864);
|
|
876
|
+
font-weight: 600;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.probeProgressCount {
|
|
880
|
+
margin-left: auto;
|
|
881
|
+
color: var(--text-secondary, #aaa);
|
|
882
|
+
font-weight: 500;
|
|
883
|
+
font-family: 'SF Mono', monospace;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
.probeProgressBar {
|
|
887
|
+
height: 4px;
|
|
888
|
+
background: var(--bg-primary, #0f0f17);
|
|
889
|
+
border-radius: 2px;
|
|
890
|
+
overflow: hidden;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
.probeProgressFill {
|
|
894
|
+
height: 100%;
|
|
895
|
+
background: linear-gradient(90deg, var(--accent, #00c864), rgba(0, 200, 100, 0.6));
|
|
896
|
+
border-radius: 2px;
|
|
897
|
+
transition: width 0.4s ease;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
901
|
+
📖 AI Latency cell in each set row — populated by Probe all.
|
|
902
|
+
════════════════════════════════════════════════════════════════════════ */
|
|
903
|
+
|
|
904
|
+
.aiLatencyCell {
|
|
905
|
+
display: inline-flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
gap: 4px;
|
|
908
|
+
min-width: 78px;
|
|
909
|
+
font-size: 10px;
|
|
910
|
+
font-family: 'SF Mono', monospace;
|
|
911
|
+
justify-content: flex-end;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.aiLatencyMs {
|
|
915
|
+
font-weight: 700;
|
|
916
|
+
color: #b400ff;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
.aiLatencyTps {
|
|
920
|
+
color: var(--text-muted, #888);
|
|
921
|
+
font-size: 9px;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.aiLatencySpin {
|
|
925
|
+
color: var(--accent, #00c864);
|
|
926
|
+
letter-spacing: 2px;
|
|
927
|
+
animation: probeSpin 0.8s steps(3, end) infinite;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
@keyframes probeSpin {
|
|
931
|
+
0% { opacity: 0.3; }
|
|
932
|
+
50% { opacity: 1; }
|
|
933
|
+
100% { opacity: 0.3; }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.aiLatencyErr {
|
|
937
|
+
color: var(--error, #ff5a5a);
|
|
938
|
+
font-size: 10px;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
.aiLatencyNone {
|
|
942
|
+
color: var(--text-muted, #888);
|
|
943
|
+
opacity: 0.4;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
947
|
+
📖 Mini Playground — live router test inside the Router view.
|
|
948
|
+
════════════════════════════════════════════════════════════════════════ */
|
|
949
|
+
|
|
950
|
+
.miniPgHint {
|
|
951
|
+
margin-left: auto;
|
|
952
|
+
font-size: 10px;
|
|
953
|
+
font-weight: 400;
|
|
954
|
+
color: var(--text-muted, #888);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.miniPgHint code {
|
|
958
|
+
color: var(--accent, #00c864);
|
|
959
|
+
font-family: 'SF Mono', monospace;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.miniPg {
|
|
963
|
+
max-height: 220px;
|
|
964
|
+
overflow-y: auto;
|
|
965
|
+
padding: 8px;
|
|
966
|
+
margin-bottom: 8px;
|
|
967
|
+
background: var(--bg-primary, #0f0f17);
|
|
968
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
969
|
+
border-radius: 8px;
|
|
970
|
+
display: flex;
|
|
971
|
+
flex-direction: column;
|
|
972
|
+
gap: 8px;
|
|
973
|
+
scroll-behavior: smooth;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.miniPgEmpty {
|
|
977
|
+
text-align: center;
|
|
978
|
+
color: var(--text-muted, #888);
|
|
979
|
+
font-size: 11px;
|
|
980
|
+
padding: 18px 8px;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.miniPgMsg {
|
|
984
|
+
display: flex;
|
|
985
|
+
flex-direction: column;
|
|
986
|
+
gap: 3px;
|
|
987
|
+
max-width: 88%;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.miniPgUser {
|
|
991
|
+
align-self: flex-end;
|
|
992
|
+
align-items: flex-end;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.miniPgAssistant {
|
|
996
|
+
align-self: flex-start;
|
|
997
|
+
align-items: flex-start;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.miniPgBubble {
|
|
1001
|
+
padding: 7px 11px;
|
|
1002
|
+
border-radius: 12px;
|
|
1003
|
+
font-size: 12px;
|
|
1004
|
+
line-height: 1.5;
|
|
1005
|
+
white-space: pre-wrap;
|
|
1006
|
+
word-break: break-word;
|
|
1007
|
+
color: var(--text-primary, #e0e0e0);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.miniPgUser .miniPgBubble {
|
|
1011
|
+
background: rgba(0, 200, 100, 0.12);
|
|
1012
|
+
border: 1px solid rgba(0, 200, 100, 0.2);
|
|
1013
|
+
border-bottom-right-radius: 4px;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.miniPgAssistant .miniPgBubble {
|
|
1017
|
+
background: var(--bg-secondary, #14141f);
|
|
1018
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
1019
|
+
border-bottom-left-radius: 4px;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.miniPgTyping {
|
|
1023
|
+
color: var(--accent, #00c864);
|
|
1024
|
+
letter-spacing: 3px;
|
|
1025
|
+
animation: probeSpin 0.9s steps(3, end) infinite;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.miniPgError {
|
|
1029
|
+
display: block;
|
|
1030
|
+
margin-top: 4px;
|
|
1031
|
+
color: var(--error, #ff5a5a);
|
|
1032
|
+
font-size: 11px;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.miniPgAborted {
|
|
1036
|
+
display: block;
|
|
1037
|
+
margin-top: 4px;
|
|
1038
|
+
color: var(--text-muted, #888);
|
|
1039
|
+
font-size: 10px;
|
|
1040
|
+
font-style: italic;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/* 📖 Target badge under each user bubble — shows the Primary model the
|
|
1044
|
+
router will try first for this message (priority-first routing, #120).
|
|
1045
|
+
If failover kicks in, the reply's .miniPgRouted badge shows a different
|
|
1046
|
+
model, making the fallback chain visible at a glance. */
|
|
1047
|
+
.miniPgTarget {
|
|
1048
|
+
font-size: 10px;
|
|
1049
|
+
color: var(--text-muted, #888);
|
|
1050
|
+
font-family: 'SF Mono', monospace;
|
|
1051
|
+
padding: 0 4px;
|
|
1052
|
+
display: inline-flex;
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
gap: 3px;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
.miniPgTarget svg {
|
|
1058
|
+
color: var(--accent, #00c864);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/* 📖 Routed-via badge under each assistant reply — shows WHICH model the
|
|
1062
|
+
priority-first router actually picked for this message (issue #120). */
|
|
1063
|
+
.miniPgRouted {
|
|
1064
|
+
font-size: 10px;
|
|
1065
|
+
color: var(--text-muted, #888);
|
|
1066
|
+
font-family: 'SF Mono', monospace;
|
|
1067
|
+
padding: 0 4px;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.miniPgRouted span {
|
|
1071
|
+
color: var(--accent, #00c864);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.miniPgInputRow {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
gap: 6px;
|
|
1077
|
+
align-items: stretch;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.miniPgInput {
|
|
1081
|
+
flex: 1;
|
|
1082
|
+
padding: 8px 12px;
|
|
1083
|
+
background: var(--bg-primary, #0f0f17);
|
|
1084
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
1085
|
+
border-radius: 8px;
|
|
1086
|
+
color: var(--text-primary, #e0e0e0);
|
|
1087
|
+
font-size: 12px;
|
|
1088
|
+
font-family: inherit;
|
|
1089
|
+
outline: none;
|
|
1090
|
+
transition: border-color 0.15s;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
.miniPgInput:focus {
|
|
1094
|
+
border-color: var(--accent, #00c864);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.miniPgInput::placeholder {
|
|
1098
|
+
color: var(--text-muted, #888);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.miniPgSendBtn,
|
|
1102
|
+
.miniPgStopBtn {
|
|
1103
|
+
display: flex;
|
|
1104
|
+
align-items: center;
|
|
1105
|
+
justify-content: center;
|
|
1106
|
+
width: 38px;
|
|
1107
|
+
border: none;
|
|
1108
|
+
border-radius: 8px;
|
|
1109
|
+
cursor: pointer;
|
|
1110
|
+
transition: opacity 0.15s, background 0.15s;
|
|
1111
|
+
flex-shrink: 0;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.miniPgSendBtn {
|
|
1115
|
+
background: var(--accent, #00c864);
|
|
1116
|
+
color: #000;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
.miniPgSendBtn:disabled {
|
|
1120
|
+
opacity: 0.4;
|
|
1121
|
+
cursor: not-allowed;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.miniPgStopBtn {
|
|
1125
|
+
background: var(--error, #ff5a5a);
|
|
1126
|
+
color: #fff;
|
|
1127
|
+
}
|