free-coding-models 0.5.29 → 0.5.31

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 (50) hide show
  1. package/README.md +1 -1
  2. package/changelog/v0.5.30.md +15 -0
  3. package/changelog/v0.5.31.md +6 -0
  4. package/package.json +1 -1
  5. package/src/core/playground.js +6 -4
  6. package/src/core/router-daemon.js +25 -2
  7. package/src/core/sync-set.js +5 -3
  8. package/web/dist/assets/index-CsFt3qt5.css +1 -0
  9. package/web/dist/assets/index-D2n0DqkV.js +40 -0
  10. package/web/dist/favicon.ico +0 -0
  11. package/web/dist/favicons/apple-touch-icon.png +0 -0
  12. package/web/dist/favicons/favicon-16x16.png +0 -0
  13. package/web/dist/favicons/favicon-192x192.png +0 -0
  14. package/web/dist/favicons/favicon-32x32.png +0 -0
  15. package/web/dist/favicons/favicon-48x48.png +0 -0
  16. package/web/dist/favicons/favicon-512x512.png +0 -0
  17. package/web/dist/favicons/favicon-96x96.png +0 -0
  18. package/web/dist/favicons/favicon.ico +0 -0
  19. package/web/dist/favicons/mstile-150x150.png +0 -0
  20. package/web/dist/favicons/mstile-310x150.png +0 -0
  21. package/web/dist/favicons/mstile-310x310.png +0 -0
  22. package/web/dist/favicons/mstile-512x512.png +0 -0
  23. package/web/dist/favicons/mstile-70x70.png +0 -0
  24. package/web/dist/index.html +2 -2
  25. package/web/public/favicon.ico +0 -0
  26. package/web/public/favicons/apple-touch-icon.png +0 -0
  27. package/web/public/favicons/favicon-16x16.png +0 -0
  28. package/web/public/favicons/favicon-192x192.png +0 -0
  29. package/web/public/favicons/favicon-32x32.png +0 -0
  30. package/web/public/favicons/favicon-48x48.png +0 -0
  31. package/web/public/favicons/favicon-512x512.png +0 -0
  32. package/web/public/favicons/favicon-96x96.png +0 -0
  33. package/web/public/favicons/favicon.ico +0 -0
  34. package/web/public/favicons/mstile-150x150.png +0 -0
  35. package/web/public/favicons/mstile-310x150.png +0 -0
  36. package/web/public/favicons/mstile-310x310.png +0 -0
  37. package/web/public/favicons/mstile-512x512.png +0 -0
  38. package/web/public/favicons/mstile-70x70.png +0 -0
  39. package/web/server.js +73 -4
  40. package/web/src/App.jsx +1 -0
  41. package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
  42. package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
  43. package/web/src/components/playground/PlaygroundChat.jsx +527 -0
  44. package/web/src/components/playground/PlaygroundChat.module.css +382 -0
  45. package/web/src/components/playground/PlaygroundView.jsx +104 -442
  46. package/web/src/components/playground/PlaygroundView.module.css +10 -0
  47. package/web/src/components/router/RouterView.jsx +198 -1
  48. package/web/src/components/router/RouterView.module.css +281 -0
  49. package/web/dist/assets/index-BMU58Jju.js +0 -41
  50. package/web/dist/assets/index-Dd1jOGEn.css +0 -1
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -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-BMU58Jju.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-Dd1jOGEn.css">
51
+ <script type="module" crossorigin src="/assets/index-D2n0DqkV.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-CsFt3qt5.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
Binary file
Binary file
package/web/server.js CHANGED
@@ -50,7 +50,7 @@ import { benchmarkModel, BENCHMARK_TIMEOUT_MS, BENCHMARK_PROMPT } from '../src/c
50
50
  import { getInstallTargetModes, installProviderEndpoints, getConfiguredInstallableProviders, getProviderCatalogModels } from '../src/core/endpoint-installer.js'
51
51
  import { isModelCompatibleWithTool } from '../src/core/tool-metadata.js'
52
52
  import { sendUsageTelemetry } from '../src/core/telemetry.js'
53
- import { getRouterDaemonStatus, startRouterDaemonBackground, stopRouterDaemon, ROUTER_TOKENS_PATH } from '../src/core/router-daemon.js'
53
+ import { getRouterDaemonStatus, startRouterDaemonBackground, stopRouterDaemon, ROUTER_TOKENS_PATH, getRouterPortPath } from '../src/core/router-daemon.js'
54
54
  import { scanAllToolConfigs, softDeleteModel } from '../src/core/installed-models-manager.js'
55
55
  import {
56
56
  TASK_TYPES,
@@ -590,7 +590,10 @@ function getEndpointModel(providerKey, modelId) {
590
590
 
591
591
  async function readDaemonPort() {
592
592
  try {
593
- const raw = readFileSync(`${process.env.HOME}/.free-coding-models-daemon.port`, 'utf8').trim()
593
+ // 📖 Use the dynamic port-path resolver so dev checkouts (FCM_DEV=1) read
594
+ // 📖 the `-dev` port file and find the dev daemon, instead of always
595
+ // 📖 reading the production file and missing it. Mirrors router-dashboard.js.
596
+ const raw = readFileSync(getRouterPortPath(), 'utf8').trim()
594
597
  if (/^\d+$/.test(raw)) return Number(raw)
595
598
  } catch {}
596
599
  return null
@@ -748,6 +751,45 @@ async function handleRequest(req, res) {
748
751
  return
749
752
  }
750
753
 
754
+ // 📖 /api/router/sets/:name — PUT to edit/rename/replace models in the set.
755
+ const setPutMatch = url.pathname.match(/^\/api\/router\/sets\/([^/]+)$/)
756
+ if (setPutMatch && req.method === 'PUT') {
757
+ const setName = decodeURIComponent(setPutMatch[1])
758
+ const body = await readJsonBody(req)
759
+ const proxy = await proxyToDaemon(`/sets/${encodeURIComponent(setName)}`, {
760
+ method: 'PUT',
761
+ headers: { 'Content-Type': 'application/json' },
762
+ body: JSON.stringify(body),
763
+ })
764
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
765
+ // 📖 Fallback: if daemon is offline, edit config directly so the UI still works.
766
+ if (!proxy || !proxy.ok) {
767
+ if (!config.router) config.router = {}
768
+ if (!config.router.sets) config.router.sets = {}
769
+ if (config.router.sets[setName]) {
770
+ const nextName = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : setName
771
+ const nextSets = { ...config.router.sets }
772
+ delete nextSets[setName]
773
+ nextSets[nextName] = {
774
+ ...config.router.sets[setName],
775
+ ...body,
776
+ name: nextName,
777
+ models: Array.isArray(body.models) ? body.models : config.router.sets[setName].models,
778
+ }
779
+ config.router.sets = nextSets
780
+ if (config.router.activeSet === setName) {
781
+ config.router.activeSet = nextName
782
+ }
783
+ saveConfig(config)
784
+ broadcastUpdate({ immediate: true })
785
+ sendJson(res, 200, { set: config.router.sets[nextName], router: config.router })
786
+ return
787
+ }
788
+ }
789
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
790
+ return
791
+ }
792
+
751
793
  // 📖 /api/router/sets/:name/sync — re-run the probe-based sync pipeline
752
794
  // 📖 against the named set. Used by the Web Router Dashboard's "Sync
753
795
  // 📖 best models" button so the user can rebuild a set with models
@@ -1460,6 +1502,26 @@ async function handleRequest(req, res) {
1460
1502
  return
1461
1503
  }
1462
1504
 
1505
+ // 📖 /api/router/probe-all — launch AI Latency benchmarks on the active
1506
+ // 📖 set's models (or an explicit list) inside the DAEMON process. Results
1507
+ // 📖 flow back through /api/router/stats (per-model `benchmark` field +
1508
+ // 📖 `globalBenchmark` progress), so the Router Dashboard's set list shows
1509
+ // 📖 live AI latency after a probe. Proxied to the daemon's
1510
+ // 📖 /api/global-benchmark (longer timeout — probing ~10 models takes time).
1511
+ case '/api/router/probe-all': {
1512
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1513
+ const body = await readJsonBody(req)
1514
+ const proxy = await proxyToDaemon('/api/global-benchmark', {
1515
+ method: 'POST',
1516
+ headers: { 'Content-Type': 'application/json' },
1517
+ body: JSON.stringify(body || {}),
1518
+ timeoutMs: 60000,
1519
+ })
1520
+ if (proxy?.ok || proxy?.status === 202) { sendJson(res, proxy?.status || 200, proxy.data); return }
1521
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
1522
+ return
1523
+ }
1524
+
1463
1525
  case '/api/router/catalog': {
1464
1526
  // 📖 Catalog of routeable models for the Web Router Dashboard's
1465
1527
  // 📖 "Add model" picker. Proxies the daemon when running, falls
@@ -1629,12 +1691,19 @@ async function handleRequest(req, res) {
1629
1691
  })
1630
1692
  if (wantsStream) {
1631
1693
  // 📖 Pipe SSE events straight from the upstream to the browser.
1632
- res.writeHead(upstreamResp.status, {
1694
+ // 📖 Forward the daemon's x-fcm-router-model header so the browser
1695
+ // 📖 knows WHICH model the priority-first router actually served
1696
+ // 📖 (the chunks only carry the bare model id, not provider/model).
1697
+ // 📖 See issue #120 — this makes the served-model badge work.
1698
+ const streamHeaders = {
1633
1699
  'Content-Type': 'text/event-stream',
1634
1700
  'Cache-Control': 'no-cache',
1635
1701
  'Connection': 'keep-alive',
1636
1702
  'X-Accel-Buffering': 'no',
1637
- })
1703
+ }
1704
+ const servedModel = upstreamResp.headers.get('x-fcm-router-model')
1705
+ if (servedModel) streamHeaders['x-fcm-router-model'] = servedModel
1706
+ res.writeHead(upstreamResp.status, streamHeaders)
1638
1707
  const reader = upstreamResp.body?.getReader()
1639
1708
  if (!reader) { res.end(); clearTimeout(timeout); return }
1640
1709
  try {
package/web/src/App.jsx CHANGED
@@ -500,6 +500,7 @@ export default function App() {
500
500
  <RouterView
501
501
  onClose={() => setRouterOpen(false)}
502
502
  onToast={addToast}
503
+ favorites={favorites}
503
504
  />
504
505
  )}
505
506
 
@@ -12,7 +12,7 @@
12
12
  * @functions ExpandedDetailRow → main component
13
13
  */
14
14
  import { useState, useRef, useCallback } from 'react'
15
- import { IconSend, IconPlayerPlayFilled, IconStar, IconStarFilled, IconLoader } from '@tabler/icons-react'
15
+ import { IconPlayerPlayFilled, IconStar, IconStarFilled, IconLoader } from '@tabler/icons-react'
16
16
  import TierBadge from '../atoms/TierBadge.jsx'
17
17
  import VerdictBadge from '../atoms/VerdictBadge.jsx'
18
18
  import StatusDot from '../atoms/StatusDot.jsx'
@@ -20,6 +20,7 @@ import StabilityCell from '../atoms/StabilityCell.jsx'
20
20
  import { formatAvg, pingClass } from '../../utils/format.js'
21
21
  import { sweClass } from '../../utils/ranks.js'
22
22
  import LaunchButton from '../launch/LaunchButton.jsx'
23
+ import PlaygroundChat from '../playground/PlaygroundChat.jsx'
23
24
  import styles from './ExpandedDetailRow.module.css'
24
25
 
25
26
  /**
@@ -61,12 +62,6 @@ export default function ExpandedDetailRow({
61
62
  onCycleToolMode,
62
63
  onOpenFallback,
63
64
  }) {
64
- // ─── Mini Playground state ───
65
- const [playgroundInput, setPlaygroundInput] = useState('')
66
- const [playgroundResponse, setPlaygroundResponse] = useState('')
67
- const [playgroundBusy, setPlaygroundBusy] = useState(false)
68
- const playgroundAbort = useRef(null)
69
-
70
65
  // ─── AI Latency benchmark state ───
71
66
  const [benchState, setBenchState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
72
67
  const [benchMetrics, setBenchMetrics] = useState({ latency: null, tokens: null, tps: null })
@@ -80,77 +75,6 @@ export default function ExpandedDetailRow({
80
75
  const avgData = formatAvg(model.avg)
81
76
  const avgCls = avgData.cls || pingClass(model.avg)
82
77
 
83
- // ─── Mini Playground: send a chat message ───
84
- const handlePlaygroundSend = useCallback(async () => {
85
- const text = playgroundInput.trim()
86
- if (!text || playgroundBusy) return
87
-
88
- setPlaygroundBusy(true)
89
- setPlaygroundResponse('')
90
- setPlaygroundInput('')
91
-
92
- // Cancel any previous request
93
- if (playgroundAbort.current) playgroundAbort.current.abort()
94
- const controller = new AbortController()
95
- playgroundAbort.current = controller
96
-
97
- try {
98
- const res = await fetch('/api/playground/chat', {
99
- method: 'POST',
100
- headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({
102
- model: `${model.providerKey}/${model.modelId}`,
103
- messages: [{ role: 'user', content: text }],
104
- stream: true,
105
- temperature: 0.7,
106
- }),
107
- signal: controller.signal,
108
- })
109
-
110
- if (!res.ok) {
111
- const errText = await res.text().catch(() => `HTTP ${res.status}`)
112
- setPlaygroundResponse(`Error: ${errText}`)
113
- return
114
- }
115
-
116
- const reader = res.body.getReader()
117
- const decoder = new TextDecoder()
118
- let accumulated = ''
119
-
120
- while (true) {
121
- const { done, value } = await reader.read()
122
- if (done) break
123
-
124
- const chunk = decoder.decode(value, { stream: true })
125
- // Parse SSE lines
126
- const lines = chunk.split('\n')
127
- for (const line of lines) {
128
- if (!line.startsWith('data: ')) continue
129
- const payload = line.slice(6).trim()
130
- if (payload === '[DONE]') continue
131
-
132
- try {
133
- const json = JSON.parse(payload)
134
- const content = json.choices?.[0]?.delta?.content
135
- if (content) {
136
- accumulated += content
137
- setPlaygroundResponse(accumulated)
138
- }
139
- } catch {
140
- // Non-JSON line — ignore
141
- }
142
- }
143
- }
144
- } catch (err) {
145
- if (err.name !== 'AbortError') {
146
- setPlaygroundResponse(`Error: ${err.message}`)
147
- }
148
- } finally {
149
- setPlaygroundBusy(false)
150
- playgroundAbort.current = null
151
- }
152
- }, [playgroundInput, playgroundBusy, model.providerKey, model.modelId])
153
-
154
78
  // ─── AI Latency: run benchmark ───
155
79
  const handleBenchStart = useCallback(async () => {
156
80
  if (benchState === 'running') return
@@ -349,47 +273,20 @@ export default function ExpandedDetailRow({
349
273
  </div>
350
274
  </div>
351
275
 
352
- {/* ═══════ Column 2: Mini Playground ═══════ */}
276
+ {/* ═══════ Column 2: Mini Playground (shared PlaygroundChat core) ═══════ */}
353
277
  <div className={styles.col}>
354
278
  <div className={styles.playgroundHeader}>
355
279
  💬 Mini Playground — {model.label}
356
280
  </div>
357
- <div className={styles.inputRow}>
358
- <input
359
- type="text"
360
- className={styles.inputField}
281
+ <div className={styles.playgroundWrap}>
282
+ <PlaygroundChat
283
+ model={`${model.providerKey}/${model.modelId}`}
284
+ variant="mini"
285
+ disabled={!model.hasApiKey}
361
286
  placeholder="Type a message…"
362
- value={playgroundInput}
363
- onChange={e => setPlaygroundInput(e.target.value)}
364
- onKeyDown={e => {
365
- if (e.key === 'Enter') handlePlaygroundSend()
366
- }}
367
- disabled={playgroundBusy}
287
+ emptyTitle={`Test ${model.label}`}
288
+ emptyHint="Send a message to test this model. Responses stream in real time with latency + TPS."
368
289
  />
369
- <button
370
- className={styles.sendBtn}
371
- onClick={handlePlaygroundSend}
372
- disabled={playgroundBusy || !playgroundInput.trim()}
373
- title="Send message"
374
- >
375
- {playgroundBusy
376
- ? <IconLoader size={14} stroke={1.8} className={styles.spinning} />
377
- : <IconSend size={14} stroke={1.8} />
378
- }
379
- </button>
380
- </div>
381
- <div className={styles.responseArea}>
382
- {playgroundBusy && !playgroundResponse ? (
383
- <span className={styles.responsePlaceholder}>
384
- Waiting for response…
385
- </span>
386
- ) : playgroundResponse ? (
387
- playgroundResponse
388
- ) : (
389
- <span className={styles.responsePlaceholder}>
390
- Send a message to test this model. Responses stream in real time.
391
- </span>
392
- )}
393
290
  </div>
394
291
  </div>
395
292
 
@@ -123,6 +123,15 @@
123
123
  margin-bottom: 2px;
124
124
  }
125
125
 
126
+ /* 📖 Wrapper that lets the shared PlaygroundChat fill the column height so its
127
+ transcript scrolls and the input stays pinned to the bottom. */
128
+ .playgroundWrap {
129
+ flex: 1;
130
+ display: flex;
131
+ flex-direction: column;
132
+ min-height: 140px;
133
+ }
134
+
126
135
  .inputRow {
127
136
  display: flex;
128
137
  gap: 6px;