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.
@@ -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
- if (!routerStatus) return null
94
- if (!routerStatus.running) {
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 β€” start it to chat
108
+ Router offline
99
109
  </span>
100
110
  )
101
111
  }
112
+ // null = checking...
102
113
  return (
103
114
  <span className={styles.metaChip}>
104
- <IconBolt size={11} />
105
- {routerStatus.activeSet || 'fcm'} Β· port {routerStatus.port || 19280}
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('fcm')
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 cls = state === 'CLOSED' ? styles.circuitClosed
35
- : state === 'OPEN' ? styles.circuitOpen
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' }
@@ -18,7 +18,7 @@ export default defineConfig({
18
18
  emptyOutDir: true,
19
19
  },
20
20
  server: {
21
- port: 5173,
21
+ port: 5179,
22
22
  proxy: {
23
23
  '/api': {
24
24
  target: 'http://localhost:3333',