free-coding-models 0.5.15 β†’ 0.5.17

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,464 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ExpandedDetailRow.jsx
3
+ * @description Expandable 3-column detail row rendered below a model's table row.
4
+ * πŸ“– Left column: key stats (tier, SWE, context, provider, status, avg ping, stability, verdict, uptime)
5
+ * with favorite toggle + launch button at the bottom.
6
+ * πŸ“– Center column: mini chat playground β€” sends a user message to the model via SSE streaming
7
+ * and displays the response in real time.
8
+ * πŸ“– Right column: AI latency benchmark β€” POSTs to /api/benchmark-stream, parses SSE events
9
+ * (start/token/done/error), and shows live metrics (latency, tokens, TPS), a progress bar,
10
+ * and the streaming generated text preview.
11
+ * πŸ“– When the model has no API key configured, a banner is shown instead of the 3 columns.
12
+ * @functions ExpandedDetailRow β†’ main component
13
+ */
14
+ import { useState, useRef, useCallback } from 'react'
15
+ import { IconSend, IconPlayerPlayFilled, IconStar, IconStarFilled, IconLoader } from '@tabler/icons-react'
16
+ import TierBadge from '../atoms/TierBadge.jsx'
17
+ import VerdictBadge from '../atoms/VerdictBadge.jsx'
18
+ import StatusDot from '../atoms/StatusDot.jsx'
19
+ import StabilityCell from '../atoms/StabilityCell.jsx'
20
+ import { formatAvg, pingClass } from '../../utils/format.js'
21
+ import { sweClass } from '../../utils/ranks.js'
22
+ import LaunchButton from '../launch/LaunchButton.jsx'
23
+ import styles from './ExpandedDetailRow.module.css'
24
+
25
+ /**
26
+ * Single stat item with label + value, used in the info column grid.
27
+ * @param {string} label - Uppercase stat label
28
+ * @param {React.ReactNode} children - Stat value content
29
+ */
30
+ function StatItem({ label, children }) {
31
+ return (
32
+ <div className={styles.statItem}>
33
+ <span className={styles.statLabel}>{label}</span>
34
+ <span className={styles.statValue}>{children}</span>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Expandable detail row with 3 columns: Info, Mini Playground, AI Latency.
41
+ *
42
+ * @param {Object} props
43
+ * @param {Object} props.model - The model data object
44
+ * @param {Object} props.favorites - Favorites controller (isFavorite, toggle)
45
+ * @param {Function} [props.onBenchmark] - Benchmark callback
46
+ * @param {Function} [props.onLaunch] - Launch callback
47
+ * @param {Function} [props.onToast] - Toast notification callback
48
+ * @param {string} [props.toolMode='opencode'] - Current tool mode
49
+ * @param {Function} [props.onSetToolMode] - Set tool mode handler
50
+ * @param {Function} [props.onCycleToolMode] - Cycle tool mode handler
51
+ * @param {Function} [props.onOpenFallback] - Open fallback tool handler
52
+ */
53
+ export default function ExpandedDetailRow({
54
+ model,
55
+ favorites,
56
+ onBenchmark,
57
+ onLaunch,
58
+ onToast,
59
+ toolMode = 'opencode',
60
+ onSetToolMode,
61
+ onCycleToolMode,
62
+ onOpenFallback,
63
+ }) {
64
+ // ─── Mini Playground state ───
65
+ const [playgroundInput, setPlaygroundInput] = useState('')
66
+ const [playgroundResponse, setPlaygroundResponse] = useState('')
67
+ const [playgroundBusy, setPlaygroundBusy] = useState(false)
68
+ const playgroundAbort = useRef(null)
69
+
70
+ // ─── AI Latency benchmark state ───
71
+ const [benchState, setBenchState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
72
+ const [benchMetrics, setBenchMetrics] = useState({ latency: null, tokens: null, tps: null })
73
+ const [benchText, setBenchText] = useState('')
74
+ const [benchProgress, setBenchProgress] = useState(0)
75
+ const benchAbort = useRef(null)
76
+
77
+ if (!model) return null
78
+
79
+ const isFav = favorites?.isFavorite(model) ?? false
80
+ const avgData = formatAvg(model.avg)
81
+ const avgCls = avgData.cls || pingClass(model.avg)
82
+
83
+ // ─── Mini Playground: send a chat message ───
84
+ const handlePlaygroundSend = useCallback(async () => {
85
+ const text = playgroundInput.trim()
86
+ if (!text || playgroundBusy) return
87
+
88
+ setPlaygroundBusy(true)
89
+ setPlaygroundResponse('')
90
+ setPlaygroundInput('')
91
+
92
+ // Cancel any previous request
93
+ if (playgroundAbort.current) playgroundAbort.current.abort()
94
+ const controller = new AbortController()
95
+ playgroundAbort.current = controller
96
+
97
+ try {
98
+ const res = await fetch('/api/playground/chat', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({
102
+ model: `${model.providerKey}/${model.modelId}`,
103
+ messages: [{ role: 'user', content: text }],
104
+ stream: true,
105
+ temperature: 0.7,
106
+ }),
107
+ signal: controller.signal,
108
+ })
109
+
110
+ if (!res.ok) {
111
+ const errText = await res.text().catch(() => `HTTP ${res.status}`)
112
+ setPlaygroundResponse(`Error: ${errText}`)
113
+ return
114
+ }
115
+
116
+ const reader = res.body.getReader()
117
+ const decoder = new TextDecoder()
118
+ let accumulated = ''
119
+
120
+ while (true) {
121
+ const { done, value } = await reader.read()
122
+ if (done) break
123
+
124
+ const chunk = decoder.decode(value, { stream: true })
125
+ // Parse SSE lines
126
+ const lines = chunk.split('\n')
127
+ for (const line of lines) {
128
+ if (!line.startsWith('data: ')) continue
129
+ const payload = line.slice(6).trim()
130
+ if (payload === '[DONE]') continue
131
+
132
+ try {
133
+ const json = JSON.parse(payload)
134
+ const content = json.choices?.[0]?.delta?.content
135
+ if (content) {
136
+ accumulated += content
137
+ setPlaygroundResponse(accumulated)
138
+ }
139
+ } catch {
140
+ // Non-JSON line β€” ignore
141
+ }
142
+ }
143
+ }
144
+ } catch (err) {
145
+ if (err.name !== 'AbortError') {
146
+ setPlaygroundResponse(`Error: ${err.message}`)
147
+ }
148
+ } finally {
149
+ setPlaygroundBusy(false)
150
+ playgroundAbort.current = null
151
+ }
152
+ }, [playgroundInput, playgroundBusy, model.providerKey, model.modelId])
153
+
154
+ // ─── AI Latency: run benchmark ───
155
+ const handleBenchStart = useCallback(async () => {
156
+ if (benchState === 'running') return
157
+
158
+ setBenchState('running')
159
+ setBenchMetrics({ latency: null, tokens: null, tps: null })
160
+ setBenchText('')
161
+ setBenchProgress(0)
162
+
163
+ // Cancel any previous request
164
+ if (benchAbort.current) benchAbort.current.abort()
165
+ const controller = new AbortController()
166
+ benchAbort.current = controller
167
+
168
+ try {
169
+ const res = await fetch('/api/benchmark-stream', {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId }),
173
+ signal: controller.signal,
174
+ })
175
+
176
+ if (!res.ok) {
177
+ const errText = await res.text().catch(() => `HTTP ${res.status}`)
178
+ setBenchState('error')
179
+ setBenchText(`Error: ${errText}`)
180
+ return
181
+ }
182
+
183
+ const reader = res.body.getReader()
184
+ const decoder = new TextDecoder()
185
+ let buffer = ''
186
+ let accumulatedText = ''
187
+
188
+ while (true) {
189
+ const { done, value } = await reader.read()
190
+ if (done) break
191
+
192
+ buffer += decoder.decode(value, { stream: true })
193
+ const lines = buffer.split('\n')
194
+ // Keep the last potentially incomplete line in the buffer
195
+ buffer = lines.pop() || ''
196
+
197
+ for (const line of lines) {
198
+ const trimmed = line.trim()
199
+ if (!trimmed) continue
200
+
201
+ if (trimmed.startsWith('event: ')) {
202
+ // Event type line β€” we use data lines for actual content
203
+ continue
204
+ }
205
+
206
+ if (trimmed.startsWith('data: ')) {
207
+ const payload = trimmed.slice(6).trim()
208
+
209
+ try {
210
+ const json = JSON.parse(payload)
211
+ const eventType = json.type || json.event
212
+
213
+ if (eventType === 'token' || json.token !== undefined) {
214
+ // Live token β€” update streaming text and metrics
215
+ if (json.text) {
216
+ accumulatedText += json.text
217
+ setBenchText(accumulatedText)
218
+ }
219
+ if (json.totalMs != null) {
220
+ setBenchMetrics(prev => ({
221
+ ...prev,
222
+ latency: (json.totalMs / 1000).toFixed(2),
223
+ }))
224
+ }
225
+ if (json.tokens != null) {
226
+ setBenchMetrics(prev => ({ ...prev, tokens: json.tokens }))
227
+ setBenchProgress(Math.min(100, Math.round((json.tokens / 140) * 100)))
228
+ }
229
+ if (json.tps != null) {
230
+ setBenchMetrics(prev => ({ ...prev, tps: json.tps.toFixed(1) }))
231
+ }
232
+ } else if (eventType === 'done') {
233
+ // Benchmark complete
234
+ if (json.totalMs != null) {
235
+ setBenchMetrics(prev => ({
236
+ ...prev,
237
+ latency: (json.totalMs / 1000).toFixed(2),
238
+ }))
239
+ }
240
+ if (json.outputTokens != null) {
241
+ setBenchMetrics(prev => ({ ...prev, tokens: json.outputTokens }))
242
+ }
243
+ if (json.tokensPerSecond != null) {
244
+ setBenchMetrics(prev => ({
245
+ ...prev,
246
+ tps: json.tokensPerSecond.toFixed(1),
247
+ }))
248
+ }
249
+ setBenchProgress(100)
250
+ setBenchState('done')
251
+ } else if (eventType === 'error') {
252
+ setBenchState('error')
253
+ setBenchText(json.message || json.error || 'Unknown error')
254
+ }
255
+ } catch {
256
+ // Non-JSON payload β€” ignore
257
+ }
258
+ }
259
+ }
260
+ }
261
+ } catch (err) {
262
+ if (err.name !== 'AbortError') {
263
+ setBenchState('error')
264
+ setBenchText(`Error: ${err.message}`)
265
+ }
266
+ } finally {
267
+ if (benchState === 'running') {
268
+ setBenchState('done')
269
+ }
270
+ benchAbort.current = null
271
+ }
272
+ }, [benchState, model.providerKey, model.modelId])
273
+
274
+ // ─── No API key β€” show banner ───
275
+ if (!model.hasApiKey) {
276
+ return (
277
+ <div className={styles.row}>
278
+ <div className={styles.noKeyBanner}>
279
+ <span className={styles.noKeyIcon}>πŸ”’</span>
280
+ <span>No API key configured for {model.origin}. Open Settings (P) to add one.</span>
281
+ </div>
282
+ </div>
283
+ )
284
+ }
285
+
286
+ return (
287
+ <div className={styles.row}>
288
+ <div className={styles.grid}>
289
+
290
+ {/* ═══════ Column 1: Info ═══════ */}
291
+ <div className={styles.col}>
292
+ <div className={styles.colTitle}>πŸ“Š Model Info</div>
293
+ <div className={styles.statGrid}>
294
+ <StatItem label="Tier">
295
+ <TierBadge tier={model.tier} />
296
+ </StatItem>
297
+ <StatItem label="SWE-bench">
298
+ <span className={styles[sweClass(model.sweScore)]}>
299
+ {model.sweScore || 'β€”'}
300
+ </span>
301
+ </StatItem>
302
+ <StatItem label="Context">
303
+ {model.ctx || 'β€”'}
304
+ </StatItem>
305
+ <StatItem label="Provider">
306
+ {model.origin}
307
+ </StatItem>
308
+ <StatItem label="Status">
309
+ <StatusDot status={model.status} />
310
+ <span style={{ fontSize: 11 }}>{model.status}</span>
311
+ </StatItem>
312
+ <StatItem label="Avg Ping">
313
+ <span className={styles[avgCls]}>
314
+ {avgData.text}
315
+ </span>
316
+ </StatItem>
317
+ <StatItem label="Stability">
318
+ <StabilityCell score={model.stability} />
319
+ </StatItem>
320
+ <StatItem label="Verdict">
321
+ <VerdictBadge verdict={model.verdict} httpCode={model.httpCode} />
322
+ </StatItem>
323
+ <StatItem label="Uptime">
324
+ {model.uptime > 0 ? `${model.uptime}%` : 'β€”'}
325
+ </StatItem>
326
+ </div>
327
+ <div className={styles.infoActions}>
328
+ {favorites && (
329
+ <button
330
+ className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
331
+ onClick={() => favorites.toggle(model)}
332
+ title={isFav ? `Unfavorite ${model.label}` : `Favorite ${model.label}`}
333
+ >
334
+ {isFav
335
+ ? <IconStarFilled size={13} stroke={1.5} />
336
+ : <IconStar size={13} stroke={1.5} />
337
+ }
338
+ <span>{isFav ? 'Favorited' : 'Favorite'}</span>
339
+ </button>
340
+ )}
341
+ {onLaunch && (
342
+ <LaunchButton
343
+ model={model}
344
+ toolMode={toolMode}
345
+ onLaunch={onLaunch}
346
+ variant="default"
347
+ />
348
+ )}
349
+ </div>
350
+ </div>
351
+
352
+ {/* ═══════ Column 2: Mini Playground ═══════ */}
353
+ <div className={styles.col}>
354
+ <div className={styles.playgroundHeader}>
355
+ πŸ’¬ Mini Playground β€” {model.label}
356
+ </div>
357
+ <div className={styles.inputRow}>
358
+ <input
359
+ type="text"
360
+ className={styles.inputField}
361
+ placeholder="Type a message…"
362
+ value={playgroundInput}
363
+ onChange={e => setPlaygroundInput(e.target.value)}
364
+ onKeyDown={e => {
365
+ if (e.key === 'Enter') handlePlaygroundSend()
366
+ }}
367
+ disabled={playgroundBusy}
368
+ />
369
+ <button
370
+ className={styles.sendBtn}
371
+ onClick={handlePlaygroundSend}
372
+ disabled={playgroundBusy || !playgroundInput.trim()}
373
+ title="Send message"
374
+ >
375
+ {playgroundBusy
376
+ ? <IconLoader size={14} stroke={1.8} className={styles.spinning} />
377
+ : <IconSend size={14} stroke={1.8} />
378
+ }
379
+ </button>
380
+ </div>
381
+ <div className={styles.responseArea}>
382
+ {playgroundBusy && !playgroundResponse ? (
383
+ <span className={styles.responsePlaceholder}>
384
+ Waiting for response…
385
+ </span>
386
+ ) : playgroundResponse ? (
387
+ playgroundResponse
388
+ ) : (
389
+ <span className={styles.responsePlaceholder}>
390
+ Send a message to test this model. Responses stream in real time.
391
+ </span>
392
+ )}
393
+ </div>
394
+ </div>
395
+
396
+ {/* ═══════ Column 3: AI Latency ═══════ */}
397
+ <div className={styles.col}>
398
+ <button
399
+ className={styles.benchBtn}
400
+ onClick={handleBenchStart}
401
+ disabled={benchState === 'running'}
402
+ title={benchState === 'running' ? 'Running benchmark…' : 'Test AI Latency'}
403
+ >
404
+ {benchState === 'running' ? (
405
+ <IconLoader size={13} stroke={1.8} className={styles.spinning} />
406
+ ) : (
407
+ <IconPlayerPlayFilled size={13} stroke={1.8} />
408
+ )}
409
+ <span>{benchState === 'running' ? 'Running…' : 'Test AI Latency'}</span>
410
+ </button>
411
+
412
+ {/* Live metrics grid */}
413
+ <div className={styles.metricsGrid}>
414
+ <div className={styles.metricCard}>
415
+ <span className={styles.metricLabel}>Latency</span>
416
+ <span className={styles.metricValue}>
417
+ {benchMetrics.latency != null ? `${benchMetrics.latency}s` : 'β€”'}
418
+ </span>
419
+ </div>
420
+ <div className={styles.metricCard}>
421
+ <span className={styles.metricLabel}>Tokens</span>
422
+ <span className={styles.metricValue}>
423
+ {benchMetrics.tokens != null ? benchMetrics.tokens : 'β€”'}
424
+ </span>
425
+ </div>
426
+ <div className={styles.metricCard}>
427
+ <span className={styles.metricLabel}>TPS</span>
428
+ <span className={styles.metricValue}>
429
+ {benchMetrics.tps != null ? benchMetrics.tps : 'β€”'}
430
+ </span>
431
+ </div>
432
+ <div className={styles.metricCard}>
433
+ <span className={styles.metricLabel}>Status</span>
434
+ <span className={styles.metricValue}>
435
+ {benchState === 'idle' && '⏳'}
436
+ {benchState === 'running' && '⚑'}
437
+ {benchState === 'done' && 'βœ…'}
438
+ {benchState === 'error' && '❌'}
439
+ </span>
440
+ </div>
441
+ </div>
442
+
443
+ {/* Progress bar */}
444
+ <div className={styles.progressBar}>
445
+ <div
446
+ className={styles.progressFill}
447
+ style={{ width: `${benchProgress}%` }}
448
+ />
449
+ </div>
450
+
451
+ {/* Streaming text preview */}
452
+ <div className={styles.streamText}>
453
+ {benchText || (
454
+ <span className={styles.responsePlaceholder}>
455
+ Generated text will appear here…
456
+ </span>
457
+ )}
458
+ </div>
459
+ </div>
460
+
461
+ </div>
462
+ </div>
463
+ )
464
+ }