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.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. 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, 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.
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 { useSSE } from './hooks/useSSE.js'
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 MapView from './components/map/MapView.jsx'
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 } = useSSE('/api/events')
30
- const { theme, toggle: toggleTheme } = useTheme()
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
- const handleSelectModel = useCallback((modelId) => {
64
- const model = models.find((m) => m.modelId === modelId)
65
- if (model) setSelectedModel(model)
66
- }, [models])
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
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
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
- if (currentView !== 'dashboard') setCurrentView('dashboard')
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
- setSelectedModel(null)
78
- setExportOpen(false)
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
- }, [currentView])
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
- <Sidebar
88
- currentView={currentView}
89
- onNavigate={setCurrentView}
90
- onToggleTheme={toggleTheme}
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
- <div className="app-content">
94
- {currentView === 'dashboard' && (
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
- const TIER_COLORS = {
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}>📊 Analytics</h1>
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}>🏆 Fastest Models</h3>
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}`}>{medal}</div>
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: TIER_COLORS[tier] }} />
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); }