free-coding-models 0.5.5 → 0.5.8

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 (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file web/src/components/recommend/RecommendView.jsx
3
+ * @description Smart Recommend modal for Web M3 parity: 3-question wizard,
4
+ * 10-second analysis phase, then Top 3 results from the shared scoring engine.
5
+ *
6
+ * @functions RecommendView → full-screen recommendation wizard
7
+ * @exports RecommendView
8
+ */
9
+ import { useMemo, useState } from 'react'
10
+ import { IconArrowLeft, IconSparkles, IconX } from '@tabler/icons-react'
11
+ import { TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS } from '../../../../src/core/utils.js'
12
+ import LaunchButton from '../launch/LaunchButton.jsx'
13
+ import { useRecommend } from '../../hooks/useRecommend.js'
14
+ import styles from './RecommendView.module.css'
15
+
16
+ const QUESTIONS = [
17
+ { key: 'taskType', title: 'What are you working on?', options: TASK_TYPES },
18
+ { key: 'priority', title: 'What matters most?', options: PRIORITY_TYPES },
19
+ { key: 'contextBudget', title: 'How big is your context?', options: CONTEXT_BUDGETS },
20
+ ]
21
+
22
+ export default function RecommendView({ onClose, onLaunch, onPinAndLaunch, toolMode, onToast }) {
23
+ const [step, setStep] = useState(0)
24
+ const [answers, setAnswers] = useState({ taskType: null, priority: null, contextBudget: null })
25
+ const { recommend, loading, progress, results, error, reset } = useRecommend({ onToast })
26
+ const question = QUESTIONS[step]
27
+ const complete = results.length > 0
28
+
29
+ const summary = useMemo(() => QUESTIONS
30
+ .map((q) => q.options[answers[q.key]]?.label)
31
+ .filter(Boolean)
32
+ .join(' · '), [answers])
33
+
34
+ async function choose(value) {
35
+ const nextAnswers = { ...answers, [question.key]: value }
36
+ setAnswers(nextAnswers)
37
+ if (step < QUESTIONS.length - 1) {
38
+ setStep(step + 1)
39
+ return
40
+ }
41
+ await recommend(nextAnswers)
42
+ }
43
+
44
+ function back() {
45
+ if (complete || loading) {
46
+ reset()
47
+ setStep(0)
48
+ setAnswers({ taskType: null, priority: null, contextBudget: null })
49
+ return
50
+ }
51
+ setStep((value) => Math.max(0, value - 1))
52
+ }
53
+
54
+ return (
55
+ <div className={styles.backdrop} onClick={onClose}>
56
+ <div className={styles.modal} onClick={(event) => event.stopPropagation()}>
57
+ <header className={styles.header}>
58
+ <div>
59
+ <p className={styles.eyebrow}>Smart Recommend</p>
60
+ <h2><IconSparkles size={20} /> Find the best model for this task</h2>
61
+ {summary && <p className={styles.summary}>{summary}</p>}
62
+ </div>
63
+ <button className={styles.close} onClick={onClose} aria-label="Close"><IconX size={18} /></button>
64
+ </header>
65
+
66
+ {!loading && !complete && (
67
+ <main className={styles.body}>
68
+ <div className={styles.steps}>Question {step + 1} / {QUESTIONS.length}</div>
69
+ <h3>{question.title}</h3>
70
+ <div className={styles.options}>
71
+ {Object.entries(question.options).map(([key, option]) => (
72
+ <button key={key} className={styles.option} onClick={() => choose(key)}>
73
+ <strong>{option.label}</strong>
74
+ <span>{key}</span>
75
+ </button>
76
+ ))}
77
+ </div>
78
+ </main>
79
+ )}
80
+
81
+ {loading && (
82
+ <main className={styles.body}>
83
+ <h3>Analyzing live catalog…</h3>
84
+ <p className={styles.muted}>10 seconds · roughly 2 checks/sec · same scoring as the TUI.</p>
85
+ <div className={styles.progress}><span style={{ width: `${progress}%` }} /></div>
86
+ <p className={styles.percent}>{progress}%</p>
87
+ </main>
88
+ )}
89
+
90
+ {complete && (
91
+ <main className={styles.body}>
92
+ <h3>Top 3 recommendations</h3>
93
+ <div className={styles.results}>
94
+ {results.map((item, index) => (
95
+ <article key={`${item.result.providerKey}/${item.result.modelId}`} className={styles.resultCard}>
96
+ <div className={styles.medal}>{['🥇', '🥈', '🥉'][index]}</div>
97
+ <div className={styles.resultMain}>
98
+ <strong>{item.result.label}</strong>
99
+ <span>{item.result.origin} · {item.result.tier} · {item.result.ctx}</span>
100
+ <p>{item.reason}</p>
101
+ </div>
102
+ <div className={styles.score}>{item.score}</div>
103
+ <LaunchButton model={item.result} toolMode={toolMode} onLaunch={() => onPinAndLaunch?.(item.result)} />
104
+ </article>
105
+ ))}
106
+ </div>
107
+ </main>
108
+ )}
109
+
110
+ {error && <p className={styles.error}>{error}</p>}
111
+
112
+ <footer className={styles.footer}>
113
+ <button className={styles.secondary} onClick={back} disabled={step === 0 && !loading && !complete}>
114
+ <IconArrowLeft size={14} /> {complete || loading ? 'Restart' : 'Back'}
115
+ </button>
116
+ <button className={styles.secondary} onClick={onClose}>Close</button>
117
+ </footer>
118
+ </div>
119
+ </div>
120
+ )
121
+ }
@@ -0,0 +1,55 @@
1
+ /** @file web/src/components/recommend/RecommendView.module.css */
2
+ .backdrop {
3
+ position: fixed;
4
+ inset: 0;
5
+ z-index: 480;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ padding: 24px;
10
+ background: rgba(0, 0, 0, .64);
11
+ backdrop-filter: blur(4px);
12
+ }
13
+ .modal {
14
+ width: min(980px, 96vw);
15
+ max-height: min(820px, 94vh);
16
+ overflow: auto;
17
+ border: 1px solid var(--color-border);
18
+ border-radius: 16px;
19
+ background: var(--color-bg-elevated);
20
+ color: var(--color-text);
21
+ box-shadow: 0 20px 60px rgba(0,0,0,.48);
22
+ }
23
+ .header,
24
+ .footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 20px 24px; border-bottom: 1px solid var(--color-border); }
25
+ .footer { border-top: 1px solid var(--color-border); border-bottom: 0; justify-content: flex-end; }
26
+ .header h2 { display: flex; align-items: center; gap: 9px; margin: 4px 0; font-size: 21px; }
27
+ .eyebrow { margin: 0; color: var(--color-accent); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; }
28
+ .summary,
29
+ .muted { color: var(--color-text-muted); margin: 0; }
30
+ .close,
31
+ .secondary { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface); color: var(--color-text); cursor: pointer; }
32
+ .close { width: 32px; height: 32px; justify-content: center; }
33
+ .secondary { padding: 8px 12px; font-weight: 800; }
34
+ .secondary:disabled { opacity: .45; cursor: not-allowed; }
35
+ .body { padding: 26px 28px; }
36
+ .steps { color: var(--color-accent); font-family: var(--font-mono); font-size: 12px; font-weight: 800; margin-bottom: 10px; }
37
+ .body h3 { margin: 0 0 18px; font-size: 20px; }
38
+ .options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
39
+ .option { min-height: 120px; padding: 18px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-surface); color: var(--color-text); cursor: pointer; text-align: left; transition: transform 120ms, border-color 120ms, background 120ms; }
40
+ .option:hover { transform: translateY(-2px); border-color: var(--color-accent); background: var(--color-bg-hover); }
41
+ .option strong { display: block; font-size: 16px; margin-bottom: 8px; }
42
+ .option span { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 11px; }
43
+ .progress { height: 14px; border: 1px solid var(--color-border); border-radius: 999px; overflow: hidden; background: var(--color-surface); }
44
+ .progress span { display: block; height: 100%; background: linear-gradient(90deg, var(--color-accent), #b400ff); transition: width 200ms; }
45
+ .percent { font-family: var(--font-mono); color: var(--color-accent); font-weight: 900; }
46
+ .results { display: flex; flex-direction: column; gap: 12px; }
47
+ .resultCard { display: grid; grid-template-columns: 38px 1fr 60px auto; align-items: center; gap: 14px; padding: 14px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-surface); }
48
+ .medal { font-size: 26px; }
49
+ .resultMain { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
50
+ .resultMain strong { font-size: 15px; }
51
+ .resultMain span { color: var(--color-text-muted); font-size: 12px; }
52
+ .resultMain p { margin: 5px 0 0; color: var(--color-text-dim); font-size: 12px; }
53
+ .score { font-family: var(--font-mono); color: var(--color-accent); font-size: 24px; font-weight: 900; text-align: center; }
54
+ .error { margin: 0 24px 16px; color: #ff5a7a; font-weight: 800; }
55
+ @media (max-width: 760px) { .options { grid-template-columns: 1fr; } .resultCard { grid-template-columns: 32px 1fr; } .score { text-align: left; } }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @file web/src/components/router/RouterView.jsx
3
+ * @description Router Dashboard modal — daemon status, start/stop, model health,
4
+ * request log, set management, probe mode, quick-setup card.
5
+ * 📖 M4: Full TUI parity for the router dashboard overlay.
6
+ */
7
+ import { useEffect, useState, useCallback } from 'react'
8
+ import {
9
+ IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
10
+ IconCopy, IconCheck, IconChevronDown, IconChevronUp,
11
+ IconActivity, IconServer,
12
+ } from '@tabler/icons-react'
13
+ import styles from './RouterView.module.css'
14
+
15
+ function formatUptime(seconds) {
16
+ if (!seconds || seconds <= 0) return '—'
17
+ const h = Math.floor(seconds / 3600)
18
+ const m = Math.floor((seconds % 3600) / 60)
19
+ const s = seconds % 60
20
+ if (h > 0) return `${h}h ${m}m`
21
+ if (m > 0) return `${m}m ${s}s`
22
+ return `${s}s`
23
+ }
24
+
25
+ function formatNumber(n) {
26
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
27
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
28
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
29
+ return String(n)
30
+ }
31
+
32
+ function CircuitBadge({ state }) {
33
+ const cls = state === 'CLOSED' ? styles.circuitClosed
34
+ : state === 'OPEN' ? styles.circuitOpen
35
+ : state === 'HALF_OPEN' ? styles.circuitHalfOpen
36
+ : state === 'AUTH_ERROR' ? styles.circuitAuth
37
+ : styles.circuitUnknown
38
+ return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
39
+ }
40
+
41
+ export default function RouterView({ onClose, onToast }) {
42
+ const [status, setStatus] = useState(null)
43
+ const [stats, setStats] = useState(null)
44
+ const [quickSetup, setQuickSetup] = useState(null)
45
+ const [actionLoading, setActionLoading] = useState(false)
46
+ const [logExpanded, setLogExpanded] = useState(false)
47
+ const [copied, setCopied] = useState(null)
48
+
49
+ const fetchStatus = useCallback(async () => {
50
+ try {
51
+ const resp = await fetch('/api/router/status')
52
+ const data = await resp.json()
53
+ setStatus(data)
54
+ if (data?.ok) {
55
+ const statsResp = await fetch('/api/router/stats')
56
+ const statsData = await statsResp.json()
57
+ if (statsData.ok) setStats(statsData)
58
+ }
59
+ } catch {}
60
+ }, [])
61
+
62
+ useEffect(() => {
63
+ void fetchStatus()
64
+ void fetch('/api/router/quick-setup').then(r => r.json()).then(setQuickSetup).catch(() => {})
65
+ const interval = setInterval(fetchStatus, 5000)
66
+ return () => clearInterval(interval)
67
+ }, [fetchStatus])
68
+
69
+ const handleStart = async () => {
70
+ setActionLoading(true)
71
+ try {
72
+ const resp = await fetch('/api/router/start', { method: 'POST' })
73
+ const data = await resp.json()
74
+ if (data.ok || data.alreadyRunning) {
75
+ onToast?.('Router daemon started.', 'success')
76
+ await fetchStatus()
77
+ } else {
78
+ onToast?.(`Failed to start: ${data.error || 'unknown'}`, 'error')
79
+ }
80
+ } catch (err) {
81
+ onToast?.(`Start failed: ${err.message}`, 'error')
82
+ } finally { setActionLoading(false) }
83
+ }
84
+
85
+ const handleStop = async () => {
86
+ setActionLoading(true)
87
+ try {
88
+ const resp = await fetch('/api/router/stop', { method: 'POST' })
89
+ const data = await resp.json()
90
+ if (data.ok) {
91
+ onToast?.('Router daemon stopped.', 'success')
92
+ setStatus({ ok: false, running: false })
93
+ setStats(null)
94
+ } else {
95
+ onToast?.(`Failed to stop: ${data.error || 'unknown'}`, 'error')
96
+ }
97
+ } catch (err) {
98
+ onToast?.(`Stop failed: ${err.message}`, 'error')
99
+ } finally { setActionLoading(false) }
100
+ }
101
+
102
+ const handleCopy = (text, label) => {
103
+ navigator.clipboard.writeText(text).then(() => {
104
+ setCopied(label)
105
+ setTimeout(() => setCopied(null), 1500)
106
+ })
107
+ }
108
+
109
+ const handleSetProbeMode = async (mode) => {
110
+ try {
111
+ await fetch('/api/router/probe-mode', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ probeMode: mode }),
115
+ })
116
+ onToast?.(`Probe mode set to ${mode}.`, 'info')
117
+ await fetchStatus()
118
+ } catch {}
119
+ }
120
+
121
+ const running = status?.ok
122
+ const models = stats?.models || []
123
+ const requestLog = stats?.requestLog || []
124
+ const circuitBreakers = stats?.circuitBreakers || {}
125
+
126
+ return (
127
+ <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
128
+ <div className={styles.modal}>
129
+ <div className={styles.header}>
130
+ <h2 className={styles.title}>
131
+ <IconRoute size={20} stroke={1.5} />
132
+ Router Dashboard
133
+ </h2>
134
+ <button className={styles.closeBtn} onClick={onClose} aria-label="Close">✕</button>
135
+ </div>
136
+
137
+ <div className={styles.body}>
138
+ {/* Hero Card */}
139
+ <div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
140
+ <div className={styles.heroLeft}>
141
+ <div className={styles.heroStatus}>
142
+ <span className={`${styles.statusDot} ${running ? styles.dotGreen : styles.dotGray}`} />
143
+ <span className={styles.heroLabel}>{running ? 'Running' : 'Stopped'}</span>
144
+ </div>
145
+ {running && (
146
+ <div className={styles.heroMeta}>
147
+ <span>Port {status.port}</span>
148
+ <span>·</span>
149
+ <span>Uptime {formatUptime(status.uptimeSeconds)}</span>
150
+ <span>·</span>
151
+ <span>{status.requestsRouted} requests</span>
152
+ </div>
153
+ )}
154
+ {!running && (
155
+ <div className={styles.heroMeta}>
156
+ Smart failover router — start to route requests to the healthiest model.
157
+ </div>
158
+ )}
159
+ </div>
160
+ <div className={styles.heroActions}>
161
+ {!running ? (
162
+ <button className={styles.startBtn} onClick={handleStart} disabled={actionLoading}>
163
+ <IconPlayerPlay size={14} />
164
+ {actionLoading ? 'Starting…' : 'Start Router'}
165
+ </button>
166
+ ) : (
167
+ <button className={styles.stopBtn} onClick={handleStop} disabled={actionLoading}>
168
+ <IconPlayerStop size={14} />
169
+ {actionLoading ? 'Stopping…' : 'Stop'}
170
+ </button>
171
+ )}
172
+ <button className={styles.refreshBtn} onClick={fetchStatus} title="Refresh">
173
+ <IconRefresh size={14} />
174
+ </button>
175
+ </div>
176
+ </div>
177
+
178
+ {/* Quick Setup */}
179
+ {running && quickSetup && (
180
+ <div className={styles.quickSetup}>
181
+ <h3 className={styles.sectionTitle}>
182
+ <IconCopy size={14} />
183
+ Quick Setup
184
+ </h3>
185
+ <div className={styles.quickRows}>
186
+ {quickSetup.baseUrl && (
187
+ <div className={styles.quickRow}>
188
+ <span className={styles.quickLabel}>Base URL</span>
189
+ <code className={styles.quickValue}>{quickSetup.baseUrl}</code>
190
+ <button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.baseUrl, 'url')}>
191
+ {copied === 'url' ? <IconCheck size={12} /> : <IconCopy size={12} />}
192
+ </button>
193
+ </div>
194
+ )}
195
+ <div className={styles.quickRow}>
196
+ <span className={styles.quickLabel}>Model</span>
197
+ <code className={styles.quickValue}>{quickSetup.model}</code>
198
+ <button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.model, 'model')}>
199
+ {copied === 'model' ? <IconCheck size={12} /> : <IconCopy size={12} />}
200
+ </button>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ )}
205
+
206
+ {/* Probe Mode */}
207
+ {running && (
208
+ <div className={styles.section}>
209
+ <h3 className={styles.sectionTitle}>
210
+ <IconActivity size={14} />
211
+ Probe Mode
212
+ </h3>
213
+ <div className={styles.probeModes}>
214
+ {['eco', 'balanced', 'aggressive'].map((mode) => (
215
+ <button
216
+ key={mode}
217
+ className={`${styles.probeBtn} ${status?.probeMode === mode ? styles.probeActive : ''}`}
218
+ onClick={() => handleSetProbeMode(mode)}
219
+ >
220
+ {mode}
221
+ </button>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ )}
226
+
227
+ {/* Model Health */}
228
+ {running && models.length > 0 && (
229
+ <div className={styles.section}>
230
+ <h3 className={styles.sectionTitle}>
231
+ <IconServer size={14} />
232
+ Model Health ({models.length})
233
+ </h3>
234
+ <div className={styles.modelList}>
235
+ {models.map((m) => {
236
+ const cb = circuitBreakers[m.key] || {}
237
+ return (
238
+ <div key={m.key} className={styles.modelRow}>
239
+ <span className={styles.modelPriority}>#{m.priority}</span>
240
+ <span className={styles.modelName} title={m.key}>{m.key}</span>
241
+ <CircuitBadge state={cb.state || m.state} />
242
+ <span className={styles.modelScore}>{m.score?.toFixed(2) || '—'}</span>
243
+ <span className={styles.modelLatency}>
244
+ {m.last_latency_ms != null ? `${m.last_latency_ms}ms` : '—'}
245
+ </span>
246
+ </div>
247
+ )
248
+ })}
249
+ </div>
250
+ </div>
251
+ )}
252
+
253
+ {/* Request Log */}
254
+ {running && requestLog.length > 0 && (
255
+ <div className={styles.section}>
256
+ <h3 className={styles.sectionTitle} onClick={() => setLogExpanded(!logExpanded)} style={{ cursor: 'pointer' }}>
257
+ <IconActivity size={14} />
258
+ Request Log ({requestLog.length})
259
+ {logExpanded ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
260
+ </h3>
261
+ {logExpanded && (
262
+ <div className={styles.logList}>
263
+ {requestLog.map((entry, i) => (
264
+ <div key={i} className={styles.logRow}>
265
+ <span className={entry.error ? styles.logErr : styles.logOk}>
266
+ {entry.status || '—'}
267
+ </span>
268
+ <span className={styles.logModel}>{entry.model}</span>
269
+ <span className={styles.logLatency}>
270
+ {entry.latency_ms != null ? `${entry.latency_ms}ms` : '—'}
271
+ </span>
272
+ <span className={styles.logTokens}>
273
+ {entry.tokens > 0 ? formatNumber(entry.tokens) + ' tok' : ''}
274
+ </span>
275
+ {entry.failover && <span className={styles.logFailover}>failover</span>}
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+ </div>
281
+ )}
282
+ </div>
283
+ </div>
284
+ </div>
285
+ )
286
+ }