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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useChangelog.js
|
|
3
|
+
* @description React hook for the changelog data — M2 parity with the TUI's `N` key overlay.
|
|
4
|
+
* 📖 Loads `/api/changelog` once on mount, exposes the parsed { versions } map,
|
|
5
|
+
* 📖 and provides helpers for the index/details two-phase modal.
|
|
6
|
+
*
|
|
7
|
+
* @functions
|
|
8
|
+
* → useChangelog() — { versions, sortedVersions, getVersion, loading, error, refresh }
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useMemo, useState, useCallback } from 'react'
|
|
11
|
+
|
|
12
|
+
export function useChangelog() {
|
|
13
|
+
const [versions, setVersions] = useState({})
|
|
14
|
+
const [loading, setLoading] = useState(true)
|
|
15
|
+
const [error, setError] = useState(null)
|
|
16
|
+
|
|
17
|
+
const refresh = useCallback(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const resp = await fetch('/api/changelog')
|
|
20
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
21
|
+
const data = await resp.json()
|
|
22
|
+
setVersions(data?.versions ?? {})
|
|
23
|
+
setError(null)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
setError(err.message || 'Failed to load changelog')
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false)
|
|
28
|
+
}
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
useEffect(() => { refresh() }, [refresh])
|
|
32
|
+
|
|
33
|
+
// 📖 Sort versions in descending semver order. We do a string-based compare
|
|
34
|
+
// 📖 on the dotted tuples so '0.10.0' > '0.9.0' works correctly.
|
|
35
|
+
const sortedVersions = useMemo(() => {
|
|
36
|
+
return Object.keys(versions).sort((a, b) => {
|
|
37
|
+
const ap = a.split('.').map(Number)
|
|
38
|
+
const bp = b.split('.').map(Number)
|
|
39
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
40
|
+
const av = ap[i] || 0
|
|
41
|
+
const bv = bp[i] || 0
|
|
42
|
+
if (bv !== av) return bv - av
|
|
43
|
+
}
|
|
44
|
+
return 0
|
|
45
|
+
})
|
|
46
|
+
}, [versions])
|
|
47
|
+
|
|
48
|
+
const getVersion = useCallback((v) => versions[v] ?? null, [versions])
|
|
49
|
+
|
|
50
|
+
return { versions, sortedVersions, getVersion, loading, error, refresh }
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useUpdateChecker.js
|
|
3
|
+
* @description React hook for the update chip + popover — M2 parity with TUI `Shift+U`.
|
|
4
|
+
* 📖 Polls `/api/version` every 5 minutes (matches the TUI cadence) and exposes
|
|
5
|
+
* 📖 `updateAvailable` (boolean) + `latestVersion` (string) for the header chip.
|
|
6
|
+
* 📖 The chip's "Update now" button calls `/api/update/run` which spawns the
|
|
7
|
+
* 📖 detected package manager in the background; the Web UI just surfaces a
|
|
8
|
+
* 📖 toast and tells the user to restart the dashboard to apply the update.
|
|
9
|
+
*
|
|
10
|
+
* @functions
|
|
11
|
+
* → useUpdateChecker({ onToast }) — { latestVersion, updateAvailable, runUpdate, checkNow, loading }
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useState, useRef } from 'react'
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 5 * 60_000
|
|
16
|
+
|
|
17
|
+
// 📖 Lightweight semver compare: returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
18
|
+
function semverCompare(a, b) {
|
|
19
|
+
if (!a || !b) return 0
|
|
20
|
+
const ap = a.replace(/^v/, '').split('.').map(Number)
|
|
21
|
+
const bp = b.replace(/^v/, '').split('.').map(Number)
|
|
22
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
23
|
+
const av = ap[i] || 0
|
|
24
|
+
const bv = bp[i] || 0
|
|
25
|
+
if (bv !== av) return bv - av
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useUpdateChecker({ onToast } = {}) {
|
|
31
|
+
const [localVersion, setLocalVersion] = useState(null)
|
|
32
|
+
const [latestVersion, setLatestVersion] = useState(null)
|
|
33
|
+
const [lastReleaseDate, setLastReleaseDate] = useState(null)
|
|
34
|
+
const [error, setError] = useState(null)
|
|
35
|
+
const [loading, setLoading] = useState(false)
|
|
36
|
+
const intervalRef = useRef(null)
|
|
37
|
+
|
|
38
|
+
const checkNow = useCallback(async () => {
|
|
39
|
+
setLoading(true)
|
|
40
|
+
try {
|
|
41
|
+
const resp = await fetch('/api/version')
|
|
42
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
43
|
+
const data = await resp.json()
|
|
44
|
+
setLocalVersion(data.local ?? null)
|
|
45
|
+
setLatestVersion(data.latest ?? null)
|
|
46
|
+
setLastReleaseDate(data.lastReleaseDate ?? null)
|
|
47
|
+
setError(data.error ?? null)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setError(err.message || 'update check failed')
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// 📖 Initial check + 5-minute polling. We stop polling on unmount.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
checkNow()
|
|
58
|
+
intervalRef.current = setInterval(checkNow, POLL_INTERVAL_MS)
|
|
59
|
+
return () => {
|
|
60
|
+
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
61
|
+
}
|
|
62
|
+
}, [checkNow])
|
|
63
|
+
|
|
64
|
+
// 📖 updateAvailable is true when latest > local. A null latest means the
|
|
65
|
+
// 📖 npm registry is unreachable, so we don't surface a chip.
|
|
66
|
+
const updateAvailable = Boolean(latestVersion && localVersion && semverCompare(latestVersion, localVersion) > 0)
|
|
67
|
+
|
|
68
|
+
const runUpdate = useCallback(async () => {
|
|
69
|
+
if (!updateAvailable) {
|
|
70
|
+
onToast?.('No update available.', 'info')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const resp = await fetch('/api/update/run', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ version: latestVersion }),
|
|
78
|
+
})
|
|
79
|
+
const data = await resp.json().catch(() => ({}))
|
|
80
|
+
if (resp.ok && data?.started) {
|
|
81
|
+
onToast?.(`Update to v${latestVersion} started — restart the dashboard to apply.`, 'success')
|
|
82
|
+
} else {
|
|
83
|
+
onToast?.(data?.error || data?.message || 'Update failed', 'error')
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
onToast?.(err.message || 'Update request failed', 'error')
|
|
87
|
+
}
|
|
88
|
+
}, [updateAvailable, latestVersion, onToast])
|
|
89
|
+
|
|
90
|
+
return { localVersion, latestVersion, lastReleaseDate, updateAvailable, checkNow, runUpdate, loading, error }
|
|
91
|
+
}
|