free-coding-models 0.5.85 → 0.5.87

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 (164) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.86.md +10 -0
  4. package/changelog/v0.5.87.md +48 -0
  5. package/package.json +8 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/shared-helpers.js +12 -1
  26. package/src/core/shell-env.js +3 -1
  27. package/src/core/telemetry.js +18 -5
  28. package/src/core/tool-launchers.js +9 -6
  29. package/src/core/updater.js +28 -2
  30. package/src/core/utils.js +76 -71
  31. package/src/tui/app.js +47 -16
  32. package/src/tui/command-palette.js +22 -2
  33. package/src/tui/key-handler.js +123 -179
  34. package/src/tui/overlays.js +35 -75
  35. package/src/tui/render-helpers.js +62 -4
  36. package/src/tui/render-table.js +46 -5
  37. package/src/tui/theme.js +36 -5
  38. package/src/tui/tui-filters.js +28 -8
  39. package/src/tui/tui-state.js +22 -6
  40. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/index.html +5 -9
  43. package/web/index.html +3 -7
  44. package/web/server.js +97 -23
  45. package/web/vite.config.js +8 -0
  46. package/web/app.legacy.js +0 -900
  47. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  48. package/web/dist/assets/index-DWUely84.js +0 -40
  49. package/web/public/favicon.ico +0 -0
  50. package/web/public/favicons/apple-touch-icon.png +0 -0
  51. package/web/public/favicons/browserconfig.xml +0 -12
  52. package/web/public/favicons/favicon-16x16.png +0 -0
  53. package/web/public/favicons/favicon-192x192.png +0 -0
  54. package/web/public/favicons/favicon-32x32.png +0 -0
  55. package/web/public/favicons/favicon-48x48.png +0 -0
  56. package/web/public/favicons/favicon-512x512.png +0 -0
  57. package/web/public/favicons/favicon-96x96.png +0 -0
  58. package/web/public/favicons/favicon.ico +0 -0
  59. package/web/public/favicons/mstile-150x150.png +0 -0
  60. package/web/public/favicons/mstile-310x150.png +0 -0
  61. package/web/public/favicons/mstile-310x310.png +0 -0
  62. package/web/public/favicons/mstile-512x512.png +0 -0
  63. package/web/public/favicons/mstile-70x70.png +0 -0
  64. package/web/public/favicons/site.webmanifest +0 -25
  65. package/web/src/App.jsx +0 -521
  66. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  67. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  68. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  69. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  70. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  71. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  72. package/web/src/components/atoms/HealthCell.jsx +0 -143
  73. package/web/src/components/atoms/HealthCell.module.css +0 -60
  74. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  75. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  76. package/web/src/components/atoms/MoodCell.jsx +0 -25
  77. package/web/src/components/atoms/MoodCell.module.css +0 -6
  78. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  79. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  80. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  81. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  82. package/web/src/components/atoms/RankCell.jsx +0 -9
  83. package/web/src/components/atoms/RankCell.module.css +0 -9
  84. package/web/src/components/atoms/Sparkline.jsx +0 -63
  85. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  86. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  87. package/web/src/components/atoms/StatusDot.jsx +0 -13
  88. package/web/src/components/atoms/StatusDot.module.css +0 -29
  89. package/web/src/components/atoms/TPSCell.jsx +0 -36
  90. package/web/src/components/atoms/TPSCell.module.css +0 -44
  91. package/web/src/components/atoms/TierBadge.jsx +0 -10
  92. package/web/src/components/atoms/TierBadge.module.css +0 -29
  93. package/web/src/components/atoms/Toast.jsx +0 -25
  94. package/web/src/components/atoms/Toast.module.css +0 -41
  95. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  96. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  97. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  98. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  99. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  100. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  101. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  102. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  103. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  104. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  105. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  106. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  107. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  108. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  109. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  110. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  111. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  112. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  113. package/web/src/components/help/HelpView.jsx +0 -201
  114. package/web/src/components/help/HelpView.module.css +0 -156
  115. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  116. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  117. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  118. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  119. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  120. package/web/src/components/launch/LaunchButton.jsx +0 -30
  121. package/web/src/components/launch/LaunchButton.module.css +0 -35
  122. package/web/src/components/launch/LaunchModal.module.css +0 -125
  123. package/web/src/components/layout/Footer.jsx +0 -19
  124. package/web/src/components/layout/Footer.module.css +0 -10
  125. package/web/src/components/layout/Header.jsx +0 -265
  126. package/web/src/components/layout/Header.module.css +0 -357
  127. package/web/src/components/palette/CommandPalette.jsx +0 -301
  128. package/web/src/components/palette/CommandPalette.module.css +0 -121
  129. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  130. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  131. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  132. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  133. package/web/src/components/recommend/RecommendView.jsx +0 -121
  134. package/web/src/components/recommend/RecommendView.module.css +0 -57
  135. package/web/src/components/router/RouterView.jsx +0 -1212
  136. package/web/src/components/router/RouterView.module.css +0 -1289
  137. package/web/src/components/settings/SettingsView.jsx +0 -555
  138. package/web/src/components/settings/SettingsView.module.css +0 -550
  139. package/web/src/components/tools/ToolPicker.jsx +0 -86
  140. package/web/src/components/tools/ToolPicker.module.css +0 -97
  141. package/web/src/components/update/UpdateChip.jsx +0 -104
  142. package/web/src/components/update/UpdateChip.module.css +0 -146
  143. package/web/src/global.css +0 -298
  144. package/web/src/hooks/urlState.constants.js +0 -28
  145. package/web/src/hooks/useChangelog.js +0 -51
  146. package/web/src/hooks/useColumnSizing.js +0 -211
  147. package/web/src/hooks/useFavorites.js +0 -150
  148. package/web/src/hooks/useFilter.js +0 -310
  149. package/web/src/hooks/useInstalledModels.js +0 -42
  150. package/web/src/hooks/useRecommend.js +0 -67
  151. package/web/src/hooks/useRouterDashboard.js +0 -133
  152. package/web/src/hooks/useSSE.js +0 -73
  153. package/web/src/hooks/useSocket.js +0 -203
  154. package/web/src/hooks/useTheme.js +0 -83
  155. package/web/src/hooks/useTokenUsage.js +0 -103
  156. package/web/src/hooks/useToolMode.js +0 -77
  157. package/web/src/hooks/useUpdateChecker.js +0 -91
  158. package/web/src/hooks/useUrlState.js +0 -170
  159. package/web/src/main.jsx +0 -15
  160. package/web/src/utils/download.js +0 -15
  161. package/web/src/utils/format.js +0 -42
  162. package/web/src/utils/m3.js +0 -57
  163. package/web/src/utils/ranks.js +0 -37
  164. package/web/styles.legacy.css +0 -963
@@ -1,42 +0,0 @@
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
- }
@@ -1,67 +0,0 @@
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
- }
@@ -1,133 +0,0 @@
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
- }
@@ -1,73 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useSSE.js
3
- * @description React hook for SSE (Server-Sent Events) connection.
4
- * 📖 Connects to /api/events, auto-reconnects on failure, returns live model data.
5
- * → useSSE
6
- */
7
- import { useState, useEffect, useRef, useCallback } from 'react'
8
-
9
- const RECONNECT_DELAY = 2000
10
-
11
- export function useSSE(url = '/api/events') {
12
- const [models, setModels] = useState([])
13
- const [connected, setConnected] = useState(false)
14
- const [updateCount, setUpdateCount] = useState(0)
15
- const esRef = useRef(null)
16
- const reconnectRef = useRef(null)
17
- const mountedRef = useRef(true)
18
-
19
- const connect = useCallback(() => {
20
- if (!mountedRef.current) return
21
-
22
- // Close existing connection
23
- if (esRef.current) {
24
- esRef.current.close()
25
- esRef.current = null
26
- }
27
-
28
- clearTimeout(reconnectRef.current)
29
-
30
- const es = new EventSource(url)
31
- esRef.current = es
32
-
33
- es.onopen = () => {
34
- if (mountedRef.current) setConnected(true)
35
- }
36
-
37
- es.onmessage = (event) => {
38
- if (!mountedRef.current) return
39
- try {
40
- const data = JSON.parse(event.data)
41
- setModels(data)
42
- setUpdateCount(c => c + 1)
43
- } catch (e) {
44
- console.warn('[useSSE] parse error:', e)
45
- }
46
- }
47
-
48
- es.onerror = () => {
49
- setConnected(false)
50
- es.close()
51
- esRef.current = null
52
-
53
- if (mountedRef.current) {
54
- reconnectRef.current = setTimeout(() => {
55
- if (mountedRef.current) connect()
56
- }, RECONNECT_DELAY)
57
- }
58
- }
59
- }, [url])
60
-
61
- useEffect(() => {
62
- mountedRef.current = true
63
- connect()
64
-
65
- return () => {
66
- mountedRef.current = false
67
- clearTimeout(reconnectRef.current)
68
- esRef.current?.close()
69
- }
70
- }, [connect])
71
-
72
- return { models, connected, updateCount }
73
- }
@@ -1,203 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useSocket.js
3
- * @description Realtime model-data hook with Socket.IO primary, SSE fallback, and REST polling safety net.
4
- *
5
- * 📖 The dashboard runs locally, so freshness matters more than shaving every
6
- * byte. Socket.IO gives the snappiest path in `pnpm dev:web`; SSE keeps Docker /
7
- * daemon-style servers working without Socket.IO; REST polling guarantees the UI
8
- * eventually recovers if both streaming transports are interrupted.
9
- *
10
- * @functions
11
- * → useSocket(serverUrl) — Subscribe to live dashboard state
12
- * @exports useSocket
13
- */
14
- import { useState, useEffect, useRef, useCallback } from 'react'
15
- import { io } from 'socket.io-client'
16
-
17
- const REST_FALLBACK_INTERVAL_MS = 2_000
18
- const STALE_UPDATE_MS = 4_000
19
- const ACTIVITY_THROTTLE_MS = 1_000
20
-
21
- function normalizePayload(data) {
22
- if (!data) return null
23
- if (Array.isArray(data)) return { models: data }
24
- if (Array.isArray(data.models)) return data
25
- return null
26
- }
27
-
28
- function sameOriginUrl(path, serverUrl) {
29
- if (!serverUrl) return path
30
- return `${serverUrl.replace(/\/$/, '')}${path}`
31
- }
32
-
33
- export function useSocket(serverUrl = '') {
34
- const [models, setModels] = useState([])
35
- const [connected, setConnected] = useState(false)
36
- const [transport, setTransport] = useState('connecting')
37
- const [updateCount, setUpdateCount] = useState(0)
38
- const [nextPingAt, setNextPingAt] = useState(null)
39
- const [serverIsPinging, setServerIsPinging] = useState(false)
40
- const [pendingPings, setPendingPings] = useState(0)
41
- const [pingMode, setPingMode] = useState('speed')
42
- const [globalBenchmarkRunning, setGlobalBenchmarkRunning] = useState(false)
43
- const [globalBenchmarkTotal, setGlobalBenchmarkTotal] = useState(0)
44
- const [globalBenchmarkCompleted, setGlobalBenchmarkCompleted] = useState(0)
45
- const [updateStatus, setUpdateStatus] = useState(null)
46
-
47
- const socketRef = useRef(null)
48
- const esRef = useRef(null)
49
- const pollRef = useRef(null)
50
- const lastUpdateRef = useRef(0)
51
- const lastActivityRef = useRef(0)
52
- const mountedRef = useRef(false)
53
-
54
- const applyPayload = useCallback((raw, source = 'unknown') => {
55
- const data = normalizePayload(raw)
56
- if (!data || !mountedRef.current) return
57
-
58
- setModels(data.models ?? [])
59
- setPingMode(data.pingMode ?? 'speed')
60
- setNextPingAt(data.nextPingAt ?? null)
61
- setServerIsPinging(Boolean(data.isPinging))
62
- setPendingPings(Number.isFinite(data.pendingPings) ? data.pendingPings : 0)
63
- setGlobalBenchmarkRunning(Boolean(data.globalBenchmarkRunning))
64
- setGlobalBenchmarkTotal(Number.isFinite(data.globalBenchmarkTotal) ? data.globalBenchmarkTotal : 0)
65
- setGlobalBenchmarkCompleted(Number.isFinite(data.globalBenchmarkCompleted) ? data.globalBenchmarkCompleted : 0)
66
- setUpdateStatus(data.updateStatus && data.updateStatus.allowedOutdated ? data.updateStatus : null)
67
- setUpdateCount((count) => count + 1)
68
- lastUpdateRef.current = Date.now()
69
- if (source !== 'poll') {
70
- setConnected(true)
71
- setTransport(source)
72
- }
73
- }, [])
74
-
75
- const fetchSnapshot = useCallback(async () => {
76
- try {
77
- const stateResponse = await fetch(sameOriginUrl('/api/state', serverUrl), { headers: { Accept: 'application/json' } })
78
- if (stateResponse.ok) {
79
- applyPayload(await stateResponse.json(), 'poll')
80
- setConnected(true)
81
- setTransport((current) => current === 'socket' || current === 'sse' ? current : 'poll')
82
- return
83
- }
84
- } catch {}
85
-
86
- try {
87
- const response = await fetch(sameOriginUrl('/api/models', serverUrl), { headers: { Accept: 'application/json' } })
88
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
89
- applyPayload(await response.json(), 'poll')
90
- setConnected(true)
91
- setTransport((current) => current === 'socket' || current === 'sse' ? current : 'poll')
92
- } catch {
93
- setConnected(false)
94
- setTransport('offline')
95
- }
96
- }, [applyPayload, serverUrl])
97
-
98
- const startSse = useCallback(() => {
99
- if (esRef.current || !mountedRef.current) return
100
- try {
101
- const es = new EventSource(sameOriginUrl('/api/events', serverUrl))
102
- esRef.current = es
103
- es.onopen = () => {
104
- if (!mountedRef.current) return
105
- setConnected(true)
106
- setTransport('sse')
107
- }
108
- const handleSsePayload = (event) => {
109
- try { applyPayload(JSON.parse(event.data), 'sse') }
110
- catch (err) { console.warn('[useSocket] SSE parse error:', err) }
111
- }
112
- es.onmessage = handleSsePayload
113
- es.addEventListener('models', handleSsePayload)
114
- es.onerror = () => {
115
- es.close()
116
- if (esRef.current === es) esRef.current = null
117
- if (!socketRef.current?.connected) setConnected(false)
118
- }
119
- } catch {
120
- esRef.current = null
121
- }
122
- }, [applyPayload, serverUrl])
123
-
124
- const sendActivity = useCallback(() => {
125
- const now = Date.now()
126
- if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return
127
- lastActivityRef.current = now
128
-
129
- if (socketRef.current?.connected) {
130
- socketRef.current.emit('client:activity')
131
- return
132
- }
133
-
134
- fetch(sameOriginUrl('/api/activity', serverUrl), { method: 'POST' }).catch(() => {})
135
- }, [serverUrl])
136
-
137
- useEffect(() => {
138
- mountedRef.current = true
139
-
140
- const socket = io(serverUrl || undefined, {
141
- transports: ['websocket', 'polling'],
142
- reconnectionDelay: 500,
143
- reconnectionDelayMax: 2500,
144
- timeout: 1200,
145
- })
146
- socketRef.current = socket
147
-
148
- socket.on('connect', () => {
149
- setConnected(true)
150
- setTransport('socket')
151
- esRef.current?.close()
152
- esRef.current = null
153
- socket.emit('models:refresh')
154
- socket.emit('client:activity')
155
- })
156
- socket.on('disconnect', () => {
157
- if (!mountedRef.current) return
158
- setConnected(false)
159
- startSse()
160
- })
161
- socket.on('connect_error', () => {
162
- if (!mountedRef.current) return
163
- setConnected(false)
164
- startSse()
165
- })
166
- socket.on('models:update', (data) => applyPayload(data, 'socket'))
167
-
168
- pollRef.current = setInterval(() => {
169
- const stale = Date.now() - lastUpdateRef.current > STALE_UPDATE_MS
170
- if (!socket.connected || stale) void fetchSnapshot()
171
- }, REST_FALLBACK_INTERVAL_MS)
172
-
173
- void fetchSnapshot()
174
-
175
- const activityEvents = ['keydown', 'pointerdown', 'mousemove', 'focus']
176
- for (const eventName of activityEvents) window.addEventListener(eventName, sendActivity, { passive: true })
177
-
178
- return () => {
179
- mountedRef.current = false
180
- for (const eventName of activityEvents) window.removeEventListener(eventName, sendActivity)
181
- if (pollRef.current) clearInterval(pollRef.current)
182
- socket.disconnect()
183
- esRef.current?.close()
184
- socketRef.current = null
185
- esRef.current = null
186
- }
187
- }, [applyPayload, fetchSnapshot, sendActivity, serverUrl, startSse])
188
-
189
- return {
190
- models,
191
- connected,
192
- transport,
193
- updateCount,
194
- nextPingAt,
195
- isPinging: serverIsPinging,
196
- pendingPings,
197
- pingMode,
198
- globalBenchmarkRunning,
199
- globalBenchmarkTotal,
200
- globalBenchmarkCompleted,
201
- updateStatus,
202
- }
203
- }
@@ -1,83 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useTheme.js
3
- * @description React hook for tri-state theme cycle (auto / dark / light) — M1 parity.
4
- * 📖 Mirrors the TUI's `G` key behavior:
5
- * 📖 auto → system preference (resolved once at mount)
6
- * 📖 dark → explicit dark
7
- * 📖 light → explicit light
8
- * 📖 Persists the choice on <html data-theme="auto|dark|light">. When set to
9
- * 📖 `auto`, the hook resolves the actual rendered theme and writes it back to
10
- * 📖 <html data-theme="..."> via resolveSystemTheme() (light/dark) so the
11
- * 📖 rest of the CSS keeps working without any "auto" branch in stylesheets.
12
- *
13
- * @functions
14
- * → useTheme() — { theme, resolvedTheme, setTheme, cycle }
15
- */
16
- import { useState, useCallback, useEffect } from 'react'
17
-
18
- export const THEME_CYCLE = ['auto', 'dark', 'light']
19
-
20
- // 📖 Read the OS-level dark/light preference. Falls back to dark on browsers
21
- // 📖 that don't support matchMedia (very rare; SSR snapshots, etc.).
22
- function resolveSystemTheme() {
23
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'dark'
24
- try {
25
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
26
- } catch {
27
- return 'dark'
28
- }
29
- }
30
-
31
- function readInitialTheme() {
32
- if (typeof document === 'undefined') return 'auto'
33
- const attr = document.documentElement.getAttribute('data-theme')
34
- if (attr === 'auto' || attr === 'dark' || attr === 'light') return attr
35
- return 'auto'
36
- }
37
-
38
- export function useTheme() {
39
- const [theme, setThemeState] = useState(readInitialTheme)
40
- const [systemTheme, setSystemTheme] = useState(resolveSystemTheme)
41
-
42
- // 📖 Listen for OS theme changes while the user is in "auto" mode.
43
- useEffect(() => {
44
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
45
- const mq = window.matchMedia('(prefers-color-scheme: dark)')
46
- const handler = (e) => setSystemTheme(e.matches ? 'dark' : 'light')
47
- if (typeof mq.addEventListener === 'function') {
48
- mq.addEventListener('change', handler)
49
- return () => mq.removeEventListener('change', handler)
50
- }
51
- // 📖 Safari < 14 fallback
52
- if (typeof mq.addListener === 'function') {
53
- mq.addListener(handler)
54
- return () => mq.removeListener(handler)
55
- }
56
- }, [])
57
-
58
- // 📖 Apply the resolved theme to <html data-theme="..."> on every change.
59
- // 📖 We write the *resolved* value (light / dark) so existing CSS keeps working.
60
- useEffect(() => {
61
- const resolved = theme === 'auto' ? systemTheme : theme
62
- document.documentElement.setAttribute('data-theme', resolved)
63
- // 📖 Also stamp the user-chosen mode so other parts of the app can read it
64
- // 📖 (e.g. to render the right "auto" badge in the theme button).
65
- document.documentElement.setAttribute('data-theme-preference', theme)
66
- }, [theme, systemTheme])
67
-
68
- // 📖 Cycle through auto → dark → light → auto, matching the TUI's `G` key.
69
- const cycle = useCallback(() => {
70
- setThemeState((prev) => {
71
- const idx = THEME_CYCLE.indexOf(prev)
72
- return THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
73
- })
74
- }, [])
75
-
76
- const setTheme = useCallback((next) => {
77
- if (THEME_CYCLE.includes(next)) setThemeState(next)
78
- }, [])
79
-
80
- const resolvedTheme = theme === 'auto' ? systemTheme : theme
81
-
82
- return { theme, resolvedTheme, setTheme, cycle }
83
- }