free-coding-models 0.5.5 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/bin/free-coding-models.js +6 -0
- package/changelog/v0.5.5.md +1 -0
- package/changelog/v0.5.6.md +24 -0
- package/changelog/v0.5.7.md +20 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/endpoint-installer.js +3 -3
- package/src/core/router-daemon.js +11 -0
- package/src/core/tool-launchers.js +2 -2
- package/web/dist/assets/index-DKmmiDip.css +1 -0
- package/web/dist/assets/index-DzVt42Nf.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +532 -2
- package/web/src/App.jsx +235 -73
- package/web/src/components/analytics/AnalyticsView.jsx +4 -0
- package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
- package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
- package/web/src/components/atoms/TierBadge.module.css +3 -3
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/dashboard/DetailPanel.jsx +29 -4
- package/web/src/components/dashboard/DetailPanel.module.css +26 -0
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +17 -0
- package/web/src/components/dashboard/ModelTable.jsx +17 -5
- package/web/src/components/dashboard/ModelTable.module.css +14 -1
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/install/InstallEndpointsView.jsx +278 -0
- package/web/src/components/install/InstallEndpointsView.module.css +351 -0
- package/web/src/components/installed/InstalledModelsView.jsx +89 -0
- package/web/src/components/installed/InstalledModelsView.module.css +200 -0
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
- package/web/src/components/launch/LaunchButton.jsx +30 -0
- package/web/src/components/launch/LaunchButton.module.css +35 -0
- package/web/src/components/launch/LaunchModal.module.css +125 -0
- package/web/src/components/layout/Header.jsx +78 -14
- package/web/src/components/layout/Header.module.css +62 -2
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/recommend/RecommendView.jsx +121 -0
- package/web/src/components/recommend/RecommendView.module.css +55 -0
- package/web/src/components/router/RouterView.jsx +286 -0
- package/web/src/components/router/RouterView.module.css +357 -0
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +28 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useInstalledModels.js +42 -0
- package/web/src/hooks/useRecommend.js +67 -0
- package/web/src/hooks/useRouterDashboard.js +133 -0
- package/web/src/hooks/useTokenUsage.js +103 -0
- package/web/src/hooks/useToolMode.js +77 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +123 -62
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-uD3faN3G.js +0 -39
- package/web/dist/assets/index-uTifVKX1.css +0 -1
package/web/dist/index.html
CHANGED
|
@@ -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-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -26,17 +26,37 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
26
26
|
import { join, dirname, extname } from 'node:path'
|
|
27
27
|
import { fileURLToPath } from 'node:url'
|
|
28
28
|
import { exec } from 'node:child_process'
|
|
29
|
+
import { createRequire } from 'node:module'
|
|
29
30
|
import { Server } from 'socket.io'
|
|
30
31
|
|
|
32
|
+
// 📖 Resolve the local package version for /api/version — same trick the TUI uses.
|
|
33
|
+
const require = createRequire(import.meta.url)
|
|
34
|
+
const { version: LOCAL_VERSION } = require('../package.json')
|
|
35
|
+
|
|
31
36
|
import { sources, MODELS } from '../sources.js'
|
|
32
37
|
import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/core/config.js'
|
|
33
38
|
import { ensureFavoritesConfig } from '../src/core/favorites.js'
|
|
34
39
|
import { ping } from '../src/core/ping.js'
|
|
40
|
+
import { loadChangelog } from '../src/core/changelog-loader.js'
|
|
41
|
+
import { checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate } from '../src/core/updater.js'
|
|
42
|
+
import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../src/core/shell-env.js'
|
|
43
|
+
import { cleanupLegacyProxyArtifacts } from '../src/core/legacy-proxy-cleanup.js'
|
|
35
44
|
import {
|
|
36
45
|
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
37
46
|
getStabilityScore,
|
|
38
47
|
} from '../src/core/utils.js'
|
|
39
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'
|
|
40
60
|
import {
|
|
41
61
|
PING_MODE_INTERVALS,
|
|
42
62
|
PING_MODE_CYCLE,
|
|
@@ -502,6 +522,108 @@ function runWithConcurrency(tasks, concurrency) {
|
|
|
502
522
|
})
|
|
503
523
|
}
|
|
504
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
|
+
|
|
505
627
|
async function handleRequest(req, res) {
|
|
506
628
|
res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
|
|
507
629
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
@@ -516,7 +638,34 @@ async function handleRequest(req, res) {
|
|
|
516
638
|
|
|
517
639
|
const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
|
|
518
640
|
|
|
519
|
-
|
|
641
|
+
// 📖 M2: /api/key/:provider/test — matched here (above the switch) so the
|
|
642
|
+
// 📖 M2 path doesn't conflict with the single-segment key reveal below.
|
|
643
|
+
// 📖 Mirrors the TUI Settings `T` key behavior: parallel auth probe + chat
|
|
644
|
+
// 📖 ping through the existing ping() helper.
|
|
645
|
+
const keyTestMatch = url.pathname.match(/^\/api\/key\/([^/]+)\/test$/)
|
|
646
|
+
if (keyTestMatch) {
|
|
647
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
648
|
+
const providerKey = decodeURIComponent(keyTestMatch[1])
|
|
649
|
+
if (!sources[providerKey]) { sendJson(res, 404, { error: 'Unknown provider' }); return }
|
|
650
|
+
const apiKey = getApiKey(config, providerKey)
|
|
651
|
+
if (!apiKey) { sendJson(res, 200, { outcome: 'missing_key', detail: `${providerKey} has no saved API key.` }); return }
|
|
652
|
+
try {
|
|
653
|
+
const result = await ping(apiKey, '', providerKey, sources[providerKey].url, { silent: true })
|
|
654
|
+
if (result?.code === 200 || result?.code === '200') { sendJson(res, 200, { outcome: 'ok', code: 200 }); return }
|
|
655
|
+
if (result?.code === 401 || result?.code === '401' || result?.code === 403 || result?.code === '403') {
|
|
656
|
+
sendJson(res, 200, { outcome: 'auth_error', code: result.code }); return
|
|
657
|
+
}
|
|
658
|
+
sendJson(res, 200, { outcome: 'fail', code: result?.code ?? 'ERR', detail: 'Probe did not return a 2xx' })
|
|
659
|
+
} catch (err) {
|
|
660
|
+
sendJson(res, 200, { outcome: 'fail', detail: err.message || 'Probe failed' })
|
|
661
|
+
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 📖 Single-provider key reveal endpoint. The M2 /api/key/:provider/test
|
|
666
|
+
// 📖 route is matched above (above the switch) and uses a stricter regex
|
|
667
|
+
// 📖 (one segment, not two) so the two routes coexist cleanly.
|
|
668
|
+
const keyMatch = url.pathname.match(/^\/api\/key\/([^/]+)$/)
|
|
520
669
|
if (keyMatch) {
|
|
521
670
|
const providerKey = decodeURIComponent(keyMatch[1])
|
|
522
671
|
if (!sources[providerKey]) {
|
|
@@ -592,6 +741,110 @@ async function handleRequest(req, res) {
|
|
|
592
741
|
sendJson(res, 200, getConfigPayload())
|
|
593
742
|
return
|
|
594
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
|
+
|
|
595
848
|
case '/api/events':
|
|
596
849
|
res.writeHead(200, {
|
|
597
850
|
'Content-Type': 'text/event-stream',
|
|
@@ -755,7 +1008,284 @@ async function handleRequest(req, res) {
|
|
|
755
1008
|
return
|
|
756
1009
|
}
|
|
757
1010
|
|
|
758
|
-
|
|
1011
|
+
// ── M2: /api/version — local vs latest + lastRelease date ─────────────
|
|
1012
|
+
case '/api/version': {
|
|
1013
|
+
try {
|
|
1014
|
+
const { latestVersion, error } = await checkForUpdateDetailed()
|
|
1015
|
+
const lastReleaseDate = await fetchLastReleaseDate()
|
|
1016
|
+
sendJson(res, 200, {
|
|
1017
|
+
local: LOCAL_VERSION,
|
|
1018
|
+
latest: latestVersion,
|
|
1019
|
+
lastReleaseDate,
|
|
1020
|
+
error: error || null,
|
|
1021
|
+
})
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
sendJson(res, 200, { local: LOCAL_VERSION, latest: null, lastReleaseDate: null, error: err.message || 'update check failed' })
|
|
1024
|
+
}
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ── M2: /api/update/check — force a fresh registry check ──────────────
|
|
1029
|
+
case '/api/update/check': {
|
|
1030
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1031
|
+
const { latestVersion, error } = await checkForUpdateDetailed()
|
|
1032
|
+
sendJson(res, 200, { latest: latestVersion, error: error || null })
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ── M2: /api/update/run — spawn the package manager upgrade ──────────
|
|
1037
|
+
case '/api/update/run': {
|
|
1038
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1039
|
+
const body = await readJsonBody(req)
|
|
1040
|
+
const target = typeof body?.version === 'string' && body.version ? body.version : null
|
|
1041
|
+
// 📖 Mirrors the TUI's `Shift+U` behavior: install + tell the user to
|
|
1042
|
+
// 📖 restart the server. We don't kill the in-process server from
|
|
1043
|
+
// 📖 here because that would interrupt every connected client.
|
|
1044
|
+
if (target) {
|
|
1045
|
+
runUpdate(target)
|
|
1046
|
+
sendJson(res, 200, { started: true, version: target, message: 'Update initiated — restart the dashboard to apply.' })
|
|
1047
|
+
} else {
|
|
1048
|
+
const { latestVersion } = await checkForUpdateDetailed()
|
|
1049
|
+
if (!latestVersion) { sendJson(res, 404, { error: 'No update available' }); return }
|
|
1050
|
+
runUpdate(latestVersion)
|
|
1051
|
+
sendJson(res, 200, { started: true, version: latestVersion, message: 'Update initiated — restart the dashboard to apply.' })
|
|
1052
|
+
}
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ── M2: /api/changelog — parsed changelog directory ─────────────────
|
|
1057
|
+
case '/api/changelog': {
|
|
1058
|
+
sendJson(res, 200, loadChangelog())
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ── M2: /api/settings/feature — single-feature toggle endpoint ───────
|
|
1063
|
+
case '/api/settings/feature': {
|
|
1064
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1065
|
+
const body = await readJsonBody(req)
|
|
1066
|
+
if (!body || typeof body !== 'object' || !body.feature) {
|
|
1067
|
+
sendJson(res, 400, { error: 'Missing "feature" key' }); return
|
|
1068
|
+
}
|
|
1069
|
+
if (!config.settings || typeof config.settings !== 'object') config.settings = {}
|
|
1070
|
+
const before = config.settings[body.feature]
|
|
1071
|
+
// 📖 Boolean features are toggled unless the caller passes an explicit value.
|
|
1072
|
+
if (body.value !== undefined) {
|
|
1073
|
+
// 📖 Explicit value (string / boolean / number) always wins. Used for
|
|
1074
|
+
// 📖 things like theme='auto'|'dark'|'light' where a string payload
|
|
1075
|
+
// 📖 is the right shape.
|
|
1076
|
+
config.settings[body.feature] = body.value
|
|
1077
|
+
} else if (typeof before === 'boolean') {
|
|
1078
|
+
// 📖 No explicit value → toggle for boolean features
|
|
1079
|
+
// 📖 (e.g. favoritesPinnedAndSticky).
|
|
1080
|
+
config.settings[body.feature] = !before
|
|
1081
|
+
} else {
|
|
1082
|
+
config.settings[body.feature] = true
|
|
1083
|
+
}
|
|
1084
|
+
const result = saveConfig(config)
|
|
1085
|
+
if (!result.success) { sendJson(res, 500, { error: result.error || 'Save failed' }); return }
|
|
1086
|
+
sendJson(res, 200, { success: true, feature: body.feature, value: config.settings[body.feature] })
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ── M2: /api/shell-env/toggle — flip shell env export for the user ──
|
|
1091
|
+
case '/api/shell-env/toggle': {
|
|
1092
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1093
|
+
const body = await readJsonBody(req)
|
|
1094
|
+
const enable = typeof body?.enabled === 'boolean' ? body.enabled : undefined
|
|
1095
|
+
if (!config.settings || typeof config.settings !== 'object') config.settings = {}
|
|
1096
|
+
if (enable === undefined) {
|
|
1097
|
+
config.settings.shellEnvEnabled = !config.settings.shellEnvEnabled
|
|
1098
|
+
} else {
|
|
1099
|
+
config.settings.shellEnvEnabled = enable
|
|
1100
|
+
}
|
|
1101
|
+
if (config.settings.shellEnvEnabled) {
|
|
1102
|
+
syncShellEnv(config)
|
|
1103
|
+
ensureShellRcSource()
|
|
1104
|
+
} else {
|
|
1105
|
+
removeShellEnv()
|
|
1106
|
+
}
|
|
1107
|
+
saveConfig(config)
|
|
1108
|
+
sendJson(res, 200, { success: true, enabled: config.settings.shellEnvEnabled })
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ── M2: /api/legacy-cleanup — run the discontinued-proxy cleanup ─────
|
|
1113
|
+
case '/api/legacy-cleanup': {
|
|
1114
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1115
|
+
const summary = cleanupLegacyProxyArtifacts()
|
|
1116
|
+
sendJson(res, 200, summary)
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
|
|
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
|
+
}
|
|
759
1289
|
// 📖 Serve Vite's /assets/* bundle, and our static favicon set that
|
|
760
1290
|
// 📖 Vite copies verbatim from web/public/ into web/dist/. The legacy
|
|
761
1291
|
// 📖 /favicon.ico lives at web/public/favicon.ico (root of public/).
|