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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/free-coding-models.js +10 -2
  3. package/changelog/v0.5.29.md +19 -0
  4. package/changelog/v0.5.30.md +15 -0
  5. package/package.json +1 -1
  6. package/sources.js +3 -2
  7. package/src/core/config.js +1 -14
  8. package/src/core/endpoint-installer.js +5 -9
  9. package/src/core/legacy-proxy-cleanup.js +2 -0
  10. package/src/core/model-merger.js +4 -13
  11. package/src/core/playground.js +6 -4
  12. package/src/core/router-daemon.js +101 -43
  13. package/src/core/router-dashboard.js +63 -25
  14. package/src/core/shared-helpers.js +117 -0
  15. package/src/core/sync-set.js +11 -24
  16. package/src/core/tool-launchers.js +24 -92
  17. package/src/core/utils.js +26 -100
  18. package/src/tui/app.js +2 -2
  19. package/src/tui/command-palette.js +1 -0
  20. package/src/tui/key-handler.js +77 -20
  21. package/src/tui/render-table.js +3 -3
  22. package/web/dist/assets/index-CsFt3qt5.css +1 -0
  23. package/web/dist/assets/index-I6N91rH7.js +40 -0
  24. package/web/dist/favicon.ico +0 -0
  25. package/web/dist/favicons/apple-touch-icon.png +0 -0
  26. package/web/dist/favicons/favicon-16x16.png +0 -0
  27. package/web/dist/favicons/favicon-192x192.png +0 -0
  28. package/web/dist/favicons/favicon-32x32.png +0 -0
  29. package/web/dist/favicons/favicon-48x48.png +0 -0
  30. package/web/dist/favicons/favicon-512x512.png +0 -0
  31. package/web/dist/favicons/favicon-96x96.png +0 -0
  32. package/web/dist/favicons/favicon.ico +0 -0
  33. package/web/dist/favicons/mstile-150x150.png +0 -0
  34. package/web/dist/favicons/mstile-310x150.png +0 -0
  35. package/web/dist/favicons/mstile-310x310.png +0 -0
  36. package/web/dist/favicons/mstile-512x512.png +0 -0
  37. package/web/dist/favicons/mstile-70x70.png +0 -0
  38. package/web/dist/index.html +2 -2
  39. package/web/public/favicon.ico +0 -0
  40. package/web/public/favicons/apple-touch-icon.png +0 -0
  41. package/web/public/favicons/favicon-16x16.png +0 -0
  42. package/web/public/favicons/favicon-192x192.png +0 -0
  43. package/web/public/favicons/favicon-32x32.png +0 -0
  44. package/web/public/favicons/favicon-48x48.png +0 -0
  45. package/web/public/favicons/favicon-512x512.png +0 -0
  46. package/web/public/favicons/favicon-96x96.png +0 -0
  47. package/web/public/favicons/favicon.ico +0 -0
  48. package/web/public/favicons/mstile-150x150.png +0 -0
  49. package/web/public/favicons/mstile-310x150.png +0 -0
  50. package/web/public/favicons/mstile-310x310.png +0 -0
  51. package/web/public/favicons/mstile-512x512.png +0 -0
  52. package/web/public/favicons/mstile-70x70.png +0 -0
  53. package/web/server.js +34 -4
  54. package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
  55. package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
  56. package/web/src/components/playground/PlaygroundChat.jsx +527 -0
  57. package/web/src/components/playground/PlaygroundChat.module.css +382 -0
  58. package/web/src/components/playground/PlaygroundView.jsx +104 -442
  59. package/web/src/components/playground/PlaygroundView.module.css +10 -0
  60. package/web/src/components/router/RouterView.jsx +268 -56
  61. package/web/src/components/router/RouterView.module.css +416 -1
  62. package/src/core/product-flags.js +0 -9
  63. package/web/dist/assets/index-BRFowJv5.css +0 -1
  64. package/web/dist/assets/index-Pr3waI0-.js +0 -41
@@ -1,31 +1,38 @@
1
1
  /**
2
2
  * @file web/src/components/playground/PlaygroundView.jsx
3
- * @description Playground chat modal — multi-turn chat through the FCM
4
- * router. Streams responses, shows the routed-via provider/model on each
5
- * assistant message, and exposes the configured pre-prompt inline so the
6
- * user knows what the system says about itself.
3
+ * @description Playground chat modal — multi-turn chat through the FCM router
4
+ * OR directly to a specific model. This is the HOST (chrome) layer; the
5
+ * actual chat (transcript, input, SSE streaming, metadata) is delegated to
6
+ * the shared <PlaygroundChat> core so the full playground, the RouterView
7
+ * "Test Router", and the ExpandedDetailRow "Mini Playground" all look and
8
+ * behave identically.
7
9
  *
8
- * 📖 All traffic goes through `/api/playground/chat` (a thin proxy in
9
- * 📖 `web/server.js`) so the browser never talks to the daemon directly
10
- * 📖 (no CORS, no exposed provider keys).
10
+ * 📖 What this host still owns:
11
+ * 📖 - Header (title, message/token counter, clear + close)
12
+ * 📖 - Model bar (selector, streaming toggle, pre-prompt toggle, router status)
13
+ * 📖 - Pre-prompt hint (fetched from /api/router/preprompt)
14
+ * 📖 - Router daemon lifecycle (auto-check, 5s auto-start, start button)
15
+ * 📖 - Smart model selection on mount (best "up" model with a key)
11
16
  *
12
- * @functions
13
- * → PlaygroundView — full-screen chat modal with streaming + metadata
17
+ * 📖 What it delegates to <PlaygroundChat>:
18
+ * 📖 - The transcript + input + send/stop
19
+ * 📖 - SSE streaming through /api/playground/chat
20
+ * 📖 - The harmonized metadata row under every reply
21
+ * 📖 (provider/model · ms · tok · t/s)
22
+ *
23
+ * @functions PlaygroundView — full-screen chat modal host wrapping PlaygroundChat
14
24
  */
15
25
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
16
26
  import {
17
27
  IconMessageChatbot,
18
28
  IconX,
19
- IconSend,
20
29
  IconTrash,
21
30
  IconBolt,
22
- IconRefresh,
23
31
  IconAlertTriangle,
24
- IconCopy,
25
- IconCheck,
26
32
  IconPlayerPlay,
27
33
  IconLoader,
28
34
  } from '@tabler/icons-react'
35
+ import PlaygroundChat from './PlaygroundChat.jsx'
29
36
  import styles from './PlaygroundView.module.css'
30
37
 
31
38
  const SUGGESTIONS = [
@@ -36,63 +43,11 @@ const SUGGESTIONS = [
36
43
  ]
37
44
 
38
45
  /**
39
- * 📖 Extract a human-readable message from an OpenAI-style error payload.
40
- * 📖 Both shapes are accepted: `{ error: "string" }` (custom errors) and
41
- * 📖 `{ error: { message, type, code } }` (the OpenAI wire format used by
42
- * 📖 the FCM router daemon and every upstream provider).
43
- *
44
- * 📖 Returning a string is critical: the playground renders the error
45
- * 📖 directly inside JSX, and React throws if a non-string child shows up
46
- * 📖 — which is exactly the bug that was happening when the router was
47
- * 📖 down and replied with `{ error: { message, type, code, ... } }`.
48
- *
49
- * @param {unknown} errBody
50
- * @returns {string|null}
51
- */
52
- function extractErrorMessage(errBody) {
53
- if (!errBody || typeof errBody !== 'object') {
54
- return typeof errBody === 'string' ? errBody : null
55
- }
56
- if (typeof errBody.error === 'string') return errBody.error
57
- if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
58
- return errBody.error.message
59
- }
60
- if (typeof errBody.message === 'string') return errBody.message
61
- return null
62
- }
63
-
64
- /**
65
- * 📖 Pretty-print the assistant text. Renders triple-backtick code blocks as
66
- * 📖 <pre> blocks; falls back to plain text otherwise. We do not pull in a
67
- * 📖 markdown library to keep the dashboard zero-dep.
46
+ * 📖 Router status pill shown in the model bar. `daemonRunning` is a tri-state:
47
+ * 📖 null = checking, true = online, false = offline. Kept here because it is
48
+ * 📖 pure chrome (not part of the chat core).
68
49
  */
69
- function renderAssistantText(text) {
70
- if (!text) return null
71
- const parts = text.split(/(```[\s\S]*?```)/g)
72
- return parts.map((part, idx) => {
73
- const codeMatch = part.match(/^```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```$/)
74
- if (codeMatch) {
75
- return (
76
- <pre key={idx} className={styles.codeBlock}>
77
- <code>{codeMatch[2].replace(/\n$/, '')}</code>
78
- </pre>
79
- )
80
- }
81
- return <span key={idx}>{part}</span>
82
- })
83
- }
84
-
85
- function MetaChip({ icon, label, tone }) {
86
- return (
87
- <span className={`${styles.metaChip} ${tone ? styles[tone] : ''}`}>
88
- {icon}
89
- {label}
90
- </span>
91
- )
92
- }
93
-
94
50
  function StatusPill({ routerStatus, daemonRunning }) {
95
- // 📖 Prefer the live daemon state over the stale prop.
96
51
  if (daemonRunning === true) {
97
52
  return (
98
53
  <span className={styles.metaChip}>
@@ -109,7 +64,6 @@ function StatusPill({ routerStatus, daemonRunning }) {
109
64
  </span>
110
65
  )
111
66
  }
112
- // null = checking...
113
67
  return (
114
68
  <span className={styles.metaChip}>
115
69
  <IconLoader size={11} className={styles.spin} />
@@ -119,19 +73,13 @@ function StatusPill({ routerStatus, daemonRunning }) {
119
73
  }
120
74
 
121
75
  export default function PlaygroundView({ onClose, onToast, models, routerStatus }) {
122
- const [messages, setMessages] = useState([]) // { role, content, meta? }
123
- const [input, setInput] = useState('')
76
+ // ── Chrome state (host-owned) ──────────────────────────────────────────
124
77
  const [model, setModel] = useState(null) // null = computing best model
125
78
  const [streamOn, setStreamOn] = useState(true)
126
79
  const [prePromptEnabled, setPrePromptEnabled] = useState(true)
127
80
  const [prePromptText, setPrePromptText] = useState('')
128
- const [isLoading, setIsLoading] = useState(false)
129
- const [error, setError] = useState(null)
130
- const [copiedIdx, setCopiedIdx] = useState(null)
131
- const abortRef = useRef(null)
132
- const transcriptRef = useRef(null)
133
81
 
134
- // 📖 Daemon state: null = checking, true = running, false = down
82
+ // 📖 Router daemon lifecycle (null = checking, true = running, false = down)
135
83
  const [daemonRunning, setDaemonRunning] = useState(
136
84
  routerStatus?.running === true ? true : null
137
85
  )
@@ -141,8 +89,14 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
141
89
  const daemonCheckRef = useRef(null)
142
90
  const autoStartTriggered = useRef(false)
143
91
 
144
- // 📖 Fetch the pre-prompt once on mount so the toggle shows the real
145
- // 📖 value and the indicator matches what the router will inject.
92
+ // 📖 Header counter + empty-state gate. Updated by PlaygroundChat via the
93
+ // 📖 onTurnComplete callback so the host chrome stays in sync with the
94
+ // 📖 chat core it no longer owns directly.
95
+ const [chatStats, setChatStats] = useState({ count: 0, totalTokens: 0 })
96
+ const chatRef = useRef(null)
97
+
98
+ // 📖 Fetch the pre-prompt once on mount so the toggle shows the real value
99
+ // 📖 and the hint matches what the router injects server-side.
146
100
  useEffect(() => {
147
101
  void fetch('/api/router/preprompt')
148
102
  .then((r) => r.json())
@@ -155,18 +109,15 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
155
109
  .catch(() => {})
156
110
  }, [])
157
111
 
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.
112
+ // 📖 Smart model selection on mount: pick the best "up" model with an API
113
+ // 📖 key, preferring lower latency and higher tier. Falls back to "fcm" so
114
+ // 📖 the playground opens immediately even without a working model.
163
115
  useEffect(() => {
164
- if (model !== null) return // already resolved
116
+ if (model !== null) return
165
117
  const upModels = (Array.isArray(models) ? models : [])
166
118
  .filter((m) => m.status === 'up' && m.hasApiKey && !m.isPinging)
167
119
 
168
120
  if (upModels.length > 0) {
169
- // 📖 Sort: best tier first (S+ > S > A+ ...), then lowest avg latency
170
121
  const tierOrder = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
171
122
  upModels.sort((a, b) => {
172
123
  const ta = tierOrder[a.tier] ?? 99
@@ -176,45 +127,26 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
176
127
  const avgB = typeof b.avg === 'number' ? b.avg : 99999
177
128
  return avgA - avgB
178
129
  })
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')
130
+ setModel(`${upModels[0].providerKey}/${upModels[0].modelId}`)
187
131
  return
188
132
  }
189
-
190
- // 📖 Nothing works — default to fcm anyway (will show daemon start panel)
191
133
  setModel('fcm')
192
- }, [models, model, routerStatus, daemonRunning])
134
+ }, [models, model])
193
135
 
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.
136
+ // 📖 Check daemon status on mount.
198
137
  useEffect(() => {
199
138
  let mounted = true
200
-
201
139
  async function checkDaemon() {
202
140
  try {
203
141
  const resp = await fetch('/api/router/status')
204
142
  const data = await resp.json()
205
143
  if (!mounted) return
206
- if (data?.running) {
207
- setDaemonRunning(true)
208
- return
209
- }
210
- setDaemonRunning(false)
144
+ setDaemonRunning(data?.running === true)
211
145
  } catch {
212
146
  if (mounted) setDaemonRunning(false)
213
147
  }
214
148
  }
215
-
216
- checkDaemon()
217
-
149
+ void checkDaemon()
218
150
  return () => {
219
151
  mounted = false
220
152
  if (cooldownRef.current) clearInterval(cooldownRef.current)
@@ -222,30 +154,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
222
154
  }
223
155
  }, [])
224
156
 
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.
157
+ // 📖 Start the router daemon, then poll until it is ready.
249
158
  const startDaemon = useCallback(async () => {
250
159
  if (daemonStarting) return
251
160
  if (cooldownRef.current) { clearInterval(cooldownRef.current); cooldownRef.current = null }
@@ -253,7 +162,6 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
253
162
  setDaemonStarting(true)
254
163
  try {
255
164
  await fetch('/api/router/start', { method: 'POST' })
256
- // 📖 Poll until the daemon is confirmed running (max 30s)
257
165
  let attempts = 0
258
166
  await new Promise((resolve) => {
259
167
  daemonCheckRef.current = setInterval(async () => {
@@ -280,15 +188,27 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
280
188
  }
281
189
  }, [daemonStarting])
282
190
 
283
- // 📖 Auto-scroll the transcript on new content.
191
+ // 📖 Auto-start the daemon after 5 seconds if it is down, the model is the
192
+ // 📖 "fcm" auto-router, and auto-start hasn't fired yet. The button is always
193
+ // 📖 clickable so the user can trigger it manually at any time.
284
194
  useEffect(() => {
285
- const el = transcriptRef.current
286
- if (el) el.scrollTop = el.scrollHeight
287
- }, [messages, isLoading])
195
+ if (daemonRunning !== false || model !== 'fcm' || autoStartTriggered.current) return
196
+ let remaining = 5
197
+ setAutoStartSec(5)
198
+ cooldownRef.current = setInterval(() => {
199
+ remaining -= 1
200
+ setAutoStartSec(remaining)
201
+ if (remaining <= 0) {
202
+ clearInterval(cooldownRef.current)
203
+ autoStartTriggered.current = true
204
+ void startDaemon()
205
+ }
206
+ }, 1000)
207
+ return () => { if (cooldownRef.current) clearInterval(cooldownRef.current) }
208
+ }, [daemonRunning, model, startDaemon])
288
209
 
289
- // 📖 Stable list of model identifiers for the dropdown — defaults to
290
- // 📖 `fcm` (the auto-router) and lets the user pin a specific catalog
291
- // 📖 entry if they want a manual target.
210
+ // 📖 Stable list of model identifiers for the dropdown — defaults to `fcm`
211
+ // 📖 (the auto-router) and lets the user pin a specific catalog entry.
292
212
  const modelOptions = useMemo(() => {
293
213
  const opts = [{ value: 'fcm', label: 'fcm — auto router (recommended)' }]
294
214
  if (Array.isArray(models)) {
@@ -302,177 +222,28 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
302
222
  return opts
303
223
  }, [models])
304
224
 
305
- const stopStream = useCallback(() => {
306
- if (abortRef.current) {
307
- abortRef.current.abort()
308
- abortRef.current = null
309
- }
310
- setIsLoading(false)
225
+ // 📖 PlaygroundChat turn callback → keep the header counter in sync and
226
+ // 📖 drive the daemon-panel gate (only show it when there are no messages).
227
+ const handleTurnComplete = useCallback((summary) => {
228
+ setChatStats({ count: summary.count, totalTokens: summary.totalTokens })
311
229
  }, [])
312
230
 
313
- const sendMessage = useCallback(async () => {
314
- const text = input.trim()
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
- }
321
- setError(null)
322
- setInput('')
323
-
324
- // 📖 Build the local transcript. We do NOT prepend the pre-prompt here
325
- // 📖 because the router injects it server-side. We just send the user
326
- // 📖 turn and let the daemon stamp the persona.
327
- const userMessage = { role: 'user', content: text, ts: Date.now() }
328
- const transcript = [...messages, userMessage]
329
- setMessages(transcript)
330
- setIsLoading(true)
331
-
332
- const assistantId = `assistant-${Date.now()}`
333
- setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', ts: Date.now() }])
334
-
335
- const controller = new AbortController()
336
- abortRef.current = controller
337
-
338
- const body = {
339
- model: model || 'fcm',
340
- messages: transcript.map(({ role, content }) => ({ role, content })),
341
- stream: streamOn,
342
- temperature: 0.7,
343
- }
344
-
345
- try {
346
- const resp = await fetch('/api/playground/chat', {
347
- method: 'POST',
348
- headers: { 'Content-Type': 'application/json' },
349
- body: JSON.stringify(body),
350
- signal: controller.signal,
351
- })
352
-
353
- if (!resp.ok) {
354
- const errBody = await resp.json().catch(() => null)
355
- const errMsg = extractErrorMessage(errBody) || `Request failed with status ${resp.status}`
356
- setError(errMsg)
357
- setMessages((prev) => prev.map((m) => (
358
- m.id === assistantId
359
- ? { ...m, content: m.content, error: errMsg, aborted: true }
360
- : m
361
- )))
362
- setIsLoading(false)
363
- return
364
- }
365
-
366
- if (streamOn) {
367
- // 📖 Read SSE chunks from the proxy. Each chunk may carry several
368
- // 📖 OpenAI-style "data: ..." lines; we only care about the delta
369
- // 📖 content and the final routed-via metadata.
370
- const reader = resp.body?.getReader()
371
- if (!reader) throw new Error('No stream reader available')
372
- const decoder = new TextDecoder()
373
- let buffer = ''
374
- const routed = {
375
- provider: null,
376
- model: null,
377
- latencyMs: null,
378
- tokens: 0,
379
- fallbackAttempts: 0,
380
- }
381
- while (true) {
382
- const { value, done } = await reader.read()
383
- if (done) break
384
- buffer += decoder.decode(value, { stream: true })
385
- // 📖 Split on blank-line SSE boundaries.
386
- const events = buffer.split(/\n\n/)
387
- buffer = events.pop() || ''
388
- for (const event of events) {
389
- const lines = event.split(/\n/)
390
- for (const line of lines) {
391
- if (!line.startsWith('data:')) continue
392
- const payload = line.slice(5).trim()
393
- if (payload === '[DONE]') continue
394
- try {
395
- const json = JSON.parse(payload)
396
- const delta = json?.choices?.[0]?.delta?.content
397
- if (delta) {
398
- setMessages((prev) => prev.map((m) => (
399
- m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m
400
- )))
401
- }
402
- if (json?.x_routed_via) routed.provider = json.x_routed_via
403
- if (json?.x_routed_model) routed.model = json.x_routed_model
404
- if (json?.x_latency_ms) routed.latencyMs = json.x_latency_ms
405
- if (json?.x_fallback_attempts) routed.fallbackAttempts = json.x_fallback_attempts
406
- if (json?.usage?.total_tokens) routed.tokens = json.usage.total_tokens
407
- } catch {
408
- // 📖 Ignore non-JSON keep-alive frames.
409
- }
410
- }
411
- }
412
- }
413
- setMessages((prev) => prev.map((m) => (
414
- m.id === assistantId ? { ...m, meta: routed } : m
415
- )))
416
- } else {
417
- // 📖 Non-streaming: wait for the full JSON response and append.
418
- const json = await resp.json()
419
- const content = json?.choices?.[0]?.message?.content || ''
420
- const usage = json?.usage || {}
421
- const routed = {
422
- provider: json?.x_routed_via || null,
423
- model: json?.x_routed_model || null,
424
- latencyMs: json?.x_latency_ms || null,
425
- tokens: usage?.total_tokens || 0,
426
- fallbackAttempts: json?.x_fallback_attempts || 0,
427
- }
428
- setMessages((prev) => prev.map((m) => (
429
- m.id === assistantId ? { ...m, content, meta: routed } : m
430
- )))
431
- }
432
- } catch (err) {
433
- if (err.name === 'AbortError') {
434
- setMessages((prev) => prev.map((m) => (
435
- m.id === assistantId ? { ...m, aborted: true } : m
436
- )))
437
- } else {
438
- setError(err.message || String(err))
439
- setMessages((prev) => prev.map((m) => (
440
- m.id === assistantId ? { ...m, error: err.message || String(err), aborted: true } : m
441
- )))
442
- }
443
- } finally {
444
- setIsLoading(false)
445
- abortRef.current = null
446
- }
447
- }, [input, isLoading, messages, model, streamOn, daemonRunning])
448
-
449
- const handleKeyDown = useCallback((e) => {
450
- if (e.key === 'Enter' && !e.shiftKey) {
451
- e.preventDefault()
452
- void sendMessage()
453
- }
454
- }, [sendMessage])
231
+ const handleClear = useCallback(() => {
232
+ chatRef.current?.clear()
233
+ setChatStats({ count: 0, totalTokens: 0 })
234
+ }, [])
455
235
 
456
- const clearTranscript = useCallback(() => {
457
- if (isLoading) stopStream()
458
- setMessages([])
459
- setError(null)
460
- }, [isLoading, stopStream])
236
+ // 📖 Can the user send right now? fcm needs the daemon; a pinned model
237
+ // 📖 routes directly to its provider and works without the daemon.
238
+ const canSend = !(model === 'fcm' && daemonRunning !== true)
461
239
 
462
- const copyMessage = useCallback(async (idx, content) => {
463
- try {
464
- await navigator.clipboard.writeText(content || '')
465
- setCopiedIdx(idx)
466
- setTimeout(() => setCopiedIdx(null), 1500)
467
- } catch {
468
- onToast?.('Copy failed', 'error')
469
- }
470
- }, [onToast])
471
-
472
- const totalTokens = useMemo(
473
- () => messages.reduce((sum, m) => sum + (m.meta?.tokens || 0), 0),
474
- [messages]
475
- )
240
+ // 📖 Daemon-panel gates. The panels replace the chat area entirely when the
241
+ // 📖 user is on the auto-router, the daemon is down/starting, and there is
242
+ // 📖 no conversation yet to keep on screen.
243
+ const hasMessages = chatStats.count > 0
244
+ const showDaemonStartPanel = model === 'fcm' && !hasMessages && daemonRunning === false && !daemonStarting
245
+ const showDaemonStartingPanel = model === 'fcm' && !hasMessages && daemonStarting
246
+ const showChat = !showDaemonStartPanel && !showDaemonStartingPanel
476
247
 
477
248
  return (
478
249
  <div className={styles.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
@@ -484,15 +255,15 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
484
255
  Playground
485
256
  </div>
486
257
  <div className={styles.headerSubtitle}>
487
- Chat with the FCM router · {messages.length} message{messages.length === 1 ? '' : 's'} · {totalTokens} tokens
258
+ Chat with the FCM router · {chatStats.count} message{chatStats.count === 1 ? '' : 's'} · {chatStats.totalTokens} tokens
488
259
  </div>
489
260
  </div>
490
261
  <div className={styles.headerActions}>
491
262
  <button
492
263
  className={styles.iconBtn}
493
- onClick={clearTranscript}
264
+ onClick={handleClear}
494
265
  title="Clear conversation"
495
- disabled={messages.length === 0 && !isLoading}
266
+ disabled={!hasMessages}
496
267
  >
497
268
  <IconTrash size={16} />
498
269
  </button>
@@ -510,9 +281,9 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
510
281
  <span className={styles.modelLabel}>Model:</span>
511
282
  <select
512
283
  className={styles.modelSelect}
513
- value={model}
284
+ value={model ?? 'fcm'}
514
285
  onChange={(e) => setModel(e.target.value)}
515
- disabled={isLoading}
286
+ disabled={!showChat}
516
287
  >
517
288
  {modelOptions.map((opt) => (
518
289
  <option key={opt.value} value={opt.value}>{opt.label}</option>
@@ -545,8 +316,8 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
545
316
  </div>
546
317
  )}
547
318
 
548
- <div className={styles.transcript} ref={transcriptRef}>
549
- {daemonRunning === false && !daemonStarting && messages.length === 0 && model === 'fcm' ? (
319
+ <div className={styles.chatBody}>
320
+ {showDaemonStartPanel ? (
550
321
  <div className={styles.daemonStartPanel}>
551
322
  <IconAlertTriangle size={32} style={{ color: '#fbbf24', opacity: 0.8 }} />
552
323
  <div className={styles.daemonStartTitle}>Router daemon is not running</div>
@@ -568,7 +339,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
568
339
  Or run <code>free-coding-models --daemon-bg</code> in your terminal
569
340
  </div>
570
341
  </div>
571
- ) : daemonStarting && messages.length === 0 && model === 'fcm' ? (
342
+ ) : showDaemonStartingPanel ? (
572
343
  <div className={styles.daemonStartPanel}>
573
344
  <IconLoader size={32} className={styles.spin} style={{ color: 'var(--accent, #22c55e)' }} />
574
345
  <div className={styles.daemonStartTitle}>Starting router daemon…</div>
@@ -576,133 +347,24 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
576
347
  The daemon is being launched. This usually takes a few seconds. You'll be able to chat as soon as it's ready.
577
348
  </div>
578
349
  </div>
579
- ) : messages.length === 0 ? (
580
- <div className={styles.empty}>
581
- <IconMessageChatbot size={42} style={{ opacity: 0.5 }} />
582
- <div className={styles.emptyTitle}>Try the FCM router in 10 seconds</div>
583
- <div className={styles.emptyHint}>
584
- Each request is auto-routed to the healthiest free coding model in your active set.
585
- The pre-prompt is injected server-side, so even plain <code>curl</code> callers get the same persona.
586
- </div>
587
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, justifyContent: 'center', marginTop: 8 }}>
588
- {SUGGESTIONS.map((s) => (
589
- <button
590
- key={s}
591
- className={styles.presetChip}
592
- onClick={() => setInput(s)}
593
- >
594
- {s}
595
- </button>
596
- ))}
597
- </div>
598
- </div>
599
- ) : (
600
- messages.map((m, idx) => (
601
- <div key={m.id || idx} className={`${styles.message} ${styles[m.role] || ''}`}>
602
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
603
- <div className={styles.messageRole}>{m.role}</div>
604
- {m.role === 'assistant' && m.content && (
605
- <button
606
- className={styles.iconBtn}
607
- style={{ padding: 2, border: 'none' }}
608
- onClick={() => copyMessage(idx, m.content)}
609
- title="Copy reply"
610
- >
611
- {copiedIdx === idx ? <IconCheck size={11} /> : <IconCopy size={11} />}
612
- </button>
613
- )}
614
- </div>
615
- <div className={styles.bubble}>
616
- {m.role === 'assistant' ? (
617
- <>
618
- {renderAssistantText(m.content)}
619
- {isLoading && idx === messages.length - 1 && !m.aborted && (
620
- <span className={styles.cursor} />
621
- )}
622
- </>
623
- ) : (
624
- m.content
625
- )}
626
- </div>
627
- {m.role === 'assistant' && m.meta && (m.meta.provider || m.meta.model) && (
628
- <div className={styles.meta}>
629
- {m.meta.provider && (
630
- <MetaChip
631
- icon={<IconBolt size={11} />}
632
- label={`routed via ${m.meta.provider}/${m.meta.model || '?'}`}
633
- tone="provider"
634
- />
635
- )}
636
- {m.meta.latencyMs != null && (
637
- <MetaChip label={`${m.meta.latencyMs} ms`} />
638
- )}
639
- {m.meta.tokens > 0 && (
640
- <MetaChip label={`${m.meta.tokens} tok`} />
641
- )}
642
- {m.meta.fallbackAttempts > 0 && (
643
- <MetaChip
644
- icon={<IconRefresh size={11} />}
645
- label={`${m.meta.fallbackAttempts} fallback${m.meta.fallbackAttempts > 1 ? 's' : ''}`}
646
- />
647
- )}
648
- </div>
649
- )}
650
- {m.role === 'assistant' && m.error && (
651
- <div className={styles.meta}>
652
- <MetaChip
653
- icon={<IconAlertTriangle size={11} />}
654
- label={m.error}
655
- tone="error"
656
- />
657
- </div>
658
- )}
659
- {m.role === 'assistant' && m.aborted && !m.error && (
660
- <div className={styles.meta}>
661
- <MetaChip label="stopped" />
662
- </div>
663
- )}
664
- </div>
665
- ))
350
+ ) : null}
351
+
352
+ {showChat && (
353
+ <PlaygroundChat
354
+ ref={chatRef}
355
+ model={model || 'fcm'}
356
+ variant="full"
357
+ stream={streamOn}
358
+ disabled={!canSend}
359
+ onTurnComplete={handleTurnComplete}
360
+ suggestions={SUGGESTIONS}
361
+ emptyTitle="Try the FCM router in 10 seconds"
362
+ emptyHint="Each request is auto-routed to the healthiest free coding model in your active set, or sent directly to the model you picked. Every reply shows the served model, latency, and TPS below it."
363
+ emptyIcon={<IconMessageChatbot size={42} style={{ opacity: 0.5 }} />}
364
+ placeholder="Ask anything. Enter to send, Shift+Enter for a newline."
365
+ />
666
366
  )}
667
367
  </div>
668
-
669
- {error && (
670
- <div className={styles.errorBar}>
671
- <IconAlertTriangle size={14} />
672
- {error}
673
- </div>
674
- )}
675
-
676
- {isLoading && (
677
- <div className={styles.stopBar}>
678
- <span>Streaming response…</span>
679
- <button className={styles.stopBtn} onClick={stopStream}>
680
- Stop
681
- </button>
682
- </div>
683
- )}
684
-
685
- <div className={styles.inputBar}>
686
- <textarea
687
- className={styles.textarea}
688
- placeholder="Ask the FCM router anything. Enter to send, Shift+Enter for newline."
689
- value={input}
690
- onChange={(e) => setInput(e.target.value)}
691
- onKeyDown={handleKeyDown}
692
- disabled={isLoading || (model === 'fcm' && daemonRunning !== true)}
693
- rows={1}
694
- data-testid="playground-input"
695
- />
696
- <button
697
- className={styles.sendBtn}
698
- onClick={sendMessage}
699
- disabled={isLoading || !input.trim() || (model === 'fcm' && daemonRunning !== true)}
700
- data-testid="playground-send"
701
- >
702
- <IconSend size={14} />
703
- Send
704
- </button>
705
- </div>
706
368
  </div>
707
369
  </div>
708
370
  )