free-coding-models 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +14 -9
  2. package/changelog/v0.5.7.md +20 -0
  3. package/package.json +1 -1
  4. package/src/core/endpoint-installer.js +3 -3
  5. package/src/core/tool-launchers.js +2 -2
  6. package/web/dist/assets/index-DKmmiDip.css +1 -0
  7. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  8. package/web/dist/index.html +2 -2
  9. package/web/server.js +386 -1
  10. package/web/src/App.jsx +145 -26
  11. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  12. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  13. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  14. package/web/src/components/atoms/TierBadge.module.css +3 -3
  15. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  16. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  17. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  18. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  19. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  20. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  21. package/web/src/components/help/HelpView.jsx +1 -1
  22. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  23. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  24. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  25. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  26. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  27. package/web/src/components/launch/LaunchButton.jsx +30 -0
  28. package/web/src/components/launch/LaunchButton.module.css +35 -0
  29. package/web/src/components/launch/LaunchModal.module.css +125 -0
  30. package/web/src/components/layout/Header.jsx +70 -11
  31. package/web/src/components/layout/Header.module.css +62 -2
  32. package/web/src/components/recommend/RecommendView.jsx +121 -0
  33. package/web/src/components/recommend/RecommendView.module.css +55 -0
  34. package/web/src/components/router/RouterView.jsx +286 -0
  35. package/web/src/components/router/RouterView.module.css +357 -0
  36. package/web/src/components/tools/ToolPicker.jsx +86 -0
  37. package/web/src/components/tools/ToolPicker.module.css +84 -0
  38. package/web/src/global.css +23 -0
  39. package/web/src/hooks/urlState.constants.js +3 -0
  40. package/web/src/hooks/useInstalledModels.js +42 -0
  41. package/web/src/hooks/useRecommend.js +67 -0
  42. package/web/src/hooks/useRouterDashboard.js +133 -0
  43. package/web/src/hooks/useTokenUsage.js +103 -0
  44. package/web/src/hooks/useToolMode.js +77 -0
  45. package/web/src/hooks/useUrlState.js +4 -3
  46. package/web/src/utils/m3.js +55 -0
  47. package/web/dist/assets/index-Blp9QJev.js +0 -39
  48. package/web/dist/assets/index-Cz_aCLTR.css +0 -1
@@ -48,8 +48,8 @@
48
48
  <link rel="preconnect" href="https://fonts.googleapis.com">
49
49
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
50
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
51
- <script type="module" crossorigin src="/assets/index-Blp9QJev.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-Cz_aCLTR.css">
51
+ <script type="module" crossorigin src="/assets/index-DzVt42Nf.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-DKmmiDip.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
package/web/server.js CHANGED
@@ -46,6 +46,17 @@ import {
46
46
  getStabilityScore,
47
47
  } from '../src/core/utils.js'
48
48
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
49
+ import { getInstallTargetModes, installProviderEndpoints, getConfiguredInstallableProviders, getProviderCatalogModels } from '../src/core/endpoint-installer.js'
50
+ import { isModelCompatibleWithTool } from '../src/core/tool-metadata.js'
51
+ import { sendUsageTelemetry } from '../src/core/telemetry.js'
52
+ import { getRouterDaemonStatus, startRouterDaemonBackground, stopRouterDaemon, ROUTER_TOKENS_PATH } from '../src/core/router-daemon.js'
53
+ import { scanAllToolConfigs, softDeleteModel } from '../src/core/installed-models-manager.js'
54
+ import {
55
+ TASK_TYPES,
56
+ PRIORITY_TYPES,
57
+ CONTEXT_BUDGETS,
58
+ getTopRecommendations,
59
+ } from '../src/core/utils.js'
49
60
  import {
50
61
  PING_MODE_INTERVALS,
51
62
  PING_MODE_CYCLE,
@@ -511,6 +522,108 @@ function runWithConcurrency(tasks, concurrency) {
511
522
  })
512
523
  }
513
524
 
525
+ const TOOL_MODE_ORDER = getInstallTargetModes()
526
+ const TOOL_MODES = new Set(TOOL_MODE_ORDER)
527
+ const DAEMON_PROXY_TIMEOUT_MS = 5000
528
+
529
+ // ─── Router daemon proxy helper ────────────────────────────────────────────
530
+ async function proxyToDaemon(path, options = {}) {
531
+ const port = await readDaemonPort()
532
+ if (!port) return null
533
+ try {
534
+ const url = `http://127.0.0.1:${port}${path}`
535
+ const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(DAEMON_PROXY_TIMEOUT_MS) })
536
+ return { ok: resp.ok, status: resp.status, data: await resp.json().catch(() => null) }
537
+ } catch { return null }
538
+ }
539
+
540
+ function readTokenFile() {
541
+ try {
542
+ if (!existsSync(ROUTER_TOKENS_PATH)) return null
543
+ return JSON.parse(readFileSync(ROUTER_TOKENS_PATH, 'utf8'))
544
+ } catch { return null }
545
+ }
546
+
547
+ function normalizeToolMode(mode) {
548
+ return typeof mode === 'string' && TOOL_MODES.has(mode) ? mode : 'opencode'
549
+ }
550
+
551
+ function getPreferredToolMode() {
552
+ return normalizeToolMode(config.settings?.preferredToolMode)
553
+ }
554
+
555
+ function persistPreferredToolMode(mode) {
556
+ const normalized = normalizeToolMode(mode)
557
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
558
+ config.settings.preferredToolMode = normalized
559
+ const saveResult = saveConfig(config)
560
+ return { mode: normalized, saveResult }
561
+ }
562
+
563
+ function getEndpointModel(providerKey, modelId) {
564
+ const result = getResult(providerKey, modelId)
565
+ if (!result) return null
566
+ return {
567
+ providerKey: result.providerKey,
568
+ modelId: result.modelId,
569
+ label: result.label,
570
+ tier: result.tier,
571
+ sweScore: result.sweScore,
572
+ ctx: result.ctx,
573
+ status: result.status,
574
+ }
575
+ }
576
+
577
+ async function readDaemonPort() {
578
+ try {
579
+ const raw = readFileSync(`${process.env.HOME}/.free-coding-models-daemon.port`, 'utf8').trim()
580
+ if (/^\d+$/.test(raw)) return Number(raw)
581
+ } catch {}
582
+ return null
583
+ }
584
+
585
+ async function syncFavoritesToRouter(selected) {
586
+ if (config?.router?.enabled !== true) return
587
+ const selKey = `${selected.providerKey}/${selected.modelId}`
588
+ const favorites = Array.isArray(config.favorites) ? config.favorites : []
589
+ const chain = [selKey, ...favorites.filter((entry) => entry !== selKey)]
590
+ const models = chain.map((entry, index) => {
591
+ const slashIdx = entry.indexOf('/')
592
+ const provider = slashIdx >= 0 ? entry.slice(0, slashIdx) : '?'
593
+ const model = slashIdx >= 0 ? entry.slice(slashIdx + 1) : entry
594
+ return { provider, model, priority: index + 1 }
595
+ })
596
+ try {
597
+ const port = await readDaemonPort()
598
+ if (!port) return
599
+ const baseUrl = `http://127.0.0.1:${port}`
600
+ const setPayload = { name: 'fast-coding', models, created: new Date().toISOString() }
601
+ await fetch(`${baseUrl}/sets/fast-coding`, {
602
+ method: 'PUT',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify(setPayload),
605
+ })
606
+ await fetch(`${baseUrl}/sets/fast-coding/activate`, { method: 'POST' })
607
+ } catch {}
608
+ }
609
+
610
+ async function installEndpointForMode(mode, model) {
611
+ return installProviderEndpoints(config, model.providerKey, mode, {
612
+ scope: 'selected',
613
+ modelIds: [model.modelId],
614
+ })
615
+ }
616
+
617
+ function buildRecommendReason(result, answers) {
618
+ const bits = []
619
+ if (result.tier) bits.push(`${result.tier} tier`)
620
+ if (result.sweScore && result.sweScore !== '—') bits.push(`${result.sweScore} SWE`)
621
+ if (result.ctx) bits.push(`${result.ctx} context`)
622
+ if (result.status === 'up') bits.push('currently up')
623
+ const priority = PRIORITY_TYPES[answers.priority]?.label || 'balanced'
624
+ return `${bits.join(' · ') || 'Strong catalog fit'} for ${priority.toLowerCase()} priority.`
625
+ }
626
+
514
627
  async function handleRequest(req, res) {
515
628
  res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
516
629
  res.setHeader('Access-Control-Allow-Origin', '*')
@@ -628,6 +741,110 @@ async function handleRequest(req, res) {
628
741
  sendJson(res, 200, getConfigPayload())
629
742
  return
630
743
 
744
+ // ── M3: shared tool mode — same preferredToolMode setting as the TUI Z cycle ──
745
+ case '/api/tool-mode': {
746
+ if (req.method === 'GET') {
747
+ sendJson(res, 200, { mode: getPreferredToolMode(), tools: TOOL_MODE_ORDER })
748
+ return
749
+ }
750
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
751
+ const body = await readJsonBody(req)
752
+ if (!TOOL_MODES.has(body?.mode)) { sendJson(res, 422, { error: 'Invalid tool mode' }); return }
753
+ const { mode, saveResult } = persistPreferredToolMode(body.mode)
754
+ if (!saveResult.success) { sendJson(res, 500, { error: saveResult.error || 'Failed to save tool mode' }); return }
755
+ noteUserActivity()
756
+ sendJson(res, 200, { mode })
757
+ return
758
+ }
759
+
760
+ // ── M3: install selected model endpoint into a tool config, no process spawn ──
761
+ case '/api/install-endpoint':
762
+ case '/api/launch': {
763
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
764
+ const body = await readJsonBody(req)
765
+ const mode = normalizeToolMode(body?.toolMode || body?.mode || getPreferredToolMode())
766
+ const model = getEndpointModel(body?.providerKey, body?.modelId)
767
+ if (!model) { sendJson(res, 404, { error: 'Model not found' }); return }
768
+ if (!isModelCompatibleWithTool(model.providerKey, mode)) {
769
+ sendJson(res, 422, { error: 'Model is incompatible with selected tool', code: 'incompatible_model', mode, model })
770
+ return
771
+ }
772
+
773
+ const { saveResult } = persistPreferredToolMode(mode)
774
+ if (!saveResult.success) { sendJson(res, 500, { error: saveResult.error || 'Failed to persist tool mode' }); return }
775
+ noteUserActivity()
776
+ try {
777
+ const installResult = await installEndpointForMode(mode, model)
778
+ void syncFavoritesToRouter(model)
779
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
780
+ event: 'app_action',
781
+ mode,
782
+ properties: {
783
+ source: 'web',
784
+ action_type: 'install_endpoint',
785
+ tool_mode: mode,
786
+ provider: model.providerKey,
787
+ model_id: model.modelId,
788
+ model_label: model.label,
789
+ model_tier: model.tier,
790
+ },
791
+ })
792
+ sendJson(res, 200, { configured: true, mode, model, installResult })
793
+ } catch (err) {
794
+ sendJson(res, 422, {
795
+ error: err?.message || 'Failed to install endpoint',
796
+ code: 'endpoint_install_failed',
797
+ mode,
798
+ model,
799
+ })
800
+ }
801
+ return
802
+ }
803
+
804
+ // ── M3: Smart Recommend — wraps the same core scoring engine as the TUI ──
805
+ case '/api/recommend': {
806
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
807
+ const body = await readJsonBody(req)
808
+ const answers = body?.answers || {}
809
+ const taskType = answers.taskType
810
+ const priority = answers.priority
811
+ const contextBudget = answers.contextBudget
812
+ if (!TASK_TYPES[taskType] || !PRIORITY_TYPES[priority] || !CONTEXT_BUDGETS[contextBudget]) {
813
+ sendJson(res, 422, { error: 'Invalid recommendation answers' })
814
+ return
815
+ }
816
+ const top3 = getTopRecommendations(results, taskType, priority, contextBudget, 3)
817
+ .map(({ result, score }) => ({
818
+ result: serializeModel(result),
819
+ score,
820
+ reason: buildRecommendReason(result, answers),
821
+ }))
822
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
823
+ event: 'app_action',
824
+ mode: getPreferredToolMode(),
825
+ properties: { source: 'web', action_type: 'smart_recommend', taskType, priority, contextBudget },
826
+ })
827
+ sendJson(res, 200, { top3, answers: { taskType, priority, contextBudget } })
828
+ return
829
+ }
830
+
831
+ // ── M3: web telemetry mirror — never blocks UX and never exposes secrets ──
832
+ case '/api/telemetry/event': {
833
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
834
+ const body = await readJsonBody(req)
835
+ const event = typeof body?.event === 'string' && body.event.trim() ? body.event.trim() : 'app_action'
836
+ const properties = body?.properties && typeof body.properties === 'object' && !Array.isArray(body.properties)
837
+ ? body.properties
838
+ : {}
839
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
840
+ event,
841
+ mode: getPreferredToolMode(),
842
+ properties: { ...properties, source: 'web' },
843
+ })
844
+ sendJson(res, 200, { ok: true })
845
+ return
846
+ }
847
+
631
848
  case '/api/events':
632
849
  res.writeHead(200, {
633
850
  'Content-Type': 'text/event-stream',
@@ -900,7 +1117,175 @@ async function handleRequest(req, res) {
900
1117
  return
901
1118
  }
902
1119
 
903
- default:
1120
+ // ── M4: Router dashboard endpoints ────────────────────────────────────
1121
+ case '/api/router/status': {
1122
+ try {
1123
+ const status = await getRouterDaemonStatus()
1124
+ sendJson(res, 200, status)
1125
+ } catch (err) {
1126
+ sendJson(res, 200, { ok: false, running: false, error: err.message })
1127
+ }
1128
+ return
1129
+ }
1130
+
1131
+ case '/api/router/stats': {
1132
+ const proxy = await proxyToDaemon('/stats')
1133
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1134
+ sendJson(res, 200, { ok: false, running: false, error: 'Daemon not reachable' })
1135
+ return
1136
+ }
1137
+
1138
+ case '/api/router/tokens': {
1139
+ // 📖 Try daemon first (live data), fall back to reading the token file
1140
+ const proxy = await proxyToDaemon('/stats/tokens')
1141
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1142
+ const fileData = readTokenFile()
1143
+ if (fileData) { sendJson(res, 200, fileData); return }
1144
+ sendJson(res, 200, { daily: {}, all_time: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, requests: 0 } })
1145
+ return
1146
+ }
1147
+
1148
+ case '/api/router/start': {
1149
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1150
+ try {
1151
+ const result = await startRouterDaemonBackground()
1152
+ noteUserActivity()
1153
+ sendJson(res, 200, result)
1154
+ } catch (err) {
1155
+ sendJson(res, 500, { ok: false, error: err.message })
1156
+ }
1157
+ return
1158
+ }
1159
+
1160
+ case '/api/router/stop': {
1161
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1162
+ try {
1163
+ const result = await stopRouterDaemon()
1164
+ sendJson(res, 200, result)
1165
+ } catch (err) {
1166
+ sendJson(res, 500, { ok: false, error: err.message })
1167
+ }
1168
+ return
1169
+ }
1170
+
1171
+ case '/api/router/sets': {
1172
+ // 📖 Proxy set operations to the daemon
1173
+ if (req.method === 'GET') {
1174
+ const proxy = await proxyToDaemon('/sets')
1175
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1176
+ // 📖 Fallback: read sets from config directly
1177
+ const routerConfig = config.router || {}
1178
+ sendJson(res, 200, { activeSet: routerConfig.activeSet || 'fast-coding', sets: routerConfig.sets || {} })
1179
+ return
1180
+ }
1181
+ if (req.method === 'POST') {
1182
+ const proxy = await proxyToDaemon('/sets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(await readJsonBody(req)) })
1183
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1184
+ if (proxy?.status === 201) { sendJson(res, 201, proxy.data); return }
1185
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
1186
+ return
1187
+ }
1188
+ res.writeHead(405); res.end('Method Not Allowed')
1189
+ return
1190
+ }
1191
+
1192
+ case '/api/router/probe-mode': {
1193
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1194
+ const body = await readJsonBody(req)
1195
+ const proxy = await proxyToDaemon('/daemon/probe-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1196
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1197
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
1198
+ return
1199
+ }
1200
+
1201
+ case '/api/router/quick-setup': {
1202
+ // 📖 Return router connection info for quick clipboard copy
1203
+ const port = await readDaemonPort()
1204
+ const routerConfig = config.router || {}
1205
+ const activeSetName = routerConfig.activeSet || 'fast-coding'
1206
+ const baseUrl = port ? `http://127.0.0.1:${port}/v1` : null
1207
+ sendJson(res, 200, {
1208
+ running: !!port,
1209
+ port: port || null,
1210
+ baseUrl,
1211
+ model: 'fcm',
1212
+ activeSet: activeSetName,
1213
+ apiKey: 'not-needed',
1214
+ })
1215
+ return
1216
+ }
1217
+
1218
+ // ── M4: Installed Models — scan tool configs + soft-delete ────────────
1219
+ case '/api/installed-models': {
1220
+ if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return }
1221
+ const results = scanAllToolConfigs()
1222
+ sendJson(res, 200, { results })
1223
+ return
1224
+ }
1225
+
1226
+ // ── M4: Install Endpoints wizard — full provider install into tool ────
1227
+ case '/api/install-endpoints/providers': {
1228
+ if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return }
1229
+ const providers = getConfiguredInstallableProviders(config)
1230
+ sendJson(res, 200, { providers })
1231
+ return
1232
+ }
1233
+
1234
+ case '/api/install-endpoints/catalog': {
1235
+ const catProvider = url.searchParams.get('provider')
1236
+ if (!catProvider) { sendJson(res, 400, { error: 'Missing ?provider= parameter' }); return }
1237
+ const models = getProviderCatalogModels(catProvider)
1238
+ sendJson(res, 200, { provider: catProvider, models })
1239
+ return
1240
+ }
1241
+
1242
+ case '/api/install-endpoints/wizard': {
1243
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1244
+ const body = await readJsonBody(req)
1245
+ const wizProvider = body?.providerKey
1246
+ const wizTool = body?.toolMode
1247
+ const wizScope = body?.scope || 'all'
1248
+ const wizModelIds = body?.modelIds || []
1249
+ if (!wizProvider || !wizTool) {
1250
+ sendJson(res, 400, { error: 'Missing providerKey or toolMode' }); return
1251
+ }
1252
+ noteUserActivity()
1253
+ try {
1254
+ const installResult = installProviderEndpoints(config, wizProvider, wizTool, {
1255
+ scope: wizScope,
1256
+ modelIds: wizModelIds,
1257
+ })
1258
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
1259
+ event: 'app_action',
1260
+ mode: wizTool,
1261
+ properties: {
1262
+ source: 'web',
1263
+ action_type: 'install_endpoints_wizard',
1264
+ provider: wizProvider,
1265
+ tool_mode: wizTool,
1266
+ scope: wizScope,
1267
+ model_count: installResult.modelCount || 0,
1268
+ },
1269
+ })
1270
+ sendJson(res, 200, { success: true, ...installResult })
1271
+ } catch (err) {
1272
+ sendJson(res, 422, { error: err.message, code: 'install_failed' })
1273
+ }
1274
+ return
1275
+ }
1276
+
1277
+ // 📖 M4: soft-delete an installed model (pattern match — must be before default:)
1278
+ // 📖 Path: /api/installed-models/:tool/:model/disable
1279
+ default: {
1280
+ const disableMatch = url.pathname.match(/^\/api\/installed-models\/([^/]+)\/([^/]+)\/disable$/)
1281
+ if (disableMatch && req.method === 'POST') {
1282
+ const toolMode = decodeURIComponent(disableMatch[1])
1283
+ const modelId = decodeURIComponent(disableMatch[2])
1284
+ const result = softDeleteModel(toolMode, modelId)
1285
+ sendJson(res, result.success ? 200 : 422, result)
1286
+ return
1287
+ }
1288
+ }
904
1289
  // 📖 Serve Vite's /assets/* bundle, and our static favicon set that
905
1290
  // 📖 Vite copies verbatim from web/public/ into web/dist/. The legacy
906
1291
  // 📖 /favicon.ico lives at web/public/favicon.ico (root of public/).