free-coding-models 0.5.86 → 0.5.88

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 (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -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/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. 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
- }