free-coding-models 0.5.5 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -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
+ }