free-coding-models 0.5.10 β 0.5.14
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 +40 -0
- package/bin/free-coding-models.js +6 -1
- package/changelog/v0.5.11.md +56 -0
- package/changelog/v0.5.12.md +68 -0
- package/changelog/v0.5.13.md +57 -0
- package/package.json +1 -1
- package/src/core/config.js +59 -1
- package/src/core/playground.js +502 -0
- package/src/core/router-daemon.js +861 -29
- package/src/core/utils.js +7 -0
- package/src/tui/app.js +9 -1
- package/src/tui/cli-help.js +2 -0
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +22 -0
- package/src/tui/overlays.js +11 -0
- package/src/tui/render-helpers.js +35 -0
- package/src/tui/render-table.js +22 -7
- package/src/tui/theme.js +3 -0
- package/web/dist/assets/index-Bzqz7KbT.js +39 -0
- package/web/dist/assets/index-H9JWDRIh.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +203 -1
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/ModelTable.jsx +9 -0
- package/web/src/components/dashboard/ModelTable.module.css +13 -0
- package/web/src/components/layout/Header.jsx +2 -1
- package/web/src/components/palette/CommandPalette.jsx +1 -0
- package/web/src/components/playground/PlaygroundView.jsx +528 -0
- package/web/src/components/playground/PlaygroundView.module.css +413 -0
- package/web/src/components/router/RouterView.jsx +519 -35
- package/web/src/components/router/RouterView.module.css +355 -0
- package/web/dist/assets/index-BrJgRhTA.css +0 -1
- package/web/dist/assets/index-BwLMt0bw.js +0 -39
|
@@ -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
|
+
}
|