free-coding-models 0.5.12 β 0.5.15
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/changelog/v0.5.13.md +23 -0
- package/changelog/v0.5.15.md +17 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-BRqzsHVw.css +1 -0
- package/web/dist/assets/index-CUYQh5_t.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +83 -14
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Ce_pr2YF.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
IconAlertTriangle,
|
|
24
24
|
IconCopy,
|
|
25
25
|
IconCheck,
|
|
26
|
+
IconPlayerPlay,
|
|
27
|
+
IconLoader,
|
|
26
28
|
} from '@tabler/icons-react'
|
|
27
29
|
import styles from './PlaygroundView.module.css'
|
|
28
30
|
|
|
@@ -89,20 +91,29 @@ function MetaChip({ icon, label, tone }) {
|
|
|
89
91
|
)
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
function StatusPill({ routerStatus }) {
|
|
93
|
-
|
|
94
|
-
if (
|
|
94
|
+
function StatusPill({ routerStatus, daemonRunning }) {
|
|
95
|
+
// π Prefer the live daemon state over the stale prop.
|
|
96
|
+
if (daemonRunning === true) {
|
|
97
|
+
return (
|
|
98
|
+
<span className={styles.metaChip}>
|
|
99
|
+
<IconBolt size={11} />
|
|
100
|
+
Router online
|
|
101
|
+
</span>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (daemonRunning === false) {
|
|
95
105
|
return (
|
|
96
106
|
<span className={`${styles.metaChip} ${styles.error}`}>
|
|
97
107
|
<IconAlertTriangle size={11} />
|
|
98
|
-
Router offline
|
|
108
|
+
Router offline
|
|
99
109
|
</span>
|
|
100
110
|
)
|
|
101
111
|
}
|
|
112
|
+
// null = checking...
|
|
102
113
|
return (
|
|
103
114
|
<span className={styles.metaChip}>
|
|
104
|
-
<
|
|
105
|
-
|
|
115
|
+
<IconLoader size={11} className={styles.spin} />
|
|
116
|
+
Checking routerβ¦
|
|
106
117
|
</span>
|
|
107
118
|
)
|
|
108
119
|
}
|
|
@@ -110,7 +121,7 @@ function StatusPill({ routerStatus }) {
|
|
|
110
121
|
export default function PlaygroundView({ onClose, onToast, models, routerStatus }) {
|
|
111
122
|
const [messages, setMessages] = useState([]) // { role, content, meta? }
|
|
112
123
|
const [input, setInput] = useState('')
|
|
113
|
-
const [model, setModel] = useState(
|
|
124
|
+
const [model, setModel] = useState(null) // null = computing best model
|
|
114
125
|
const [streamOn, setStreamOn] = useState(true)
|
|
115
126
|
const [prePromptEnabled, setPrePromptEnabled] = useState(true)
|
|
116
127
|
const [prePromptText, setPrePromptText] = useState('')
|
|
@@ -120,6 +131,16 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
120
131
|
const abortRef = useRef(null)
|
|
121
132
|
const transcriptRef = useRef(null)
|
|
122
133
|
|
|
134
|
+
// π Daemon state: null = checking, true = running, false = down
|
|
135
|
+
const [daemonRunning, setDaemonRunning] = useState(
|
|
136
|
+
routerStatus?.running === true ? true : null
|
|
137
|
+
)
|
|
138
|
+
const [daemonStarting, setDaemonStarting] = useState(false)
|
|
139
|
+
const [autoStartSec, setAutoStartSec] = useState(5)
|
|
140
|
+
const cooldownRef = useRef(null)
|
|
141
|
+
const daemonCheckRef = useRef(null)
|
|
142
|
+
const autoStartTriggered = useRef(false)
|
|
143
|
+
|
|
123
144
|
// π Fetch the pre-prompt once on mount so the toggle shows the real
|
|
124
145
|
// π value and the indicator matches what the router will inject.
|
|
125
146
|
useEffect(() => {
|
|
@@ -134,6 +155,131 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
134
155
|
.catch(() => {})
|
|
135
156
|
}, [])
|
|
136
157
|
|
|
158
|
+
// π Smart model selection on mount: pick the best "up" model with
|
|
159
|
+
// π an API key, preferring lower latency and higher tier. Falls back
|
|
160
|
+
// π to "fcm" (the auto-router) only if the daemon is already running
|
|
161
|
+
// π or no working model is found. This ensures the playground works
|
|
162
|
+
// π immediately on open, even without the router daemon.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (model !== null) return // already resolved
|
|
165
|
+
const upModels = (Array.isArray(models) ? models : [])
|
|
166
|
+
.filter((m) => m.status === 'up' && m.hasApiKey && !m.isPinging)
|
|
167
|
+
|
|
168
|
+
if (upModels.length > 0) {
|
|
169
|
+
// π Sort: best tier first (S+ > S > A+ ...), then lowest avg latency
|
|
170
|
+
const tierOrder = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
|
|
171
|
+
upModels.sort((a, b) => {
|
|
172
|
+
const ta = tierOrder[a.tier] ?? 99
|
|
173
|
+
const tb = tierOrder[b.tier] ?? 99
|
|
174
|
+
if (ta !== tb) return ta - tb
|
|
175
|
+
const avgA = typeof a.avg === 'number' ? a.avg : 99999
|
|
176
|
+
const avgB = typeof b.avg === 'number' ? b.avg : 99999
|
|
177
|
+
return avgA - avgB
|
|
178
|
+
})
|
|
179
|
+
const best = upModels[0]
|
|
180
|
+
setModel(`${best.providerKey}/${best.modelId}`)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// π No working model found β if daemon is running, use fcm
|
|
185
|
+
if (routerStatus?.running || daemonRunning === true) {
|
|
186
|
+
setModel('fcm')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// π Nothing works β default to fcm anyway (will show daemon start panel)
|
|
191
|
+
setModel('fcm')
|
|
192
|
+
}, [models, model, routerStatus, daemonRunning])
|
|
193
|
+
|
|
194
|
+
// π Check daemon status on mount. If the daemon is down AND the user
|
|
195
|
+
// π is on the "fcm" auto-router model, start a 5-second countdown to
|
|
196
|
+
// π auto-start the daemon. The "Start Router" button is always
|
|
197
|
+
// π clickable so the user can trigger it manually at any time.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
let mounted = true
|
|
200
|
+
|
|
201
|
+
async function checkDaemon() {
|
|
202
|
+
try {
|
|
203
|
+
const resp = await fetch('/api/router/status')
|
|
204
|
+
const data = await resp.json()
|
|
205
|
+
if (!mounted) return
|
|
206
|
+
if (data?.running) {
|
|
207
|
+
setDaemonRunning(true)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
setDaemonRunning(false)
|
|
211
|
+
} catch {
|
|
212
|
+
if (mounted) setDaemonRunning(false)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
checkDaemon()
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
mounted = false
|
|
220
|
+
if (cooldownRef.current) clearInterval(cooldownRef.current)
|
|
221
|
+
if (daemonCheckRef.current) clearInterval(daemonCheckRef.current)
|
|
222
|
+
}
|
|
223
|
+
}, [])
|
|
224
|
+
|
|
225
|
+
// π Auto-start the daemon after 5 seconds if: daemon is down, model
|
|
226
|
+
// π is "fcm" (auto-router), and auto-start hasn't been triggered yet.
|
|
227
|
+
// π Countdown ticks every second and shows remaining time on the button.
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (daemonRunning !== false || model !== 'fcm' || autoStartTriggered.current) return
|
|
230
|
+
|
|
231
|
+
let remaining = 5
|
|
232
|
+
setAutoStartSec(5)
|
|
233
|
+
cooldownRef.current = setInterval(() => {
|
|
234
|
+
remaining -= 1
|
|
235
|
+
setAutoStartSec(remaining)
|
|
236
|
+
if (remaining <= 0) {
|
|
237
|
+
clearInterval(cooldownRef.current)
|
|
238
|
+
autoStartTriggered.current = true
|
|
239
|
+
void startDaemon()
|
|
240
|
+
}
|
|
241
|
+
}, 1000)
|
|
242
|
+
|
|
243
|
+
return () => { if (cooldownRef.current) clearInterval(cooldownRef.current) }
|
|
244
|
+
}, [daemonRunning, model])
|
|
245
|
+
|
|
246
|
+
// π Start the router daemon from the playground. After starting,
|
|
247
|
+
// π polls every 1s to detect when the daemon is ready, then
|
|
248
|
+
// π updates the UI so the user can chat immediately.
|
|
249
|
+
const startDaemon = useCallback(async () => {
|
|
250
|
+
if (daemonStarting) return
|
|
251
|
+
if (cooldownRef.current) { clearInterval(cooldownRef.current); cooldownRef.current = null }
|
|
252
|
+
autoStartTriggered.current = true
|
|
253
|
+
setDaemonStarting(true)
|
|
254
|
+
try {
|
|
255
|
+
await fetch('/api/router/start', { method: 'POST' })
|
|
256
|
+
// π Poll until the daemon is confirmed running (max 30s)
|
|
257
|
+
let attempts = 0
|
|
258
|
+
await new Promise((resolve) => {
|
|
259
|
+
daemonCheckRef.current = setInterval(async () => {
|
|
260
|
+
attempts += 1
|
|
261
|
+
try {
|
|
262
|
+
const resp = await fetch('/api/router/status')
|
|
263
|
+
const data = await resp.json()
|
|
264
|
+
if (data?.running) {
|
|
265
|
+
clearInterval(daemonCheckRef.current)
|
|
266
|
+
setDaemonRunning(true)
|
|
267
|
+
setDaemonStarting(false)
|
|
268
|
+
resolve()
|
|
269
|
+
}
|
|
270
|
+
} catch {}
|
|
271
|
+
if (attempts >= 30) {
|
|
272
|
+
clearInterval(daemonCheckRef.current)
|
|
273
|
+
setDaemonStarting(false)
|
|
274
|
+
resolve()
|
|
275
|
+
}
|
|
276
|
+
}, 1000)
|
|
277
|
+
})
|
|
278
|
+
} catch {
|
|
279
|
+
setDaemonStarting(false)
|
|
280
|
+
}
|
|
281
|
+
}, [daemonStarting])
|
|
282
|
+
|
|
137
283
|
// π Auto-scroll the transcript on new content.
|
|
138
284
|
useEffect(() => {
|
|
139
285
|
const el = transcriptRef.current
|
|
@@ -167,6 +313,11 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
167
313
|
const sendMessage = useCallback(async () => {
|
|
168
314
|
const text = input.trim()
|
|
169
315
|
if (!text || isLoading) return
|
|
316
|
+
const isFcm = model === 'fcm'
|
|
317
|
+
if (isFcm && daemonRunning !== true) {
|
|
318
|
+
setError('Router daemon is not running. Start it first using the button above or run `free-coding-models --daemon-bg`.')
|
|
319
|
+
return
|
|
320
|
+
}
|
|
170
321
|
setError(null)
|
|
171
322
|
setInput('')
|
|
172
323
|
|
|
@@ -293,7 +444,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
293
444
|
setIsLoading(false)
|
|
294
445
|
abortRef.current = null
|
|
295
446
|
}
|
|
296
|
-
}, [input, isLoading, messages, model, streamOn])
|
|
447
|
+
}, [input, isLoading, messages, model, streamOn, daemonRunning])
|
|
297
448
|
|
|
298
449
|
const handleKeyDown = useCallback((e) => {
|
|
299
450
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -382,7 +533,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
382
533
|
/>
|
|
383
534
|
Pre-prompt
|
|
384
535
|
</label>
|
|
385
|
-
<StatusPill routerStatus={routerStatus} />
|
|
536
|
+
<StatusPill routerStatus={routerStatus} daemonRunning={daemonRunning} />
|
|
386
537
|
</div>
|
|
387
538
|
|
|
388
539
|
{prePromptEnabled && prePromptText && (
|
|
@@ -395,7 +546,37 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
395
546
|
)}
|
|
396
547
|
|
|
397
548
|
<div className={styles.transcript} ref={transcriptRef}>
|
|
398
|
-
{messages.length === 0 ? (
|
|
549
|
+
{daemonRunning === false && !daemonStarting && messages.length === 0 && model === 'fcm' ? (
|
|
550
|
+
<div className={styles.daemonStartPanel}>
|
|
551
|
+
<IconAlertTriangle size={32} style={{ color: '#fbbf24', opacity: 0.8 }} />
|
|
552
|
+
<div className={styles.daemonStartTitle}>Router daemon is not running</div>
|
|
553
|
+
<div className={styles.daemonStartHint}>
|
|
554
|
+
The playground routes your chats through the FCM router daemon. Start it to begin chatting with free coding models, or pick a specific model above.
|
|
555
|
+
</div>
|
|
556
|
+
<button
|
|
557
|
+
className={styles.daemonStartBtn}
|
|
558
|
+
onClick={startDaemon}
|
|
559
|
+
title="Start the router daemon"
|
|
560
|
+
>
|
|
561
|
+
{daemonStarting ? (
|
|
562
|
+
<><IconLoader size={14} className={styles.spin} /> Startingβ¦</>
|
|
563
|
+
) : (
|
|
564
|
+
<><IconPlayerPlay size={14} /> Start Router{autoStartSec > 0 && !autoStartTriggered.current ? ` (auto in ${autoStartSec}s)` : ''}</>
|
|
565
|
+
)}
|
|
566
|
+
</button>
|
|
567
|
+
<div className={styles.daemonStartAlt}>
|
|
568
|
+
Or run <code>free-coding-models --daemon-bg</code> in your terminal
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
) : daemonStarting && messages.length === 0 && model === 'fcm' ? (
|
|
572
|
+
<div className={styles.daemonStartPanel}>
|
|
573
|
+
<IconLoader size={32} className={styles.spin} style={{ color: 'var(--accent, #22c55e)' }} />
|
|
574
|
+
<div className={styles.daemonStartTitle}>Starting router daemonβ¦</div>
|
|
575
|
+
<div className={styles.daemonStartHint}>
|
|
576
|
+
The daemon is being launched. This usually takes a few seconds. You'll be able to chat as soon as it's ready.
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
) : messages.length === 0 ? (
|
|
399
580
|
<div className={styles.empty}>
|
|
400
581
|
<IconMessageChatbot size={42} style={{ opacity: 0.5 }} />
|
|
401
582
|
<div className={styles.emptyTitle}>Try the FCM router in 10 seconds</div>
|
|
@@ -508,14 +689,14 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
508
689
|
value={input}
|
|
509
690
|
onChange={(e) => setInput(e.target.value)}
|
|
510
691
|
onKeyDown={handleKeyDown}
|
|
511
|
-
disabled={isLoading}
|
|
692
|
+
disabled={isLoading || (model === 'fcm' && daemonRunning !== true)}
|
|
512
693
|
rows={1}
|
|
513
694
|
data-testid="playground-input"
|
|
514
695
|
/>
|
|
515
696
|
<button
|
|
516
697
|
className={styles.sendBtn}
|
|
517
698
|
onClick={sendMessage}
|
|
518
|
-
disabled={isLoading || !input.trim()}
|
|
699
|
+
disabled={isLoading || !input.trim() || (model === 'fcm' && daemonRunning !== true)}
|
|
519
700
|
data-testid="playground-send"
|
|
520
701
|
>
|
|
521
702
|
<IconSend size={14} />
|
|
@@ -411,3 +411,85 @@
|
|
|
411
411
|
.stopBtn:hover {
|
|
412
412
|
background: rgba(248, 113, 113, 0.1);
|
|
413
413
|
}
|
|
414
|
+
|
|
415
|
+
/* ββ Daemon start panel βββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
416
|
+
|
|
417
|
+
.daemonStartPanel {
|
|
418
|
+
flex: 1;
|
|
419
|
+
display: flex;
|
|
420
|
+
flex-direction: column;
|
|
421
|
+
align-items: center;
|
|
422
|
+
justify-content: center;
|
|
423
|
+
color: var(--text-muted, #888);
|
|
424
|
+
text-align: center;
|
|
425
|
+
padding: 40px 24px;
|
|
426
|
+
gap: 12px;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.daemonStartTitle {
|
|
430
|
+
font-size: 16px;
|
|
431
|
+
color: var(--text, #eee);
|
|
432
|
+
font-weight: 600;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.daemonStartHint {
|
|
436
|
+
font-size: 12px;
|
|
437
|
+
max-width: 400px;
|
|
438
|
+
line-height: 1.5;
|
|
439
|
+
opacity: 0.7;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.daemonStartBtn {
|
|
443
|
+
margin-top: 4px;
|
|
444
|
+
background: var(--accent, #22c55e);
|
|
445
|
+
color: #061a08;
|
|
446
|
+
border: none;
|
|
447
|
+
border-radius: 8px;
|
|
448
|
+
padding: 10px 20px;
|
|
449
|
+
font-size: 14px;
|
|
450
|
+
font-weight: 600;
|
|
451
|
+
cursor: pointer;
|
|
452
|
+
display: flex;
|
|
453
|
+
align-items: center;
|
|
454
|
+
gap: 8px;
|
|
455
|
+
font-family: inherit;
|
|
456
|
+
transition: opacity 0.2s, transform 0.15s, filter 0.12s;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.daemonStartBtn:disabled {
|
|
460
|
+
opacity: 0.45;
|
|
461
|
+
cursor: not-allowed;
|
|
462
|
+
filter: grayscale(0.5);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.daemonStartBtn:hover:not(:disabled) {
|
|
466
|
+
filter: brightness(1.1);
|
|
467
|
+
transform: scale(1.02);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.daemonStartBtn:active:not(:disabled) {
|
|
471
|
+
transform: scale(0.98);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.daemonStartAlt {
|
|
475
|
+
font-size: 11px;
|
|
476
|
+
opacity: 0.5;
|
|
477
|
+
margin-top: 4px;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.daemonStartAlt code {
|
|
481
|
+
background: var(--bg-secondary, #16161e);
|
|
482
|
+
padding: 2px 6px;
|
|
483
|
+
border-radius: 4px;
|
|
484
|
+
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
485
|
+
font-size: 11px;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.spin {
|
|
489
|
+
animation: spinAnim 1s linear infinite;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
@keyframes spinAnim {
|
|
493
|
+
from { transform: rotate(0deg); }
|
|
494
|
+
to { transform: rotate(360deg); }
|
|
495
|
+
}
|
|
@@ -30,13 +30,22 @@ function formatNumber(n) {
|
|
|
30
30
|
return String(n)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// π Friendly labels for the circuit breaker states. The raw names
|
|
34
|
+
// π (CLOSED/OPEN/HALF_OPEN/AUTH_ERROR) are jargon β translate them
|
|
35
|
+
// π to words a normal developer can scan in <1 second.
|
|
36
|
+
const CIRCUIT_STATE_LABELS = {
|
|
37
|
+
CLOSED: { label: 'Healthy', cls: 'circuitClosed' },
|
|
38
|
+
OPEN: { label: 'Down', cls: 'circuitOpen' },
|
|
39
|
+
HALF_OPEN: { label: 'Recovering', cls: 'circuitHalfOpen' },
|
|
40
|
+
AUTH_ERROR: { label: 'Auth error', cls: 'circuitAuth' },
|
|
41
|
+
STALE: { label: 'Deprecated', cls: 'circuitUnknown' },
|
|
42
|
+
UNSUPPORTED:{ label: 'Unsupported',cls: 'circuitUnknown' },
|
|
43
|
+
UNKNOWN: { label: 'Unknown', cls: 'circuitUnknown' },
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function CircuitBadge({ state }) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
: state === 'HALF_OPEN' ? styles.circuitHalfOpen
|
|
37
|
-
: state === 'AUTH_ERROR' ? styles.circuitAuth
|
|
38
|
-
: styles.circuitUnknown
|
|
39
|
-
return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
|
|
47
|
+
const entry = CIRCUIT_STATE_LABELS[state] || CIRCUIT_STATE_LABELS.UNKNOWN
|
|
48
|
+
return <span className={`${styles.circuitBadge} ${styles[entry.cls]}`}>{entry.label}</span>
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
const SAVE_STATUS_IDLE = { kind: 'idle' }
|