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
@@ -0,0 +1,527 @@
1
+ /**
2
+ * @file web/src/components/playground/PlaygroundChat.jsx
3
+ * @description The SINGLE shared playground chat core used by all three
4
+ * playgrounds in the web app:
5
+ * 1. PlaygroundView (full-screen modal, multi-turn) — variant="full"
6
+ * 2. RouterView ("Test Router" section) — variant="mini"
7
+ * 3. ExpandedDetailRow ("Mini Playground" column) — variant="mini"
8
+ *
9
+ * 📖 Why this exists: the three playgrounds used to each ship their own copy
10
+ * 📖 of the input/transcript/SSE-parsing/metadata code, which drifted apart —
11
+ * 📖 ExpandedDetailRow showed NO metadata at all, RouterView showed a partial
12
+ * 📖 badge, and PlaygroundView read body fields (x_routed_via / x_latency_ms)
13
+ * 📖 that the daemon never actually emits, so its chips were dead code. This
14
+ * 📖 component is the single source of truth: identical look + identical
15
+ * 📖 metadata on every surface.
16
+ *
17
+ * 📖 Metadata contract (the thing the user asked to harmonize):
18
+ * 📖 • Under every USER bubble → the addressed model (`provider/model`),
19
+ * 📖 or the router's Primary pick when routing through `fcm`.
20
+ * 📖 • Under every ASSISTANT bubble →
21
+ * 📖 `provider/model · 1234ms · 42 tok · 34.1 t/s`
22
+ * 📖 The served model is read from the response header `x-fcm-router-model`
23
+ * 📖 (forwarded by the daemon for `fcm`); for direct routing the served
24
+ * 📖 model is simply the requested model. Latency is measured client-side
25
+ * 📖 (send → stream end); tokens come from the final chunk's `usage`;
26
+ * 📖 TPS = tokens / (latencyMs / 1000).
27
+ *
28
+ * 📖 All traffic goes through `/api/playground/chat` (the proxy in server.js)
29
+ * 📖 so the browser never touches providers directly (no CORS, no exposed keys).
30
+ *
31
+ * @functions
32
+ * → PlaygroundChat — self-contained chat core (input + transcript + meta)
33
+ * → parseModelKey — split "provider/modelId" into { provider, model }
34
+ * → extractErrorMessage — normalize OpenAI-style error payloads to a string
35
+ * → renderAssistantText — render triple-backtick code blocks without a dep
36
+ */
37
+ import {
38
+ useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef,
39
+ } from 'react'
40
+ import {
41
+ IconSend, IconX, IconCopy, IconCheck, IconBolt, IconClock,
42
+ IconAlertTriangle, IconRefresh, IconLoader,
43
+ } from '@tabler/icons-react'
44
+ import styles from './PlaygroundChat.module.css'
45
+
46
+ /**
47
+ * Split a "provider/modelId" key into its parts. Returns { provider, model }.
48
+ * Tolerant: "fcm" → { provider: 'fcm', model: '' }; "a/b/c" → provider='a', model='b/c'.
49
+ * @param {string|null|undefined} key
50
+ * @returns {{provider:string, model:string}|null}
51
+ */
52
+ function parseModelKey(key) {
53
+ if (!key) return null
54
+ const idx = key.indexOf('/')
55
+ if (idx <= 0) return { provider: key, model: '' }
56
+ return { provider: key.slice(0, idx), model: key.slice(idx + 1) }
57
+ }
58
+
59
+ /**
60
+ * 📖 Normalize an OpenAI-style error body to a plain string. Both shapes are
61
+ * 📖 accepted: `{ error: "msg" }` (custom) and `{ error: { message, type, code } }`
62
+ * 📖 (the OpenAI wire format). Returning a string is critical because React
63
+ * 📖 throws if a non-string sneaks into JSX.
64
+ * @param {unknown} errBody
65
+ * @returns {string|null}
66
+ */
67
+ function extractErrorMessage(errBody) {
68
+ if (!errBody || typeof errBody !== 'object') {
69
+ return typeof errBody === 'string' ? errBody : null
70
+ }
71
+ if (typeof errBody.error === 'string') return errBody.error
72
+ if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
73
+ return errBody.error.message
74
+ }
75
+ if (typeof errBody.message === 'string') return errBody.message
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * 📖 Render assistant text with triple-backtick code blocks rendered as <pre>.
81
+ * 📖 No markdown dependency — keeps the dashboard zero-dep. Shared by every
82
+ * 📖 playground so code looks identical everywhere.
83
+ * @param {string} text
84
+ * @returns {React.ReactNode}
85
+ */
86
+ function renderAssistantText(text) {
87
+ if (!text) return null
88
+ const parts = text.split(/(```[\s\S]*?```)/g)
89
+ return parts.map((part, idx) => {
90
+ const codeMatch = part.match(/^```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```$/)
91
+ if (codeMatch) {
92
+ return (
93
+ <pre key={idx} className={styles.codeBlock}>
94
+ <code>{codeMatch[2].replace(/\n$/, '')}</code>
95
+ </pre>
96
+ )
97
+ }
98
+ return <span key={idx}>{part}</span>
99
+ })
100
+ }
101
+
102
+ /** Round to one decimal, returns '—' for non-finite numbers. */
103
+ function round1(n) {
104
+ return Number.isFinite(n) ? (Math.round(n * 10) / 10).toFixed(1) : '—'
105
+ }
106
+
107
+ /**
108
+ * Shared playground chat core. See file header.
109
+ *
110
+ * @param {Object} props
111
+ * @param {string} props.model — 'fcm' (auto-router) or 'providerKey/modelId' (direct).
112
+ * @param {boolean} [props.disabled=false] — disable the input + send button (e.g. daemon down).
113
+ * @param {'full'|'mini'} [props.variant='mini'] — density/size of the UI.
114
+ * @param {boolean} [props.stream=true] — request SSE streaming vs. one-shot JSON.
115
+ * @param {string} [props.placeholder] — input placeholder.
116
+ * @param {string} [props.targetLabel] — override the "addressed model" label shown under the
117
+ * user bubble. The RouterView passes its Primary pick here so the user sees which model the
118
+ * priority-first router will try first. When omitted, the addressed model is derived from `model`.
119
+ * @param {string[]} [props.suggestions] — clickable chips in the empty state.
120
+ * @param {string} [props.emptyTitle] — empty-state title.
121
+ * @param {string} [props.emptyHint] — empty-state hint line.
122
+ * @param {React.ReactNode} [props.emptyIcon] — empty-state icon.
123
+ * @param {React.ReactNode} [props.children] — optional extra node rendered above the transcript
124
+ * (used by PlaygroundView to host its daemon-start panel / pre-prompt hint).
125
+ * @param {(summary:{count:number,totalTokens:number,lastMeta?:object}) => void} [props.onTurnComplete]
126
+ * — fired after every turn so hosts can update their own chrome (token counters, etc.).
127
+ * @param {React.Ref} ref — imperative handle exposing { clear(), focus(), getMessages() }.
128
+ */
129
+ const PlaygroundChat = forwardRef(function PlaygroundChat({
130
+ model,
131
+ disabled = false,
132
+ variant = 'mini',
133
+ stream = true,
134
+ placeholder,
135
+ targetLabel,
136
+ suggestions = [],
137
+ emptyTitle,
138
+ emptyHint,
139
+ emptyIcon,
140
+ children,
141
+ onTurnComplete,
142
+ }, ref) {
143
+ // 📖 Each message: { id, role, content, target?, meta?, error?, aborted?, pending? }
144
+ // 📖 meta = { provider, model, latencyMs, tokens, tps }
145
+ const [messages, setMessages] = useState([])
146
+ const [input, setInput] = useState('')
147
+ const [loading, setLoading] = useState(false)
148
+ const [copiedIdx, setCopiedIdx] = useState(null)
149
+ const abortRef = useRef(null)
150
+ const inputRef = useRef(null)
151
+ const transcriptRef = useRef(null)
152
+ // 📖 Ref mirror of `messages` so the imperative handle (below) can read the
153
+ // 📖 live transcript without being recreated on every message change.
154
+ const messagesRef = useRef([])
155
+
156
+ useImperativeHandle(ref, () => ({
157
+ /** Stop any in-flight stream and clear the transcript. */
158
+ clear: () => {
159
+ if (abortRef.current) { abortRef.current.abort(); abortRef.current = null }
160
+ setLoading(false)
161
+ setMessages([])
162
+ },
163
+ focus: () => inputRef.current?.focus(),
164
+ // 📖 `getMessages` is a stable ref read, so the handle never has to be
165
+ // 📖 recreated when messages change.
166
+ getMessages: () => messagesRef.current,
167
+ }), [])
168
+
169
+ // 📖 Keep the ref mirror in sync with the state transcript.
170
+ useEffect(() => { messagesRef.current = messages }, [messages])
171
+
172
+ // 📖 Notify the host after a turn SETTLES (token counters etc.). Gated on
173
+ // 📖 the last message being finalized (meta / error / aborted) so it doesn't
174
+ // 📖 fire on every streamed token — only once per completed exchange.
175
+ useEffect(() => {
176
+ if (typeof onTurnComplete !== 'function') return
177
+ const last = messages[messages.length - 1]
178
+ if (!last || last.pending) return // still streaming
179
+ const settled = last.role === 'assistant' && (!!last.meta || last.error || last.aborted)
180
+ if (!settled) return
181
+ const totalTokens = messages.reduce((sum, m) => sum + (m.meta?.tokens || 0), 0)
182
+ onTurnComplete({ count: messages.length, totalTokens, lastMeta: last.meta })
183
+ }, [messages, onTurnComplete])
184
+
185
+ // 📖 Auto-scroll the transcript to the bottom as content streams in.
186
+ useEffect(() => {
187
+ const el = transcriptRef.current
188
+ if (el) el.scrollTop = el.scrollHeight
189
+ }, [messages, loading])
190
+
191
+ const stop = useCallback(() => {
192
+ if (abortRef.current) { abortRef.current.abort(); abortRef.current = null }
193
+ setLoading(false)
194
+ }, [])
195
+
196
+ const sendMessage = useCallback(async (overrideText) => {
197
+ const text = (typeof overrideText === 'string' ? overrideText : input).trim()
198
+ if (!text || loading || disabled) return
199
+
200
+ setInput('')
201
+
202
+ // 📖 Build the addressed-model label for the USER bubble.
203
+ const targetKey = targetLabel || (model === 'fcm' ? 'fcm' : model)
204
+
205
+ const userMsg = { id: `u-${Date.now()}`, role: 'user', content: text, ts: Date.now(), target: targetKey }
206
+ const assistantId = `a-${Date.now()}`
207
+ const assistantMsg = { id: assistantId, role: 'assistant', content: '', pending: true }
208
+ const transcript = [...messages, userMsg]
209
+ setMessages([...transcript, assistantMsg])
210
+ setLoading(true)
211
+
212
+ const controller = new AbortController()
213
+ abortRef.current = controller
214
+ const startedAt = Date.now()
215
+
216
+ // 📖 Patch helpers: update the in-flight assistant bubble in place.
217
+ const patchAssistant = (patch) => setMessages((prev) => prev.map((m) => (
218
+ m.id === assistantId ? { ...m, ...patch } : m
219
+ )))
220
+
221
+ // 📖 Served-model resolution. The daemon (fcm route) forwards the real
222
+ // 📖 served model through the `x-fcm-router-model` response header; for
223
+ // 📖 direct routing there is no such header, so the served model is the
224
+ // 📖 requested one. We resolve the header lazily once we have the resp.
225
+ let servedKey = null
226
+ const resolveServed = (resp) => {
227
+ const headerKey = resp.headers.get('x-fcm-router-model')
228
+ servedKey = headerKey || (model !== 'fcm' ? model : null)
229
+ return parseModelKey(servedKey) || { provider: model, model: '' }
230
+ }
231
+
232
+ const body = {
233
+ model: model || 'fcm',
234
+ messages: transcript.map(({ role, content }) => ({ role, content })),
235
+ stream,
236
+ temperature: 0.7,
237
+ }
238
+
239
+ try {
240
+ const resp = await fetch('/api/playground/chat', {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify(body),
244
+ signal: controller.signal,
245
+ })
246
+
247
+ if (!resp.ok) {
248
+ const errBody = await resp.json().catch(() => null)
249
+ const errMsg = extractErrorMessage(errBody) || `Request failed (${resp.status})`
250
+ patchAssistant({ pending: false, error: errMsg })
251
+ return
252
+ }
253
+
254
+ if (stream) {
255
+ // ── SSE streaming path ─────────────────────────────────────────
256
+ const reader = resp.body?.getReader()
257
+ if (!reader) throw new Error('No stream reader available')
258
+ const decoder = new TextDecoder()
259
+ let buffer = ''
260
+ let acc = ''
261
+ let served = resolveServed(resp)
262
+ let tokens = 0
263
+ let servedKnown = !!servedKey
264
+
265
+ while (true) {
266
+ const { value, done } = await reader.read()
267
+ if (done) break
268
+ buffer += decoder.decode(value, { stream: true })
269
+ // 📖 Split on blank-line SSE boundaries; keep the trailing partial.
270
+ const events = buffer.split(/\n\n/)
271
+ buffer = events.pop() || ''
272
+ for (const event of events) {
273
+ const lines = event.split(/\n/)
274
+ for (const line of lines) {
275
+ if (!line.startsWith('data:')) continue
276
+ const payload = line.slice(5).trim()
277
+ if (payload === '[DONE]') continue
278
+ try {
279
+ const json = JSON.parse(payload)
280
+ const delta = json?.choices?.[0]?.delta?.content
281
+ if (typeof delta === 'string' && delta) {
282
+ acc += delta
283
+ patchAssistant({ content: acc, pending: false })
284
+ }
285
+ // 📖 Tokens: the final chunk carries usage. Prefer completion
286
+ // 📖 tokens (matches what was generated) over total.
287
+ const usage = json?.usage
288
+ if (usage) {
289
+ if (typeof usage.completion_tokens === 'number') tokens = usage.completion_tokens
290
+ else if (typeof usage.total_tokens === 'number') tokens = usage.total_tokens
291
+ }
292
+ // 📖 Some daemons/proxies also forward the served model in the
293
+ // 📖 body — accept it as a fallback to the header.
294
+ if (!servedKnown && json?.x_routed_via) {
295
+ served = { provider: json.x_routed_via, model: json.x_routed_model || '' }
296
+ servedKnown = true
297
+ }
298
+ } catch {
299
+ // 📖 Ignore non-JSON keep-alive frames.
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ const latencyMs = Date.now() - startedAt
306
+ const tps = tokens > 0 && latencyMs > 0 ? tokens / (latencyMs / 1000) : null
307
+ patchAssistant({
308
+ pending: false,
309
+ meta: {
310
+ provider: served.provider,
311
+ model: served.model,
312
+ latencyMs,
313
+ tokens,
314
+ tps,
315
+ },
316
+ })
317
+ } else {
318
+ // ── One-shot JSON path ─────────────────────────────────────────
319
+ const json = await resp.json().catch(() => null)
320
+ const content = json?.choices?.[0]?.message?.content || ''
321
+ const usage = json?.usage || {}
322
+ const tokens = typeof usage.completion_tokens === 'number'
323
+ ? usage.completion_tokens
324
+ : (usage.total_tokens || 0)
325
+ const served = resolveServed(resp)
326
+ const latencyMs = Date.now() - startedAt
327
+ const tps = tokens > 0 && latencyMs > 0 ? tokens / (latencyMs / 1000) : null
328
+ patchAssistant({
329
+ pending: false,
330
+ content,
331
+ meta: {
332
+ provider: served.provider,
333
+ model: served.model,
334
+ latencyMs,
335
+ tokens,
336
+ tps,
337
+ },
338
+ })
339
+ }
340
+
341
+ } catch (err) {
342
+ if (err?.name === 'AbortError') {
343
+ patchAssistant({ pending: false, aborted: true })
344
+ } else {
345
+ patchAssistant({ pending: false, error: err?.message || String(err) })
346
+ }
347
+ } finally {
348
+ setLoading(false)
349
+ abortRef.current = null
350
+ }
351
+ }, [input, loading, disabled, messages, model, stream, targetLabel, onTurnComplete])
352
+
353
+ const handleKeyDown = useCallback((e) => {
354
+ // 📖 Enter sends, Shift+Enter inserts a newline (full variant only).
355
+ if (e.key === 'Enter' && !e.shiftKey && variant === 'full') {
356
+ e.preventDefault()
357
+ void sendMessage()
358
+ } else if (e.key === 'Enter' && variant === 'mini') {
359
+ e.preventDefault()
360
+ void sendMessage()
361
+ }
362
+ }, [sendMessage, variant])
363
+
364
+ const copyMessage = useCallback(async (idx, content) => {
365
+ try {
366
+ await navigator.clipboard.writeText(content || '')
367
+ setCopiedIdx(idx)
368
+ setTimeout(() => setCopiedIdx(null), 1500)
369
+ } catch {}
370
+ }, [])
371
+
372
+ // 📖 Tiny auto-grow for the textarea so 'full' expands with long prompts.
373
+ const handleInputChange = useCallback((e) => {
374
+ setInput(e.target.value)
375
+ if (variant === 'full') {
376
+ const el = e.target
377
+ el.style.height = 'auto'
378
+ el.style.height = `${Math.min(el.scrollHeight, 180)}px`
379
+ }
380
+ }, [variant])
381
+
382
+ const variantClass = variant === 'full' ? styles.full : styles.mini
383
+
384
+ return (
385
+ <div className={`${styles.root} ${variantClass}`}>
386
+ {children}
387
+
388
+ <div className={styles.transcript} ref={transcriptRef}>
389
+ {messages.length === 0 ? (
390
+ <div className={styles.empty}>
391
+ {emptyIcon && <div className={styles.emptyIcon}>{emptyIcon}</div>}
392
+ {emptyTitle && <div className={styles.emptyTitle}>{emptyTitle}</div>}
393
+ {emptyHint && <div className={styles.emptyHint}>{emptyHint}</div>}
394
+ {suggestions.length > 0 && (
395
+ <div className={styles.suggestions}>
396
+ {suggestions.map((s) => (
397
+ <button key={s} className={styles.chip} onClick={() => void sendMessage(s)} type="button">
398
+ {s}
399
+ </button>
400
+ ))}
401
+ </div>
402
+ )}
403
+ </div>
404
+ ) : (
405
+ messages.map((m, idx) => (
406
+ <div key={m.id || idx} className={`${styles.message} ${styles[m.role] || ''}`}>
407
+ <div className={styles.bubbleRow}>
408
+ <span className={styles.role}>{m.role}</span>
409
+ {m.role === 'assistant' && m.content && (
410
+ <button
411
+ className={styles.copyBtn}
412
+ onClick={() => copyMessage(idx, m.content)}
413
+ title="Copy reply"
414
+ type="button"
415
+ >
416
+ {copiedIdx === idx ? <IconCheck size={11} /> : <IconCopy size={11} />}
417
+ </button>
418
+ )}
419
+ </div>
420
+
421
+ <div className={styles.bubble}>
422
+ {m.role === 'assistant' ? (
423
+ <>
424
+ {m.content ? renderAssistantText(m.content) : null}
425
+ {loading && idx === messages.length - 1 && m.pending && !m.aborted && (
426
+ <span className={styles.cursor} />
427
+ )}
428
+ {!m.content && m.pending && <span className={styles.typing}>…</span>}
429
+ </>
430
+ ) : (
431
+ m.content
432
+ )}
433
+ </div>
434
+
435
+ {/* ── Metadata row under the USER bubble: addressed model ── */}
436
+ {m.role === 'user' && m.target && (
437
+ <div className={styles.meta}>
438
+ <span className={`${styles.metaChip} ${styles.target}`} title="Addressed model">
439
+ <IconBolt size={11} />
440
+ {m.target}
441
+ </span>
442
+ </div>
443
+ )}
444
+
445
+ {/* ── Metadata row under the ASSISTANT bubble: served model + ms + tok + t/s ── */}
446
+ {m.role === 'assistant' && !m.pending && m.meta && (
447
+ <div className={styles.meta}>
448
+ {m.meta.provider && (
449
+ <span className={`${styles.metaChip} ${styles.modelChip}`} title="Model that served this reply">
450
+ <IconBolt size={11} />
451
+ {m.meta.provider}{m.meta.model ? `/${m.meta.model}` : ''}
452
+ </span>
453
+ )}
454
+ {m.meta.latencyMs != null && (
455
+ <span className={`${styles.metaChip} ${styles.latencyChip}`} title="Response time (send → end)">
456
+ <IconClock size={11} />
457
+ {Math.round(m.meta.latencyMs)}ms
458
+ </span>
459
+ )}
460
+ {m.meta.tokens > 0 && (
461
+ <span className={styles.metaChip} title="Completion tokens">
462
+ {m.meta.tokens} tok
463
+ </span>
464
+ )}
465
+ {m.meta.tps != null && m.meta.tps > 0 && (
466
+ <span className={`${styles.metaChip} ${styles.tpsChip}`} title="Tokens per second">
467
+ <IconRefresh size={11} />
468
+ {round1(m.meta.tps)} t/s
469
+ </span>
470
+ )}
471
+ </div>
472
+ )}
473
+
474
+ {m.role === 'assistant' && m.error && (
475
+ <div className={styles.meta}>
476
+ <span className={`${styles.metaChip} ${styles.errorChip}`} title="Error">
477
+ <IconAlertTriangle size={11} />
478
+ {m.error}
479
+ </span>
480
+ </div>
481
+ )}
482
+ {m.role === 'assistant' && m.aborted && !m.error && (
483
+ <div className={styles.meta}>
484
+ <span className={styles.metaChip}>stopped</span>
485
+ </div>
486
+ )}
487
+ </div>
488
+ ))
489
+ )}
490
+ </div>
491
+
492
+ <div className={styles.inputBar}>
493
+ <textarea
494
+ ref={inputRef}
495
+ className={styles.input}
496
+ placeholder={placeholder || (variant === 'full'
497
+ ? 'Ask anything. Enter to send, Shift+Enter for a newline.'
498
+ : 'Send a message…')}
499
+ value={input}
500
+ onChange={handleInputChange}
501
+ onKeyDown={handleKeyDown}
502
+ disabled={disabled}
503
+ rows={1}
504
+ />
505
+ {loading ? (
506
+ <button className={styles.stopBtn} onClick={stop} title="Stop generating" type="button">
507
+ <IconLoader size={14} className={styles.spin} />
508
+ {variant === 'full' ? 'Stop' : ''}
509
+ </button>
510
+ ) : (
511
+ <button
512
+ className={styles.sendBtn}
513
+ onClick={() => void sendMessage()}
514
+ disabled={disabled || !input.trim()}
515
+ title="Send"
516
+ type="button"
517
+ >
518
+ <IconSend size={14} />
519
+ {variant === 'full' ? 'Send' : ''}
520
+ </button>
521
+ )}
522
+ </div>
523
+ </div>
524
+ )
525
+ })
526
+
527
+ export default PlaygroundChat