free-coding-models 0.5.14 → 0.5.16
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/changelog/v0.5.12.md +86 -2
- package/changelog/v0.5.13.md +19 -53
- package/changelog/v0.5.15.md +17 -0
- package/changelog/v0.5.16.md +15 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-avN2GM3H.css +1 -0
- package/web/dist/assets/index-vsysCImz.js +41 -0
- package/web/dist/index.html +6 -2
- package/web/index.html +4 -0
- package/web/server.js +249 -15
- package/web/src/App.jsx +46 -15
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +464 -0
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +347 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ModelTable.jsx +49 -29
- package/web/src/components/dashboard/ModelTable.module.css +12 -0
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Bzqz7KbT.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
package/web/dist/index.html
CHANGED
|
@@ -48,10 +48,14 @@
|
|
|
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-vsysCImz.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-avN2GM3H.css">
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="root"></div>
|
|
56
|
+
<!-- 📖 PostHog analytics — privacy-first, self-hosted option available -->
|
|
57
|
+
<script>
|
|
58
|
+
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
|
|
59
|
+
</script>
|
|
56
60
|
</body>
|
|
57
61
|
</html>
|
package/web/index.html
CHANGED
|
@@ -52,5 +52,9 @@
|
|
|
52
52
|
<body>
|
|
53
53
|
<div id="root"></div>
|
|
54
54
|
<script type="module" src="/src/main.jsx"></script>
|
|
55
|
+
<!-- 📖 PostHog analytics — privacy-first, self-hosted option available -->
|
|
56
|
+
<script>
|
|
57
|
+
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
|
|
58
|
+
</script>
|
|
55
59
|
</body>
|
|
56
60
|
</html>
|
package/web/server.js
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
46
46
|
getStabilityScore,
|
|
47
47
|
} from '../src/core/utils.js'
|
|
48
|
-
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
|
|
48
|
+
import { benchmarkModel, BENCHMARK_TIMEOUT_MS, BENCHMARK_PROMPT } from '../src/core/benchmark.js'
|
|
49
49
|
import { getInstallTargetModes, installProviderEndpoints, getConfiguredInstallableProviders, getProviderCatalogModels } from '../src/core/endpoint-installer.js'
|
|
50
50
|
import { isModelCompatibleWithTool } from '../src/core/tool-metadata.js'
|
|
51
51
|
import { sendUsageTelemetry } from '../src/core/telemetry.js'
|
|
@@ -1030,6 +1030,171 @@ async function handleRequest(req, res) {
|
|
|
1030
1030
|
return
|
|
1031
1031
|
}
|
|
1032
1032
|
|
|
1033
|
+
case '/api/benchmark-stream': {
|
|
1034
|
+
if (req.method !== 'POST') {
|
|
1035
|
+
res.writeHead(405)
|
|
1036
|
+
res.end('Method Not Allowed')
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
let body
|
|
1041
|
+
try {
|
|
1042
|
+
body = await readJsonBody(req)
|
|
1043
|
+
} catch {
|
|
1044
|
+
res.writeHead(400)
|
|
1045
|
+
res.end('Invalid JSON')
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const result = getResult(body.providerKey, body.modelId)
|
|
1050
|
+
if (!result) {
|
|
1051
|
+
sendJson(res, 404, { error: 'Model not found' })
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const source = sources[result.providerKey]
|
|
1056
|
+
const apiKey = getApiKey(config, result.providerKey)
|
|
1057
|
+
if (!apiKey) {
|
|
1058
|
+
sendJson(res, 401, { error: 'No API key configured' })
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const key = getResultKey(result)
|
|
1063
|
+
benchmarkRunning.add(key)
|
|
1064
|
+
broadcastUpdate({ immediate: true })
|
|
1065
|
+
|
|
1066
|
+
// 📖 Set SSE headers for streaming response to the browser
|
|
1067
|
+
res.writeHead(200, {
|
|
1068
|
+
'Content-Type': 'text/event-stream',
|
|
1069
|
+
'Cache-Control': 'no-cache',
|
|
1070
|
+
'Connection': 'keep-alive',
|
|
1071
|
+
'X-Accel-Buffering': 'no',
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
// Build the upstream URL — append /v1/chat/completions if needed
|
|
1076
|
+
let upstreamUrl = source?.url || result.url
|
|
1077
|
+
if (!upstreamUrl.includes('/chat/completions')) {
|
|
1078
|
+
upstreamUrl = upstreamUrl.replace(/\/+$/, '') + '/v1/chat/completions'
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// 📖 ZAI provider: strip the "zai/" prefix from modelId for the API
|
|
1082
|
+
let apiModelId = result.modelId
|
|
1083
|
+
if (result.providerKey === 'zai' && apiModelId.startsWith('zai/')) {
|
|
1084
|
+
apiModelId = apiModelId.slice(4)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const upstreamHeaders = {
|
|
1088
|
+
'Content-Type': 'application/json',
|
|
1089
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// 📖 OpenRouter requires HTTP-Referer and X-Title headers
|
|
1093
|
+
if (result.providerKey === 'openrouter') {
|
|
1094
|
+
upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
1095
|
+
upstreamHeaders['X-Title'] = 'free-coding-models'
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const reqBody = {
|
|
1099
|
+
model: apiModelId,
|
|
1100
|
+
messages: [{ role: 'user', content: BENCHMARK_PROMPT }],
|
|
1101
|
+
max_tokens: 140,
|
|
1102
|
+
temperature: 0,
|
|
1103
|
+
stream: true,
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const resp = await fetch(upstreamUrl, {
|
|
1107
|
+
method: 'POST',
|
|
1108
|
+
headers: upstreamHeaders,
|
|
1109
|
+
body: JSON.stringify(reqBody),
|
|
1110
|
+
signal: AbortSignal.timeout(20000),
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
if (!resp.ok) {
|
|
1114
|
+
let message = `HTTP ${resp.status}`
|
|
1115
|
+
try {
|
|
1116
|
+
const errJson = await resp.json()
|
|
1117
|
+
message = errJson?.error?.message || errJson?.error || errJson?.message || message
|
|
1118
|
+
} catch { /* keep default */ }
|
|
1119
|
+
res.write('event: error\ndata: ' + JSON.stringify({ error: message }) + '\n\n')
|
|
1120
|
+
res.end()
|
|
1121
|
+
benchmarkRunning.delete(key)
|
|
1122
|
+
broadcastUpdate({ immediate: true })
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// 📖 Parse SSE stream from the provider — extract tokens and measure TPS
|
|
1127
|
+
const reader = resp.body.getReader()
|
|
1128
|
+
const decoder = new TextDecoder()
|
|
1129
|
+
const t0 = performance.now()
|
|
1130
|
+
let tokenCount = 0
|
|
1131
|
+
let fullText = ''
|
|
1132
|
+
let buffer = ''
|
|
1133
|
+
|
|
1134
|
+
while (true) {
|
|
1135
|
+
const { done, value } = await reader.read()
|
|
1136
|
+
if (done) break
|
|
1137
|
+
|
|
1138
|
+
buffer += decoder.decode(value, { stream: true })
|
|
1139
|
+
const lines = buffer.split('\n')
|
|
1140
|
+
buffer = lines.pop() || '' // keep incomplete line in buffer
|
|
1141
|
+
|
|
1142
|
+
for (const line of lines) {
|
|
1143
|
+
const trimmed = line.trim()
|
|
1144
|
+
if (!trimmed.startsWith('data: ')) continue
|
|
1145
|
+
const payload = trimmed.slice(6)
|
|
1146
|
+
if (payload === '[DONE]') continue
|
|
1147
|
+
|
|
1148
|
+
let parsed
|
|
1149
|
+
try { parsed = JSON.parse(payload) } catch { continue }
|
|
1150
|
+
|
|
1151
|
+
const delta = parsed?.choices?.[0]?.delta?.content
|
|
1152
|
+
if (!delta) continue
|
|
1153
|
+
|
|
1154
|
+
fullText += delta
|
|
1155
|
+
tokenCount++
|
|
1156
|
+
res.write('event: token\ndata: ' + JSON.stringify({
|
|
1157
|
+
token: delta,
|
|
1158
|
+
totalMs: Math.round(performance.now() - t0),
|
|
1159
|
+
tokens: tokenCount,
|
|
1160
|
+
tps: Math.round(tokenCount / ((performance.now() - t0) / 1000) * 10) / 10,
|
|
1161
|
+
text: fullText,
|
|
1162
|
+
}) + '\n\n')
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const totalMs = Math.round(performance.now() - t0)
|
|
1167
|
+
const tps = tokenCount / (totalMs / 1000)
|
|
1168
|
+
|
|
1169
|
+
res.write('event: done\ndata: ' + JSON.stringify({
|
|
1170
|
+
totalMs,
|
|
1171
|
+
outputTokens: tokenCount,
|
|
1172
|
+
tokensPerSecond: Math.round(tps * 10) / 10,
|
|
1173
|
+
answerPreview: fullText.slice(0, 100),
|
|
1174
|
+
}) + '\n\n')
|
|
1175
|
+
res.end()
|
|
1176
|
+
|
|
1177
|
+
// 📖 Update shared benchmark state so the dashboard reflects the result
|
|
1178
|
+
const benchmarkResult = {
|
|
1179
|
+
ok: true,
|
|
1180
|
+
totalMs,
|
|
1181
|
+
outputTokens: tokenCount,
|
|
1182
|
+
tokensPerSecond: tps,
|
|
1183
|
+
answerPreview: fullText.slice(0, 60),
|
|
1184
|
+
}
|
|
1185
|
+
benchmarkResults.set(key, benchmarkResult)
|
|
1186
|
+
benchmarkRunning.delete(key)
|
|
1187
|
+
updateHealthFromBenchmark(result, benchmarkResult)
|
|
1188
|
+
broadcastUpdate({ immediate: true })
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
res.write('event: error\ndata: ' + JSON.stringify({ error: err?.message || 'Stream failed' }) + '\n\n')
|
|
1191
|
+
res.end()
|
|
1192
|
+
benchmarkRunning.delete(key)
|
|
1193
|
+
broadcastUpdate({ immediate: true })
|
|
1194
|
+
}
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1033
1198
|
case '/api/global-benchmark': {
|
|
1034
1199
|
if (req.method === 'GET') {
|
|
1035
1200
|
sendJson(res, 200, {
|
|
@@ -1360,32 +1525,101 @@ async function handleRequest(req, res) {
|
|
|
1360
1525
|
}
|
|
1361
1526
|
|
|
1362
1527
|
case '/api/playground/chat': {
|
|
1363
|
-
// 📖 Playground proxy:
|
|
1364
|
-
// 📖 local router daemon
|
|
1365
|
-
// 📖
|
|
1366
|
-
// 📖
|
|
1367
|
-
// 📖 an external domain (no CORS, no exposed provider keys).
|
|
1528
|
+
// 📖 Playground proxy: routes chat-completions requests either to
|
|
1529
|
+
// 📖 the local router daemon (for "fcm" auto-router) or directly
|
|
1530
|
+
// 📖 to the provider (for specific models). This way the playground
|
|
1531
|
+
// 📖 works immediately with any "up" model, even without the daemon.
|
|
1368
1532
|
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1533
|
+
const body = await readJsonBody(req)
|
|
1534
|
+
const wantsStream = body?.stream === true
|
|
1535
|
+
const requestedModel = body?.model || 'fcm'
|
|
1536
|
+
const isFcm = requestedModel === 'fcm'
|
|
1537
|
+
|
|
1538
|
+
// 📖 Resolve direct provider routing for non-fcm models.
|
|
1539
|
+
// 📖 Format: "providerKey/modelId" → look up source URL + API key.
|
|
1540
|
+
let directUrl = null
|
|
1541
|
+
let directApiKey = null
|
|
1542
|
+
let directModelId = null
|
|
1543
|
+
if (!isFcm) {
|
|
1544
|
+
const slashIdx = requestedModel.indexOf('/')
|
|
1545
|
+
if (slashIdx > 0) {
|
|
1546
|
+
const providerKey = requestedModel.slice(0, slashIdx)
|
|
1547
|
+
const modelId = requestedModel.slice(slashIdx + 1)
|
|
1548
|
+
const source = sources[providerKey]
|
|
1549
|
+
if (source?.url) {
|
|
1550
|
+
directApiKey = getApiKey(config, providerKey)
|
|
1551
|
+
// 📖 Always resolve the URL so we can differentiate "no key"
|
|
1552
|
+
// 📖 from "unknown model" in the error handler below.
|
|
1553
|
+
let baseUrl = source.url
|
|
1554
|
+
if (!baseUrl.includes('/chat/completions')) {
|
|
1555
|
+
baseUrl = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions'
|
|
1556
|
+
}
|
|
1557
|
+
directUrl = baseUrl
|
|
1558
|
+
directModelId = modelId
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// 📖 Decide routing: direct to provider (non-fcm, has key) or via daemon.
|
|
1564
|
+
const useDirectRoute = !isFcm && directUrl && directApiKey
|
|
1565
|
+
|
|
1566
|
+
// 📖 If user picked a specific model but has no API key for it,
|
|
1567
|
+
// 📖 tell them instead of falling through to the daemon 503.
|
|
1568
|
+
if (!isFcm && directUrl && !directApiKey) {
|
|
1569
|
+
const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
|
|
1570
|
+
sendJson(res, 401, { ok: false, error: `No API key configured for ${sources[providerKey]?.name || providerKey}. Add one in Settings to chat directly with this model.` })
|
|
1571
|
+
return
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// 📖 If user picked a specific model but we couldn't resolve it,
|
|
1575
|
+
// 📖 tell them instead of falling through to the daemon 503.
|
|
1576
|
+
if (!isFcm && !directUrl) {
|
|
1577
|
+
sendJson(res, 404, { ok: false, error: `Could not resolve provider for model "${requestedModel}". Try selecting a different model or use the auto-router (fcm).` })
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
const upstreamUrl = useDirectRoute
|
|
1581
|
+
? directUrl
|
|
1582
|
+
: `http://127.0.0.1:${await readDaemonPort()}/v1/chat/completions`
|
|
1583
|
+
|
|
1584
|
+
if (!useDirectRoute && !await readDaemonPort()) {
|
|
1371
1585
|
sendJson(res, 503, { ok: false, error: 'Router daemon is not running. Start it from the Router card or with `free-coding-models --daemon-bg`.' })
|
|
1372
1586
|
return
|
|
1373
1587
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1588
|
+
|
|
1589
|
+
// 📖 Build the upstream payload. For direct routing, extract the
|
|
1590
|
+
// 📖 actual model ID from the providerKey/modelId format.
|
|
1591
|
+
const upstreamPayload = {
|
|
1592
|
+
...body,
|
|
1593
|
+
stream: wantsStream,
|
|
1594
|
+
model: useDirectRoute ? directModelId : requestedModel,
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// 📖 Build headers: direct routing needs auth + any provider-specific
|
|
1598
|
+
// 📖 headers. Daemon routing is just JSON content-type.
|
|
1599
|
+
const upstreamHeaders = { 'Content-Type': 'application/json' }
|
|
1600
|
+
if (useDirectRoute) {
|
|
1601
|
+
// 📖 Most providers use standard Bearer auth. A few need extra
|
|
1602
|
+
// 📖 headers (OpenRouter wants Referer + X-Title for ex).
|
|
1603
|
+
upstreamHeaders['Authorization'] = `Bearer ${directApiKey}`
|
|
1604
|
+
const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
|
|
1605
|
+
if (providerKey === 'openrouter') {
|
|
1606
|
+
upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
1607
|
+
upstreamHeaders['X-Title'] = 'free-coding-models'
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1377
1611
|
const controller = new AbortController()
|
|
1378
1612
|
const timeout = setTimeout(() => controller.abort(), 120000)
|
|
1379
1613
|
req.on('close', () => controller.abort())
|
|
1380
1614
|
try {
|
|
1381
1615
|
const upstreamResp = await fetch(upstreamUrl, {
|
|
1382
1616
|
method: 'POST',
|
|
1383
|
-
headers:
|
|
1384
|
-
body: JSON.stringify(
|
|
1617
|
+
headers: upstreamHeaders,
|
|
1618
|
+
body: JSON.stringify(upstreamPayload),
|
|
1385
1619
|
signal: controller.signal,
|
|
1386
1620
|
})
|
|
1387
1621
|
if (wantsStream) {
|
|
1388
|
-
// 📖 Pipe SSE events straight from the
|
|
1622
|
+
// 📖 Pipe SSE events straight from the upstream to the browser.
|
|
1389
1623
|
res.writeHead(upstreamResp.status, {
|
|
1390
1624
|
'Content-Type': 'text/event-stream',
|
|
1391
1625
|
'Cache-Control': 'no-cache',
|
|
@@ -1407,7 +1641,7 @@ async function handleRequest(req, res) {
|
|
|
1407
1641
|
return
|
|
1408
1642
|
}
|
|
1409
1643
|
const json = await upstreamResp.json().catch(() => null)
|
|
1410
|
-
sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from
|
|
1644
|
+
sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from upstream' })
|
|
1411
1645
|
return
|
|
1412
1646
|
} catch (err) {
|
|
1413
1647
|
sendJson(res, 502, { ok: false, error: `Playground proxy failed: ${err.message || String(err)}` })
|
package/web/src/App.jsx
CHANGED
|
@@ -77,6 +77,31 @@ export default function App() {
|
|
|
77
77
|
const [toasts, setToasts] = useState([])
|
|
78
78
|
const lastActivityRef = useRef(Date.now())
|
|
79
79
|
|
|
80
|
+
// 📖 PostHog: track app_web_start on mount
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
try {
|
|
83
|
+
if (typeof window !== 'undefined' && window.posthog?.capture) {
|
|
84
|
+
window.posthog.capture('app_web_start', {
|
|
85
|
+
version: __APP_VERSION__ || 'unknown',
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
// 📖 PostHog: track app_router_start when router opens
|
|
93
|
+
const handleRouterOpen = useCallback(() => {
|
|
94
|
+
setRouterOpen(true)
|
|
95
|
+
try {
|
|
96
|
+
if (typeof window !== 'undefined' && window.posthog?.capture) {
|
|
97
|
+
window.posthog.capture('app_router_start', {
|
|
98
|
+
version: __APP_VERSION__ || 'unknown',
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
80
105
|
// ── Toast helpers ────────────────────────────────────────────────────────
|
|
81
106
|
const addToast = useCallback((message, type = 'info') => {
|
|
82
107
|
const id = ++toastIdCounter
|
|
@@ -171,11 +196,25 @@ export default function App() {
|
|
|
171
196
|
} = useUpdateChecker({ onToast: addToast })
|
|
172
197
|
|
|
173
198
|
// 📖 Build the provider list for the FilterBar dropdown.
|
|
199
|
+
// 📖 Build the provider list for the FilterBar dropdown with aggregated health.
|
|
200
|
+
// 📖 `hasKey` = at least one model has an API key configured.
|
|
201
|
+
// 📖 `anyUp` = at least one model with status 'up' (key works).
|
|
202
|
+
// 📖 These drive the colored indicator dot in the custom provider dropdown.
|
|
174
203
|
const providers = useMemo(() => {
|
|
175
204
|
const map = {}
|
|
176
205
|
models.forEach((m) => {
|
|
177
|
-
if (!map[m.providerKey])
|
|
206
|
+
if (!map[m.providerKey]) {
|
|
207
|
+
map[m.providerKey] = {
|
|
208
|
+
key: m.providerKey,
|
|
209
|
+
name: m.origin,
|
|
210
|
+
count: 0,
|
|
211
|
+
hasKey: false,
|
|
212
|
+
anyUp: false,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
178
215
|
map[m.providerKey].count++
|
|
216
|
+
if (m.hasApiKey) map[m.providerKey].hasKey = true
|
|
217
|
+
if (m.status === 'up') map[m.providerKey].anyUp = true
|
|
179
218
|
})
|
|
180
219
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
|
|
181
220
|
}, [models])
|
|
@@ -234,7 +273,7 @@ export default function App() {
|
|
|
234
273
|
if (viewId === 'help') { setHelpOpen(true); return }
|
|
235
274
|
if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
|
|
236
275
|
if (viewId === 'recommend') { setRecommendOpen(true); return }
|
|
237
|
-
if (viewId === 'router') {
|
|
276
|
+
if (viewId === 'router') { handleRouterOpen(); return }
|
|
238
277
|
if (viewId === 'playground') { setPlaygroundOpen(true); return }
|
|
239
278
|
if (viewId === 'install-endpoints') { setInstallEndpointsOpen(true); return }
|
|
240
279
|
if (viewId === 'installed-models') { setInstalledModelsOpen(true); return }
|
|
@@ -365,6 +404,10 @@ export default function App() {
|
|
|
365
404
|
sortDirection={sortDirection}
|
|
366
405
|
onSort={toggleSort}
|
|
367
406
|
toolMode={toolMode}
|
|
407
|
+
onToast={addToast}
|
|
408
|
+
onSetToolMode={setToolMode}
|
|
409
|
+
onCycleToolMode={cycleToolMode}
|
|
410
|
+
onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
|
|
368
411
|
/>
|
|
369
412
|
</main>
|
|
370
413
|
)}
|
|
@@ -389,19 +432,7 @@ export default function App() {
|
|
|
389
432
|
</div>
|
|
390
433
|
</div>
|
|
391
434
|
|
|
392
|
-
|
|
393
|
-
model={selectedModel}
|
|
394
|
-
onClose={handleCloseDetail}
|
|
395
|
-
favorites={favorites}
|
|
396
|
-
onBenchmark={handleBenchmarkRow}
|
|
397
|
-
onLaunch={handleInstallEndpoint}
|
|
398
|
-
toolMode={toolMode}
|
|
399
|
-
onSetToolMode={setToolMode}
|
|
400
|
-
onCycleToolMode={cycleToolMode}
|
|
401
|
-
models={models}
|
|
402
|
-
onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
|
|
403
|
-
onToast={addToast}
|
|
404
|
-
/>
|
|
435
|
+
{/* 📖 DetailPanel side panel replaced by expand row in ModelTable */}
|
|
405
436
|
|
|
406
437
|
{exportOpen && (
|
|
407
438
|
<ExportModal
|
|
@@ -87,7 +87,7 @@ export default function DetailPanel({
|
|
|
87
87
|
const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
|
|
88
88
|
|
|
89
89
|
return (
|
|
90
|
-
<div className={styles.panel}>
|
|
90
|
+
<div className={`${styles.panel} ${styles.panelOpen}`}>
|
|
91
91
|
<div className={styles.header}>
|
|
92
92
|
<h3 className={styles.title}>{model.label}</h3>
|
|
93
93
|
<button className={styles.closeBtn} onClick={onClose}>×</button>
|