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.
- package/README.md +19 -11
- package/bin/free-coding-models.js +6 -0
- package/changelog/v0.5.5.md +1 -0
- package/changelog/v0.5.6.md +24 -0
- package/changelog/v0.5.7.md +20 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/endpoint-installer.js +3 -3
- package/src/core/router-daemon.js +11 -0
- package/src/core/tool-launchers.js +2 -2
- package/web/dist/assets/index-DKmmiDip.css +1 -0
- package/web/dist/assets/index-DzVt42Nf.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +532 -2
- package/web/src/App.jsx +235 -73
- package/web/src/components/analytics/AnalyticsView.jsx +4 -0
- package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
- package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
- package/web/src/components/atoms/TierBadge.module.css +3 -3
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/dashboard/DetailPanel.jsx +29 -4
- package/web/src/components/dashboard/DetailPanel.module.css +26 -0
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +17 -0
- package/web/src/components/dashboard/ModelTable.jsx +17 -5
- package/web/src/components/dashboard/ModelTable.module.css +14 -1
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/install/InstallEndpointsView.jsx +278 -0
- package/web/src/components/install/InstallEndpointsView.module.css +351 -0
- package/web/src/components/installed/InstalledModelsView.jsx +89 -0
- package/web/src/components/installed/InstalledModelsView.module.css +200 -0
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
- package/web/src/components/launch/LaunchButton.jsx +30 -0
- package/web/src/components/launch/LaunchButton.module.css +35 -0
- package/web/src/components/launch/LaunchModal.module.css +125 -0
- package/web/src/components/layout/Header.jsx +78 -14
- package/web/src/components/layout/Header.module.css +62 -2
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/recommend/RecommendView.jsx +121 -0
- package/web/src/components/recommend/RecommendView.module.css +55 -0
- package/web/src/components/router/RouterView.jsx +286 -0
- package/web/src/components/router/RouterView.module.css +357 -0
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +28 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useInstalledModels.js +42 -0
- package/web/src/hooks/useRecommend.js +67 -0
- package/web/src/hooks/useRouterDashboard.js +133 -0
- package/web/src/hooks/useTokenUsage.js +103 -0
- package/web/src/hooks/useToolMode.js +77 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +123 -62
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-uD3faN3G.js +0 -39
- 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.
|
|
5
|
-
*
|
|
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
|
-
* 📖
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
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
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
365
|
+
</main>
|
|
273
366
|
)}
|
|
274
367
|
|
|
275
368
|
{currentView === 'settings' && (
|
|
276
369
|
<div className="view">
|
|
277
|
-
<SettingsView
|
|
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
|
-
|
|
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
|
+
}
|