free-coding-models 0.5.9 → 0.5.11

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.
@@ -0,0 +1,528 @@
1
+ /**
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.
7
+ *
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).
11
+ *
12
+ * @functions
13
+ * → PlaygroundView — full-screen chat modal with streaming + metadata
14
+ */
15
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
16
+ import {
17
+ IconMessageChatbot,
18
+ IconX,
19
+ IconSend,
20
+ IconTrash,
21
+ IconBolt,
22
+ IconRefresh,
23
+ IconAlertTriangle,
24
+ IconCopy,
25
+ IconCheck,
26
+ } from '@tabler/icons-react'
27
+ import styles from './PlaygroundView.module.css'
28
+
29
+ const SUGGESTIONS = [
30
+ 'Write a Python fizzbuzz with type hints',
31
+ 'Explain Big O notation with three examples',
32
+ 'Refactor a deeply nested for-loop into map/filter',
33
+ 'Write a tiny Express endpoint that returns JSON',
34
+ ]
35
+
36
+ /**
37
+ * 📖 Extract a human-readable message from an OpenAI-style error payload.
38
+ * 📖 Both shapes are accepted: `{ error: "string" }` (custom errors) and
39
+ * 📖 `{ error: { message, type, code } }` (the OpenAI wire format used by
40
+ * 📖 the FCM router daemon and every upstream provider).
41
+ *
42
+ * 📖 Returning a string is critical: the playground renders the error
43
+ * 📖 directly inside JSX, and React throws if a non-string child shows up
44
+ * 📖 — which is exactly the bug that was happening when the router was
45
+ * 📖 down and replied with `{ error: { message, type, code, ... } }`.
46
+ *
47
+ * @param {unknown} errBody
48
+ * @returns {string|null}
49
+ */
50
+ function extractErrorMessage(errBody) {
51
+ if (!errBody || typeof errBody !== 'object') {
52
+ return typeof errBody === 'string' ? errBody : null
53
+ }
54
+ if (typeof errBody.error === 'string') return errBody.error
55
+ if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
56
+ return errBody.error.message
57
+ }
58
+ if (typeof errBody.message === 'string') return errBody.message
59
+ return null
60
+ }
61
+
62
+ /**
63
+ * 📖 Pretty-print the assistant text. Renders triple-backtick code blocks as
64
+ * 📖 <pre> blocks; falls back to plain text otherwise. We do not pull in a
65
+ * 📖 markdown library to keep the dashboard zero-dep.
66
+ */
67
+ function renderAssistantText(text) {
68
+ if (!text) return null
69
+ const parts = text.split(/(```[\s\S]*?```)/g)
70
+ return parts.map((part, idx) => {
71
+ const codeMatch = part.match(/^```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```$/)
72
+ if (codeMatch) {
73
+ return (
74
+ <pre key={idx} className={styles.codeBlock}>
75
+ <code>{codeMatch[2].replace(/\n$/, '')}</code>
76
+ </pre>
77
+ )
78
+ }
79
+ return <span key={idx}>{part}</span>
80
+ })
81
+ }
82
+
83
+ function MetaChip({ icon, label, tone }) {
84
+ return (
85
+ <span className={`${styles.metaChip} ${tone ? styles[tone] : ''}`}>
86
+ {icon}
87
+ {label}
88
+ </span>
89
+ )
90
+ }
91
+
92
+ function StatusPill({ routerStatus }) {
93
+ if (!routerStatus) return null
94
+ if (!routerStatus.running) {
95
+ return (
96
+ <span className={`${styles.metaChip} ${styles.error}`}>
97
+ <IconAlertTriangle size={11} />
98
+ Router offline — start it to chat
99
+ </span>
100
+ )
101
+ }
102
+ return (
103
+ <span className={styles.metaChip}>
104
+ <IconBolt size={11} />
105
+ {routerStatus.activeSet || 'fcm'} · port {routerStatus.port || 19280}
106
+ </span>
107
+ )
108
+ }
109
+
110
+ export default function PlaygroundView({ onClose, onToast, models, routerStatus }) {
111
+ const [messages, setMessages] = useState([]) // { role, content, meta? }
112
+ const [input, setInput] = useState('')
113
+ const [model, setModel] = useState('fcm')
114
+ const [streamOn, setStreamOn] = useState(true)
115
+ const [prePromptEnabled, setPrePromptEnabled] = useState(true)
116
+ const [prePromptText, setPrePromptText] = useState('')
117
+ const [isLoading, setIsLoading] = useState(false)
118
+ const [error, setError] = useState(null)
119
+ const [copiedIdx, setCopiedIdx] = useState(null)
120
+ const abortRef = useRef(null)
121
+ const transcriptRef = useRef(null)
122
+
123
+ // 📖 Fetch the pre-prompt once on mount so the toggle shows the real
124
+ // 📖 value and the indicator matches what the router will inject.
125
+ useEffect(() => {
126
+ void fetch('/api/router/preprompt')
127
+ .then((r) => r.json())
128
+ .then((data) => {
129
+ if (data && typeof data === 'object') {
130
+ setPrePromptEnabled(data.enabled === true)
131
+ if (typeof data.text === 'string') setPrePromptText(data.text)
132
+ }
133
+ })
134
+ .catch(() => {})
135
+ }, [])
136
+
137
+ // 📖 Auto-scroll the transcript on new content.
138
+ useEffect(() => {
139
+ const el = transcriptRef.current
140
+ if (el) el.scrollTop = el.scrollHeight
141
+ }, [messages, isLoading])
142
+
143
+ // 📖 Stable list of model identifiers for the dropdown — defaults to
144
+ // 📖 `fcm` (the auto-router) and lets the user pin a specific catalog
145
+ // 📖 entry if they want a manual target.
146
+ const modelOptions = useMemo(() => {
147
+ const opts = [{ value: 'fcm', label: 'fcm — auto router (recommended)' }]
148
+ if (Array.isArray(models)) {
149
+ for (const m of models.slice(0, 200)) {
150
+ const id = m.modelId || m.id
151
+ if (!id) continue
152
+ const label = m.label || id
153
+ opts.push({ value: `${m.providerKey}/${id}`, label: `${m.providerKey}/${id} (${label})` })
154
+ }
155
+ }
156
+ return opts
157
+ }, [models])
158
+
159
+ const stopStream = useCallback(() => {
160
+ if (abortRef.current) {
161
+ abortRef.current.abort()
162
+ abortRef.current = null
163
+ }
164
+ setIsLoading(false)
165
+ }, [])
166
+
167
+ const sendMessage = useCallback(async () => {
168
+ const text = input.trim()
169
+ if (!text || isLoading) return
170
+ setError(null)
171
+ setInput('')
172
+
173
+ // 📖 Build the local transcript. We do NOT prepend the pre-prompt here
174
+ // 📖 because the router injects it server-side. We just send the user
175
+ // 📖 turn and let the daemon stamp the persona.
176
+ const userMessage = { role: 'user', content: text, ts: Date.now() }
177
+ const transcript = [...messages, userMessage]
178
+ setMessages(transcript)
179
+ setIsLoading(true)
180
+
181
+ const assistantId = `assistant-${Date.now()}`
182
+ setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', ts: Date.now() }])
183
+
184
+ const controller = new AbortController()
185
+ abortRef.current = controller
186
+
187
+ const body = {
188
+ model: model || 'fcm',
189
+ messages: transcript.map(({ role, content }) => ({ role, content })),
190
+ stream: streamOn,
191
+ temperature: 0.7,
192
+ }
193
+
194
+ try {
195
+ const resp = await fetch('/api/playground/chat', {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify(body),
199
+ signal: controller.signal,
200
+ })
201
+
202
+ if (!resp.ok) {
203
+ const errBody = await resp.json().catch(() => null)
204
+ const errMsg = extractErrorMessage(errBody) || `Request failed with status ${resp.status}`
205
+ setError(errMsg)
206
+ setMessages((prev) => prev.map((m) => (
207
+ m.id === assistantId
208
+ ? { ...m, content: m.content, error: errMsg, aborted: true }
209
+ : m
210
+ )))
211
+ setIsLoading(false)
212
+ return
213
+ }
214
+
215
+ if (streamOn) {
216
+ // 📖 Read SSE chunks from the proxy. Each chunk may carry several
217
+ // 📖 OpenAI-style "data: ..." lines; we only care about the delta
218
+ // 📖 content and the final routed-via metadata.
219
+ const reader = resp.body?.getReader()
220
+ if (!reader) throw new Error('No stream reader available')
221
+ const decoder = new TextDecoder()
222
+ let buffer = ''
223
+ const routed = {
224
+ provider: null,
225
+ model: null,
226
+ latencyMs: null,
227
+ tokens: 0,
228
+ fallbackAttempts: 0,
229
+ }
230
+ while (true) {
231
+ const { value, done } = await reader.read()
232
+ if (done) break
233
+ buffer += decoder.decode(value, { stream: true })
234
+ // 📖 Split on blank-line SSE boundaries.
235
+ const events = buffer.split(/\n\n/)
236
+ buffer = events.pop() || ''
237
+ for (const event of events) {
238
+ const lines = event.split(/\n/)
239
+ for (const line of lines) {
240
+ if (!line.startsWith('data:')) continue
241
+ const payload = line.slice(5).trim()
242
+ if (payload === '[DONE]') continue
243
+ try {
244
+ const json = JSON.parse(payload)
245
+ const delta = json?.choices?.[0]?.delta?.content
246
+ if (delta) {
247
+ setMessages((prev) => prev.map((m) => (
248
+ m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m
249
+ )))
250
+ }
251
+ if (json?.x_routed_via) routed.provider = json.x_routed_via
252
+ if (json?.x_routed_model) routed.model = json.x_routed_model
253
+ if (json?.x_latency_ms) routed.latencyMs = json.x_latency_ms
254
+ if (json?.x_fallback_attempts) routed.fallbackAttempts = json.x_fallback_attempts
255
+ if (json?.usage?.total_tokens) routed.tokens = json.usage.total_tokens
256
+ } catch {
257
+ // 📖 Ignore non-JSON keep-alive frames.
258
+ }
259
+ }
260
+ }
261
+ }
262
+ setMessages((prev) => prev.map((m) => (
263
+ m.id === assistantId ? { ...m, meta: routed } : m
264
+ )))
265
+ } else {
266
+ // 📖 Non-streaming: wait for the full JSON response and append.
267
+ const json = await resp.json()
268
+ const content = json?.choices?.[0]?.message?.content || ''
269
+ const usage = json?.usage || {}
270
+ const routed = {
271
+ provider: json?.x_routed_via || null,
272
+ model: json?.x_routed_model || null,
273
+ latencyMs: json?.x_latency_ms || null,
274
+ tokens: usage?.total_tokens || 0,
275
+ fallbackAttempts: json?.x_fallback_attempts || 0,
276
+ }
277
+ setMessages((prev) => prev.map((m) => (
278
+ m.id === assistantId ? { ...m, content, meta: routed } : m
279
+ )))
280
+ }
281
+ } catch (err) {
282
+ if (err.name === 'AbortError') {
283
+ setMessages((prev) => prev.map((m) => (
284
+ m.id === assistantId ? { ...m, aborted: true } : m
285
+ )))
286
+ } else {
287
+ setError(err.message || String(err))
288
+ setMessages((prev) => prev.map((m) => (
289
+ m.id === assistantId ? { ...m, error: err.message || String(err), aborted: true } : m
290
+ )))
291
+ }
292
+ } finally {
293
+ setIsLoading(false)
294
+ abortRef.current = null
295
+ }
296
+ }, [input, isLoading, messages, model, streamOn])
297
+
298
+ const handleKeyDown = useCallback((e) => {
299
+ if (e.key === 'Enter' && !e.shiftKey) {
300
+ e.preventDefault()
301
+ void sendMessage()
302
+ }
303
+ }, [sendMessage])
304
+
305
+ const clearTranscript = useCallback(() => {
306
+ if (isLoading) stopStream()
307
+ setMessages([])
308
+ setError(null)
309
+ }, [isLoading, stopStream])
310
+
311
+ const copyMessage = useCallback(async (idx, content) => {
312
+ try {
313
+ await navigator.clipboard.writeText(content || '')
314
+ setCopiedIdx(idx)
315
+ setTimeout(() => setCopiedIdx(null), 1500)
316
+ } catch {
317
+ onToast?.('Copy failed', 'error')
318
+ }
319
+ }, [onToast])
320
+
321
+ const totalTokens = useMemo(
322
+ () => messages.reduce((sum, m) => sum + (m.meta?.tokens || 0), 0),
323
+ [messages]
324
+ )
325
+
326
+ return (
327
+ <div className={styles.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
328
+ <div className={styles.modal} role="dialog" aria-label="Free Coding Models Playground">
329
+ <div className={styles.header}>
330
+ <div className={styles.headerLeft}>
331
+ <div className={styles.headerTitle}>
332
+ <IconMessageChatbot size={18} />
333
+ Playground
334
+ </div>
335
+ <div className={styles.headerSubtitle}>
336
+ Chat with the FCM router · {messages.length} message{messages.length === 1 ? '' : 's'} · {totalTokens} tokens
337
+ </div>
338
+ </div>
339
+ <div className={styles.headerActions}>
340
+ <button
341
+ className={styles.iconBtn}
342
+ onClick={clearTranscript}
343
+ title="Clear conversation"
344
+ disabled={messages.length === 0 && !isLoading}
345
+ >
346
+ <IconTrash size={16} />
347
+ </button>
348
+ <button
349
+ className={styles.iconBtn}
350
+ onClick={onClose}
351
+ title="Close (Esc)"
352
+ >
353
+ <IconX size={16} />
354
+ </button>
355
+ </div>
356
+ </div>
357
+
358
+ <div className={styles.modelBar}>
359
+ <span className={styles.modelLabel}>Model:</span>
360
+ <select
361
+ className={styles.modelSelect}
362
+ value={model}
363
+ onChange={(e) => setModel(e.target.value)}
364
+ disabled={isLoading}
365
+ >
366
+ {modelOptions.map((opt) => (
367
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
368
+ ))}
369
+ </select>
370
+ <button
371
+ className={styles.presetChip}
372
+ onClick={() => setStreamOn((v) => !v)}
373
+ title="Toggle streaming"
374
+ >
375
+ {streamOn ? '⚡ Streaming' : '🐢 One-shot'}
376
+ </button>
377
+ <label className={styles.prePromptToggle} title="Router persona injected as the first system message">
378
+ <input
379
+ type="checkbox"
380
+ checked={prePromptEnabled}
381
+ onChange={(e) => setPrePromptEnabled(e.target.checked)}
382
+ />
383
+ Pre-prompt
384
+ </label>
385
+ <StatusPill routerStatus={routerStatus} />
386
+ </div>
387
+
388
+ {prePromptEnabled && prePromptText && (
389
+ <div className={styles.modelBar} style={{ borderTop: 'none', background: 'transparent', paddingTop: 4, paddingBottom: 8, fontSize: 11 }}>
390
+ <span className={styles.modelLabel} style={{ flexShrink: 0 }}>Persona:</span>
391
+ <span style={{ opacity: 0.7, fontStyle: 'italic' }}>
392
+ {prePromptText.length > 160 ? `${prePromptText.slice(0, 160)}…` : prePromptText}
393
+ </span>
394
+ </div>
395
+ )}
396
+
397
+ <div className={styles.transcript} ref={transcriptRef}>
398
+ {messages.length === 0 ? (
399
+ <div className={styles.empty}>
400
+ <IconMessageChatbot size={42} style={{ opacity: 0.5 }} />
401
+ <div className={styles.emptyTitle}>Try the FCM router in 10 seconds</div>
402
+ <div className={styles.emptyHint}>
403
+ Each request is auto-routed to the healthiest free coding model in your active set.
404
+ The pre-prompt is injected server-side, so even plain <code>curl</code> callers get the same persona.
405
+ </div>
406
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, justifyContent: 'center', marginTop: 8 }}>
407
+ {SUGGESTIONS.map((s) => (
408
+ <button
409
+ key={s}
410
+ className={styles.presetChip}
411
+ onClick={() => setInput(s)}
412
+ >
413
+ {s}
414
+ </button>
415
+ ))}
416
+ </div>
417
+ </div>
418
+ ) : (
419
+ messages.map((m, idx) => (
420
+ <div key={m.id || idx} className={`${styles.message} ${styles[m.role] || ''}`}>
421
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
422
+ <div className={styles.messageRole}>{m.role}</div>
423
+ {m.role === 'assistant' && m.content && (
424
+ <button
425
+ className={styles.iconBtn}
426
+ style={{ padding: 2, border: 'none' }}
427
+ onClick={() => copyMessage(idx, m.content)}
428
+ title="Copy reply"
429
+ >
430
+ {copiedIdx === idx ? <IconCheck size={11} /> : <IconCopy size={11} />}
431
+ </button>
432
+ )}
433
+ </div>
434
+ <div className={styles.bubble}>
435
+ {m.role === 'assistant' ? (
436
+ <>
437
+ {renderAssistantText(m.content)}
438
+ {isLoading && idx === messages.length - 1 && !m.aborted && (
439
+ <span className={styles.cursor} />
440
+ )}
441
+ </>
442
+ ) : (
443
+ m.content
444
+ )}
445
+ </div>
446
+ {m.role === 'assistant' && m.meta && (m.meta.provider || m.meta.model) && (
447
+ <div className={styles.meta}>
448
+ {m.meta.provider && (
449
+ <MetaChip
450
+ icon={<IconBolt size={11} />}
451
+ label={`routed via ${m.meta.provider}/${m.meta.model || '?'}`}
452
+ tone="provider"
453
+ />
454
+ )}
455
+ {m.meta.latencyMs != null && (
456
+ <MetaChip label={`${m.meta.latencyMs} ms`} />
457
+ )}
458
+ {m.meta.tokens > 0 && (
459
+ <MetaChip label={`${m.meta.tokens} tok`} />
460
+ )}
461
+ {m.meta.fallbackAttempts > 0 && (
462
+ <MetaChip
463
+ icon={<IconRefresh size={11} />}
464
+ label={`${m.meta.fallbackAttempts} fallback${m.meta.fallbackAttempts > 1 ? 's' : ''}`}
465
+ />
466
+ )}
467
+ </div>
468
+ )}
469
+ {m.role === 'assistant' && m.error && (
470
+ <div className={styles.meta}>
471
+ <MetaChip
472
+ icon={<IconAlertTriangle size={11} />}
473
+ label={m.error}
474
+ tone="error"
475
+ />
476
+ </div>
477
+ )}
478
+ {m.role === 'assistant' && m.aborted && !m.error && (
479
+ <div className={styles.meta}>
480
+ <MetaChip label="stopped" />
481
+ </div>
482
+ )}
483
+ </div>
484
+ ))
485
+ )}
486
+ </div>
487
+
488
+ {error && (
489
+ <div className={styles.errorBar}>
490
+ <IconAlertTriangle size={14} />
491
+ {error}
492
+ </div>
493
+ )}
494
+
495
+ {isLoading && (
496
+ <div className={styles.stopBar}>
497
+ <span>Streaming response…</span>
498
+ <button className={styles.stopBtn} onClick={stopStream}>
499
+ Stop
500
+ </button>
501
+ </div>
502
+ )}
503
+
504
+ <div className={styles.inputBar}>
505
+ <textarea
506
+ className={styles.textarea}
507
+ placeholder="Ask the FCM router anything. Enter to send, Shift+Enter for newline."
508
+ value={input}
509
+ onChange={(e) => setInput(e.target.value)}
510
+ onKeyDown={handleKeyDown}
511
+ disabled={isLoading}
512
+ rows={1}
513
+ data-testid="playground-input"
514
+ />
515
+ <button
516
+ className={styles.sendBtn}
517
+ onClick={sendMessage}
518
+ disabled={isLoading || !input.trim()}
519
+ data-testid="playground-send"
520
+ >
521
+ <IconSend size={14} />
522
+ Send
523
+ </button>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ )
528
+ }