free-coding-models 0.5.6 → 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 +14 -9
- package/changelog/v0.5.7.md +20 -0
- package/package.json +1 -1
- package/src/core/endpoint-installer.js +3 -3
- 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 +386 -1
- package/web/src/App.jsx +145 -26
- 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/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 +1 -1
- 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 +70 -11
- package/web/src/components/layout/Header.module.css +62 -2
- 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/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +3 -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/useUrlState.js +4 -3
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-Blp9QJev.js +0 -39
- package/web/dist/assets/index-Cz_aCLTR.css +0 -1
package/web/src/App.jsx
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 📖 - UpdateChip in header (polls /api/version, popover with "Update now" + "What's new")
|
|
13
13
|
* 📖 - URL write-back (every filter / sort / view / palette / toolMode change
|
|
14
14
|
* 📖 updates the URL via history.replaceState, debounced at 80ms)
|
|
15
|
+
* 📖 - M3 Web endpoint install flow: writes tool config only; never starts CLIs.
|
|
15
16
|
* 📖 - New Settings rows: theme dropdown, favorites mode toggle, startup AI scan
|
|
16
17
|
* 📖 toggle, shell-env toggle, legacy proxy cleanup button, open Changelog link,
|
|
17
18
|
* 📖 update status row, per-provider test key button
|
|
@@ -23,6 +24,7 @@ import { useSocket } from './hooks/useSocket.js'
|
|
|
23
24
|
import { useFilter } from './hooks/useFilter.js'
|
|
24
25
|
import { useTheme } from './hooks/useTheme.js'
|
|
25
26
|
import { useFavorites } from './hooks/useFavorites.js'
|
|
27
|
+
import { useToolMode } from './hooks/useToolMode.js'
|
|
26
28
|
import { useUrlState } from './hooks/useUrlState.js'
|
|
27
29
|
import { useUpdateChecker } from './hooks/useUpdateChecker.js'
|
|
28
30
|
import Header from './components/layout/Header.jsx'
|
|
@@ -37,7 +39,13 @@ import CommandPalette from './components/palette/CommandPalette.jsx'
|
|
|
37
39
|
import HelpView from './components/help/HelpView.jsx'
|
|
38
40
|
import ChangelogView from './components/changelog/ChangelogView.jsx'
|
|
39
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'
|
|
40
47
|
import ToastContainer from './components/atoms/ToastContainer.jsx'
|
|
48
|
+
import { isModelCompatibleWithTool } from '../../src/core/tool-metadata.js'
|
|
41
49
|
|
|
42
50
|
let toastIdCounter = 0
|
|
43
51
|
|
|
@@ -59,10 +67,27 @@ export default function App() {
|
|
|
59
67
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
60
68
|
const [changelogOpen, setChangelogOpen] = useState(false)
|
|
61
69
|
const [changelogDefaultVersion, setChangelogDefaultVersion] = useState(null)
|
|
62
|
-
const [
|
|
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)
|
|
63
75
|
const [toasts, setToasts] = useState([])
|
|
64
76
|
const lastActivityRef = useRef(Date.now())
|
|
65
77
|
|
|
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
|
+
}, [])
|
|
90
|
+
|
|
66
91
|
const {
|
|
67
92
|
filtered,
|
|
68
93
|
filterTier, setFilterTier,
|
|
@@ -77,6 +102,8 @@ export default function App() {
|
|
|
77
102
|
resetView,
|
|
78
103
|
} = useFilter(models)
|
|
79
104
|
|
|
105
|
+
const { toolMode, setToolMode, cycleToolMode } = useToolMode({ onToast: addToast })
|
|
106
|
+
|
|
80
107
|
// 📖 URL deep-linking (M2 = read + write). Hydrates on mount, then pushes
|
|
81
108
|
// 📖 every change back via history.replaceState (debounced 80ms).
|
|
82
109
|
useUrlState({
|
|
@@ -99,10 +126,47 @@ export default function App() {
|
|
|
99
126
|
// 📖 Favorites — single source of truth shared with the TUI.
|
|
100
127
|
const favorites = useFavorites({ models })
|
|
101
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
|
+
|
|
102
166
|
// 📖 Update checker (5-minute poll). Returns `updateAvailable` for the chip.
|
|
103
167
|
const {
|
|
104
168
|
localVersion, latestVersion, updateAvailable, runUpdate, checkNow, error: updateError,
|
|
105
|
-
} = useUpdateChecker({ onToast:
|
|
169
|
+
} = useUpdateChecker({ onToast: addToast })
|
|
106
170
|
|
|
107
171
|
// 📖 Build the provider list for the FilterBar dropdown.
|
|
108
172
|
const providers = useMemo(() => {
|
|
@@ -140,25 +204,12 @@ export default function App() {
|
|
|
140
204
|
})
|
|
141
205
|
if (!resp.ok && resp.status !== 202) {
|
|
142
206
|
const err = await resp.json().catch(() => ({}))
|
|
143
|
-
|
|
207
|
+
addToast(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
|
|
144
208
|
}
|
|
145
209
|
} catch (err) {
|
|
146
210
|
console.error('[Benchmark] per-row failed:', err.message)
|
|
147
211
|
}
|
|
148
|
-
}, [])
|
|
149
|
-
|
|
150
|
-
// ── Toast helpers ────────────────────────────────────────────────────────
|
|
151
|
-
function addToastInternal(message, type = 'info') {
|
|
152
|
-
const id = ++toastIdCounter
|
|
153
|
-
setToasts((prev) => [...prev, { id, message, type }])
|
|
154
|
-
setTimeout(() => {
|
|
155
|
-
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
156
|
-
}, 4000)
|
|
157
|
-
}
|
|
158
|
-
const addToast = useCallback(addToastInternal, [])
|
|
159
|
-
const dismissToast = useCallback((id) => {
|
|
160
|
-
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
161
|
-
}, [])
|
|
212
|
+
}, [addToast])
|
|
162
213
|
|
|
163
214
|
// ── Selection / detail panel ─────────────────────────────────────────────
|
|
164
215
|
const handleSelectModel = useCallback((model) => {
|
|
@@ -180,10 +231,10 @@ export default function App() {
|
|
|
180
231
|
// 📖 toasts (M3/M4) — they don't switch the currentView.
|
|
181
232
|
if (viewId === 'help') { setHelpOpen(true); return }
|
|
182
233
|
if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
|
|
183
|
-
if (viewId === 'recommend') {
|
|
184
|
-
if (viewId === 'router') {
|
|
185
|
-
if (viewId === 'install-endpoints') {
|
|
186
|
-
if (viewId === 'installed-models') {
|
|
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 }
|
|
187
238
|
setCurrentView(VIEW_TO_NAV[viewId] || viewId)
|
|
188
239
|
lastActivityRef.current = Date.now()
|
|
189
240
|
}, [addToast])
|
|
@@ -192,8 +243,8 @@ export default function App() {
|
|
|
192
243
|
const handleResetView = useCallback(() => {
|
|
193
244
|
resetView()
|
|
194
245
|
setSearchQuery('')
|
|
195
|
-
|
|
196
|
-
}, [resetView, setSearchQuery])
|
|
246
|
+
addToast('View reset to defaults.', 'info')
|
|
247
|
+
}, [addToast, resetView, setSearchQuery])
|
|
197
248
|
|
|
198
249
|
// ── Changelog open with optional version (e.g. from UpdateChip "What's new") ─
|
|
199
250
|
const openChangelogAt = useCallback((version) => {
|
|
@@ -219,13 +270,25 @@ export default function App() {
|
|
|
219
270
|
if (paletteOpen) { setPaletteOpen(false); return }
|
|
220
271
|
if (helpOpen) { setHelpOpen(false); return }
|
|
221
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 }
|
|
222
278
|
if (selectedModel) { setSelectedModel(null); return }
|
|
223
279
|
if (exportOpen) { setExportOpen(false); return }
|
|
224
280
|
}
|
|
225
281
|
}
|
|
226
282
|
window.addEventListener('keydown', handler)
|
|
227
283
|
return () => window.removeEventListener('keydown', handler)
|
|
228
|
-
}, [paletteOpen, helpOpen, changelogOpen, selectedModel, exportOpen])
|
|
284
|
+
}, [paletteOpen, helpOpen, changelogOpen, recommendOpen, routerOpen, installEndpointsOpen, installedModelsOpen, incompatibleRequest, selectedModel, exportOpen])
|
|
285
|
+
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (currentView === 'recommend') {
|
|
288
|
+
setRecommendOpen(true)
|
|
289
|
+
setCurrentView('dashboard')
|
|
290
|
+
}
|
|
291
|
+
}, [currentView])
|
|
229
292
|
|
|
230
293
|
return (
|
|
231
294
|
<>
|
|
@@ -245,6 +308,9 @@ export default function App() {
|
|
|
245
308
|
modelsCount={filtered.length}
|
|
246
309
|
theme={theme}
|
|
247
310
|
onToast={addToast}
|
|
311
|
+
toolMode={toolMode}
|
|
312
|
+
onSetToolMode={setToolMode}
|
|
313
|
+
onCycleToolMode={cycleToolMode}
|
|
248
314
|
updateSlot={
|
|
249
315
|
<UpdateChip
|
|
250
316
|
updateAvailable={updateAvailable}
|
|
@@ -257,7 +323,7 @@ export default function App() {
|
|
|
257
323
|
|
|
258
324
|
<div className="app-content">
|
|
259
325
|
{currentView === 'dashboard' && (
|
|
260
|
-
<
|
|
326
|
+
<main className="view">
|
|
261
327
|
<FilterBar
|
|
262
328
|
filterTier={filterTier}
|
|
263
329
|
setFilterTier={setFilterTier}
|
|
@@ -283,18 +349,20 @@ export default function App() {
|
|
|
283
349
|
globalBenchmarkRunning={globalBenchmarkRunning}
|
|
284
350
|
globalBenchmarkTotal={globalBenchmarkTotal}
|
|
285
351
|
globalBenchmarkCompleted={globalBenchmarkCompleted}
|
|
352
|
+
toolMode={toolMode}
|
|
286
353
|
/>
|
|
287
354
|
<ModelTable
|
|
288
355
|
filtered={filtered}
|
|
289
356
|
onSelectModel={handleSelectModel}
|
|
290
357
|
onBenchmarkRow={handleBenchmarkRow}
|
|
358
|
+
onLaunch={handleInstallEndpoint}
|
|
291
359
|
favorites={favorites}
|
|
292
360
|
sortColumn={sortColumn}
|
|
293
361
|
sortDirection={sortDirection}
|
|
294
362
|
onSort={toggleSort}
|
|
295
363
|
toolMode={toolMode}
|
|
296
364
|
/>
|
|
297
|
-
</
|
|
365
|
+
</main>
|
|
298
366
|
)}
|
|
299
367
|
|
|
300
368
|
{currentView === 'settings' && (
|
|
@@ -322,6 +390,12 @@ export default function App() {
|
|
|
322
390
|
onClose={handleCloseDetail}
|
|
323
391
|
favorites={favorites}
|
|
324
392
|
onBenchmark={handleBenchmarkRow}
|
|
393
|
+
onLaunch={handleInstallEndpoint}
|
|
394
|
+
toolMode={toolMode}
|
|
395
|
+
onSetToolMode={setToolMode}
|
|
396
|
+
onCycleToolMode={cycleToolMode}
|
|
397
|
+
models={models}
|
|
398
|
+
onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
|
|
325
399
|
onToast={addToast}
|
|
326
400
|
/>
|
|
327
401
|
|
|
@@ -354,6 +428,30 @@ export default function App() {
|
|
|
354
428
|
/>
|
|
355
429
|
)}
|
|
356
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
|
+
|
|
357
455
|
{helpOpen && <HelpView onClose={() => setHelpOpen(false)} />}
|
|
358
456
|
{changelogOpen && (
|
|
359
457
|
<ChangelogView
|
|
@@ -362,6 +460,27 @@ export default function App() {
|
|
|
362
460
|
/>
|
|
363
461
|
)}
|
|
364
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)}
|
|
480
|
+
onToast={addToast}
|
|
481
|
+
/>
|
|
482
|
+
)}
|
|
483
|
+
|
|
365
484
|
<ToastContainer toasts={toasts} dismissToast={dismissToast} />
|
|
366
485
|
</>
|
|
367
486
|
)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
.card {
|
|
2
|
+
margin-top: 16px;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.cardTitle {
|
|
6
|
+
font-size: 13px;
|
|
7
|
+
font-weight: 600;
|
|
8
|
+
color: var(--text-secondary, #aaa);
|
|
9
|
+
margin: 0 0 10px 0;
|
|
10
|
+
text-transform: uppercase;
|
|
11
|
+
letter-spacing: 0.5px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.empty {
|
|
15
|
+
color: var(--text-muted, #666);
|
|
16
|
+
font-size: 13px;
|
|
17
|
+
padding: 20px 0;
|
|
18
|
+
text-align: center;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Summary Grid */
|
|
22
|
+
.summaryGrid {
|
|
23
|
+
display: grid;
|
|
24
|
+
grid-template-columns: 1fr 1fr;
|
|
25
|
+
gap: 12px;
|
|
26
|
+
margin-top: 16px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.summaryCard {
|
|
30
|
+
background: var(--bg-secondary, #14141f);
|
|
31
|
+
border: 1px solid var(--border, #1e1e2e);
|
|
32
|
+
border-radius: 8px;
|
|
33
|
+
padding: 14px 16px;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 2px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.summaryLabel {
|
|
40
|
+
font-size: 11px;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
color: var(--text-muted, #888);
|
|
43
|
+
text-transform: uppercase;
|
|
44
|
+
letter-spacing: 0.5px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.summaryValue {
|
|
48
|
+
font-size: 22px;
|
|
49
|
+
font-weight: 700;
|
|
50
|
+
color: var(--text-primary, #e0e0e0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.summarySub {
|
|
54
|
+
font-size: 11px;
|
|
55
|
+
color: var(--text-muted, #888);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* 7-Day Chart */
|
|
59
|
+
.chart {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: flex-end;
|
|
62
|
+
gap: 6px;
|
|
63
|
+
height: 100px;
|
|
64
|
+
padding: 0 4px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.chartBar {
|
|
68
|
+
flex: 1;
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
align-items: center;
|
|
72
|
+
height: 100%;
|
|
73
|
+
justify-content: flex-end;
|
|
74
|
+
gap: 4px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.chartFill {
|
|
78
|
+
width: 100%;
|
|
79
|
+
min-height: 2px;
|
|
80
|
+
background: linear-gradient(180deg, #00c864 0%, rgba(0, 200, 100, 0.3) 100%);
|
|
81
|
+
border-radius: 3px 3px 0 0;
|
|
82
|
+
transition: height 0.3s ease;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.chartLabel {
|
|
86
|
+
font-size: 10px;
|
|
87
|
+
color: var(--text-muted, #888);
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Top Lists */
|
|
92
|
+
.topList {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
gap: 4px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.topRow {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 8px;
|
|
102
|
+
padding: 4px 0;
|
|
103
|
+
font-size: 12px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.topKey {
|
|
107
|
+
flex: 1;
|
|
108
|
+
color: var(--text-primary, #e0e0e0);
|
|
109
|
+
font-family: 'SF Mono', monospace;
|
|
110
|
+
overflow: hidden;
|
|
111
|
+
text-overflow: ellipsis;
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.topVal {
|
|
116
|
+
color: var(--text-muted, #888);
|
|
117
|
+
min-width: 80px;
|
|
118
|
+
text-align: right;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.topReq {
|
|
122
|
+
color: var(--text-muted, #666);
|
|
123
|
+
min-width: 50px;
|
|
124
|
+
text-align: right;
|
|
125
|
+
font-size: 11px;
|
|
126
|
+
}
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
.tier_aplus { background: rgba(0,200,255,0.1); color: #00c8ff; border: 1px solid rgba(0,200,255,0.2); }
|
|
14
14
|
.tier_a { background: rgba(61,220,132,0.1); color: #3ddc84; border: 1px solid rgba(61,220,132,0.2); }
|
|
15
15
|
.tier_aminus{ background: rgba(126,207,126,0.1); color: #7ecf7e; border: 1px solid rgba(126,207,126,0.2); }
|
|
16
|
-
.tier_bplus { background: rgba(168,168,200,0.1); color: #
|
|
17
|
-
.tier_b { background: rgba(128,128,152,0.1); color: #
|
|
18
|
-
.tier_c { background: rgba(96,96,120,0.1); color: #
|
|
16
|
+
.tier_bplus { background: rgba(168,168,200,0.1); color: #b8b8d8; border: 1px solid rgba(168,168,200,0.15); }
|
|
17
|
+
.tier_b { background: rgba(128,128,152,0.1); color: #a0a0b8; border: 1px solid rgba(128,128,152,0.15); }
|
|
18
|
+
.tier_c { background: rgba(96,96,120,0.1); color: #909098; border: 1px solid rgba(96,96,120,0.15); }
|
|
19
19
|
|
|
20
20
|
/* 📖 Light theme: darker, AA-contrast tier text colors. Borders stay the same
|
|
21
21
|
📖 hue so the badge still feels "tier-colored", just more legible. */
|
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* 📖 action section (star + reorder + per-row AI Speed Test button).
|
|
8
8
|
* @functions DetailPanel → main panel component, buildDetailChart → SVG chart builder
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
11
|
-
import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled } from '@tabler/icons-react'
|
|
10
|
+
import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled, IconAlertTriangle } from '@tabler/icons-react'
|
|
12
11
|
import TierBadge from '../atoms/TierBadge.jsx'
|
|
13
12
|
import VerdictBadge from '../atoms/VerdictBadge.jsx'
|
|
14
13
|
import StatusDot from '../atoms/StatusDot.jsx'
|
|
15
14
|
import StabilityCell from '../atoms/StabilityCell.jsx'
|
|
16
15
|
import { formatPing, formatAvg, pingClass } from '../../utils/format.js'
|
|
17
16
|
import { sweClass } from '../../utils/ranks.js'
|
|
17
|
+
import LaunchButton from '../launch/LaunchButton.jsx'
|
|
18
|
+
import ToolPicker from '../tools/ToolPicker.jsx'
|
|
19
|
+
import { getToolMeta, isModelCompatibleWithTool } from '../../../../src/core/tool-metadata.js'
|
|
18
20
|
import styles from './DetailPanel.module.css'
|
|
19
21
|
|
|
20
22
|
function buildDetailChart(history) {
|
|
@@ -70,7 +72,10 @@ function StatRow({ label, children }) {
|
|
|
70
72
|
)
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
export default function DetailPanel({
|
|
75
|
+
export default function DetailPanel({
|
|
76
|
+
model, onClose, favorites, onBenchmark, onLaunch, onToast,
|
|
77
|
+
toolMode = 'opencode', onSetToolMode, onCycleToolMode, onOpenFallback,
|
|
78
|
+
}) {
|
|
74
79
|
if (!model) return null
|
|
75
80
|
|
|
76
81
|
const chartSvg = buildDetailChart(model.pingHistory)
|
|
@@ -78,6 +83,8 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
|
|
|
78
83
|
const favRank = favorites?.favoriteRank(model) ?? Number.MAX_SAFE_INTEGER
|
|
79
84
|
const isFavAtTop = isFav && favRank === 0
|
|
80
85
|
const isFavAtBottom = isFav && favRank === (favorites?.favorites.length ?? 0) - 1
|
|
86
|
+
const toolMeta = getToolMeta(toolMode)
|
|
87
|
+
const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
|
|
81
88
|
|
|
82
89
|
return (
|
|
83
90
|
<div className={styles.panel}>
|
|
@@ -127,8 +134,23 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
|
|
|
127
134
|
<StatRow label="API Key">
|
|
128
135
|
{model.hasApiKey ? '✅ Configured' : '❌ Missing'}
|
|
129
136
|
</StatRow>
|
|
137
|
+
<StatRow label="Tool">
|
|
138
|
+
<ToolPicker
|
|
139
|
+
compact
|
|
140
|
+
toolMode={toolMode}
|
|
141
|
+
onSetToolMode={onSetToolMode}
|
|
142
|
+
onCycleToolMode={onCycleToolMode}
|
|
143
|
+
/>
|
|
144
|
+
</StatRow>
|
|
145
|
+
{!compatible && (
|
|
146
|
+
<div className={styles.compatWarning}>
|
|
147
|
+
<IconAlertTriangle size={14} />
|
|
148
|
+
<span>{model.label} is not compatible with {toolMeta.emoji} {toolMeta.label}.</span>
|
|
149
|
+
<button onClick={() => onOpenFallback?.(model)}>Install in compatible tool</button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
130
152
|
|
|
131
|
-
{/* ── M1: favorites +
|
|
153
|
+
{/* ── M1/M3: favorites + benchmark + endpoint install action section ── */}
|
|
132
154
|
{(favorites || onBenchmark) && (
|
|
133
155
|
<div className={styles.actions}>
|
|
134
156
|
{favorites && (
|
|
@@ -166,6 +188,9 @@ export default function DetailPanel({ model, onClose, favorites, onBenchmark, on
|
|
|
166
188
|
)}
|
|
167
189
|
</div>
|
|
168
190
|
)}
|
|
191
|
+
{onLaunch && (
|
|
192
|
+
<LaunchButton model={model} toolMode={toolMode} onLaunch={onLaunch} />
|
|
193
|
+
)}
|
|
169
194
|
{onBenchmark && (
|
|
170
195
|
<button
|
|
171
196
|
className={styles.benchBtn}
|
|
@@ -219,6 +219,32 @@
|
|
|
219
219
|
background: rgba(180, 0, 255, 0.20);
|
|
220
220
|
border-color: rgba(180, 0, 255, 0.7);
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
.compatWarning {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
gap: 8px;
|
|
227
|
+
margin: 12px 0;
|
|
228
|
+
padding: 10px;
|
|
229
|
+
border: 1px solid rgba(255, 90, 122, 0.35);
|
|
230
|
+
border-radius: 8px;
|
|
231
|
+
background: rgba(255, 90, 122, 0.10);
|
|
232
|
+
color: #ff8da3;
|
|
233
|
+
font-size: 12px;
|
|
234
|
+
line-height: 1.35;
|
|
235
|
+
}
|
|
236
|
+
.compatWarning span { flex: 1; }
|
|
237
|
+
.compatWarning button {
|
|
238
|
+
border: 1px solid rgba(255, 90, 122, 0.45);
|
|
239
|
+
border-radius: 6px;
|
|
240
|
+
background: var(--color-bg-card);
|
|
241
|
+
color: #ff8da3;
|
|
242
|
+
padding: 5px 8px;
|
|
243
|
+
cursor: pointer;
|
|
244
|
+
font-size: 11px;
|
|
245
|
+
font-weight: 800;
|
|
246
|
+
}
|
|
247
|
+
.compatWarning button:hover { background: rgba(255, 90, 122, 0.16); }
|
|
222
248
|
:global([data-theme="light"]) .benchBtn {
|
|
223
249
|
background: rgba(140, 0, 200, 0.06);
|
|
224
250
|
color: #6a00a0;
|