free-coding-models 0.5.0 → 0.5.4
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 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
- package/web/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
package/web/src/App.jsx
CHANGED
|
@@ -1,37 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/App.jsx
|
|
3
|
-
* @description Root application component — orchestrates all views,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @description Root application component — orchestrates all views, header nav, Socket.IO
|
|
4
|
+
* connection, toast notifications, and global state. M1 layout refactor: no more left
|
|
5
|
+
* sidebar. Navigation lives in the Header (always-visible nav + overflow menu).
|
|
6
|
+
*
|
|
7
|
+
* 📖 M1 also wires in:
|
|
8
|
+
* - StatsBar above the ModelTable (was orphaned)
|
|
9
|
+
* - URL deep-linking (read-only — ?tier=…&sort=…&origin=…)
|
|
10
|
+
* - ⌘K command palette (placeholder modal for M1, real palette in M2)
|
|
11
|
+
* - Favorites hook (per-row star + reorder + display mode toggle)
|
|
12
|
+
* - Per-model benchmark button (uses existing /api/benchmark)
|
|
13
|
+
*
|
|
7
14
|
* @functions App → root component with all state and layout composition
|
|
8
15
|
*/
|
|
9
|
-
import { useState, useCallback, useEffect } from 'react'
|
|
10
|
-
import {
|
|
16
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
17
|
+
import { useSocket } from './hooks/useSocket.js'
|
|
11
18
|
import { useFilter } from './hooks/useFilter.js'
|
|
12
19
|
import { useTheme } from './hooks/useTheme.js'
|
|
20
|
+
import { useFavorites } from './hooks/useFavorites.js'
|
|
21
|
+
import { useUrlState } from './hooks/useUrlState.js'
|
|
13
22
|
import Header from './components/layout/Header.jsx'
|
|
14
|
-
import Sidebar from './components/layout/Sidebar.jsx'
|
|
15
23
|
import Footer from './components/layout/Footer.jsx'
|
|
16
|
-
import StatsBar from './components/dashboard/StatsBar.jsx'
|
|
17
24
|
import FilterBar from './components/dashboard/FilterBar.jsx'
|
|
18
25
|
import ModelTable from './components/dashboard/ModelTable.jsx'
|
|
19
26
|
import DetailPanel from './components/dashboard/DetailPanel.jsx'
|
|
20
27
|
import ExportModal from './components/dashboard/ExportModal.jsx'
|
|
21
28
|
import SettingsView from './components/settings/SettingsView.jsx'
|
|
22
29
|
import AnalyticsView from './components/analytics/AnalyticsView.jsx'
|
|
23
|
-
import
|
|
30
|
+
import CommandPalette from './components/palette/CommandPalette.jsx'
|
|
24
31
|
import ToastContainer from './components/atoms/ToastContainer.jsx'
|
|
25
32
|
|
|
26
33
|
let toastIdCounter = 0
|
|
27
34
|
|
|
35
|
+
// 📖 Map current view to the header nav id. M1 only ships dashboard/settings/analytics;
|
|
36
|
+
// 📖 recommend/router/help/changelog/install-endpoints/installed-models are wired but
|
|
37
|
+
// 📖 still show a "Coming in M2/M3/M4" toast from the header for now.
|
|
38
|
+
const VIEW_TO_NAV = {
|
|
39
|
+
dashboard: 'dashboard',
|
|
40
|
+
settings: 'settings',
|
|
41
|
+
analytics: 'analytics',
|
|
42
|
+
recommend: 'recommend',
|
|
43
|
+
router: 'router',
|
|
44
|
+
help: 'help',
|
|
45
|
+
changelog: 'changelog',
|
|
46
|
+
'install-endpoints': 'install-endpoints',
|
|
47
|
+
'installed-models': 'installed-models',
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
export default function App() {
|
|
29
|
-
const { models, connected } =
|
|
30
|
-
const { theme,
|
|
51
|
+
const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted } = useSocket()
|
|
52
|
+
const { theme, cycle: cycleTheme } = useTheme()
|
|
31
53
|
const [currentView, setCurrentView] = useState('dashboard')
|
|
32
54
|
const [selectedModel, setSelectedModel] = useState(null)
|
|
33
55
|
const [exportOpen, setExportOpen] = useState(false)
|
|
56
|
+
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
34
57
|
const [toasts, setToasts] = useState([])
|
|
58
|
+
const lastActivityRef = useRef(Date.now())
|
|
59
|
+
|
|
60
|
+
// 📖 URL deep-linking (M1 = read-only hydration on mount; write-back lands in M2).
|
|
61
|
+
// 📖 Syncs `currentView`, filter state, and selection from query params so users
|
|
62
|
+
// 📖 can share pre-configured dashboard URLs.
|
|
63
|
+
useUrlState({
|
|
64
|
+
currentView, setCurrentView,
|
|
65
|
+
filterState: null, // wired in M2
|
|
66
|
+
})
|
|
35
67
|
|
|
36
68
|
const {
|
|
37
69
|
filtered,
|
|
@@ -40,8 +72,17 @@ export default function App() {
|
|
|
40
72
|
filterProvider, setFilterProvider,
|
|
41
73
|
searchQuery, setSearchQuery,
|
|
42
74
|
sortColumn, sortDirection, toggleSort,
|
|
75
|
+
filterVerdict, setFilterVerdict,
|
|
76
|
+
filterHealth, setFilterHealth,
|
|
77
|
+
visibilityMode, setVisibilityMode,
|
|
78
|
+
customTextFilter, setCustomTextFilter,
|
|
79
|
+
resetView,
|
|
43
80
|
} = useFilter(models)
|
|
44
81
|
|
|
82
|
+
// 📖 Favorites — single source of truth shared with the TUI via ~/.free-coding-models.json.
|
|
83
|
+
const favorites = useFavorites({ models })
|
|
84
|
+
|
|
85
|
+
// 📖 Build the provider list for the FilterBar dropdown.
|
|
45
86
|
const providers = (() => {
|
|
46
87
|
const map = {}
|
|
47
88
|
models.forEach((m) => {
|
|
@@ -51,96 +92,202 @@ export default function App() {
|
|
|
51
92
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
|
|
52
93
|
})()
|
|
53
94
|
|
|
95
|
+
// ── Global benchmark (AI Speed Test) ─────────────────────────────────────
|
|
96
|
+
const handleBenchmark = useCallback(async () => {
|
|
97
|
+
if (globalBenchmarkRunning) return
|
|
98
|
+
try {
|
|
99
|
+
await fetch('/api/global-benchmark', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
models: filtered.map((model) => ({ providerKey: model.providerKey, modelId: model.modelId })),
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('[Benchmark] Failed to start global benchmark:', err.message)
|
|
108
|
+
}
|
|
109
|
+
}, [filtered, globalBenchmarkRunning])
|
|
110
|
+
|
|
111
|
+
// ── Per-model benchmark (M1 parity with TUI Ctrl+A) ──────────────────────
|
|
112
|
+
const handleBenchmarkRow = useCallback(async (model) => {
|
|
113
|
+
try {
|
|
114
|
+
const resp = await fetch('/api/benchmark', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId }),
|
|
118
|
+
})
|
|
119
|
+
if (!resp.ok && resp.status !== 202) {
|
|
120
|
+
const err = await resp.json().catch(() => ({}))
|
|
121
|
+
addToast?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error('[Benchmark] per-row failed:', err.message)
|
|
125
|
+
}
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
// ── Toast helpers ────────────────────────────────────────────────────────
|
|
54
129
|
const addToast = useCallback((message, type = 'info') => {
|
|
55
130
|
const id = ++toastIdCounter
|
|
56
131
|
setToasts((prev) => [...prev, { id, message, type }])
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
134
|
+
}, 4000)
|
|
57
135
|
}, [])
|
|
58
136
|
|
|
59
137
|
const dismissToast = useCallback((id) => {
|
|
60
138
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
61
139
|
}, [])
|
|
62
140
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
141
|
+
// ── Selection / detail panel ─────────────────────────────────────────────
|
|
142
|
+
const handleSelectModel = useCallback((model) => {
|
|
143
|
+
setSelectedModel(model)
|
|
144
|
+
lastActivityRef.current = Date.now()
|
|
145
|
+
}, [])
|
|
67
146
|
|
|
68
147
|
const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
|
|
69
148
|
|
|
149
|
+
// ── Ping mode change → server → broadcast ───────────────────────────────
|
|
150
|
+
const handlePingModeChange = useCallback(async (mode) => {
|
|
151
|
+
try {
|
|
152
|
+
await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
|
|
153
|
+
} catch {}
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
// ── Navigation handler (Header nav + overflow menu) ──────────────────────
|
|
157
|
+
const handleNavigate = useCallback((viewId) => {
|
|
158
|
+
setCurrentView(VIEW_TO_NAV[viewId] || viewId)
|
|
159
|
+
lastActivityRef.current = Date.now()
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
// ── Reset view (N key equivalent) ────────────────────────────────────────
|
|
163
|
+
const handleResetView = useCallback(() => {
|
|
164
|
+
resetView()
|
|
165
|
+
setSearchQuery('')
|
|
166
|
+
addToast('View reset to defaults.', 'info')
|
|
167
|
+
}, [resetView, setSearchQuery, addToast])
|
|
168
|
+
|
|
169
|
+
// ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette ────────────────
|
|
70
170
|
useEffect(() => {
|
|
71
171
|
const handler = (e) => {
|
|
72
|
-
|
|
172
|
+
const cmdOrCtrl = e.metaKey || e.ctrlKey
|
|
173
|
+
// ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
|
|
174
|
+
if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
|
|
73
175
|
e.preventDefault()
|
|
74
|
-
|
|
176
|
+
setPaletteOpen((o) => !o)
|
|
177
|
+
return
|
|
75
178
|
}
|
|
179
|
+
// Ctrl+P is also accepted as a TUI-style alias for the same palette.
|
|
180
|
+
if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
|
|
181
|
+
e.preventDefault()
|
|
182
|
+
setPaletteOpen((o) => !o)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
// Esc closes whatever is open.
|
|
76
186
|
if (e.key === 'Escape') {
|
|
77
|
-
|
|
78
|
-
|
|
187
|
+
if (paletteOpen) { setPaletteOpen(false); return }
|
|
188
|
+
if (selectedModel) { setSelectedModel(null); return }
|
|
189
|
+
if (exportOpen) { setExportOpen(false); return }
|
|
79
190
|
}
|
|
80
191
|
}
|
|
81
192
|
window.addEventListener('keydown', handler)
|
|
82
193
|
return () => window.removeEventListener('keydown', handler)
|
|
83
|
-
}, [
|
|
194
|
+
}, [paletteOpen, selectedModel, exportOpen])
|
|
195
|
+
|
|
196
|
+
// 📖 Reset view if URL contains the reset flag.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const params = new URLSearchParams(window.location.search)
|
|
199
|
+
if (params.get('reset') === '1') {
|
|
200
|
+
handleResetView()
|
|
201
|
+
const url = new URL(window.location.href)
|
|
202
|
+
url.searchParams.delete('reset')
|
|
203
|
+
window.history.replaceState({}, '', url.toString())
|
|
204
|
+
}
|
|
205
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
|
+
}, [])
|
|
84
207
|
|
|
85
208
|
return (
|
|
86
209
|
<>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
210
|
+
<div className="app-shell">
|
|
211
|
+
<Header
|
|
212
|
+
searchQuery={searchQuery}
|
|
213
|
+
onSearchChange={setSearchQuery}
|
|
214
|
+
currentView={currentView}
|
|
215
|
+
onNavigate={handleNavigate}
|
|
216
|
+
onToggleTheme={cycleTheme}
|
|
217
|
+
onOpenExport={() => setExportOpen(true)}
|
|
218
|
+
onOpenCommandPalette={() => setPaletteOpen(true)}
|
|
219
|
+
onBenchmark={handleBenchmark}
|
|
220
|
+
benchmarkRunning={globalBenchmarkRunning}
|
|
221
|
+
benchmarkTotal={globalBenchmarkTotal}
|
|
222
|
+
benchmarkCompleted={globalBenchmarkCompleted}
|
|
223
|
+
modelsCount={filtered.length}
|
|
224
|
+
theme={theme}
|
|
225
|
+
onToast={addToast}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<div className="app-content">
|
|
229
|
+
{currentView === 'dashboard' && (
|
|
230
|
+
<div className="view">
|
|
231
|
+
<FilterBar
|
|
232
|
+
filterTier={filterTier}
|
|
233
|
+
setFilterTier={setFilterTier}
|
|
234
|
+
filterStatus={filterStatus}
|
|
235
|
+
setFilterStatus={setFilterStatus}
|
|
236
|
+
filterProvider={filterProvider}
|
|
237
|
+
setFilterProvider={setFilterProvider}
|
|
238
|
+
filterVerdict={filterVerdict}
|
|
239
|
+
setFilterVerdict={setFilterVerdict}
|
|
240
|
+
filterHealth={filterHealth}
|
|
241
|
+
setFilterHealth={setFilterHealth}
|
|
242
|
+
visibilityMode={visibilityMode}
|
|
243
|
+
setVisibilityMode={setVisibilityMode}
|
|
244
|
+
customTextFilter={customTextFilter}
|
|
245
|
+
setCustomTextFilter={setCustomTextFilter}
|
|
246
|
+
searchQuery={searchQuery}
|
|
247
|
+
onResetView={handleResetView}
|
|
248
|
+
providers={providers}
|
|
249
|
+
pingMode={pingMode}
|
|
250
|
+
setPingMode={handlePingModeChange}
|
|
251
|
+
nextPingAt={nextPingAt}
|
|
252
|
+
isPinging={isPinging}
|
|
253
|
+
globalBenchmarkRunning={globalBenchmarkRunning}
|
|
254
|
+
globalBenchmarkTotal={globalBenchmarkTotal}
|
|
255
|
+
globalBenchmarkCompleted={globalBenchmarkCompleted}
|
|
256
|
+
/>
|
|
257
|
+
<ModelTable
|
|
258
|
+
filtered={filtered}
|
|
259
|
+
onSelectModel={handleSelectModel}
|
|
260
|
+
onBenchmarkRow={handleBenchmarkRow}
|
|
261
|
+
favorites={favorites}
|
|
262
|
+
sortColumn={sortColumn}
|
|
263
|
+
sortDirection={sortDirection}
|
|
264
|
+
onSort={toggleSort}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{currentView === 'settings' && (
|
|
270
|
+
<div className="view">
|
|
271
|
+
<SettingsView onToast={addToast} />
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{currentView === 'analytics' && (
|
|
276
|
+
<div className="view">
|
|
277
|
+
<AnalyticsView models={models} />
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
92
280
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<div className="view">
|
|
96
|
-
<Header
|
|
97
|
-
searchQuery={searchQuery}
|
|
98
|
-
onSearchChange={setSearchQuery}
|
|
99
|
-
onToggleTheme={toggleTheme}
|
|
100
|
-
onOpenSettings={() => setCurrentView('settings')}
|
|
101
|
-
onOpenExport={() => setExportOpen(true)}
|
|
102
|
-
/>
|
|
103
|
-
<StatsBar models={models} />
|
|
104
|
-
<FilterBar
|
|
105
|
-
filterTier={filterTier}
|
|
106
|
-
setFilterTier={setFilterTier}
|
|
107
|
-
filterStatus={filterStatus}
|
|
108
|
-
setFilterStatus={setFilterStatus}
|
|
109
|
-
filterProvider={filterProvider}
|
|
110
|
-
setFilterProvider={setFilterProvider}
|
|
111
|
-
providers={providers}
|
|
112
|
-
/>
|
|
113
|
-
<ModelTable
|
|
114
|
-
filtered={filtered}
|
|
115
|
-
onSelectModel={handleSelectModel}
|
|
116
|
-
/>
|
|
117
|
-
</div>
|
|
118
|
-
)}
|
|
119
|
-
|
|
120
|
-
{currentView === 'settings' && (
|
|
121
|
-
<div className="view">
|
|
122
|
-
<SettingsView onToast={addToast} />
|
|
123
|
-
</div>
|
|
124
|
-
)}
|
|
125
|
-
|
|
126
|
-
{currentView === 'analytics' && (
|
|
127
|
-
<div className="view">
|
|
128
|
-
<AnalyticsView models={models} />
|
|
129
|
-
</div>
|
|
130
|
-
)}
|
|
131
|
-
|
|
132
|
-
{currentView === 'map' && (
|
|
133
|
-
<div className="view">
|
|
134
|
-
<MapView />
|
|
135
|
-
</div>
|
|
136
|
-
)}
|
|
137
|
-
|
|
138
|
-
<Footer />
|
|
281
|
+
<Footer />
|
|
282
|
+
</div>
|
|
139
283
|
</div>
|
|
140
284
|
|
|
141
285
|
<DetailPanel
|
|
142
286
|
model={selectedModel}
|
|
143
287
|
onClose={handleCloseDetail}
|
|
288
|
+
favorites={favorites}
|
|
289
|
+
onBenchmark={handleBenchmarkRow}
|
|
290
|
+
onToast={addToast}
|
|
144
291
|
/>
|
|
145
292
|
|
|
146
293
|
{exportOpen && (
|
|
@@ -151,6 +298,21 @@ export default function App() {
|
|
|
151
298
|
/>
|
|
152
299
|
)}
|
|
153
300
|
|
|
301
|
+
{paletteOpen && (
|
|
302
|
+
<CommandPalette
|
|
303
|
+
onClose={() => setPaletteOpen(false)}
|
|
304
|
+
onNavigate={handleNavigate}
|
|
305
|
+
onCycleTheme={cycleTheme}
|
|
306
|
+
onResetView={handleResetView}
|
|
307
|
+
currentView={currentView}
|
|
308
|
+
theme={theme}
|
|
309
|
+
pingMode={pingMode}
|
|
310
|
+
onSetPingMode={handlePingModeChange}
|
|
311
|
+
onToast={addToast}
|
|
312
|
+
onExport={() => setExportOpen(true)}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
|
|
154
316
|
<ToastContainer toasts={toasts} dismissToast={dismissToast} />
|
|
155
317
|
</>
|
|
156
318
|
)
|
|
@@ -5,13 +5,42 @@
|
|
|
5
5
|
* @functions AnalyticsView → renders the three analytics cards
|
|
6
6
|
*/
|
|
7
7
|
import { useMemo } from 'react'
|
|
8
|
+
import { IconActivity, IconTrophy } from '@tabler/icons-react'
|
|
8
9
|
import TierBadge from '../atoms/TierBadge.jsx'
|
|
9
10
|
import styles from './AnalyticsView.module.css'
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// 📖 TIER_COLORS is now derived from CSS custom properties so the chart fills
|
|
13
|
+
// 📖 swap to AA-contrast shades in light mode automatically. The keys stay
|
|
14
|
+
// 📖 the same; the values come from --tier-splus / --tier-s / etc.
|
|
15
|
+
function readTierColor(name, fallback) {
|
|
16
|
+
if (typeof window === 'undefined' || !window.document?.documentElement) return fallback
|
|
17
|
+
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
18
|
+
return v || fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TIER_COLOR_KEYS = {
|
|
22
|
+
'S+': '--tier-splus',
|
|
23
|
+
'S': '--tier-s',
|
|
24
|
+
'A+': '--tier-aplus',
|
|
25
|
+
'A': '--tier-a',
|
|
26
|
+
'A-': '--tier-aminus',
|
|
27
|
+
'B+': '--tier-bplus',
|
|
28
|
+
'B': '--tier-b',
|
|
29
|
+
'C': '--tier-c',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TIER_COLOR_FALLBACKS = {
|
|
12
33
|
'S+': '#ffd700', S: '#ff8c00', 'A+': '#00c8ff', A: '#3ddc84',
|
|
13
34
|
'A-': '#7ecf7e', 'B+': '#a8a8c8', B: '#808098', C: '#606078',
|
|
14
35
|
}
|
|
36
|
+
|
|
37
|
+
// 📖 Resolve the tier color at render time. Memoizing the map per theme switch
|
|
38
|
+
// 📖 would be more efficient, but the cost is one getComputedStyle per tier and
|
|
39
|
+
// 📖 the chart only re-renders when `models` changes, so the extra work is
|
|
40
|
+
// 📖 imperceptible.
|
|
41
|
+
function tierColor(key) {
|
|
42
|
+
return readTierColor(TIER_COLOR_KEYS[key], TIER_COLOR_FALLBACKS[key])
|
|
43
|
+
}
|
|
15
44
|
const TIERS = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
16
45
|
|
|
17
46
|
export default function AnalyticsView({ models }) {
|
|
@@ -40,7 +69,10 @@ export default function AnalyticsView({ models }) {
|
|
|
40
69
|
return (
|
|
41
70
|
<div className={styles.page}>
|
|
42
71
|
<div className={styles.pageHeader}>
|
|
43
|
-
<h1 className={styles.pageTitle}
|
|
72
|
+
<h1 className={styles.pageTitle}>
|
|
73
|
+
<IconActivity size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
|
74
|
+
Analytics
|
|
75
|
+
</h1>
|
|
44
76
|
<p className={styles.pageSubtitle}>Real-time insights across all providers and models</p>
|
|
45
77
|
</div>
|
|
46
78
|
|
|
@@ -69,17 +101,19 @@ export default function AnalyticsView({ models }) {
|
|
|
69
101
|
</div>
|
|
70
102
|
|
|
71
103
|
<div className={styles.card}>
|
|
72
|
-
<h3 className={styles.cardTitle}
|
|
104
|
+
<h3 className={styles.cardTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
105
|
+
<IconTrophy size={16} stroke={1.5} />
|
|
106
|
+
Fastest Models
|
|
107
|
+
</h3>
|
|
73
108
|
<div className={styles.cardBody}>
|
|
74
109
|
{leaderboard.length === 0 ? (
|
|
75
110
|
<div className={styles.empty}>Waiting for ping data...</div>
|
|
76
111
|
) : (
|
|
77
112
|
leaderboard.map((m, i) => {
|
|
78
113
|
const rankCls = i < 3 ? styles[`rank${i + 1}`] : ''
|
|
79
|
-
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1)
|
|
80
114
|
return (
|
|
81
115
|
<div key={m.modelId} className={styles.leaderItem}>
|
|
82
|
-
<div className={`${styles.leaderRank} ${rankCls}`}>{
|
|
116
|
+
<div className={`${styles.leaderRank} ${rankCls}`}>{i + 1}</div>
|
|
83
117
|
<span className={styles.leaderName}>{m.label}</span>
|
|
84
118
|
<span className={styles.leaderLatency}>{m.avg}ms</span>
|
|
85
119
|
</div>
|
|
@@ -96,7 +130,7 @@ export default function AnalyticsView({ models }) {
|
|
|
96
130
|
<div key={tier} className={styles.tierItem}>
|
|
97
131
|
<div className={styles.tierBadge}><TierBadge tier={tier} /></div>
|
|
98
132
|
<div className={styles.tierBar}>
|
|
99
|
-
<div className={styles.tierFill} style={{ width: `${pct}%`, background:
|
|
133
|
+
<div className={styles.tierFill} style={{ width: `${pct}%`, background: tierColor(tier) }} />
|
|
100
134
|
</div>
|
|
101
135
|
<span className={styles.tierCount}>{count}</span>
|
|
102
136
|
</div>
|
|
@@ -98,6 +98,10 @@
|
|
|
98
98
|
.pctMedium { color: #ffaa00; }
|
|
99
99
|
.pctSlow { color: #ff4444; }
|
|
100
100
|
|
|
101
|
+
:global([data-theme="light"]) .pctFast { color: #008f4d; }
|
|
102
|
+
:global([data-theme="light"]) .pctMedium { color: #b86b00; }
|
|
103
|
+
:global([data-theme="light"]) .pctSlow { color: #c8143a; }
|
|
104
|
+
|
|
101
105
|
.leaderItem {
|
|
102
106
|
display: flex;
|
|
103
107
|
align-items: center;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/AILatencyCell.jsx
|
|
3
|
+
* @description Benchmark AI latency column — shows animated spinner during benchmarks.
|
|
4
|
+
* 📖 Shows: 4.3s, 12s, ERR, TIMEOUT. Retry badge ↻N in blue.
|
|
5
|
+
* 📖 When isRunning: animated CSS spinner + pulsing row highlight.
|
|
6
|
+
*/
|
|
7
|
+
import styles from './AILatencyCell.module.css'
|
|
8
|
+
|
|
9
|
+
function formatLatency(result, isRunning) {
|
|
10
|
+
if (isRunning) return { text: 'RUN', badge: '' }
|
|
11
|
+
if (!result || !result.ok) return { text: result?.code || '—', badge: '' }
|
|
12
|
+
const totalSec = result.totalMs / 1000
|
|
13
|
+
const badge = result.retries > 0 ? `↻${result.retries}` : ''
|
|
14
|
+
const text = totalSec >= 10 ? `${totalSec.toFixed(0)}s` : `${totalSec.toFixed(1)}s`
|
|
15
|
+
return { text, badge }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function AILatencyCell({ result, isRunning }) {
|
|
19
|
+
const { text, badge } = formatLatency(result, isRunning)
|
|
20
|
+
const ok = result?.ok
|
|
21
|
+
const colorCls = ok ? styles.fast : (result ? styles.slow : styles.dim)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<span className={`${styles.cell} ${isRunning ? styles.runningCell : ''}`}>
|
|
25
|
+
{isRunning ? (
|
|
26
|
+
<>
|
|
27
|
+
<span className={styles.benchmarkSpinner} />
|
|
28
|
+
<span className={`${styles.value} ${styles.running}`}>{text}</span>
|
|
29
|
+
</>
|
|
30
|
+
) : (
|
|
31
|
+
<>
|
|
32
|
+
<span className={`${styles.value} ${colorCls}`}>{text}</span>
|
|
33
|
+
{badge && <span className={styles.badge}>{badge}</span>}
|
|
34
|
+
</>
|
|
35
|
+
)}
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.cell {
|
|
2
|
+
font-family: var(--font-mono);
|
|
3
|
+
font-size: 12px;
|
|
4
|
+
font-weight: 600;
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: 4px;
|
|
8
|
+
min-width: 70px;
|
|
9
|
+
transition: background 200ms;
|
|
10
|
+
border-radius: 4px;
|
|
11
|
+
padding: 1px 3px;
|
|
12
|
+
}
|
|
13
|
+
.value { }
|
|
14
|
+
.fast { color: #00ff88; }
|
|
15
|
+
.medium { color: #ffaa00; }
|
|
16
|
+
.slow { color: #ff4444; }
|
|
17
|
+
.dim { color: var(--color-text-dim); }
|
|
18
|
+
.running { color: #b400ff; font-weight: 700; }
|
|
19
|
+
.badge { color: #06b6d4; font-size: 10px; }
|
|
20
|
+
|
|
21
|
+
/* ── Running/benchmarking row highlight ── */
|
|
22
|
+
.runningCell {
|
|
23
|
+
background: rgba(180, 0, 255, 0.12);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ── Benchmark spinner: animated ring ── */
|
|
27
|
+
@keyframes benchSpin {
|
|
28
|
+
to { transform: rotate(360deg); }
|
|
29
|
+
}
|
|
30
|
+
@keyframes benchPulse {
|
|
31
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(180, 0, 255, 0.4); }
|
|
32
|
+
50% { box-shadow: 0 0 0 4px rgba(180, 0, 255, 0); }
|
|
33
|
+
}
|
|
34
|
+
.benchmarkSpinner {
|
|
35
|
+
display: inline-block;
|
|
36
|
+
width: 12px;
|
|
37
|
+
height: 12px;
|
|
38
|
+
border: 2px solid rgba(180, 0, 255, 0.3);
|
|
39
|
+
border-top-color: #b400ff;
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
animation: benchSpin 0.6s linear infinite;
|
|
42
|
+
flex-shrink: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* 📖 Light theme: swap the neon palette for darker, AA-contrast shades. The
|
|
46
|
+
📖 purple running color stays vibrant since it works on both backgrounds. */
|
|
47
|
+
:global([data-theme="light"]) .fast { color: #008f4d; }
|
|
48
|
+
:global([data-theme="light"]) .medium { color: #b86b00; }
|
|
49
|
+
:global([data-theme="light"]) .slow { color: #c8143a; }
|
|
50
|
+
:global([data-theme="light"]) .badge { color: #0e7490; }
|
|
51
|
+
:global([data-theme="light"]) .runningCell { background: rgba(125, 0, 180, 0.08); }
|