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.
Files changed (48) hide show
  1. package/README.md +14 -9
  2. package/changelog/v0.5.7.md +20 -0
  3. package/package.json +1 -1
  4. package/src/core/endpoint-installer.js +3 -3
  5. package/src/core/tool-launchers.js +2 -2
  6. package/web/dist/assets/index-DKmmiDip.css +1 -0
  7. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  8. package/web/dist/index.html +2 -2
  9. package/web/server.js +386 -1
  10. package/web/src/App.jsx +145 -26
  11. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  12. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  13. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  14. package/web/src/components/atoms/TierBadge.module.css +3 -3
  15. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  16. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  17. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  18. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  19. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  20. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  21. package/web/src/components/help/HelpView.jsx +1 -1
  22. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  23. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  24. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  25. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  26. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  27. package/web/src/components/launch/LaunchButton.jsx +30 -0
  28. package/web/src/components/launch/LaunchButton.module.css +35 -0
  29. package/web/src/components/launch/LaunchModal.module.css +125 -0
  30. package/web/src/components/layout/Header.jsx +70 -11
  31. package/web/src/components/layout/Header.module.css +62 -2
  32. package/web/src/components/recommend/RecommendView.jsx +121 -0
  33. package/web/src/components/recommend/RecommendView.module.css +55 -0
  34. package/web/src/components/router/RouterView.jsx +286 -0
  35. package/web/src/components/router/RouterView.module.css +357 -0
  36. package/web/src/components/tools/ToolPicker.jsx +86 -0
  37. package/web/src/components/tools/ToolPicker.module.css +84 -0
  38. package/web/src/global.css +23 -0
  39. package/web/src/hooks/urlState.constants.js +3 -0
  40. package/web/src/hooks/useInstalledModels.js +42 -0
  41. package/web/src/hooks/useRecommend.js +67 -0
  42. package/web/src/hooks/useRouterDashboard.js +133 -0
  43. package/web/src/hooks/useTokenUsage.js +103 -0
  44. package/web/src/hooks/useToolMode.js +77 -0
  45. package/web/src/hooks/useUrlState.js +4 -3
  46. package/web/src/utils/m3.js +55 -0
  47. package/web/dist/assets/index-Blp9QJev.js +0 -39
  48. package/web/dist/assets/index-Cz_aCLTR.css +0 -1
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @file web/src/components/tools/ToolPicker.jsx
3
+ * @description Header/dropdown control for the active Web endpoint-install target.
4
+ * Mirrors the TUI install target flow with a mouse-first dropdown and cycle button.
5
+ *
6
+ * @functions
7
+ * → ToolPicker — renders active tool, cycle control, and selectable tool list
8
+ * @exports ToolPicker
9
+ */
10
+ import { useEffect, useRef, useState } from 'react'
11
+ import { IconChevronDown, IconRefresh } from '@tabler/icons-react'
12
+ import { TOOL_METADATA, getToolMeta } from '../../../../src/core/tool-metadata.js'
13
+ import { INSTALL_ENDPOINT_TOOL_MODES } from '../../utils/m3.js'
14
+ import styles from './ToolPicker.module.css'
15
+
16
+ export default function ToolPicker({ toolMode = 'opencode', onSetToolMode, onCycleToolMode, compact = false, tools = INSTALL_ENDPOINT_TOOL_MODES }) {
17
+ const [open, setOpen] = useState(false)
18
+ const wrapRef = useRef(null)
19
+ const active = getToolMeta(toolMode)
20
+
21
+ useEffect(() => {
22
+ if (!open) return
23
+ const onPointer = (event) => {
24
+ if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false)
25
+ }
26
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
27
+ document.addEventListener('mousedown', onPointer)
28
+ document.addEventListener('keydown', onKey)
29
+ return () => {
30
+ document.removeEventListener('mousedown', onPointer)
31
+ document.removeEventListener('keydown', onKey)
32
+ }
33
+ }, [open])
34
+
35
+ return (
36
+ <div className={`${styles.wrap} ${compact ? styles.compact : ''}`} ref={wrapRef}>
37
+ <button
38
+ type="button"
39
+ className={styles.trigger}
40
+ onClick={() => setOpen((value) => !value)}
41
+ aria-haspopup="listbox"
42
+ aria-expanded={open}
43
+ title={`Install endpoint into: ${active.label}`}
44
+ >
45
+ <span className={styles.emoji}>{active.emoji}</span>
46
+ <span className={styles.label}>{compact ? active.label.replace(/ CLI$/, '') : active.label}</span>
47
+ <IconChevronDown size={13} stroke={1.7} />
48
+ </button>
49
+ <button
50
+ type="button"
51
+ className={styles.cycle}
52
+ onClick={onCycleToolMode}
53
+ title="Cycle endpoint target"
54
+ aria-label="Cycle endpoint target"
55
+ >
56
+ <IconRefresh size={13} stroke={1.7} />
57
+ </button>
58
+
59
+ {open && (
60
+ <div className={styles.menu} role="listbox" aria-label="Endpoint install target">
61
+ {tools.map((mode) => {
62
+ const meta = TOOL_METADATA[mode]
63
+ const activeMode = mode === toolMode
64
+ return (
65
+ <button
66
+ type="button"
67
+ key={mode}
68
+ className={`${styles.item} ${activeMode ? styles.itemActive : ''}`}
69
+ onClick={() => { onSetToolMode?.(mode); setOpen(false) }}
70
+ role="option"
71
+ aria-selected={activeMode}
72
+ >
73
+ <span className={styles.itemEmoji}>{meta.emoji}</span>
74
+ <span className={styles.itemText}>
75
+ <strong>{meta.label}</strong>
76
+ <small>{meta.flag}</small>
77
+ </span>
78
+ {activeMode && <span className={styles.activeDot}>●</span>}
79
+ </button>
80
+ )
81
+ })}
82
+ </div>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file web/src/components/tools/ToolPicker.module.css
3
+ * @description Compact header dropdown styles for the M3 tool-mode picker.
4
+ */
5
+ .wrap {
6
+ position: relative;
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 4px;
10
+ }
11
+ .trigger,
12
+ .cycle {
13
+ height: 30px;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ border: 1px solid var(--color-border);
18
+ background: var(--color-surface);
19
+ color: var(--color-text);
20
+ cursor: pointer;
21
+ transition: background 150ms, border-color 150ms, color 150ms;
22
+ }
23
+ .trigger {
24
+ gap: 6px;
25
+ padding: 0 9px;
26
+ border-radius: 6px;
27
+ font-size: 12px;
28
+ font-weight: 700;
29
+ }
30
+ .cycle {
31
+ width: 30px;
32
+ border-radius: 6px;
33
+ }
34
+ .trigger:hover,
35
+ .cycle:hover {
36
+ background: var(--color-bg-hover);
37
+ border-color: var(--color-accent);
38
+ color: var(--color-accent);
39
+ }
40
+ .emoji { font-size: 14px; line-height: 1; }
41
+ .label { white-space: nowrap; }
42
+ .menu {
43
+ position: absolute;
44
+ right: 0;
45
+ top: calc(100% + 7px);
46
+ width: 280px;
47
+ max-height: min(520px, calc(100vh - 90px));
48
+ overflow: auto;
49
+ padding: 5px;
50
+ border: 1px solid var(--color-border);
51
+ border-radius: 10px;
52
+ background: var(--color-bg-elevated);
53
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.38);
54
+ z-index: 250;
55
+ }
56
+ .item {
57
+ width: 100%;
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 9px;
61
+ padding: 8px 9px;
62
+ border: 0;
63
+ border-radius: 7px;
64
+ color: var(--color-text);
65
+ background: transparent;
66
+ cursor: pointer;
67
+ text-align: left;
68
+ }
69
+ .item:hover { background: var(--color-bg-hover); }
70
+ .itemActive {
71
+ background: var(--color-accent-dim);
72
+ color: var(--color-accent);
73
+ }
74
+ .itemEmoji { width: 22px; text-align: center; font-size: 16px; }
75
+ .itemText { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
76
+ .itemText strong { font-size: 12px; font-weight: 800; }
77
+ .itemText small { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 10px; }
78
+ .activeDot { color: var(--color-accent); font-size: 10px; }
79
+ .compact .trigger { width: 100%; justify-content: flex-start; }
80
+ .compact .menu { left: 0; right: auto; }
81
+ @media (max-width: 1024px) {
82
+ .label { display: none; }
83
+ .trigger { padding: 0 8px; }
84
+ }
@@ -136,6 +136,29 @@
136
136
  /* ─── Reset & Base ─── */
137
137
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
138
138
 
139
+ /* 📖 M5: Global :focus-visible outline for keyboard navigation (a11y).
140
+ 📖 Only shows for keyboard users, not mouse clicks. Uses the accent color
141
+ 📖 with a subtle glow ring so focus is always visible but not jarring. */
142
+ :focus-visible {
143
+ outline: 2px solid var(--color-accent);
144
+ outline-offset: 2px;
145
+ border-radius: var(--radius-sm);
146
+ }
147
+
148
+ /* 📖 Remove default browser :focus ring — we only want :focus-visible */
149
+ :focus:not(:focus-visible) {
150
+ outline: none;
151
+ }
152
+
153
+ /* 📖 Inputs keep their own focus styling via component CSS, but keyboard
154
+ 📖 users still see the global ring as a fallback safety net. */
155
+ input:focus-visible,
156
+ select:focus-visible,
157
+ textarea:focus-visible {
158
+ outline: 2px solid var(--color-accent);
159
+ outline-offset: 1px;
160
+ }
161
+
139
162
  html {
140
163
  font-size: 14px;
141
164
  scroll-behavior: smooth;
@@ -5,6 +5,7 @@
5
5
  * 📖 HEALTH_CYCLE constants so the URL reflects the same filter universe
6
6
  * 📖 the TUI uses.
7
7
  */
8
+ import { TOOL_MODE_ORDER } from '../../../src/core/tool-metadata.js'
8
9
 
9
10
  const TIER_VALUES = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
10
11
  const STATUS_VALUES = new Set(['up', 'down', 'pending', 'all'])
@@ -15,6 +16,7 @@ const SORT_VALUES = new Set([
15
16
  ])
16
17
  const VIEW_VALUES = new Set(['dashboard', 'settings', 'analytics', 'recommend', 'router', 'help', 'changelog'])
17
18
  const DIR_VALUES = new Set(['asc', 'desc'])
19
+ const TOOL_MODE_VALUES = new Set(TOOL_MODE_ORDER)
18
20
 
19
21
  export {
20
22
  TIER_VALUES as VALID_TIERS,
@@ -22,4 +24,5 @@ export {
22
24
  SORT_VALUES as VALID_SORTS,
23
25
  VIEW_VALUES as VALID_VIEWS,
24
26
  DIR_VALUES as VALID_DIRS,
27
+ TOOL_MODE_VALUES as VALID_TOOL_MODES,
25
28
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @file web/src/hooks/useInstalledModels.js
3
+ * @description Hook for Installed Models — scans tool configs and soft-deletes models.
4
+ * 📖 M4: Fetches /api/installed-models, provides disable(action) for soft-delete.
5
+ *
6
+ * @functions useInstalledModels → { results, loading, refresh, disableModel }
7
+ */
8
+ import { useState, useEffect, useCallback } from 'react'
9
+
10
+ export function useInstalledModels() {
11
+ const [results, setResults] = useState([])
12
+ const [loading, setLoading] = useState(true)
13
+
14
+ const refresh = useCallback(async () => {
15
+ setLoading(true)
16
+ try {
17
+ const resp = await fetch('/api/installed-models')
18
+ const data = await resp.json()
19
+ setResults(data.results || [])
20
+ } catch {
21
+ setResults([])
22
+ } finally {
23
+ setLoading(false)
24
+ }
25
+ }, [])
26
+
27
+ useEffect(() => { void refresh() }, [refresh])
28
+
29
+ const disableModel = useCallback(async (toolMode, modelId) => {
30
+ const resp = await fetch(`/api/installed-models/${encodeURIComponent(toolMode)}/${encodeURIComponent(modelId)}/disable`, {
31
+ method: 'POST',
32
+ })
33
+ const result = await resp.json()
34
+ if (result.success) {
35
+ // 📖 Refresh after successful delete
36
+ await refresh()
37
+ }
38
+ return result
39
+ }, [refresh])
40
+
41
+ return { results, loading, refresh, disableModel }
42
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file web/src/hooks/useRecommend.js
3
+ * @description Smart Recommend hook for M3 Web parity. Owns the 10 second
4
+ * analysis phase and calls `/api/recommend`, which uses the shared TUI scoring
5
+ * engine in `src/core/utils.js`.
6
+ *
7
+ * @functions useRecommend → run/cancel recommendation analysis
8
+ * @exports useRecommend
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from 'react'
11
+
12
+ export function useRecommend({ onToast } = {}) {
13
+ const [loading, setLoading] = useState(false)
14
+ const [progress, setProgress] = useState(0)
15
+ const [results, setResults] = useState([])
16
+ const [error, setError] = useState(null)
17
+ const timerRef = useRef(null)
18
+
19
+ useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
20
+
21
+ const recommend = useCallback(async (answers) => {
22
+ if (timerRef.current) clearInterval(timerRef.current)
23
+ setLoading(true)
24
+ setProgress(0)
25
+ setResults([])
26
+ setError(null)
27
+
28
+ const started = Date.now()
29
+ timerRef.current = setInterval(() => {
30
+ const pct = Math.min(98, Math.round(((Date.now() - started) / 10_000) * 100))
31
+ setProgress(pct)
32
+ }, 250)
33
+
34
+ try {
35
+ await new Promise((resolve) => setTimeout(resolve, 10_000))
36
+ const resp = await fetch('/api/recommend', {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ answers }),
40
+ })
41
+ const payload = await resp.json().catch(() => ({}))
42
+ if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`)
43
+ setResults(Array.isArray(payload.top3) ? payload.top3 : [])
44
+ setProgress(100)
45
+ return { ok: true, top3: payload.top3 || [] }
46
+ } catch (err) {
47
+ setError(err.message)
48
+ onToast?.(`Recommend failed: ${err.message}`, 'error')
49
+ return { ok: false, error: err.message }
50
+ } finally {
51
+ if (timerRef.current) clearInterval(timerRef.current)
52
+ timerRef.current = null
53
+ setLoading(false)
54
+ }
55
+ }, [onToast])
56
+
57
+ const reset = useCallback(() => {
58
+ if (timerRef.current) clearInterval(timerRef.current)
59
+ timerRef.current = null
60
+ setLoading(false)
61
+ setProgress(0)
62
+ setResults([])
63
+ setError(null)
64
+ }, [])
65
+
66
+ return { recommend, loading, progress, results, error, reset }
67
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @file web/src/hooks/useRouterDashboard.js
3
+ * @description Hook for Router Dashboard — polls daemon status, proxies actions.
4
+ * 📖 M4: Provides status, stats, tokens, sets, start/stop, probe-mode control.
5
+ * 📖 Polls /api/router/status every 5s; fetches full stats on demand.
6
+ *
7
+ * @functions useRouterDashboard → { status, stats, tokens, start, stop, setProbeMode, refresh }
8
+ */
9
+ import { useState, useEffect, useCallback, useRef } from 'react'
10
+
11
+ const POLL_INTERVAL_MS = 5000
12
+
13
+ export function useRouterDashboard() {
14
+ const [status, setStatus] = useState(null)
15
+ const [stats, setStats] = useState(null)
16
+ const [tokens, setTokens] = useState(null)
17
+ const [sets, setSets] = useState(null)
18
+ const [loading, setLoading] = useState(true)
19
+ const [actionLoading, setActionLoading] = useState(false)
20
+ const pollRef = useRef(null)
21
+
22
+ const fetchStatus = useCallback(async () => {
23
+ try {
24
+ const resp = await fetch('/api/router/status')
25
+ const data = await resp.json()
26
+ setStatus(data)
27
+ return data
28
+ } catch {
29
+ setStatus({ ok: false, running: false })
30
+ return null
31
+ }
32
+ }, [])
33
+
34
+ const fetchStats = useCallback(async () => {
35
+ try {
36
+ const resp = await fetch('/api/router/stats')
37
+ const data = await resp.json()
38
+ if (data.ok) setStats(data)
39
+ return data
40
+ } catch { return null }
41
+ }, [])
42
+
43
+ const fetchTokens = useCallback(async () => {
44
+ try {
45
+ const resp = await fetch('/api/router/tokens')
46
+ const data = await resp.json()
47
+ setTokens(data)
48
+ return data
49
+ } catch { return null }
50
+ }, [])
51
+
52
+ const fetchSets = useCallback(async () => {
53
+ try {
54
+ const resp = await fetch('/api/router/sets')
55
+ const data = await resp.json()
56
+ setSets(data)
57
+ return data
58
+ } catch { return null }
59
+ }, [])
60
+
61
+ // 📖 Poll daemon status + refresh stats when running
62
+ useEffect(() => {
63
+ let mounted = true
64
+
65
+ const poll = async () => {
66
+ const s = await fetchStatus()
67
+ if (s?.ok && mounted) {
68
+ await fetchStats()
69
+ }
70
+ }
71
+
72
+ poll().then(() => { if (mounted) setLoading(false) })
73
+ pollRef.current = setInterval(poll, POLL_INTERVAL_MS)
74
+
75
+ return () => {
76
+ mounted = false
77
+ clearInterval(pollRef.current)
78
+ }
79
+ }, [fetchStatus, fetchStats])
80
+
81
+ const start = useCallback(async () => {
82
+ setActionLoading(true)
83
+ try {
84
+ const resp = await fetch('/api/router/start', { method: 'POST' })
85
+ const data = await resp.json()
86
+ await fetchStatus()
87
+ if (data.ok) await fetchStats()
88
+ return data
89
+ } finally { setActionLoading(false) }
90
+ }, [fetchStatus, fetchStats])
91
+
92
+ const stop = useCallback(async () => {
93
+ setActionLoading(true)
94
+ try {
95
+ const resp = await fetch('/api/router/stop', { method: 'POST' })
96
+ const data = await resp.json()
97
+ setStatus({ ok: false, running: false })
98
+ setStats(null)
99
+ return data
100
+ } finally { setActionLoading(false) }
101
+ }, [])
102
+
103
+ const setProbeMode = useCallback(async (mode) => {
104
+ try {
105
+ await fetch('/api/router/probe-mode', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ probeMode: mode }),
109
+ })
110
+ await fetchStats()
111
+ } catch {}
112
+ }, [fetchStats])
113
+
114
+ const refresh = useCallback(async () => {
115
+ setLoading(true)
116
+ await Promise.all([fetchStatus(), fetchTokens(), fetchSets()])
117
+ setLoading(false)
118
+ }, [fetchStatus, fetchTokens, fetchSets])
119
+
120
+ const getQuickSetup = useCallback(async () => {
121
+ try {
122
+ const resp = await fetch('/api/router/quick-setup')
123
+ return await resp.json()
124
+ } catch { return null }
125
+ }, [])
126
+
127
+ return {
128
+ status, stats, tokens, sets,
129
+ loading, actionLoading,
130
+ start, stop, setProbeMode, refresh,
131
+ fetchTokens, fetchSets, getQuickSetup,
132
+ }
133
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @file web/src/hooks/useTokenUsage.js
3
+ * @description Hook for Token Usage panel — fetches token data from daemon or file.
4
+ * 📖 M4: Returns today's usage, 7-day breakdown, top models, top providers.
5
+ *
6
+ * @functions useTokenUsage → { data, loading, refresh }
7
+ */
8
+ import { useState, useEffect, useCallback } from 'react'
9
+
10
+ export function useTokenUsage() {
11
+ const [data, setData] = useState(null)
12
+ const [loading, setLoading] = useState(true)
13
+
14
+ const refresh = useCallback(async () => {
15
+ setLoading(true)
16
+ try {
17
+ const resp = await fetch('/api/router/tokens')
18
+ const raw = await resp.json()
19
+ setData(processTokenData(raw))
20
+ } catch {
21
+ setData(null)
22
+ } finally {
23
+ setLoading(false)
24
+ }
25
+ }, [])
26
+
27
+ useEffect(() => { void refresh() }, [refresh])
28
+
29
+ return { data, loading, refresh }
30
+ }
31
+
32
+ function processTokenData(raw) {
33
+ if (!raw) return null
34
+
35
+ const daily = raw.daily || {}
36
+ const allTime = raw.all_time || {}
37
+
38
+ // 📖 Build 7-day chart data
39
+ const days = []
40
+ const today = new Date()
41
+ for (let i = 6; i >= 0; i--) {
42
+ const d = new Date(today)
43
+ d.setDate(d.getDate() - i)
44
+ const key = d.toISOString().slice(0, 10)
45
+ const entry = daily[key] || { total_tokens: 0, requests: 0 }
46
+ days.push({
47
+ date: key,
48
+ label: d.toLocaleDateString('en', { weekday: 'short' }),
49
+ totalTokens: entry.total_tokens || 0,
50
+ requests: entry.requests || 0,
51
+ })
52
+ }
53
+
54
+ // 📖 Top models across all tracked days
55
+ const modelTotals = {}
56
+ for (const entry of Object.values(daily)) {
57
+ const byModel = entry.by_model || {}
58
+ for (const [key, val] of Object.entries(byModel)) {
59
+ if (!modelTotals[key]) modelTotals[key] = { total: 0, requests: 0 }
60
+ modelTotals[key].total += val.total || 0
61
+ modelTotals[key].requests += val.requests || 0
62
+ }
63
+ }
64
+ const topModels = Object.entries(modelTotals)
65
+ .sort((a, b) => b[1].total - a[1].total)
66
+ .slice(0, 10)
67
+ .map(([key, val]) => ({ key, ...val }))
68
+
69
+ // 📖 Top providers (aggregated from model keys)
70
+ const providerTotals = {}
71
+ for (const [key, val] of Object.entries(modelTotals)) {
72
+ const provider = key.split('/')[0]
73
+ if (!providerTotals[provider]) providerTotals[provider] = { total: 0, requests: 0 }
74
+ providerTotals[provider].total += val.total
75
+ providerTotals[provider].requests += val.requests
76
+ }
77
+ const topProviders = Object.entries(providerTotals)
78
+ .sort((a, b) => b[1].total - a[1].total)
79
+ .slice(0, 10)
80
+ .map(([key, val]) => ({ key, ...val }))
81
+
82
+ const todayKey = today.toISOString().slice(0, 10)
83
+ const todayData = daily[todayKey] || {}
84
+
85
+ return {
86
+ today: {
87
+ totalTokens: todayData.total_tokens || 0,
88
+ promptTokens: todayData.prompt_tokens || 0,
89
+ completionTokens: todayData.completion_tokens || 0,
90
+ requests: todayData.requests || 0,
91
+ },
92
+ allTime: {
93
+ totalTokens: allTime.total_tokens || 0,
94
+ promptTokens: allTime.prompt_tokens || 0,
95
+ completionTokens: allTime.completion_tokens || 0,
96
+ requests: allTime.requests || 0,
97
+ },
98
+ sevenDays: days,
99
+ topModels,
100
+ topProviders,
101
+ hasData: allTime.total_tokens > 0 || Object.keys(daily).length > 0,
102
+ }
103
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @file web/src/hooks/useToolMode.js
3
+ * @description M3 tool-mode hook for the Web Dashboard. It cycles endpoint install
4
+ * targets by loading and persisting `settings.preferredToolMode` through
5
+ * `/api/tool-mode`, while keeping URL hydration safe from late API responses.
6
+ *
7
+ * @functions
8
+ * → useToolMode — loads, sets, and cycles the active endpoint target
9
+ * @exports useToolMode
10
+ */
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { INSTALL_ENDPOINT_TOOL_MODES } from '../utils/m3.js'
13
+
14
+ const DEFAULT_TOOL_MODE = 'opencode'
15
+
16
+ function normalizeToolMode(mode) {
17
+ return INSTALL_ENDPOINT_TOOL_MODES.includes(mode) ? mode : DEFAULT_TOOL_MODE
18
+ }
19
+
20
+ export function useToolMode({ onToast } = {}) {
21
+ const [toolModeState, setToolModeState] = useState(DEFAULT_TOOL_MODE)
22
+ const userSetRef = useRef(false)
23
+
24
+ useEffect(() => {
25
+ let cancelled = false
26
+ async function loadToolMode() {
27
+ try {
28
+ const resp = await fetch('/api/tool-mode')
29
+ const payload = await resp.json().catch(() => ({}))
30
+ if (!cancelled && !userSetRef.current && resp.ok) {
31
+ setToolModeState(normalizeToolMode(payload.mode))
32
+ }
33
+ } catch (err) {
34
+ if (!cancelled) onToast?.(`Tool mode load failed: ${err.message}`, 'error')
35
+ }
36
+ }
37
+ void loadToolMode()
38
+ return () => { cancelled = true }
39
+ }, [onToast])
40
+
41
+ const persistToolMode = useCallback(async (mode) => {
42
+ const normalized = normalizeToolMode(mode)
43
+ userSetRef.current = true
44
+ setToolModeState(normalized)
45
+ try {
46
+ const resp = await fetch('/api/tool-mode', {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ mode: normalized }),
50
+ })
51
+ const payload = await resp.json().catch(() => ({}))
52
+ if (!resp.ok) {
53
+ throw new Error(payload.error || `HTTP ${resp.status}`)
54
+ }
55
+ setToolModeState(normalizeToolMode(payload.mode))
56
+ return { ok: true, mode: normalizeToolMode(payload.mode) }
57
+ } catch (err) {
58
+ onToast?.(`Tool mode save failed: ${err.message}`, 'error')
59
+ return { ok: false, error: err.message, mode: normalized }
60
+ }
61
+ }, [onToast])
62
+
63
+ const cycleToolMode = useCallback(() => {
64
+ const currentIndex = TOOL_MODE_ORDER.indexOf(toolModeState)
65
+ const next = INSTALL_ENDPOINT_TOOL_MODES[(currentIndex + 1) % INSTALL_ENDPOINT_TOOL_MODES.length] || DEFAULT_TOOL_MODE
66
+ return persistToolMode(next)
67
+ }, [persistToolMode, toolModeState])
68
+
69
+ const tools = useMemo(() => [...INSTALL_ENDPOINT_TOOL_MODES], [])
70
+
71
+ return {
72
+ toolMode: toolModeState,
73
+ tools,
74
+ setToolMode: persistToolMode,
75
+ cycleToolMode,
76
+ }
77
+ }
@@ -22,8 +22,8 @@
22
22
  * → useUrlState({ currentView, setCurrentView, filterState, paletteOpen, setPaletteOpen })
23
23
  * → buildUrlParams(state) — pure helper exposed for tests
24
24
  */
25
- import { useEffect, useRef, useCallback } from 'react'
26
- import { VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS } from './urlState.constants.js'
25
+ import { useEffect, useRef } from 'react'
26
+ import { VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS, VALID_TOOL_MODES } from './urlState.constants.js'
27
27
 
28
28
  // 📖 Read the current URL params as a normalized object. Returns null on SSR
29
29
  // 📖 or when the URL is invalid.
@@ -40,7 +40,8 @@ function parseUrlParams() {
40
40
  if (params.has('sort') && VALID_SORTS.has(params.get('sort'))) out.sort = params.get('sort')
41
41
  if (params.has('dir') && VALID_DIRS.has(params.get('dir'))) out.dir = params.get('dir')
42
42
  if (params.has('q')) out.q = params.get('q')
43
- if (params.has('toolMode')) out.toolMode = params.get('toolMode')
43
+ if (params.has('toolMode') && VALID_TOOL_MODES.has(params.get('toolMode'))) out.toolMode = params.get('toolMode')
44
+ if (params.has('recommend') && ['1', 'true', 'open'].includes(params.get('recommend'))) out.view = 'recommend'
44
45
  if (params.has('palette') && params.get('palette') === 'open') out.palette = 'open'
45
46
  return out
46
47
  }