free-coding-models 0.3.37 → 0.3.41

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 (62) hide show
  1. package/CHANGELOG.md +5 -1800
  2. package/README.md +10 -1
  3. package/bin/free-coding-models.js +8 -0
  4. package/package.json +13 -3
  5. package/src/app.js +30 -0
  6. package/src/cli-help.js +2 -0
  7. package/src/command-palette.js +3 -0
  8. package/src/config.js +7 -0
  9. package/src/endpoint-installer.js +1 -1
  10. package/src/key-handler.js +27 -1
  11. package/src/overlays.js +11 -1
  12. package/src/shell-env.js +393 -0
  13. package/src/tool-bootstrap.js +41 -0
  14. package/src/tool-launchers.js +166 -1
  15. package/src/tool-metadata.js +12 -0
  16. package/src/utils.js +12 -0
  17. package/web/app.legacy.js +900 -0
  18. package/web/index.html +20 -0
  19. package/web/server.js +443 -0
  20. package/web/src/App.jsx +150 -0
  21. package/web/src/components/analytics/AnalyticsView.jsx +109 -0
  22. package/web/src/components/analytics/AnalyticsView.module.css +186 -0
  23. package/web/src/components/atoms/Sparkline.jsx +44 -0
  24. package/web/src/components/atoms/StabilityCell.jsx +18 -0
  25. package/web/src/components/atoms/StabilityCell.module.css +8 -0
  26. package/web/src/components/atoms/StatusDot.jsx +10 -0
  27. package/web/src/components/atoms/StatusDot.module.css +17 -0
  28. package/web/src/components/atoms/TierBadge.jsx +10 -0
  29. package/web/src/components/atoms/TierBadge.module.css +18 -0
  30. package/web/src/components/atoms/Toast.jsx +25 -0
  31. package/web/src/components/atoms/Toast.module.css +35 -0
  32. package/web/src/components/atoms/ToastContainer.jsx +16 -0
  33. package/web/src/components/atoms/ToastContainer.module.css +10 -0
  34. package/web/src/components/atoms/VerdictBadge.jsx +13 -0
  35. package/web/src/components/atoms/VerdictBadge.module.css +19 -0
  36. package/web/src/components/dashboard/DetailPanel.jsx +131 -0
  37. package/web/src/components/dashboard/DetailPanel.module.css +99 -0
  38. package/web/src/components/dashboard/ExportModal.jsx +79 -0
  39. package/web/src/components/dashboard/ExportModal.module.css +99 -0
  40. package/web/src/components/dashboard/FilterBar.jsx +73 -0
  41. package/web/src/components/dashboard/FilterBar.module.css +43 -0
  42. package/web/src/components/dashboard/ModelTable.jsx +86 -0
  43. package/web/src/components/dashboard/ModelTable.module.css +46 -0
  44. package/web/src/components/dashboard/StatsBar.jsx +40 -0
  45. package/web/src/components/dashboard/StatsBar.module.css +28 -0
  46. package/web/src/components/layout/Footer.jsx +19 -0
  47. package/web/src/components/layout/Footer.module.css +10 -0
  48. package/web/src/components/layout/Header.jsx +38 -0
  49. package/web/src/components/layout/Header.module.css +73 -0
  50. package/web/src/components/layout/Sidebar.jsx +41 -0
  51. package/web/src/components/layout/Sidebar.module.css +76 -0
  52. package/web/src/components/settings/SettingsView.jsx +264 -0
  53. package/web/src/components/settings/SettingsView.module.css +377 -0
  54. package/web/src/global.css +199 -0
  55. package/web/src/hooks/useFilter.js +83 -0
  56. package/web/src/hooks/useSSE.js +49 -0
  57. package/web/src/hooks/useTheme.js +27 -0
  58. package/web/src/main.jsx +15 -0
  59. package/web/src/utils/download.js +15 -0
  60. package/web/src/utils/format.js +42 -0
  61. package/web/src/utils/ranks.js +37 -0
  62. package/web/styles.legacy.css +963 -0
package/web/index.html ADDED
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>free-coding-models — Live Dashboard</title>
7
+ <meta name="description" content="Find the fastest free coding LLM model in seconds. Live dashboard monitoring 230+ models across 24 providers.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div class="bg-grid"></div>
14
+ <div class="bg-glow bg-glow--1"></div>
15
+ <div class="bg-glow bg-glow--2"></div>
16
+ <div class="bg-glow bg-glow--3"></div>
17
+ <div id="root"></div>
18
+ <script type="module" src="/src/main.jsx"></script>
19
+ </body>
20
+ </html>
package/web/server.js ADDED
@@ -0,0 +1,443 @@
1
+ /**
2
+ * @file web/server.js
3
+ * @description HTTP server for the free-coding-models Web Dashboard V2.
4
+ *
5
+ * Reuses the existing ping engine, model sources, and utility functions
6
+ * from the CLI tool. Serves the dashboard HTML/CSS/JS and provides
7
+ * API endpoints + SSE for real-time ping data.
8
+ *
9
+ * Endpoints:
10
+ * GET / → Dashboard HTML
11
+ * GET /styles.css → Dashboard styles
12
+ * GET /app.js → Dashboard client JS
13
+ * GET /api/models → All model metadata (JSON)
14
+ * GET /api/health → Lightweight dashboard health probe
15
+ * GET /api/config → Current config (sanitized — masked keys)
16
+ * GET /api/key/:prov → Reveal a provider's full API key
17
+ * GET /api/events → SSE stream of live ping results
18
+ * POST /api/settings → Update API keys / provider toggles
19
+ */
20
+
21
+ import { createServer } from 'node:http'
22
+ import { readFileSync, existsSync } from 'node:fs'
23
+ import { join, dirname, extname } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { exec } from 'node:child_process'
26
+
27
+ import { sources, MODELS } from '../sources.js'
28
+ import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/config.js'
29
+ import { ping } from '../src/ping.js'
30
+ import {
31
+ getAvg, getVerdict, getUptime, getP95, getJitter,
32
+ getStabilityScore, TIER_ORDER
33
+ } from '../src/utils.js'
34
+
35
+ const __dirname = dirname(fileURLToPath(import.meta.url))
36
+ const SERVER_SIGNATURE = 'free-coding-models-web'
37
+
38
+ // ─── State ───────────────────────────────────────────────────────────────────
39
+
40
+ let config = loadConfig()
41
+
42
+ // Build results array from MODELS (same shape as the TUI)
43
+ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
44
+ idx: idx + 1,
45
+ modelId,
46
+ label,
47
+ tier,
48
+ sweScore,
49
+ ctx,
50
+ providerKey,
51
+ status: 'pending',
52
+ pings: [],
53
+ httpCode: null,
54
+ origin: sources[providerKey]?.name || providerKey,
55
+ url: sources[providerKey]?.url || null,
56
+ cliOnly: sources[providerKey]?.cliOnly || false,
57
+ zenOnly: sources[providerKey]?.zenOnly || false,
58
+ }))
59
+
60
+ // SSE clients
61
+ const sseClients = new Set()
62
+
63
+ // ─── Ping Loop ───────────────────────────────────────────────────────────────
64
+ // Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
65
+ // Each new round starts only after the previous one completes.
66
+
67
+ let pingRound = 0
68
+ let pingLoopRunning = false
69
+
70
+ async function pingAllModels() {
71
+ if (pingLoopRunning) return // guard against overlapping calls
72
+ pingLoopRunning = true
73
+ pingRound++
74
+ const batchSize = 30
75
+ // P2 fix: honor provider enabled flags — skip disabled providers
76
+ const modelsToPing = results.filter(r =>
77
+ !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
78
+ )
79
+
80
+ for (let i = 0; i < modelsToPing.length; i += batchSize) {
81
+ const batch = modelsToPing.slice(i, i + batchSize)
82
+ const promises = batch.map(async (r) => {
83
+ const apiKey = getApiKey(config, r.providerKey)
84
+ try {
85
+ const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
86
+ r.httpCode = result.code
87
+ if (result.code === '200') {
88
+ r.status = 'up'
89
+ r.pings.push({ ms: result.ms, code: result.code })
90
+ } else if (result.code === '401') {
91
+ r.status = 'up'
92
+ r.pings.push({ ms: result.ms, code: result.code })
93
+ } else if (result.code === '429') {
94
+ r.status = 'up'
95
+ r.pings.push({ ms: result.ms, code: result.code })
96
+ } else if (result.code === '000') {
97
+ r.status = 'timeout'
98
+ } else {
99
+ r.status = 'down'
100
+ r.pings.push({ ms: result.ms, code: result.code })
101
+ }
102
+ // Keep only last 60 pings
103
+ if (r.pings.length > 60) r.pings = r.pings.slice(-60)
104
+ } catch {
105
+ r.status = 'timeout'
106
+ }
107
+ })
108
+ await Promise.all(promises)
109
+ }
110
+
111
+ // Broadcast update to all SSE clients
112
+ broadcastUpdate()
113
+ pingLoopRunning = false
114
+ }
115
+
116
+ function broadcastUpdate() {
117
+ const data = JSON.stringify(getModelsPayload())
118
+ for (const client of sseClients) {
119
+ try {
120
+ client.write(`data: ${data}\n\n`)
121
+ } catch {
122
+ sseClients.delete(client)
123
+ }
124
+ }
125
+ }
126
+
127
+ function getModelsPayload() {
128
+ return results.map(r => ({
129
+ idx: r.idx,
130
+ modelId: r.modelId,
131
+ label: r.label,
132
+ tier: r.tier,
133
+ sweScore: r.sweScore,
134
+ ctx: r.ctx,
135
+ providerKey: r.providerKey,
136
+ origin: r.origin,
137
+ status: r.status,
138
+ httpCode: r.httpCode,
139
+ cliOnly: r.cliOnly,
140
+ zenOnly: r.zenOnly,
141
+ avg: getAvg(r),
142
+ verdict: getVerdict(r),
143
+ uptime: getUptime(r),
144
+ p95: getP95(r),
145
+ jitter: getJitter(r),
146
+ stability: getStabilityScore(r),
147
+ latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
148
+ latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
149
+ pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
150
+ pingCount: r.pings.length,
151
+ hasApiKey: !!getApiKey(config, r.providerKey),
152
+ }))
153
+ }
154
+
155
+ function getConfigPayload() {
156
+ // Sanitize — show which providers have keys, but not the actual keys
157
+ const providers = {}
158
+ for (const [key, src] of Object.entries(sources)) {
159
+ const rawKey = getApiKey(config, key)
160
+ providers[key] = {
161
+ name: src.name,
162
+ hasKey: !!rawKey,
163
+ maskedKey: rawKey ? maskApiKey(rawKey) : null,
164
+ enabled: isProviderEnabled(config, key),
165
+ modelCount: src.models?.length || 0,
166
+ cliOnly: src.cliOnly || false,
167
+ }
168
+ }
169
+ return { providers, totalModels: MODELS.length }
170
+ }
171
+
172
+ function maskApiKey(key) {
173
+ if (!key || typeof key !== 'string') return ''
174
+ if (key.length <= 8) return '••••••••'
175
+ return '••••••••' + key.slice(-4)
176
+ }
177
+
178
+ // ─── HTTP Server ─────────────────────────────────────────────────────────────
179
+
180
+ const MIME_TYPES = {
181
+ '.html': 'text/html; charset=utf-8',
182
+ '.css': 'text/css; charset=utf-8',
183
+ '.js': 'application/javascript; charset=utf-8',
184
+ '.json': 'application/json; charset=utf-8',
185
+ '.svg': 'image/svg+xml',
186
+ '.png': 'image/png',
187
+ '.ico': 'image/x-icon',
188
+ }
189
+
190
+ function serveFile(res, filename, contentType) {
191
+ try {
192
+ const content = readFileSync(join(__dirname, filename), 'utf8')
193
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' })
194
+ res.end(content)
195
+ } catch {
196
+ res.writeHead(404)
197
+ res.end('Not Found')
198
+ }
199
+ }
200
+
201
+ function serveDistFile(res, pathname) {
202
+ const filePath = join(__dirname, 'dist', pathname === '/' ? 'index.html' : pathname)
203
+ if (!existsSync(filePath)) {
204
+ serveFile(res, 'dist/index.html', 'text/html; charset=utf-8')
205
+ return
206
+ }
207
+ const ext = extname(filePath)
208
+ const ct = MIME_TYPES[ext] || 'application/octet-stream'
209
+ try {
210
+ const content = readFileSync(filePath)
211
+ res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' })
212
+ res.end(content)
213
+ } catch {
214
+ res.writeHead(404)
215
+ res.end('Not Found')
216
+ }
217
+ }
218
+
219
+ function handleRequest(req, res) {
220
+ res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
221
+
222
+ // CORS for local dev
223
+ res.setHeader('Access-Control-Allow-Origin', '*')
224
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
225
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
226
+
227
+ if (req.method === 'OPTIONS') {
228
+ res.writeHead(204)
229
+ res.end()
230
+ return
231
+ }
232
+
233
+ const url = new URL(req.url, `http://${req.headers.host}`)
234
+
235
+ // ─── API: Reveal full key for a provider ───
236
+ const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
237
+ if (keyMatch) {
238
+ const providerKey = decodeURIComponent(keyMatch[1])
239
+ const rawKey = getApiKey(config, providerKey)
240
+ res.writeHead(200, { 'Content-Type': 'application/json' })
241
+ res.end(JSON.stringify({ key: rawKey || null }))
242
+ return
243
+ }
244
+
245
+ switch (url.pathname) {
246
+ case '/':
247
+ serveDistFile(res, '/')
248
+ break
249
+
250
+ case '/styles.css':
251
+ case '/app.js':
252
+ serveDistFile(res, url.pathname)
253
+ break
254
+
255
+ default:
256
+ if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
257
+ serveDistFile(res, url.pathname)
258
+ break
259
+ }
260
+
261
+ if (!url.pathname.startsWith('/api/')) {
262
+ res.writeHead(404)
263
+ res.end('Not Found')
264
+ break
265
+ }
266
+
267
+ case '/api/models':
268
+ res.writeHead(200, { 'Content-Type': 'application/json' })
269
+ res.end(JSON.stringify(getModelsPayload()))
270
+ break
271
+
272
+ case '/api/health':
273
+ res.writeHead(200, { 'Content-Type': 'application/json' })
274
+ res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
275
+ break
276
+
277
+ case '/api/config':
278
+ res.writeHead(200, { 'Content-Type': 'application/json' })
279
+ res.end(JSON.stringify(getConfigPayload()))
280
+ break
281
+
282
+ case '/api/events':
283
+ // SSE endpoint
284
+ res.writeHead(200, {
285
+ 'Content-Type': 'text/event-stream',
286
+ 'Cache-Control': 'no-cache',
287
+ 'Connection': 'keep-alive',
288
+ })
289
+ res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
290
+ sseClients.add(res)
291
+ req.on('close', () => sseClients.delete(res))
292
+ break
293
+
294
+ case '/api/settings':
295
+ if (req.method === 'POST') {
296
+ let body = ''
297
+ req.on('data', chunk => body += chunk)
298
+ req.on('end', () => {
299
+ try {
300
+ const settings = JSON.parse(body)
301
+ if (settings.apiKeys) {
302
+ for (const [key, value] of Object.entries(settings.apiKeys)) {
303
+ if (value) config.apiKeys[key] = value
304
+ else delete config.apiKeys[key]
305
+ }
306
+ }
307
+ if (settings.providers) {
308
+ for (const [key, value] of Object.entries(settings.providers)) {
309
+ if (!config.providers[key]) config.providers[key] = {}
310
+ config.providers[key].enabled = value.enabled !== false
311
+ }
312
+ }
313
+ // P2 fix: catch saveConfig failures and report to client
314
+ try {
315
+ saveConfig(config)
316
+ } catch (saveErr) {
317
+ res.writeHead(500, { 'Content-Type': 'application/json' })
318
+ res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
319
+ return
320
+ }
321
+ res.writeHead(200, { 'Content-Type': 'application/json' })
322
+ res.end(JSON.stringify({ success: true }))
323
+ } catch (err) {
324
+ res.writeHead(400, { 'Content-Type': 'application/json' })
325
+ res.end(JSON.stringify({ error: err.message }))
326
+ }
327
+ })
328
+ } else {
329
+ res.writeHead(405)
330
+ res.end('Method Not Allowed')
331
+ }
332
+ break
333
+ }
334
+ }
335
+
336
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
337
+
338
+ function checkPortInUse(port) {
339
+ return new Promise((resolve) => {
340
+ const s = createServer()
341
+ s.once('error', (err) => { if (err.code === 'EADDRINUSE') resolve(true); else resolve(false) })
342
+ s.once('listening', () => { s.close(); resolve(false) })
343
+ s.listen(port)
344
+ })
345
+ }
346
+
347
+ export async function inspectExistingWebServer(port) {
348
+ const inUse = await checkPortInUse(port)
349
+ if (!inUse) return { inUse: false, isFcm: false }
350
+
351
+ const controller = new AbortController()
352
+ const timeout = setTimeout(() => controller.abort(), 750)
353
+
354
+ try {
355
+ // 📖 Probe a tiny health route so we only reuse a port when the running
356
+ // 📖 process is actually the free-coding-models dashboard, not any random app.
357
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
358
+ signal: controller.signal,
359
+ headers: { Accept: 'application/json' },
360
+ })
361
+ const payload = await response.json().catch(() => null)
362
+ const signature = response.headers.get('x-fcm-server')
363
+ return {
364
+ inUse: true,
365
+ isFcm: signature === SERVER_SIGNATURE || payload?.app === SERVER_SIGNATURE,
366
+ }
367
+ } catch {
368
+ return { inUse: true, isFcm: false }
369
+ } finally {
370
+ clearTimeout(timeout)
371
+ }
372
+ }
373
+
374
+ export async function findAvailablePort(startPort, maxAttempts = 20) {
375
+ for (let port = startPort; port < startPort + maxAttempts; port++) {
376
+ if (!(await checkPortInUse(port))) return port
377
+ }
378
+ throw new Error(`No free local port found between ${startPort} and ${startPort + maxAttempts - 1}`)
379
+ }
380
+
381
+ function openBrowser(url) {
382
+ const cmd = process.platform === 'darwin' ? 'open'
383
+ : process.platform === 'win32' ? 'start'
384
+ : 'xdg-open'
385
+ exec(`${cmd} "${url}"`, (err) => {
386
+ if (err) console.log(` 💡 Open manually: ${url}`)
387
+ })
388
+ }
389
+
390
+ // ─── Exports ─────────────────────────────────────────────────────────────────
391
+
392
+ export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
393
+ const portStatus = await inspectExistingWebServer(port)
394
+
395
+ if (portStatus.inUse && portStatus.isFcm) {
396
+ const url = `http://localhost:${port}`
397
+
398
+ console.log()
399
+ console.log(` ⚡ free-coding-models Web Dashboard already running`)
400
+ console.log(` 🌐 ${url}`)
401
+ console.log()
402
+ if (open) openBrowser(url)
403
+ return null
404
+ }
405
+
406
+ let resolvedPort = port
407
+ if (portStatus.inUse && !portStatus.isFcm) {
408
+ resolvedPort = await findAvailablePort(port + 1)
409
+ console.log()
410
+ console.log(` ⚠️ Port ${port} is already in use by another local app`)
411
+ console.log(` ↪ Starting free-coding-models Web Dashboard on port ${resolvedPort} instead`)
412
+ console.log()
413
+ }
414
+
415
+ const url = `http://localhost:${resolvedPort}`
416
+
417
+ const server = createServer(handleRequest)
418
+ let pingLoopTimer = null
419
+
420
+ server.listen(resolvedPort, () => {
421
+ console.log()
422
+ console.log(` ⚡ free-coding-models Web Dashboard`)
423
+ console.log(` 🌐 ${url}`)
424
+ console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
425
+ console.log()
426
+ console.log(` Press Ctrl+C to stop`)
427
+ console.log()
428
+ if (open) openBrowser(url)
429
+ })
430
+
431
+ async function schedulePingLoop() {
432
+ if (!server.listening) return
433
+ await pingAllModels()
434
+ pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
435
+ }
436
+
437
+ if (startPingLoop) schedulePingLoop()
438
+ server.on('close', () => {
439
+ if (pingLoopTimer) clearTimeout(pingLoopTimer)
440
+ })
441
+
442
+ return server
443
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file web/src/App.jsx
3
+ * @description Root application component — orchestrates all views, layout, SSE connection, and global state.
4
+ * 📖 Manages current view (dashboard/settings/analytics), theme toggle, search, filters,
5
+ * selected model for detail panel, export modal, and toast notifications.
6
+ * Uses useSSE for live data, useFilter for model filtering/sorting, useTheme for dark/light.
7
+ * @functions App → root component with all state and layout composition
8
+ */
9
+ import { useState, useCallback, useEffect } from 'react'
10
+ import { useSSE } from './hooks/useSSE.js'
11
+ import { useFilter } from './hooks/useFilter.js'
12
+ import { useTheme } from './hooks/useTheme.js'
13
+ import Header from './components/layout/Header.jsx'
14
+ import Sidebar from './components/layout/Sidebar.jsx'
15
+ import Footer from './components/layout/Footer.jsx'
16
+ import StatsBar from './components/dashboard/StatsBar.jsx'
17
+ import FilterBar from './components/dashboard/FilterBar.jsx'
18
+ import ModelTable from './components/dashboard/ModelTable.jsx'
19
+ import DetailPanel from './components/dashboard/DetailPanel.jsx'
20
+ import ExportModal from './components/dashboard/ExportModal.jsx'
21
+ import SettingsView from './components/settings/SettingsView.jsx'
22
+ import AnalyticsView from './components/analytics/AnalyticsView.jsx'
23
+ import ToastContainer from './components/atoms/ToastContainer.jsx'
24
+
25
+ let toastIdCounter = 0
26
+
27
+ export default function App() {
28
+ const { models, connected } = useSSE('/api/events')
29
+ const { theme, toggle: toggleTheme } = useTheme()
30
+ const [currentView, setCurrentView] = useState('dashboard')
31
+ const [selectedModel, setSelectedModel] = useState(null)
32
+ const [exportOpen, setExportOpen] = useState(false)
33
+ const [toasts, setToasts] = useState([])
34
+
35
+ const {
36
+ filtered,
37
+ filterTier, setFilterTier,
38
+ filterStatus, setFilterStatus,
39
+ filterProvider, setFilterProvider,
40
+ searchQuery, setSearchQuery,
41
+ sortColumn, sortDirection, toggleSort,
42
+ } = useFilter(models)
43
+
44
+ const providers = (() => {
45
+ const map = {}
46
+ models.forEach((m) => {
47
+ if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
48
+ map[m.providerKey].count++
49
+ })
50
+ return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
51
+ })()
52
+
53
+ const addToast = useCallback((message, type = 'info') => {
54
+ const id = ++toastIdCounter
55
+ setToasts((prev) => [...prev, { id, message, type }])
56
+ }, [])
57
+
58
+ const dismissToast = useCallback((id) => {
59
+ setToasts((prev) => prev.filter((t) => t.id !== id))
60
+ }, [])
61
+
62
+ const handleSelectModel = useCallback((modelId) => {
63
+ const model = models.find((m) => m.modelId === modelId)
64
+ if (model) setSelectedModel(model)
65
+ }, [models])
66
+
67
+ const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
68
+
69
+ useEffect(() => {
70
+ const handler = (e) => {
71
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
72
+ e.preventDefault()
73
+ if (currentView !== 'dashboard') setCurrentView('dashboard')
74
+ }
75
+ if (e.key === 'Escape') {
76
+ setSelectedModel(null)
77
+ setExportOpen(false)
78
+ }
79
+ }
80
+ window.addEventListener('keydown', handler)
81
+ return () => window.removeEventListener('keydown', handler)
82
+ }, [currentView])
83
+
84
+ return (
85
+ <>
86
+ <Sidebar
87
+ currentView={currentView}
88
+ onNavigate={setCurrentView}
89
+ onToggleTheme={toggleTheme}
90
+ />
91
+
92
+ <div className="app-content">
93
+ {currentView === 'dashboard' && (
94
+ <div className="view">
95
+ <Header
96
+ searchQuery={searchQuery}
97
+ onSearchChange={setSearchQuery}
98
+ onToggleTheme={toggleTheme}
99
+ onOpenSettings={() => setCurrentView('settings')}
100
+ onOpenExport={() => setExportOpen(true)}
101
+ />
102
+ <StatsBar models={models} />
103
+ <FilterBar
104
+ filterTier={filterTier}
105
+ setFilterTier={setFilterTier}
106
+ filterStatus={filterStatus}
107
+ setFilterStatus={setFilterStatus}
108
+ filterProvider={filterProvider}
109
+ setFilterProvider={setFilterProvider}
110
+ providers={providers}
111
+ />
112
+ <ModelTable
113
+ filtered={filtered}
114
+ onSelectModel={handleSelectModel}
115
+ />
116
+ </div>
117
+ )}
118
+
119
+ {currentView === 'settings' && (
120
+ <div className="view">
121
+ <SettingsView onToast={addToast} />
122
+ </div>
123
+ )}
124
+
125
+ {currentView === 'analytics' && (
126
+ <div className="view">
127
+ <AnalyticsView models={models} />
128
+ </div>
129
+ )}
130
+
131
+ <Footer />
132
+ </div>
133
+
134
+ <DetailPanel
135
+ model={selectedModel}
136
+ onClose={handleCloseDetail}
137
+ />
138
+
139
+ {exportOpen && (
140
+ <ExportModal
141
+ models={filtered}
142
+ onClose={() => setExportOpen(false)}
143
+ onToast={addToast}
144
+ />
145
+ )}
146
+
147
+ <ToastContainer toasts={toasts} dismissToast={dismissToast} />
148
+ </>
149
+ )
150
+ }