free-coding-models 0.5.0 → 0.5.4

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 (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. package/web/src/components/map/MapView.module.css +0 -25
package/web/server.js CHANGED
@@ -1,21 +1,24 @@
1
1
  /**
2
2
  * @file web/server.js
3
- * @description HTTP server for the free-coding-models Web Dashboard V2.
3
+ * @description HTTP + Socket.IO/SSE server for the free-coding-models realtime Web Dashboard.
4
4
  *
5
- * Reuses the existing ping engine, model sources, and utility functions
6
- * from the CLI tool. Serves the dashboard HTML/CSS/JS and provides
7
- * API endpoints + SSE for real-time ping data.
5
+ * @details
6
+ * 📖 This server intentionally mirrors the TUI health loop instead of exposing a
7
+ * slow request/response snapshot. The browser gets per-model ping state, frequent
8
+ * updates while probes complete, and the same startup speed burst → normal → idle
9
+ * slow cadence used by the terminal UI.
8
10
  *
9
- * Endpoints:
10
- * GET / → Dashboard HTML
11
- * GET /styles.css → Dashboard styles
12
- * GET /app.js → Dashboard client JS
13
- * GET /api/models → All model metadata (JSON)
14
- * GET /api/health → Lightweight dashboard health probe
15
- * GET /api/config Current config (sanitized masked keys)
16
- * GET /api/key/:prov Reveal a provider's full API key
17
- * GET /api/events SSE stream of live ping results
18
- * POST /api/settings → Update API keys / provider toggles
11
+ * Realtime transport strategy:
12
+ * - Socket.IO is the primary channel for the local web app.
13
+ * - `/api/events` keeps an SSE stream alive as a zero-dependency fallback.
14
+ * - `/api/models` remains a plain JSON endpoint for polling/fallback clients.
15
+ *
16
+ * @functions
17
+ * → startWebServer(port, options)Start the dashboard server and realtime loops
18
+ * inspectExistingWebServer(port) Detect if a port already hosts this dashboard
19
+ * → findAvailablePort(startPort, maxAttempts) Find a local fallback port
20
+ *
21
+ * @exports startWebServer, inspectExistingWebServer, findAvailablePort
19
22
  */
20
23
 
21
24
  import { createServer } from 'node:http'
@@ -23,23 +26,59 @@ import { readFileSync, existsSync } from 'node:fs'
23
26
  import { join, dirname, extname } from 'node:path'
24
27
  import { fileURLToPath } from 'node:url'
25
28
  import { exec } from 'node:child_process'
29
+ import { Server } from 'socket.io'
26
30
 
27
31
  import { sources, MODELS } from '../sources.js'
28
32
  import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/core/config.js'
33
+ import { ensureFavoritesConfig } from '../src/core/favorites.js'
29
34
  import { ping } from '../src/core/ping.js'
30
35
  import {
31
36
  getAvg, getVerdict, getUptime, getP95, getJitter,
32
- getStabilityScore, TIER_ORDER
37
+ getStabilityScore,
33
38
  } from '../src/core/utils.js'
39
+ import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
40
+ import {
41
+ PING_MODE_INTERVALS,
42
+ PING_MODE_CYCLE,
43
+ SPEED_MODE_DURATION_MS,
44
+ IDLE_SLOW_AFTER_MS,
45
+ } from '../src/tui/tui-state.js'
34
46
 
35
47
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
48
  const SERVER_SIGNATURE = 'free-coding-models-web'
49
+ const BROADCAST_THROTTLE_MS = 80
50
+ const MAX_PING_HISTORY = 60
51
+ const GLOBAL_BENCHMARK_CONCURRENCY = 5
52
+ const DEFAULT_WEB_PORT = 3333
53
+ const BODY_LIMIT_BYTES = 1024 * 1024
37
54
 
38
- // ─── State ───────────────────────────────────────────────────────────────────
55
+ // ─── Mutable server state ───────────────────────────────────────────────────
39
56
 
40
57
  let config = loadConfig()
58
+ let io = null
59
+ let pingLoopTimer = null
60
+ let broadcastTimer = null
61
+ let heartbeatTimer = null
62
+ let startedServer = null
63
+
64
+ const sseClients = new Set()
65
+
66
+ const runtime = {
67
+ pingMode: 'speed',
68
+ pingModeSource: 'startup',
69
+ activePingInterval: PING_MODE_INTERVALS.speed,
70
+ speedModeUntil: Date.now() + SPEED_MODE_DURATION_MS,
71
+ lastUserActivityAt: Date.now(),
72
+ resumeSpeedOnActivity: false,
73
+ lastPingTime: Date.now(),
74
+ nextPingAt: Date.now(),
75
+ pendingPings: 0,
76
+ pingRound: 0,
77
+ globalBenchmarkRunning: false,
78
+ globalBenchmarkTotal: 0,
79
+ globalBenchmarkCompleted: 0,
80
+ }
41
81
 
42
- // Build results array from MODELS (same shape as the TUI)
43
82
  const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
44
83
  idx: idx + 1,
45
84
  modelId,
@@ -55,105 +94,203 @@ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey],
55
94
  url: sources[providerKey]?.url || null,
56
95
  cliOnly: sources[providerKey]?.cliOnly || false,
57
96
  zenOnly: sources[providerKey]?.zenOnly || false,
97
+ isPinging: false,
58
98
  }))
59
99
 
60
- // SSE clients
61
- const sseClients = new Set()
100
+ const benchmarkRunning = new Set()
101
+ const benchmarkResults = new Map()
62
102
 
63
- // ─── Ping Loop ───────────────────────────────────────────────────────────────
64
- // Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
65
- // Each new round starts only after the previous one completes.
66
-
67
- let pingRound = 0
68
- let pingLoopRunning = false
69
-
70
- async function pingAllModels() {
71
- if (pingLoopRunning) return // guard against overlapping calls
72
- pingLoopRunning = true
73
- pingRound++
74
- const batchSize = 30
75
- // P2 fix: honor provider enabled flags — skip disabled providers
76
- const modelsToPing = results.filter(r =>
77
- !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
78
- )
79
-
80
- for (let i = 0; i < modelsToPing.length; i += batchSize) {
81
- const batch = modelsToPing.slice(i, i + batchSize)
82
- const promises = batch.map(async (r) => {
83
- const apiKey = getApiKey(config, r.providerKey)
84
- try {
85
- const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
86
- r.httpCode = result.code
87
- if (result.code === '200') {
88
- r.status = 'up'
89
- r.pings.push({ ms: result.ms, code: result.code })
90
- } else if (result.code === '401') {
91
- r.status = 'up'
92
- r.pings.push({ ms: result.ms, code: result.code })
93
- } else if (result.code === '429') {
94
- r.status = 'up'
95
- r.pings.push({ ms: result.ms, code: result.code })
96
- } else if (result.code === '000') {
97
- r.status = 'timeout'
98
- } else {
99
- r.status = 'down'
100
- r.pings.push({ ms: result.ms, code: result.code })
101
- }
102
- // Keep only last 60 pings
103
- if (r.pings.length > 60) r.pings = r.pings.slice(-60)
104
- } catch {
105
- r.status = 'timeout'
106
- }
107
- })
108
- await Promise.all(promises)
103
+ // ─── Shared state helpers ───────────────────────────────────────────────────
104
+
105
+ function benchmarkKey(providerKey, modelId) {
106
+ return `${providerKey}/${modelId}`
107
+ }
108
+
109
+ function getResultKey(result) {
110
+ return benchmarkKey(result.providerKey, result.modelId)
111
+ }
112
+
113
+ function getResult(providerKey, modelId) {
114
+ return results.find((r) => r.providerKey === providerKey && r.modelId === modelId) || null
115
+ }
116
+
117
+ function noteUserActivity() {
118
+ runtime.lastUserActivityAt = Date.now()
119
+ if (runtime.pingMode === 'forced') return
120
+ if (runtime.resumeSpeedOnActivity) setPingMode('speed', 'activity')
121
+ }
122
+
123
+ function setPingMode(nextMode, source = 'manual') {
124
+ const mode = PING_MODE_INTERVALS[nextMode] ? nextMode : 'normal'
125
+ runtime.pingMode = mode
126
+ runtime.pingModeSource = source
127
+ runtime.activePingInterval = PING_MODE_INTERVALS[mode]
128
+ runtime.speedModeUntil = mode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
129
+ runtime.resumeSpeedOnActivity = source === 'idle'
130
+ scheduleNextPing()
131
+ broadcastUpdate({ immediate: true })
132
+ }
133
+
134
+ function cyclePingMode() {
135
+ const idx = PING_MODE_CYCLE.indexOf(runtime.pingMode)
136
+ setPingMode(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length] || 'normal')
137
+ }
138
+
139
+ function refreshPingMode() {
140
+ const now = Date.now()
141
+ if (runtime.pingMode === 'forced') return
142
+
143
+ if (runtime.speedModeUntil && now >= runtime.speedModeUntil) {
144
+ setPingMode('normal', 'auto')
145
+ return
146
+ }
147
+
148
+ if (now - runtime.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
149
+ if (runtime.pingMode !== 'slow' || runtime.pingModeSource !== 'idle') {
150
+ setPingMode('slow', 'idle')
151
+ } else {
152
+ runtime.resumeSpeedOnActivity = true
153
+ }
154
+ }
155
+ }
156
+
157
+ function scheduleNextPing() {
158
+ if (!startedServer?.listening) return
159
+ clearTimeout(pingLoopTimer)
160
+ refreshPingMode()
161
+ const elapsed = Date.now() - runtime.lastPingTime
162
+ const delay = Math.max(0, runtime.activePingInterval - elapsed)
163
+ runtime.nextPingAt = Date.now() + delay
164
+ pingLoopTimer = setTimeout(startPingCycle, delay)
165
+ }
166
+
167
+ function trimPingHistory(result) {
168
+ if (result.pings.length > MAX_PING_HISTORY) result.pings = result.pings.slice(-MAX_PING_HISTORY)
169
+ }
170
+
171
+ function updateHealthFromPing(result, pingResult, hasApiKey) {
172
+ const code = String(pingResult.code || 'ERR')
173
+ result.httpCode = code
174
+
175
+ // 📖 Match the TUI: every probe contributes to availability history. Average,
176
+ // 📖 p95, and jitter still ignore non-measurable codes through src/core/utils.js.
177
+ result.pings.push({ ms: pingResult.ms, code })
178
+ trimPingHistory(result)
179
+
180
+ if (code === '200') result.status = 'up'
181
+ else if (code === '000') result.status = 'timeout'
182
+ else if (code === '401' || code === '403') result.status = hasApiKey ? 'auth_error' : 'noauth'
183
+ else result.status = 'down'
184
+ }
185
+
186
+ function updateHealthFromBenchmark(result, benchmarkResult) {
187
+ if (!result || !benchmarkResult) return
188
+ if (benchmarkResult.ok) {
189
+ result.status = 'up'
190
+ result.httpCode = '200'
191
+ return
109
192
  }
110
193
 
111
- // Broadcast update to all SSE clients
194
+ const code = String(benchmarkResult.code || 'ERR')
195
+ if (code === 'TIMEOUT') result.status = 'timeout'
196
+ else if (code === '401' || code === '403') result.status = getApiKey(config, result.providerKey) ? 'auth_error' : 'noauth'
197
+ else if (code !== 'ERR' && code !== 'UNSUPPORTED') result.status = 'down'
198
+ result.httpCode = code
199
+ }
200
+
201
+ async function pingModel(result) {
202
+ if (!result || result.isPinging || result.cliOnly || !result.url || !isProviderEnabled(config, result.providerKey)) return
203
+
204
+ result.isPinging = true
205
+ runtime.pendingPings += 1
112
206
  broadcastUpdate()
113
- pingLoopRunning = false
207
+
208
+ const apiKey = getApiKey(config, result.providerKey) ?? null
209
+ try {
210
+ const pingResult = await ping(apiKey, result.modelId, result.providerKey, result.url)
211
+ updateHealthFromPing(result, pingResult, !!apiKey)
212
+ } catch (err) {
213
+ updateHealthFromPing(result, { code: '000', ms: null, error: err?.message || 'Ping failed' }, !!apiKey)
214
+ } finally {
215
+ result.isPinging = false
216
+ runtime.pendingPings = Math.max(0, runtime.pendingPings - 1)
217
+ broadcastUpdate()
218
+ }
114
219
  }
115
220
 
116
- function broadcastUpdate() {
117
- const data = JSON.stringify(getModelsPayload())
118
- for (const client of sseClients) {
119
- try {
120
- client.write(`data: ${data}\n\n`)
121
- } catch {
122
- sseClients.delete(client)
123
- }
221
+ function startPingCycle() {
222
+ if (!startedServer?.listening) return
223
+ refreshPingMode()
224
+
225
+ runtime.lastPingTime = Date.now()
226
+ runtime.pingRound += 1
227
+ runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
228
+
229
+ const modelsToPing = results.filter((r) => !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey))
230
+ for (const result of modelsToPing) {
231
+ void pingModel(result)
232
+ }
233
+
234
+ broadcastUpdate({ immediate: true })
235
+ scheduleNextPing()
236
+ }
237
+
238
+ function serializeModel(result) {
239
+ const key = getResultKey(result)
240
+ const avg = getAvg(result)
241
+ const p95 = getP95(result)
242
+ const jitter = getJitter(result)
243
+ const stability = getStabilityScore(result)
244
+ const latest = result.pings.length > 0 ? result.pings[result.pings.length - 1] : null
245
+
246
+ return {
247
+ idx: result.idx,
248
+ modelId: result.modelId,
249
+ label: result.label,
250
+ tier: result.tier,
251
+ sweScore: result.sweScore,
252
+ ctx: result.ctx,
253
+ providerKey: result.providerKey,
254
+ origin: result.origin,
255
+ status: result.status,
256
+ httpCode: result.httpCode,
257
+ cliOnly: result.cliOnly,
258
+ zenOnly: result.zenOnly,
259
+ isPinging: result.isPinging,
260
+ avg: Number.isFinite(avg) ? avg : null,
261
+ verdict: getVerdict(result),
262
+ uptime: getUptime(result),
263
+ p95: Number.isFinite(p95) ? p95 : null,
264
+ jitter: Number.isFinite(jitter) ? jitter : null,
265
+ stability,
266
+ latestPing: latest?.ms ?? null,
267
+ latestCode: latest?.code ?? null,
268
+ pingHistory: result.pings.slice(-20).map((p) => ({ ms: p.ms, code: p.code })),
269
+ pingCount: result.pings.length,
270
+ hasApiKey: !!getApiKey(config, result.providerKey),
271
+ benchmarkKey: key,
272
+ isBenchmarking: benchmarkRunning.has(key),
273
+ benchmark: benchmarkResults.get(key) || null,
124
274
  }
125
275
  }
126
276
 
127
277
  function getModelsPayload() {
128
- return results.map(r => ({
129
- idx: r.idx,
130
- modelId: r.modelId,
131
- label: r.label,
132
- tier: r.tier,
133
- sweScore: r.sweScore,
134
- ctx: r.ctx,
135
- providerKey: r.providerKey,
136
- origin: r.origin,
137
- status: r.status,
138
- httpCode: r.httpCode,
139
- cliOnly: r.cliOnly,
140
- zenOnly: r.zenOnly,
141
- avg: getAvg(r),
142
- verdict: getVerdict(r),
143
- uptime: getUptime(r),
144
- p95: getP95(r),
145
- jitter: getJitter(r),
146
- stability: getStabilityScore(r),
147
- latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
148
- latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
149
- pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
150
- pingCount: r.pings.length,
151
- hasApiKey: !!getApiKey(config, r.providerKey),
152
- }))
278
+ return {
279
+ pingMode: runtime.pingMode,
280
+ pingModeSource: runtime.pingModeSource,
281
+ pingInterval: runtime.activePingInterval,
282
+ nextPingAt: runtime.nextPingAt,
283
+ pendingPings: runtime.pendingPings,
284
+ isPinging: runtime.pendingPings > 0,
285
+ pingRound: runtime.pingRound,
286
+ globalBenchmarkRunning: runtime.globalBenchmarkRunning,
287
+ globalBenchmarkTotal: runtime.globalBenchmarkTotal,
288
+ globalBenchmarkCompleted: runtime.globalBenchmarkCompleted,
289
+ models: results.map(serializeModel),
290
+ }
153
291
  }
154
292
 
155
293
  function getConfigPayload() {
156
- // Sanitize — show which providers have keys, but not the actual keys
157
294
  const providers = {}
158
295
  for (const [key, src] of Object.entries(sources)) {
159
296
  const rawKey = getApiKey(config, key)
@@ -175,7 +312,36 @@ function maskApiKey(key) {
175
312
  return '••••••••' + key.slice(-4)
176
313
  }
177
314
 
178
- // ─── HTTP Server ─────────────────────────────────────────────────────────────
315
+ function writeSsePayload(res, payload) {
316
+ try {
317
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
318
+ } catch {
319
+ sseClients.delete(res)
320
+ }
321
+ }
322
+
323
+ function broadcastNow() {
324
+ const payload = getModelsPayload()
325
+ if (io) io.emit('models:update', payload)
326
+ for (const res of [...sseClients]) writeSsePayload(res, payload)
327
+ }
328
+
329
+ function broadcastUpdate({ immediate = false } = {}) {
330
+ if (immediate) {
331
+ clearTimeout(broadcastTimer)
332
+ broadcastTimer = null
333
+ broadcastNow()
334
+ return
335
+ }
336
+
337
+ if (broadcastTimer) return
338
+ broadcastTimer = setTimeout(() => {
339
+ broadcastTimer = null
340
+ broadcastNow()
341
+ }, BROADCAST_THROTTLE_MS)
342
+ }
343
+
344
+ // ─── HTTP helpers ───────────────────────────────────────────────────────────
179
345
 
180
346
  const MIME_TYPES = {
181
347
  '.html': 'text/html; charset=utf-8',
@@ -185,6 +351,8 @@ const MIME_TYPES = {
185
351
  '.svg': 'image/svg+xml',
186
352
  '.png': 'image/png',
187
353
  '.ico': 'image/x-icon',
354
+ '.webmanifest': 'application/manifest+json',
355
+ '.xml': 'application/xml; charset=utf-8',
188
356
  }
189
357
 
190
358
  function serveFile(res, filename, contentType) {
@@ -201,6 +369,15 @@ function serveFile(res, filename, contentType) {
201
369
  function serveDistFile(res, pathname) {
202
370
  const filePath = join(__dirname, 'dist', pathname === '/' ? 'index.html' : pathname)
203
371
  if (!existsSync(filePath)) {
372
+ // 📖 SPA fallback: GETs to non-asset paths return index.html so the React
373
+ // 📖 router can take over. Static assets (favicons, /assets/*, anything
374
+ // 📖 with a known extension) must 404 — never serve HTML for a missing PNG.
375
+ const hasExt = extname(pathname) !== ''
376
+ if (hasExt || pathname.startsWith('/assets/') || pathname.startsWith('/favicons/')) {
377
+ res.writeHead(404)
378
+ res.end('Not Found')
379
+ return
380
+ }
204
381
  serveFile(res, 'dist/index.html', 'text/html; charset=utf-8')
205
382
  return
206
383
  }
@@ -208,7 +385,10 @@ function serveDistFile(res, pathname) {
208
385
  const ct = MIME_TYPES[ext] || 'application/octet-stream'
209
386
  try {
210
387
  const content = readFileSync(filePath)
211
- res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' })
388
+ res.writeHead(200, {
389
+ 'Content-Type': ct,
390
+ 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
391
+ })
212
392
  res.end(content)
213
393
  } catch {
214
394
  res.writeHead(404)
@@ -216,10 +396,112 @@ function serveDistFile(res, pathname) {
216
396
  }
217
397
  }
218
398
 
219
- function handleRequest(req, res) {
220
- res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
399
+ function sendJson(res, status, payload) {
400
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
401
+ res.end(JSON.stringify(payload))
402
+ }
403
+
404
+ function readJsonBody(req) {
405
+ return new Promise((resolve, reject) => {
406
+ let body = ''
407
+ req.on('data', (chunk) => {
408
+ body += chunk
409
+ if (body.length > BODY_LIMIT_BYTES) {
410
+ reject(new Error('Request body too large'))
411
+ req.destroy()
412
+ }
413
+ })
414
+ req.on('end', () => {
415
+ if (!body.trim()) {
416
+ resolve({})
417
+ return
418
+ }
419
+ try { resolve(JSON.parse(body)) }
420
+ catch (err) { reject(err) }
421
+ })
422
+ req.on('error', reject)
423
+ })
424
+ }
425
+
426
+ function parseVisibleBenchmarkModels(body) {
427
+ const rawModels = Array.isArray(body?.models) ? body.models : null
428
+ if (!rawModels) return results.filter((r) => !r.cliOnly && r.url)
429
+
430
+ const unique = new Map()
431
+ for (const item of rawModels) {
432
+ if (!item || typeof item !== 'object') continue
433
+ const providerKey = typeof item.providerKey === 'string' ? item.providerKey : ''
434
+ const modelId = typeof item.modelId === 'string' ? item.modelId : ''
435
+ const result = getResult(providerKey, modelId)
436
+ if (result && !result.cliOnly && result.url) unique.set(getResultKey(result), result)
437
+ }
438
+ return [...unique.values()]
439
+ }
440
+
441
+ async function runSingleBenchmark(result) {
442
+ const key = getResultKey(result)
443
+ if (benchmarkRunning.has(key)) return { skipped: true }
444
+
445
+ benchmarkRunning.add(key)
446
+ broadcastUpdate({ immediate: true })
447
+ try {
448
+ const benchmarkResult = await benchmarkModel({
449
+ apiKey: getApiKey(config, result.providerKey) ?? null,
450
+ modelId: result.modelId,
451
+ providerKey: result.providerKey,
452
+ url: result.url,
453
+ timeoutMs: BENCHMARK_TIMEOUT_MS,
454
+ })
455
+ benchmarkResults.set(key, benchmarkResult)
456
+ updateHealthFromBenchmark(result, benchmarkResult)
457
+ return benchmarkResult
458
+ } catch (err) {
459
+ const fallback = {
460
+ ok: false,
461
+ code: 'ERR',
462
+ totalMs: 0,
463
+ error: err?.message || 'Benchmark failed',
464
+ retries: 0,
465
+ }
466
+ benchmarkResults.set(key, fallback)
467
+ updateHealthFromBenchmark(result, fallback)
468
+ return fallback
469
+ } finally {
470
+ benchmarkRunning.delete(key)
471
+ broadcastUpdate({ immediate: true })
472
+ }
473
+ }
474
+
475
+ function runWithConcurrency(tasks, concurrency) {
476
+ return new Promise((resolve) => {
477
+ const resultsOut = new Array(tasks.length)
478
+ let nextIndex = 0
479
+ let active = 0
480
+ let completed = 0
481
+
482
+ function startNext() {
483
+ while (active < concurrency && nextIndex < tasks.length) {
484
+ const index = nextIndex++
485
+ active += 1
486
+ Promise.resolve(tasks[index]())
487
+ .then((value) => { resultsOut[index] = value })
488
+ .catch((err) => { resultsOut[index] = { error: err } })
489
+ .finally(() => {
490
+ active -= 1
491
+ completed += 1
492
+ if (completed >= tasks.length) resolve(resultsOut)
493
+ else startNext()
494
+ })
495
+ }
496
+ if (tasks.length === 0) resolve(resultsOut)
497
+ }
498
+
499
+ startNext()
500
+ })
501
+ }
221
502
 
222
- // CORS for local dev
503
+ async function handleRequest(req, res) {
504
+ res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
223
505
  res.setHeader('Access-Control-Allow-Origin', '*')
224
506
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
225
507
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
@@ -230,111 +512,274 @@ function handleRequest(req, res) {
230
512
  return
231
513
  }
232
514
 
233
- const url = new URL(req.url, `http://${req.headers.host}`)
515
+ const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
234
516
 
235
- // ─── API: Reveal full key for a provider ───
236
517
  const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
237
518
  if (keyMatch) {
238
519
  const providerKey = decodeURIComponent(keyMatch[1])
239
- const rawKey = getApiKey(config, providerKey)
240
- res.writeHead(200, { 'Content-Type': 'application/json' })
241
- res.end(JSON.stringify({ key: rawKey || null }))
520
+ if (!sources[providerKey]) {
521
+ sendJson(res, 404, { error: 'Unknown provider' })
522
+ return
523
+ }
524
+ sendJson(res, 200, { key: getApiKey(config, providerKey) || null })
242
525
  return
243
526
  }
244
527
 
245
- switch (url.pathname) {
246
- case '/':
247
- serveDistFile(res, '/')
248
- break
249
-
250
- case '/styles.css':
251
- case '/app.js':
252
- serveDistFile(res, url.pathname)
253
- break
528
+ try {
529
+ switch (url.pathname) {
530
+ case '/':
531
+ serveDistFile(res, '/')
532
+ return
254
533
 
255
- default:
256
- if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
534
+ case '/styles.css':
535
+ case '/app.js':
257
536
  serveDistFile(res, url.pathname)
258
- break
537
+ return
538
+
539
+ case '/api/activity':
540
+ if (req.method !== 'POST') {
541
+ res.writeHead(405)
542
+ res.end('Method Not Allowed')
543
+ return
544
+ }
545
+ noteUserActivity()
546
+ sendJson(res, 200, { ok: true, pingMode: runtime.pingMode })
547
+ return
548
+
549
+ case '/api/ping-mode': {
550
+ if (req.method !== 'POST' && req.method !== 'GET') {
551
+ res.writeHead(405)
552
+ res.end('Method Not Allowed')
553
+ return
554
+ }
555
+ noteUserActivity()
556
+ const action = url.searchParams.get('action')
557
+ if (action === 'cycle') cyclePingMode()
558
+ else if (PING_MODE_INTERVALS[action]) setPingMode(action, 'manual')
559
+ sendJson(res, 200, {
560
+ pingMode: runtime.pingMode,
561
+ pingModeSource: runtime.pingModeSource,
562
+ interval: runtime.activePingInterval,
563
+ nextPingAt: runtime.nextPingAt,
564
+ })
565
+ return
259
566
  }
260
567
 
261
- if (!url.pathname.startsWith('/api/')) {
262
- res.writeHead(404)
263
- res.end('Not Found')
264
- break
568
+ case '/api/ping-timer':
569
+ sendJson(res, 200, {
570
+ nextPingAt: runtime.nextPingAt,
571
+ isPinging: runtime.pendingPings > 0,
572
+ pendingPings: runtime.pendingPings,
573
+ })
574
+ return
575
+
576
+ case '/api/models':
577
+ // 📖 Legacy REST contract: keep returning the flat model array.
578
+ sendJson(res, 200, getModelsPayload().models)
579
+ return
580
+
581
+ case '/api/state':
582
+ sendJson(res, 200, getModelsPayload())
583
+ return
584
+
585
+ case '/api/health':
586
+ sendJson(res, 200, { ok: true, app: SERVER_SIGNATURE })
587
+ return
588
+
589
+ case '/api/config':
590
+ sendJson(res, 200, getConfigPayload())
591
+ return
592
+
593
+ case '/api/events':
594
+ res.writeHead(200, {
595
+ 'Content-Type': 'text/event-stream',
596
+ 'Cache-Control': 'no-cache',
597
+ Connection: 'keep-alive',
598
+ })
599
+ writeSsePayload(res, getModelsPayload())
600
+ sseClients.add(res)
601
+ req.on('close', () => sseClients.delete(res))
602
+ return
603
+
604
+ case '/api/settings': {
605
+ if (req.method !== 'POST') {
606
+ res.writeHead(405)
607
+ res.end('Method Not Allowed')
608
+ return
609
+ }
610
+ const settings = await readJsonBody(req)
611
+ noteUserActivity()
612
+ if (settings.apiKeys) {
613
+ if (!config.apiKeys) config.apiKeys = {}
614
+ for (const [key, value] of Object.entries(settings.apiKeys)) {
615
+ if (value) config.apiKeys[key] = value
616
+ else delete config.apiKeys[key]
617
+ }
618
+ }
619
+ if (settings.providers) {
620
+ if (!config.providers) config.providers = {}
621
+ for (const [key, value] of Object.entries(settings.providers)) {
622
+ if (!config.providers[key]) config.providers[key] = {}
623
+ config.providers[key].enabled = value?.enabled !== false
624
+ }
625
+ }
626
+ saveConfig(config)
627
+ broadcastUpdate({ immediate: true })
628
+ sendJson(res, 200, { success: true })
629
+ return
265
630
  }
266
631
 
267
- case '/api/models':
268
- res.writeHead(200, { 'Content-Type': 'application/json' })
269
- res.end(JSON.stringify(getModelsPayload()))
270
- break
271
-
272
- case '/api/health':
273
- res.writeHead(200, { 'Content-Type': 'application/json' })
274
- res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
275
- break
276
-
277
- case '/api/config':
278
- res.writeHead(200, { 'Content-Type': 'application/json' })
279
- res.end(JSON.stringify(getConfigPayload()))
280
- break
281
-
282
- case '/api/events':
283
- // SSE endpoint
284
- res.writeHead(200, {
285
- 'Content-Type': 'text/event-stream',
286
- 'Cache-Control': 'no-cache',
287
- 'Connection': 'keep-alive',
288
- })
289
- res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
290
- sseClients.add(res)
291
- req.on('close', () => sseClients.delete(res))
292
- break
293
-
294
- case '/api/settings':
295
- if (req.method === 'POST') {
296
- let body = ''
297
- req.on('data', chunk => body += chunk)
298
- req.on('end', () => {
632
+ // ── M1: /api/favorites — single source of truth for favorites, shared
633
+ // ── with the TUI through ~/.free-coding-models.json. Read on load,
634
+ // ── write on toggle/reorder/pinnedAndSticky changes.
635
+ case '/api/favorites': {
636
+ ensureFavoritesConfig(config)
637
+ if (req.method === 'GET') {
638
+ sendJson(res, 200, {
639
+ favorites: config.favorites,
640
+ pinnedAndSticky: Boolean(config.settings?.favoritesPinnedAndSticky),
641
+ })
642
+ return
643
+ }
644
+ if (req.method !== 'POST') {
645
+ res.writeHead(405)
646
+ res.end('Method Not Allowed')
647
+ return
648
+ }
649
+ const body = await readJsonBody(req)
650
+ noteUserActivity()
651
+
652
+ if (Array.isArray(body.favorites)) {
653
+ // 📖 Validate each entry is a non-empty string. Anything else is dropped
654
+ // 📖 silently so a partial / malformed payload never breaks the config.
655
+ const cleaned = body.favorites.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
656
+ config.favorites = Array.from(new Set(cleaned))
657
+ }
658
+ if (typeof body.pinnedAndSticky === 'boolean') {
659
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
660
+ config.settings.favoritesPinnedAndSticky = body.pinnedAndSticky
661
+ }
662
+
663
+ const saveResult = saveConfig(config, { replaceFavorites: true })
664
+ if (!saveResult.success) {
665
+ sendJson(res, 500, { success: false, error: saveResult.error || 'Failed to persist favorites' })
666
+ return
667
+ }
668
+ sendJson(res, 200, {
669
+ success: true,
670
+ favorites: config.favorites,
671
+ pinnedAndSticky: Boolean(config.settings?.favoritesPinnedAndSticky),
672
+ })
673
+ return
674
+ }
675
+
676
+ case '/api/benchmark': {
677
+ if (req.method !== 'POST') {
678
+ res.writeHead(405)
679
+ res.end('Method Not Allowed')
680
+ return
681
+ }
682
+ const body = await readJsonBody(req)
683
+ const result = getResult(body.providerKey, body.modelId)
684
+ if (!result) {
685
+ sendJson(res, 404, { error: 'Model not found' })
686
+ return
687
+ }
688
+ const key = getResultKey(result)
689
+ if (benchmarkRunning.has(key)) {
690
+ sendJson(res, 409, { error: 'Benchmark already in progress for this model' })
691
+ return
692
+ }
693
+ noteUserActivity()
694
+ const benchmarkResult = await runSingleBenchmark(result)
695
+ sendJson(res, 200, benchmarkResult)
696
+ return
697
+ }
698
+
699
+ case '/api/global-benchmark': {
700
+ if (req.method === 'GET') {
701
+ sendJson(res, 200, {
702
+ running: runtime.globalBenchmarkRunning,
703
+ total: runtime.globalBenchmarkTotal,
704
+ completed: runtime.globalBenchmarkCompleted,
705
+ })
706
+ return
707
+ }
708
+ if (req.method !== 'POST') {
709
+ res.writeHead(405)
710
+ res.end('Method Not Allowed')
711
+ return
712
+ }
713
+ if (runtime.globalBenchmarkRunning) {
714
+ sendJson(res, 409, { error: 'Global benchmark already running' })
715
+ return
716
+ }
717
+
718
+ const body = await readJsonBody(req)
719
+ noteUserActivity()
720
+ const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
721
+ const modelsToBenchmark = parseVisibleBenchmarkModels(body)
722
+ .sort((a, b) => {
723
+ const hpA = healthPriority[a.status] ?? 6
724
+ const hpB = healthPriority[b.status] ?? 6
725
+ if (hpA !== hpB) return hpA - hpB
726
+ const pingA = typeof a.pings?.[a.pings.length - 1]?.ms === 'number' ? a.pings[a.pings.length - 1].ms : 99999
727
+ const pingB = typeof b.pings?.[b.pings.length - 1]?.ms === 'number' ? b.pings[b.pings.length - 1].ms : 99999
728
+ return pingA - pingB
729
+ })
730
+
731
+ runtime.globalBenchmarkRunning = true
732
+ runtime.globalBenchmarkTotal = modelsToBenchmark.length
733
+ runtime.globalBenchmarkCompleted = 0
734
+ broadcastUpdate({ immediate: true })
735
+
736
+ const tasks = modelsToBenchmark.map((model) => async () => {
299
737
  try {
300
- const settings = JSON.parse(body)
301
- if (settings.apiKeys) {
302
- for (const [key, value] of Object.entries(settings.apiKeys)) {
303
- if (value) config.apiKeys[key] = value
304
- else delete config.apiKeys[key]
305
- }
306
- }
307
- if (settings.providers) {
308
- for (const [key, value] of Object.entries(settings.providers)) {
309
- if (!config.providers[key]) config.providers[key] = {}
310
- config.providers[key].enabled = value.enabled !== false
311
- }
312
- }
313
- // P2 fix: catch saveConfig failures and report to client
314
- try {
315
- saveConfig(config)
316
- } catch (saveErr) {
317
- res.writeHead(500, { 'Content-Type': 'application/json' })
318
- res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
319
- return
320
- }
321
- res.writeHead(200, { 'Content-Type': 'application/json' })
322
- res.end(JSON.stringify({ success: true }))
323
- } catch (err) {
324
- res.writeHead(400, { 'Content-Type': 'application/json' })
325
- res.end(JSON.stringify({ error: err.message }))
738
+ return await runSingleBenchmark(model)
739
+ } finally {
740
+ runtime.globalBenchmarkCompleted += 1
741
+ broadcastUpdate({ immediate: true })
326
742
  }
327
743
  })
328
- } else {
329
- res.writeHead(405)
330
- res.end('Method Not Allowed')
744
+
745
+ void runWithConcurrency(tasks, GLOBAL_BENCHMARK_CONCURRENCY).finally(() => {
746
+ runtime.globalBenchmarkRunning = false
747
+ runtime.globalBenchmarkTotal = 0
748
+ runtime.globalBenchmarkCompleted = 0
749
+ broadcastUpdate({ immediate: true })
750
+ })
751
+
752
+ sendJson(res, 202, { started: true, total: modelsToBenchmark.length })
753
+ return
331
754
  }
332
- break
755
+
756
+ default:
757
+ // 📖 Serve Vite's /assets/* bundle, and our static favicon set that
758
+ // 📖 Vite copies verbatim from web/public/ into web/dist/. The legacy
759
+ // 📖 /favicon.ico lives at web/public/favicon.ico (root of public/).
760
+ if (
761
+ url.pathname.startsWith('/assets/')
762
+ || url.pathname.startsWith('/favicons/')
763
+ || url.pathname === '/favicon.ico'
764
+ || url.pathname.endsWith('.js')
765
+ || url.pathname.endsWith('.css')
766
+ || url.pathname.endsWith('.png')
767
+ || url.pathname.endsWith('.svg')
768
+ || url.pathname.endsWith('.webmanifest')
769
+ || url.pathname.endsWith('.xml')
770
+ || url.pathname.endsWith('.ico')
771
+ ) {
772
+ serveDistFile(res, url.pathname)
773
+ return
774
+ }
775
+ res.writeHead(404)
776
+ res.end('Not Found')
777
+ }
778
+ } catch (err) {
779
+ if (!res.writableEnded) sendJson(res, 500, { error: err?.message || 'Internal server error' })
333
780
  }
334
781
  }
335
782
 
336
- // ─── Helpers ─────────────────────────────────────────────────────────────────
337
-
338
783
  function checkPortInUse(port) {
339
784
  return new Promise((resolve) => {
340
785
  const s = createServer()
@@ -352,8 +797,6 @@ export async function inspectExistingWebServer(port) {
352
797
  const timeout = setTimeout(() => controller.abort(), 750)
353
798
 
354
799
  try {
355
- // 📖 Probe a tiny health route so we only reuse a port when the running
356
- // 📖 process is actually the free-coding-models dashboard, not any random app.
357
800
  const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
358
801
  signal: controller.signal,
359
802
  headers: { Accept: 'application/json' },
@@ -387,16 +830,13 @@ function openBrowser(url) {
387
830
  })
388
831
  }
389
832
 
390
- // ─── Exports ─────────────────────────────────────────────────────────────────
391
-
392
- export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
833
+ export async function startWebServer(port = DEFAULT_WEB_PORT, { open = true, startPingLoop = true } = {}) {
393
834
  const portStatus = await inspectExistingWebServer(port)
394
835
 
395
836
  if (portStatus.inUse && portStatus.isFcm) {
396
837
  const url = `http://localhost:${port}`
397
-
398
838
  console.log()
399
- console.log(` ⚡ free-coding-models Web Dashboard already running`)
839
+ console.log(' ⚡ free-coding-models Web Dashboard already running')
400
840
  console.log(` 🌐 ${url}`)
401
841
  console.log()
402
842
  if (open) openBrowser(url)
@@ -413,31 +853,56 @@ export async function startWebServer(port = 3333, { open = true, startPingLoop =
413
853
  }
414
854
 
415
855
  const url = `http://localhost:${resolvedPort}`
856
+ const server = createServer((req, res) => void handleRequest(req, res))
857
+ startedServer = server
416
858
 
417
- const server = createServer(handleRequest)
418
- let pingLoopTimer = null
859
+ io = new Server(server, {
860
+ cors: { origin: '*', methods: ['GET', 'POST'] },
861
+ transports: ['websocket', 'polling'],
862
+ })
863
+
864
+ io.on('connection', (socket) => {
865
+ noteUserActivity()
866
+ socket.emit('models:update', getModelsPayload())
867
+ socket.on('client:activity', () => noteUserActivity())
868
+ socket.on('models:refresh', () => socket.emit('models:update', getModelsPayload()))
869
+ })
419
870
 
420
871
  server.listen(resolvedPort, () => {
421
872
  console.log()
422
- console.log(` ⚡ free-coding-models Web Dashboard`)
873
+ console.log(' ⚡ free-coding-models Web Dashboard')
423
874
  console.log(` 🌐 ${url}`)
424
- console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
875
+ console.log(` 📊 Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
425
876
  console.log()
426
- console.log(` Press Ctrl+C to stop`)
877
+ console.log(' Press Ctrl+C to stop')
427
878
  console.log()
879
+ if (startPingLoop && !pingLoopTimer) {
880
+ runtime.lastPingTime = Date.now()
881
+ runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
882
+ startPingCycle()
883
+ }
428
884
  if (open) openBrowser(url)
429
885
  })
430
886
 
431
- async function schedulePingLoop() {
432
- if (!server.listening) return
433
- await pingAllModels()
434
- pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
435
- }
436
-
437
- if (startPingLoop) schedulePingLoop()
438
887
  server.on('close', () => {
439
- if (pingLoopTimer) clearTimeout(pingLoopTimer)
888
+ clearTimeout(pingLoopTimer)
889
+ clearTimeout(broadcastTimer)
890
+ clearInterval(heartbeatTimer)
891
+ for (const res of [...sseClients]) {
892
+ try { res.end() } catch {}
893
+ }
894
+ sseClients.clear()
895
+ io?.close()
896
+ io = null
897
+ if (startedServer === server) startedServer = null
440
898
  })
441
899
 
900
+ heartbeatTimer = setInterval(() => {
901
+ refreshPingMode()
902
+ for (const res of [...sseClients]) {
903
+ try { res.write(': heartbeat\n\n') } catch { sseClients.delete(res) }
904
+ }
905
+ }, 15_000)
906
+
442
907
  return server
443
908
  }