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
package/web/src/App.jsx CHANGED
@@ -1,24 +1,32 @@
1
1
  /**
2
2
  * @file web/src/App.jsx
3
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).
4
+ * connection, toast notifications, and global state. M2 layout: no sidebar, header
5
+ * menu + ⌘K palette, full Settings parity, Help + Changelog modals, UpdateChip,
6
+ * URL write-back.
6
7
  *
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)
8
+ * 📖 M2 additions on top of M1:
9
+ * 📖 - Full command palette (TUI registry via `buildCommandPaletteEntries`)
10
+ * 📖 - HelpView modal (TUI parity help)
11
+ * 📖 - ChangelogView modal (2-phase: index + details)
12
+ * 📖 - UpdateChip in header (polls /api/version, popover with "Update now" + "What's new")
13
+ * 📖 - URL write-back (every filter / sort / view / palette / toolMode change
14
+ * 📖 updates the URL via history.replaceState, debounced at 80ms)
15
+ * 📖 - M3 Web endpoint install flow: writes tool config only; never starts CLIs.
16
+ * 📖 - New Settings rows: theme dropdown, favorites mode toggle, startup AI scan
17
+ * 📖 toggle, shell-env toggle, legacy proxy cleanup button, open Changelog link,
18
+ * 📖 update status row, per-provider test key button
13
19
  *
14
20
  * @functions App → root component with all state and layout composition
15
21
  */
16
- import { useState, useCallback, useEffect, useRef } from 'react'
22
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
17
23
  import { useSocket } from './hooks/useSocket.js'
18
24
  import { useFilter } from './hooks/useFilter.js'
19
25
  import { useTheme } from './hooks/useTheme.js'
20
26
  import { useFavorites } from './hooks/useFavorites.js'
27
+ import { useToolMode } from './hooks/useToolMode.js'
21
28
  import { useUrlState } from './hooks/useUrlState.js'
29
+ import { useUpdateChecker } from './hooks/useUpdateChecker.js'
22
30
  import Header from './components/layout/Header.jsx'
23
31
  import Footer from './components/layout/Footer.jsx'
24
32
  import FilterBar from './components/dashboard/FilterBar.jsx'
@@ -28,69 +36,147 @@ import ExportModal from './components/dashboard/ExportModal.jsx'
28
36
  import SettingsView from './components/settings/SettingsView.jsx'
29
37
  import AnalyticsView from './components/analytics/AnalyticsView.jsx'
30
38
  import CommandPalette from './components/palette/CommandPalette.jsx'
39
+ import HelpView from './components/help/HelpView.jsx'
40
+ import ChangelogView from './components/changelog/ChangelogView.jsx'
41
+ import UpdateChip from './components/update/UpdateChip.jsx'
42
+ import RecommendView from './components/recommend/RecommendView.jsx'
43
+ import IncompatibleFallbackModal from './components/launch/IncompatibleFallbackModal.jsx'
44
+ import RouterView from './components/router/RouterView.jsx'
45
+ import InstalledModelsView from './components/installed/InstalledModelsView.jsx'
46
+ import InstallEndpointsView from './components/install/InstallEndpointsView.jsx'
31
47
  import ToastContainer from './components/atoms/ToastContainer.jsx'
48
+ import { isModelCompatibleWithTool } from '../../src/core/tool-metadata.js'
32
49
 
33
50
  let toastIdCounter = 0
34
51
 
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
52
  const VIEW_TO_NAV = {
39
53
  dashboard: 'dashboard',
40
54
  settings: 'settings',
41
55
  analytics: 'analytics',
42
56
  recommend: 'recommend',
43
57
  router: 'router',
44
- help: 'help',
45
- changelog: 'changelog',
46
- 'install-endpoints': 'install-endpoints',
47
- 'installed-models': 'installed-models',
48
58
  }
49
59
 
50
60
  export default function App() {
51
- const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted, updateStatus } = useSocket()
61
+ const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted } = useSocket()
52
62
  const { theme, cycle: cycleTheme } = useTheme()
53
63
  const [currentView, setCurrentView] = useState('dashboard')
54
64
  const [selectedModel, setSelectedModel] = useState(null)
55
65
  const [exportOpen, setExportOpen] = useState(false)
56
66
  const [paletteOpen, setPaletteOpen] = useState(false)
67
+ const [helpOpen, setHelpOpen] = useState(false)
68
+ const [changelogOpen, setChangelogOpen] = useState(false)
69
+ const [changelogDefaultVersion, setChangelogDefaultVersion] = useState(null)
70
+ const [recommendOpen, setRecommendOpen] = useState(false)
71
+ const [routerOpen, setRouterOpen] = useState(false)
72
+ const [installedModelsOpen, setInstalledModelsOpen] = useState(false)
73
+ const [installEndpointsOpen, setInstallEndpointsOpen] = useState(false)
74
+ const [incompatibleRequest, setIncompatibleRequest] = useState(null)
57
75
  const [toasts, setToasts] = useState([])
58
76
  const lastActivityRef = useRef(Date.now())
59
77
 
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
- })
78
+ // ── Toast helpers ────────────────────────────────────────────────────────
79
+ const addToast = useCallback((message, type = 'info') => {
80
+ const id = ++toastIdCounter
81
+ setToasts((prev) => [...prev, { id, message, type }])
82
+ setTimeout(() => {
83
+ setToasts((prev) => prev.filter((t) => t.id !== id))
84
+ }, 4000)
85
+ }, [])
86
+
87
+ const dismissToast = useCallback((id) => {
88
+ setToasts((prev) => prev.filter((t) => t.id !== id))
89
+ }, [])
67
90
 
68
91
  const {
69
92
  filtered,
70
93
  filterTier, setFilterTier,
71
94
  filterStatus, setFilterStatus,
72
95
  filterProvider, setFilterProvider,
73
- searchQuery, setSearchQuery,
74
- sortColumn, sortDirection, toggleSort,
75
96
  filterVerdict, setFilterVerdict,
76
97
  filterHealth, setFilterHealth,
77
98
  visibilityMode, setVisibilityMode,
99
+ searchQuery, setSearchQuery,
78
100
  customTextFilter, setCustomTextFilter,
101
+ sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
79
102
  resetView,
80
103
  } = useFilter(models)
81
104
 
82
- // 📖 Favorites single source of truth shared with the TUI via ~/.free-coding-models.json.
105
+ const { toolMode, setToolMode, cycleToolMode } = useToolMode({ onToast: addToast })
106
+
107
+ // 📖 URL deep-linking (M2 = read + write). Hydrates on mount, then pushes
108
+ // 📖 every change back via history.replaceState (debounced 80ms).
109
+ useUrlState({
110
+ currentView, setCurrentView,
111
+ filterState: {
112
+ filterTier, setFilterTier,
113
+ filterStatus, setFilterStatus,
114
+ filterProvider, setFilterProvider,
115
+ filterVerdict, setFilterVerdict,
116
+ filterHealth, setFilterHealth,
117
+ sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
118
+ setSearchQuery,
119
+ filterState: null, // sentinel; useFilter doesn't expose this name
120
+ searchQuery,
121
+ },
122
+ paletteOpen, setPaletteOpen,
123
+ toolMode, setToolMode,
124
+ })
125
+
126
+ // 📖 Favorites — single source of truth shared with the TUI.
83
127
  const favorites = useFavorites({ models })
84
128
 
129
+ // ── M3 endpoint flow: compat guard → /api/install-endpoint → fallback modal ──
130
+ const handleInstallEndpoint = useCallback(async (model, overrideMode = null) => {
131
+ const mode = overrideMode || toolMode || 'opencode'
132
+ if (!model) return
133
+ if (!isModelCompatibleWithTool(model.providerKey, mode)) {
134
+ setIncompatibleRequest({ model, toolMode: mode })
135
+ return
136
+ }
137
+ try {
138
+ const resp = await fetch('/api/install-endpoint', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId, toolMode: mode }),
142
+ })
143
+ const payload = await resp.json().catch(() => ({}))
144
+ if (resp.status === 422 && payload.code === 'incompatible_model') {
145
+ setIncompatibleRequest({ model, toolMode: mode })
146
+ return
147
+ }
148
+ if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`)
149
+ addToast(`Endpoint installed for ${model.label} in ${mode}. Start the tool yourself.`, 'success')
150
+ } catch (err) {
151
+ addToast(`Endpoint install failed: ${err.message}`, 'error')
152
+ }
153
+ }, [addToast, toolMode])
154
+
155
+ const handleSwitchToolAndInstall = useCallback(async (mode, model) => {
156
+ await setToolMode(mode)
157
+ setIncompatibleRequest(null)
158
+ await handleInstallEndpoint(model, mode)
159
+ }, [handleInstallEndpoint, setToolMode])
160
+
161
+ const handlePinAndInstall = useCallback(async (model) => {
162
+ if (!favorites.isFavorite(model)) await favorites.toggle(model)
163
+ await handleInstallEndpoint(model)
164
+ }, [favorites, handleInstallEndpoint])
165
+
166
+ // 📖 Update checker (5-minute poll). Returns `updateAvailable` for the chip.
167
+ const {
168
+ localVersion, latestVersion, updateAvailable, runUpdate, checkNow, error: updateError,
169
+ } = useUpdateChecker({ onToast: addToast })
170
+
85
171
  // 📖 Build the provider list for the FilterBar dropdown.
86
- const providers = (() => {
172
+ const providers = useMemo(() => {
87
173
  const map = {}
88
174
  models.forEach((m) => {
89
175
  if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
90
176
  map[m.providerKey].count++
91
177
  })
92
178
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
93
- })()
179
+ }, [models])
94
180
 
95
181
  // ── Global benchmark (AI Speed Test) ─────────────────────────────────────
96
182
  const handleBenchmark = useCallback(async () => {
@@ -118,35 +204,21 @@ export default function App() {
118
204
  })
119
205
  if (!resp.ok && resp.status !== 202) {
120
206
  const err = await resp.json().catch(() => ({}))
121
- addToast?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
207
+ addToast(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
122
208
  }
123
209
  } catch (err) {
124
210
  console.error('[Benchmark] per-row failed:', err.message)
125
211
  }
126
- }, [])
127
-
128
- // ── Toast helpers ────────────────────────────────────────────────────────
129
- const addToast = useCallback((message, type = 'info') => {
130
- const id = ++toastIdCounter
131
- setToasts((prev) => [...prev, { id, message, type }])
132
- setTimeout(() => {
133
- setToasts((prev) => prev.filter((t) => t.id !== id))
134
- }, 4000)
135
- }, [])
136
-
137
- const dismissToast = useCallback((id) => {
138
- setToasts((prev) => prev.filter((t) => t.id !== id))
139
- }, [])
212
+ }, [addToast])
140
213
 
141
214
  // ── Selection / detail panel ─────────────────────────────────────────────
142
215
  const handleSelectModel = useCallback((model) => {
143
216
  setSelectedModel(model)
144
217
  lastActivityRef.current = Date.now()
145
218
  }, [])
146
-
147
219
  const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
148
220
 
149
- // ── Ping mode change → server → broadcast ───────────────────────────────
221
+ // ── Ping mode change → server → broadcast ─────────────────────────────
150
222
  const handlePingModeChange = useCallback(async (mode) => {
151
223
  try {
152
224
  await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
@@ -155,55 +227,68 @@ export default function App() {
155
227
 
156
228
  // ── Navigation handler (Header nav + overflow menu) ──────────────────────
157
229
  const handleNavigate = useCallback((viewId) => {
230
+ // 📖 'help' / 'changelog' / 'recommend' / 'router' open modals (M2) or
231
+ // 📖 toasts (M3/M4) — they don't switch the currentView.
232
+ if (viewId === 'help') { setHelpOpen(true); return }
233
+ if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
234
+ if (viewId === 'recommend') { setRecommendOpen(true); return }
235
+ if (viewId === 'router') { setRouterOpen(true); return }
236
+ if (viewId === 'install-endpoints') { setInstallEndpointsOpen(true); return }
237
+ if (viewId === 'installed-models') { setInstalledModelsOpen(true); return }
158
238
  setCurrentView(VIEW_TO_NAV[viewId] || viewId)
159
239
  lastActivityRef.current = Date.now()
160
- }, [])
240
+ }, [addToast])
161
241
 
162
242
  // ── Reset view (N key equivalent) ────────────────────────────────────────
163
243
  const handleResetView = useCallback(() => {
164
244
  resetView()
165
245
  setSearchQuery('')
166
246
  addToast('View reset to defaults.', 'info')
167
- }, [resetView, setSearchQuery, addToast])
247
+ }, [addToast, resetView, setSearchQuery])
248
+
249
+ // ── Changelog open with optional version (e.g. from UpdateChip "What's new") ─
250
+ const openChangelogAt = useCallback((version) => {
251
+ setChangelogDefaultVersion(version)
252
+ setChangelogOpen(true)
253
+ }, [])
168
254
 
169
- // ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette ────────────────
255
+ // ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette, Esc for any modal ─
170
256
  useEffect(() => {
171
257
  const handler = (e) => {
172
258
  const cmdOrCtrl = e.metaKey || e.ctrlKey
173
- // ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
174
259
  if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
175
260
  e.preventDefault()
176
261
  setPaletteOpen((o) => !o)
177
262
  return
178
263
  }
179
- // Ctrl+P is also accepted as a TUI-style alias for the same palette.
180
264
  if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
181
265
  e.preventDefault()
182
266
  setPaletteOpen((o) => !o)
183
267
  return
184
268
  }
185
- // Esc closes whatever is open.
186
269
  if (e.key === 'Escape') {
187
270
  if (paletteOpen) { setPaletteOpen(false); return }
271
+ if (helpOpen) { setHelpOpen(false); return }
272
+ if (changelogOpen) { setChangelogOpen(false); return }
273
+ if (recommendOpen) { setRecommendOpen(false); return }
274
+ if (routerOpen) { setRouterOpen(false); return }
275
+ if (installEndpointsOpen) { setInstallEndpointsOpen(false); return }
276
+ if (installedModelsOpen) { setInstalledModelsOpen(false); return }
277
+ if (incompatibleRequest) { setIncompatibleRequest(null); return }
188
278
  if (selectedModel) { setSelectedModel(null); return }
189
279
  if (exportOpen) { setExportOpen(false); return }
190
280
  }
191
281
  }
192
282
  window.addEventListener('keydown', handler)
193
283
  return () => window.removeEventListener('keydown', handler)
194
- }, [paletteOpen, selectedModel, exportOpen])
284
+ }, [paletteOpen, helpOpen, changelogOpen, recommendOpen, routerOpen, installEndpointsOpen, installedModelsOpen, incompatibleRequest, selectedModel, exportOpen])
195
285
 
196
- // 📖 Reset view if URL contains the reset flag.
197
286
  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())
287
+ if (currentView === 'recommend') {
288
+ setRecommendOpen(true)
289
+ setCurrentView('dashboard')
204
290
  }
205
- // eslint-disable-next-line react-hooks/exhaustive-deps
206
- }, [])
291
+ }, [currentView])
207
292
 
208
293
  return (
209
294
  <>
@@ -223,17 +308,22 @@ export default function App() {
223
308
  modelsCount={filtered.length}
224
309
  theme={theme}
225
310
  onToast={addToast}
311
+ toolMode={toolMode}
312
+ onSetToolMode={setToolMode}
313
+ onCycleToolMode={cycleToolMode}
314
+ updateSlot={
315
+ <UpdateChip
316
+ updateAvailable={updateAvailable}
317
+ latestVersion={latestVersion}
318
+ onRunUpdate={runUpdate}
319
+ onOpenChangelog={openChangelogAt}
320
+ />
321
+ }
226
322
  />
227
323
 
228
- {updateStatus?.warningMessage && (
229
- <div className="update-warning-banner" role="alert">
230
- {updateStatus.warningMessage} — restart or run npm install -g free-coding-models@latest when your network/permissions are fixed.
231
- </div>
232
- )}
233
-
234
324
  <div className="app-content">
235
325
  {currentView === 'dashboard' && (
236
- <div className="view">
326
+ <main className="view">
237
327
  <FilterBar
238
328
  filterTier={filterTier}
239
329
  setFilterTier={setFilterTier}
@@ -259,22 +349,29 @@ export default function App() {
259
349
  globalBenchmarkRunning={globalBenchmarkRunning}
260
350
  globalBenchmarkTotal={globalBenchmarkTotal}
261
351
  globalBenchmarkCompleted={globalBenchmarkCompleted}
352
+ toolMode={toolMode}
262
353
  />
263
354
  <ModelTable
264
355
  filtered={filtered}
265
356
  onSelectModel={handleSelectModel}
266
357
  onBenchmarkRow={handleBenchmarkRow}
358
+ onLaunch={handleInstallEndpoint}
267
359
  favorites={favorites}
268
360
  sortColumn={sortColumn}
269
361
  sortDirection={sortDirection}
270
362
  onSort={toggleSort}
363
+ toolMode={toolMode}
271
364
  />
272
- </div>
365
+ </main>
273
366
  )}
274
367
 
275
368
  {currentView === 'settings' && (
276
369
  <div className="view">
277
- <SettingsView onToast={addToast} />
370
+ <SettingsView
371
+ onToast={addToast}
372
+ onOpenChangelog={(version) => { setChangelogDefaultVersion(version); setChangelogOpen(true) }}
373
+ onCheckForUpdate={() => { checkNow(); addToast?.('Checking for updates…', 'info') }}
374
+ />
278
375
  </div>
279
376
  )}
280
377
 
@@ -293,6 +390,12 @@ export default function App() {
293
390
  onClose={handleCloseDetail}
294
391
  favorites={favorites}
295
392
  onBenchmark={handleBenchmarkRow}
393
+ onLaunch={handleInstallEndpoint}
394
+ toolMode={toolMode}
395
+ onSetToolMode={setToolMode}
396
+ onCycleToolMode={cycleToolMode}
397
+ models={models}
398
+ onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
296
399
  onToast={addToast}
297
400
  />
298
401
 
@@ -310,12 +413,71 @@ export default function App() {
310
413
  onNavigate={handleNavigate}
311
414
  onCycleTheme={cycleTheme}
312
415
  onResetView={handleResetView}
416
+ onSetPingMode={handlePingModeChange}
417
+ onOpenHelp={() => setHelpOpen(true)}
418
+ onOpenChangelog={() => { setChangelogDefaultVersion(null); setChangelogOpen(true) }}
419
+ onExport={() => setExportOpen(true)}
420
+ onRunUpdate={runUpdate}
313
421
  currentView={currentView}
314
422
  theme={theme}
315
423
  pingMode={pingMode}
316
- onSetPingMode={handlePingModeChange}
424
+ models={models}
425
+ updateAvailable={updateAvailable}
426
+ latestVersion={latestVersion}
427
+ onToast={addToast}
428
+ />
429
+ )}
430
+
431
+ {recommendOpen && (
432
+ <RecommendView
433
+ onClose={() => setRecommendOpen(false)}
434
+ toolMode={toolMode}
435
+ onLaunch={handleInstallEndpoint}
436
+ onPinAndLaunch={handlePinAndInstall}
437
+ onToast={addToast}
438
+ />
439
+ )}
440
+
441
+ {incompatibleRequest && (
442
+ <IncompatibleFallbackModal
443
+ request={incompatibleRequest}
444
+ models={models}
445
+ onClose={() => setIncompatibleRequest(null)}
446
+ onSwitchToolLaunch={handleSwitchToolAndInstall}
447
+ onLaunchSimilar={(candidate) => {
448
+ setIncompatibleRequest(null)
449
+ const model = models.find((m) => m.providerKey === candidate.providerKey && m.modelId === candidate.modelId) || candidate
450
+ void handleInstallEndpoint(model)
451
+ }}
452
+ />
453
+ )}
454
+
455
+ {helpOpen && <HelpView onClose={() => setHelpOpen(false)} />}
456
+ {changelogOpen && (
457
+ <ChangelogView
458
+ onClose={() => setChangelogOpen(false)}
459
+ defaultVersion={changelogDefaultVersion}
460
+ />
461
+ )}
462
+
463
+ {routerOpen && (
464
+ <RouterView
465
+ onClose={() => setRouterOpen(false)}
466
+ onToast={addToast}
467
+ />
468
+ )}
469
+
470
+ {installedModelsOpen && (
471
+ <InstalledModelsView
472
+ onClose={() => setInstalledModelsOpen(false)}
473
+ onToast={addToast}
474
+ />
475
+ )}
476
+
477
+ {installEndpointsOpen && (
478
+ <InstallEndpointsView
479
+ onClose={() => setInstallEndpointsOpen(false)}
317
480
  onToast={addToast}
318
- onExport={() => setExportOpen(true)}
319
481
  />
320
482
  )}
321
483
 
@@ -7,6 +7,7 @@
7
7
  import { useMemo } from 'react'
8
8
  import { IconActivity, IconTrophy } from '@tabler/icons-react'
9
9
  import TierBadge from '../atoms/TierBadge.jsx'
10
+ import TokenUsagePanel from './TokenUsagePanel.jsx'
10
11
  import styles from './AnalyticsView.module.css'
11
12
 
12
13
  // 📖 TIER_COLORS is now derived from CSS custom properties so the chart fills
@@ -138,6 +139,9 @@ export default function AnalyticsView({ models }) {
138
139
  </div>
139
140
  </div>
140
141
  </div>
142
+
143
+ {/* M4: Token Usage — always shown, displays "no data" if router hasn't been used */}
144
+ <TokenUsagePanel />
141
145
  </div>
142
146
  )
143
147
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file web/src/components/analytics/TokenUsagePanel.jsx
3
+ * @description Token Usage sub-section inside Analytics — 7-day chart, top models/providers.
4
+ * 📖 M4: Uses useTokenUsage hook, pure CSS bars (no charting library).
5
+ */
6
+ import { useTokenUsage } from '../../hooks/useTokenUsage.js'
7
+ import styles from './TokenUsagePanel.module.css'
8
+
9
+ function formatTokens(n) {
10
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
11
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
12
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
13
+ return String(Math.round(n))
14
+ }
15
+
16
+ export default function TokenUsagePanel() {
17
+ const { data, loading } = useTokenUsage()
18
+
19
+ if (loading) {
20
+ return (
21
+ <div className={styles.card}>
22
+ <h3 className={styles.cardTitle}>Token Usage</h3>
23
+ <div className={styles.empty}>Loading…</div>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ if (!data || !data.hasData) {
29
+ return (
30
+ <div className={styles.card}>
31
+ <h3 className={styles.cardTitle}>Token Usage</h3>
32
+ <div className={styles.empty}>No token data yet. Start the router and send requests to track usage.</div>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ const maxDayTokens = Math.max(...data.sevenDays.map(d => d.totalTokens), 1)
38
+
39
+ return (
40
+ <>
41
+ {/* Summary cards */}
42
+ <div className={styles.summaryGrid}>
43
+ <div className={styles.summaryCard}>
44
+ <span className={styles.summaryLabel}>Today</span>
45
+ <span className={styles.summaryValue}>{formatTokens(data.today.totalTokens)}</span>
46
+ <span className={styles.summarySub}>{data.today.requests} requests</span>
47
+ </div>
48
+ <div className={styles.summaryCard}>
49
+ <span className={styles.summaryLabel}>All Time</span>
50
+ <span className={styles.summaryValue}>{formatTokens(data.allTime.totalTokens)}</span>
51
+ <span className={styles.summarySub}>{data.allTime.requests} requests</span>
52
+ </div>
53
+ </div>
54
+
55
+ {/* 7-day chart */}
56
+ <div className={styles.card}>
57
+ <h3 className={styles.cardTitle}>7-Day Usage</h3>
58
+ <div className={styles.chart}>
59
+ {data.sevenDays.map((day) => (
60
+ <div key={day.date} className={styles.chartBar}>
61
+ <div
62
+ className={styles.chartFill}
63
+ style={{ height: `${Math.max(2, (day.totalTokens / maxDayTokens) * 100)}%` }}
64
+ title={`${day.date}: ${formatTokens(day.totalTokens)} tokens`}
65
+ />
66
+ <span className={styles.chartLabel}>{day.label}</span>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ </div>
71
+
72
+ {/* Top Models */}
73
+ {data.topModels.length > 0 && (
74
+ <div className={styles.card}>
75
+ <h3 className={styles.cardTitle}>Top Models</h3>
76
+ <div className={styles.topList}>
77
+ {data.topModels.map((m) => (
78
+ <div key={m.key} className={styles.topRow}>
79
+ <span className={styles.topKey}>{m.key}</span>
80
+ <span className={styles.topVal}>{formatTokens(m.total)} tokens</span>
81
+ <span className={styles.topReq}>{m.requests} req</span>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ )}
87
+
88
+ {/* Top Providers */}
89
+ {data.topProviders.length > 0 && (
90
+ <div className={styles.card}>
91
+ <h3 className={styles.cardTitle}>Top Providers</h3>
92
+ <div className={styles.topList}>
93
+ {data.topProviders.map((p) => (
94
+ <div key={p.key} className={styles.topRow}>
95
+ <span className={styles.topKey}>{p.key}</span>
96
+ <span className={styles.topVal}>{formatTokens(p.total)} tokens</span>
97
+ <span className={styles.topReq}>{p.requests} req</span>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ )}
103
+ </>
104
+ )
105
+ }