free-coding-models 0.5.28 → 0.5.30
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 +1 -1
- package/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.29.md +19 -0
- package/changelog/v0.5.30.md +15 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +1 -14
- package/src/core/endpoint-installer.js +5 -9
- package/src/core/legacy-proxy-cleanup.js +2 -0
- package/src/core/model-merger.js +4 -13
- package/src/core/playground.js +6 -4
- package/src/core/router-daemon.js +101 -43
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +11 -24
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +2 -2
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +77 -20
- package/src/tui/render-table.js +3 -3
- package/web/dist/assets/index-CsFt3qt5.css +1 -0
- package/web/dist/assets/index-I6N91rH7.js +40 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/index.html +2 -2
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/server.js +34 -4
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
- package/web/src/components/playground/PlaygroundChat.jsx +527 -0
- package/web/src/components/playground/PlaygroundChat.module.css +382 -0
- package/web/src/components/playground/PlaygroundView.jsx +104 -442
- package/web/src/components/playground/PlaygroundView.module.css +10 -0
- package/web/src/components/router/RouterView.jsx +268 -56
- package/web/src/components/router/RouterView.module.css +416 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-BRFowJv5.css +0 -1
- package/web/dist/assets/index-Pr3waI0-.js +0 -41
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/playground/PlaygroundView.jsx
|
|
3
|
-
* @description Playground chat modal — multi-turn chat through the FCM
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @description Playground chat modal — multi-turn chat through the FCM router
|
|
4
|
+
* OR directly to a specific model. This is the HOST (chrome) layer; the
|
|
5
|
+
* actual chat (transcript, input, SSE streaming, metadata) is delegated to
|
|
6
|
+
* the shared <PlaygroundChat> core so the full playground, the RouterView
|
|
7
|
+
* "Test Router", and the ExpandedDetailRow "Mini Playground" all look and
|
|
8
|
+
* behave identically.
|
|
7
9
|
*
|
|
8
|
-
* 📖
|
|
9
|
-
* 📖
|
|
10
|
-
* 📖 (
|
|
10
|
+
* 📖 What this host still owns:
|
|
11
|
+
* 📖 - Header (title, message/token counter, clear + close)
|
|
12
|
+
* 📖 - Model bar (selector, streaming toggle, pre-prompt toggle, router status)
|
|
13
|
+
* 📖 - Pre-prompt hint (fetched from /api/router/preprompt)
|
|
14
|
+
* 📖 - Router daemon lifecycle (auto-check, 5s auto-start, start button)
|
|
15
|
+
* 📖 - Smart model selection on mount (best "up" model with a key)
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* 📖 What it delegates to <PlaygroundChat>:
|
|
18
|
+
* 📖 - The transcript + input + send/stop
|
|
19
|
+
* 📖 - SSE streaming through /api/playground/chat
|
|
20
|
+
* 📖 - The harmonized metadata row under every reply
|
|
21
|
+
* 📖 (provider/model · ms · tok · t/s)
|
|
22
|
+
*
|
|
23
|
+
* @functions PlaygroundView — full-screen chat modal host wrapping PlaygroundChat
|
|
14
24
|
*/
|
|
15
25
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
16
26
|
import {
|
|
17
27
|
IconMessageChatbot,
|
|
18
28
|
IconX,
|
|
19
|
-
IconSend,
|
|
20
29
|
IconTrash,
|
|
21
30
|
IconBolt,
|
|
22
|
-
IconRefresh,
|
|
23
31
|
IconAlertTriangle,
|
|
24
|
-
IconCopy,
|
|
25
|
-
IconCheck,
|
|
26
32
|
IconPlayerPlay,
|
|
27
33
|
IconLoader,
|
|
28
34
|
} from '@tabler/icons-react'
|
|
35
|
+
import PlaygroundChat from './PlaygroundChat.jsx'
|
|
29
36
|
import styles from './PlaygroundView.module.css'
|
|
30
37
|
|
|
31
38
|
const SUGGESTIONS = [
|
|
@@ -36,63 +43,11 @@ const SUGGESTIONS = [
|
|
|
36
43
|
]
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
|
-
* 📖
|
|
40
|
-
* 📖
|
|
41
|
-
* 📖
|
|
42
|
-
* 📖 the FCM router daemon and every upstream provider).
|
|
43
|
-
*
|
|
44
|
-
* 📖 Returning a string is critical: the playground renders the error
|
|
45
|
-
* 📖 directly inside JSX, and React throws if a non-string child shows up
|
|
46
|
-
* 📖 — which is exactly the bug that was happening when the router was
|
|
47
|
-
* 📖 down and replied with `{ error: { message, type, code, ... } }`.
|
|
48
|
-
*
|
|
49
|
-
* @param {unknown} errBody
|
|
50
|
-
* @returns {string|null}
|
|
51
|
-
*/
|
|
52
|
-
function extractErrorMessage(errBody) {
|
|
53
|
-
if (!errBody || typeof errBody !== 'object') {
|
|
54
|
-
return typeof errBody === 'string' ? errBody : null
|
|
55
|
-
}
|
|
56
|
-
if (typeof errBody.error === 'string') return errBody.error
|
|
57
|
-
if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
|
|
58
|
-
return errBody.error.message
|
|
59
|
-
}
|
|
60
|
-
if (typeof errBody.message === 'string') return errBody.message
|
|
61
|
-
return null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* 📖 Pretty-print the assistant text. Renders triple-backtick code blocks as
|
|
66
|
-
* 📖 <pre> blocks; falls back to plain text otherwise. We do not pull in a
|
|
67
|
-
* 📖 markdown library to keep the dashboard zero-dep.
|
|
46
|
+
* 📖 Router status pill shown in the model bar. `daemonRunning` is a tri-state:
|
|
47
|
+
* 📖 null = checking, true = online, false = offline. Kept here because it is
|
|
48
|
+
* 📖 pure chrome (not part of the chat core).
|
|
68
49
|
*/
|
|
69
|
-
function renderAssistantText(text) {
|
|
70
|
-
if (!text) return null
|
|
71
|
-
const parts = text.split(/(```[\s\S]*?```)/g)
|
|
72
|
-
return parts.map((part, idx) => {
|
|
73
|
-
const codeMatch = part.match(/^```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```$/)
|
|
74
|
-
if (codeMatch) {
|
|
75
|
-
return (
|
|
76
|
-
<pre key={idx} className={styles.codeBlock}>
|
|
77
|
-
<code>{codeMatch[2].replace(/\n$/, '')}</code>
|
|
78
|
-
</pre>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
return <span key={idx}>{part}</span>
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function MetaChip({ icon, label, tone }) {
|
|
86
|
-
return (
|
|
87
|
-
<span className={`${styles.metaChip} ${tone ? styles[tone] : ''}`}>
|
|
88
|
-
{icon}
|
|
89
|
-
{label}
|
|
90
|
-
</span>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
50
|
function StatusPill({ routerStatus, daemonRunning }) {
|
|
95
|
-
// 📖 Prefer the live daemon state over the stale prop.
|
|
96
51
|
if (daemonRunning === true) {
|
|
97
52
|
return (
|
|
98
53
|
<span className={styles.metaChip}>
|
|
@@ -109,7 +64,6 @@ function StatusPill({ routerStatus, daemonRunning }) {
|
|
|
109
64
|
</span>
|
|
110
65
|
)
|
|
111
66
|
}
|
|
112
|
-
// null = checking...
|
|
113
67
|
return (
|
|
114
68
|
<span className={styles.metaChip}>
|
|
115
69
|
<IconLoader size={11} className={styles.spin} />
|
|
@@ -119,19 +73,13 @@ function StatusPill({ routerStatus, daemonRunning }) {
|
|
|
119
73
|
}
|
|
120
74
|
|
|
121
75
|
export default function PlaygroundView({ onClose, onToast, models, routerStatus }) {
|
|
122
|
-
|
|
123
|
-
const [input, setInput] = useState('')
|
|
76
|
+
// ── Chrome state (host-owned) ──────────────────────────────────────────
|
|
124
77
|
const [model, setModel] = useState(null) // null = computing best model
|
|
125
78
|
const [streamOn, setStreamOn] = useState(true)
|
|
126
79
|
const [prePromptEnabled, setPrePromptEnabled] = useState(true)
|
|
127
80
|
const [prePromptText, setPrePromptText] = useState('')
|
|
128
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
129
|
-
const [error, setError] = useState(null)
|
|
130
|
-
const [copiedIdx, setCopiedIdx] = useState(null)
|
|
131
|
-
const abortRef = useRef(null)
|
|
132
|
-
const transcriptRef = useRef(null)
|
|
133
81
|
|
|
134
|
-
// 📖
|
|
82
|
+
// 📖 Router daemon lifecycle (null = checking, true = running, false = down)
|
|
135
83
|
const [daemonRunning, setDaemonRunning] = useState(
|
|
136
84
|
routerStatus?.running === true ? true : null
|
|
137
85
|
)
|
|
@@ -141,8 +89,14 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
141
89
|
const daemonCheckRef = useRef(null)
|
|
142
90
|
const autoStartTriggered = useRef(false)
|
|
143
91
|
|
|
144
|
-
// 📖
|
|
145
|
-
// 📖
|
|
92
|
+
// 📖 Header counter + empty-state gate. Updated by PlaygroundChat via the
|
|
93
|
+
// 📖 onTurnComplete callback so the host chrome stays in sync with the
|
|
94
|
+
// 📖 chat core it no longer owns directly.
|
|
95
|
+
const [chatStats, setChatStats] = useState({ count: 0, totalTokens: 0 })
|
|
96
|
+
const chatRef = useRef(null)
|
|
97
|
+
|
|
98
|
+
// 📖 Fetch the pre-prompt once on mount so the toggle shows the real value
|
|
99
|
+
// 📖 and the hint matches what the router injects server-side.
|
|
146
100
|
useEffect(() => {
|
|
147
101
|
void fetch('/api/router/preprompt')
|
|
148
102
|
.then((r) => r.json())
|
|
@@ -155,18 +109,15 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
155
109
|
.catch(() => {})
|
|
156
110
|
}, [])
|
|
157
111
|
|
|
158
|
-
// 📖 Smart model selection on mount: pick the best "up" model with
|
|
159
|
-
// 📖
|
|
160
|
-
// 📖
|
|
161
|
-
// 📖 or no working model is found. This ensures the playground works
|
|
162
|
-
// 📖 immediately on open, even without the router daemon.
|
|
112
|
+
// 📖 Smart model selection on mount: pick the best "up" model with an API
|
|
113
|
+
// 📖 key, preferring lower latency and higher tier. Falls back to "fcm" so
|
|
114
|
+
// 📖 the playground opens immediately even without a working model.
|
|
163
115
|
useEffect(() => {
|
|
164
|
-
if (model !== null) return
|
|
116
|
+
if (model !== null) return
|
|
165
117
|
const upModels = (Array.isArray(models) ? models : [])
|
|
166
118
|
.filter((m) => m.status === 'up' && m.hasApiKey && !m.isPinging)
|
|
167
119
|
|
|
168
120
|
if (upModels.length > 0) {
|
|
169
|
-
// 📖 Sort: best tier first (S+ > S > A+ ...), then lowest avg latency
|
|
170
121
|
const tierOrder = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
|
|
171
122
|
upModels.sort((a, b) => {
|
|
172
123
|
const ta = tierOrder[a.tier] ?? 99
|
|
@@ -176,45 +127,26 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
176
127
|
const avgB = typeof b.avg === 'number' ? b.avg : 99999
|
|
177
128
|
return avgA - avgB
|
|
178
129
|
})
|
|
179
|
-
|
|
180
|
-
setModel(`${best.providerKey}/${best.modelId}`)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 📖 No working model found — if daemon is running, use fcm
|
|
185
|
-
if (routerStatus?.running || daemonRunning === true) {
|
|
186
|
-
setModel('fcm')
|
|
130
|
+
setModel(`${upModels[0].providerKey}/${upModels[0].modelId}`)
|
|
187
131
|
return
|
|
188
132
|
}
|
|
189
|
-
|
|
190
|
-
// 📖 Nothing works — default to fcm anyway (will show daemon start panel)
|
|
191
133
|
setModel('fcm')
|
|
192
|
-
}, [models, model
|
|
134
|
+
}, [models, model])
|
|
193
135
|
|
|
194
|
-
// 📖 Check daemon status on mount.
|
|
195
|
-
// 📖 is on the "fcm" auto-router model, start a 5-second countdown to
|
|
196
|
-
// 📖 auto-start the daemon. The "Start Router" button is always
|
|
197
|
-
// 📖 clickable so the user can trigger it manually at any time.
|
|
136
|
+
// 📖 Check daemon status on mount.
|
|
198
137
|
useEffect(() => {
|
|
199
138
|
let mounted = true
|
|
200
|
-
|
|
201
139
|
async function checkDaemon() {
|
|
202
140
|
try {
|
|
203
141
|
const resp = await fetch('/api/router/status')
|
|
204
142
|
const data = await resp.json()
|
|
205
143
|
if (!mounted) return
|
|
206
|
-
|
|
207
|
-
setDaemonRunning(true)
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
setDaemonRunning(false)
|
|
144
|
+
setDaemonRunning(data?.running === true)
|
|
211
145
|
} catch {
|
|
212
146
|
if (mounted) setDaemonRunning(false)
|
|
213
147
|
}
|
|
214
148
|
}
|
|
215
|
-
|
|
216
|
-
checkDaemon()
|
|
217
|
-
|
|
149
|
+
void checkDaemon()
|
|
218
150
|
return () => {
|
|
219
151
|
mounted = false
|
|
220
152
|
if (cooldownRef.current) clearInterval(cooldownRef.current)
|
|
@@ -222,30 +154,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
222
154
|
}
|
|
223
155
|
}, [])
|
|
224
156
|
|
|
225
|
-
// 📖
|
|
226
|
-
// 📖 is "fcm" (auto-router), and auto-start hasn't been triggered yet.
|
|
227
|
-
// 📖 Countdown ticks every second and shows remaining time on the button.
|
|
228
|
-
useEffect(() => {
|
|
229
|
-
if (daemonRunning !== false || model !== 'fcm' || autoStartTriggered.current) return
|
|
230
|
-
|
|
231
|
-
let remaining = 5
|
|
232
|
-
setAutoStartSec(5)
|
|
233
|
-
cooldownRef.current = setInterval(() => {
|
|
234
|
-
remaining -= 1
|
|
235
|
-
setAutoStartSec(remaining)
|
|
236
|
-
if (remaining <= 0) {
|
|
237
|
-
clearInterval(cooldownRef.current)
|
|
238
|
-
autoStartTriggered.current = true
|
|
239
|
-
void startDaemon()
|
|
240
|
-
}
|
|
241
|
-
}, 1000)
|
|
242
|
-
|
|
243
|
-
return () => { if (cooldownRef.current) clearInterval(cooldownRef.current) }
|
|
244
|
-
}, [daemonRunning, model])
|
|
245
|
-
|
|
246
|
-
// 📖 Start the router daemon from the playground. After starting,
|
|
247
|
-
// 📖 polls every 1s to detect when the daemon is ready, then
|
|
248
|
-
// 📖 updates the UI so the user can chat immediately.
|
|
157
|
+
// 📖 Start the router daemon, then poll until it is ready.
|
|
249
158
|
const startDaemon = useCallback(async () => {
|
|
250
159
|
if (daemonStarting) return
|
|
251
160
|
if (cooldownRef.current) { clearInterval(cooldownRef.current); cooldownRef.current = null }
|
|
@@ -253,7 +162,6 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
253
162
|
setDaemonStarting(true)
|
|
254
163
|
try {
|
|
255
164
|
await fetch('/api/router/start', { method: 'POST' })
|
|
256
|
-
// 📖 Poll until the daemon is confirmed running (max 30s)
|
|
257
165
|
let attempts = 0
|
|
258
166
|
await new Promise((resolve) => {
|
|
259
167
|
daemonCheckRef.current = setInterval(async () => {
|
|
@@ -280,15 +188,27 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
280
188
|
}
|
|
281
189
|
}, [daemonStarting])
|
|
282
190
|
|
|
283
|
-
// 📖 Auto-
|
|
191
|
+
// 📖 Auto-start the daemon after 5 seconds if it is down, the model is the
|
|
192
|
+
// 📖 "fcm" auto-router, and auto-start hasn't fired yet. The button is always
|
|
193
|
+
// 📖 clickable so the user can trigger it manually at any time.
|
|
284
194
|
useEffect(() => {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
195
|
+
if (daemonRunning !== false || model !== 'fcm' || autoStartTriggered.current) return
|
|
196
|
+
let remaining = 5
|
|
197
|
+
setAutoStartSec(5)
|
|
198
|
+
cooldownRef.current = setInterval(() => {
|
|
199
|
+
remaining -= 1
|
|
200
|
+
setAutoStartSec(remaining)
|
|
201
|
+
if (remaining <= 0) {
|
|
202
|
+
clearInterval(cooldownRef.current)
|
|
203
|
+
autoStartTriggered.current = true
|
|
204
|
+
void startDaemon()
|
|
205
|
+
}
|
|
206
|
+
}, 1000)
|
|
207
|
+
return () => { if (cooldownRef.current) clearInterval(cooldownRef.current) }
|
|
208
|
+
}, [daemonRunning, model, startDaemon])
|
|
288
209
|
|
|
289
|
-
// 📖 Stable list of model identifiers for the dropdown — defaults to
|
|
290
|
-
// 📖
|
|
291
|
-
// 📖 entry if they want a manual target.
|
|
210
|
+
// 📖 Stable list of model identifiers for the dropdown — defaults to `fcm`
|
|
211
|
+
// 📖 (the auto-router) and lets the user pin a specific catalog entry.
|
|
292
212
|
const modelOptions = useMemo(() => {
|
|
293
213
|
const opts = [{ value: 'fcm', label: 'fcm — auto router (recommended)' }]
|
|
294
214
|
if (Array.isArray(models)) {
|
|
@@ -302,177 +222,28 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
302
222
|
return opts
|
|
303
223
|
}, [models])
|
|
304
224
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
setIsLoading(false)
|
|
225
|
+
// 📖 PlaygroundChat turn callback → keep the header counter in sync and
|
|
226
|
+
// 📖 drive the daemon-panel gate (only show it when there are no messages).
|
|
227
|
+
const handleTurnComplete = useCallback((summary) => {
|
|
228
|
+
setChatStats({ count: summary.count, totalTokens: summary.totalTokens })
|
|
311
229
|
}, [])
|
|
312
230
|
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (isFcm && daemonRunning !== true) {
|
|
318
|
-
setError('Router daemon is not running. Start it first using the button above or run `free-coding-models --daemon-bg`.')
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
setError(null)
|
|
322
|
-
setInput('')
|
|
323
|
-
|
|
324
|
-
// 📖 Build the local transcript. We do NOT prepend the pre-prompt here
|
|
325
|
-
// 📖 because the router injects it server-side. We just send the user
|
|
326
|
-
// 📖 turn and let the daemon stamp the persona.
|
|
327
|
-
const userMessage = { role: 'user', content: text, ts: Date.now() }
|
|
328
|
-
const transcript = [...messages, userMessage]
|
|
329
|
-
setMessages(transcript)
|
|
330
|
-
setIsLoading(true)
|
|
331
|
-
|
|
332
|
-
const assistantId = `assistant-${Date.now()}`
|
|
333
|
-
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', ts: Date.now() }])
|
|
334
|
-
|
|
335
|
-
const controller = new AbortController()
|
|
336
|
-
abortRef.current = controller
|
|
337
|
-
|
|
338
|
-
const body = {
|
|
339
|
-
model: model || 'fcm',
|
|
340
|
-
messages: transcript.map(({ role, content }) => ({ role, content })),
|
|
341
|
-
stream: streamOn,
|
|
342
|
-
temperature: 0.7,
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
try {
|
|
346
|
-
const resp = await fetch('/api/playground/chat', {
|
|
347
|
-
method: 'POST',
|
|
348
|
-
headers: { 'Content-Type': 'application/json' },
|
|
349
|
-
body: JSON.stringify(body),
|
|
350
|
-
signal: controller.signal,
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
if (!resp.ok) {
|
|
354
|
-
const errBody = await resp.json().catch(() => null)
|
|
355
|
-
const errMsg = extractErrorMessage(errBody) || `Request failed with status ${resp.status}`
|
|
356
|
-
setError(errMsg)
|
|
357
|
-
setMessages((prev) => prev.map((m) => (
|
|
358
|
-
m.id === assistantId
|
|
359
|
-
? { ...m, content: m.content, error: errMsg, aborted: true }
|
|
360
|
-
: m
|
|
361
|
-
)))
|
|
362
|
-
setIsLoading(false)
|
|
363
|
-
return
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (streamOn) {
|
|
367
|
-
// 📖 Read SSE chunks from the proxy. Each chunk may carry several
|
|
368
|
-
// 📖 OpenAI-style "data: ..." lines; we only care about the delta
|
|
369
|
-
// 📖 content and the final routed-via metadata.
|
|
370
|
-
const reader = resp.body?.getReader()
|
|
371
|
-
if (!reader) throw new Error('No stream reader available')
|
|
372
|
-
const decoder = new TextDecoder()
|
|
373
|
-
let buffer = ''
|
|
374
|
-
const routed = {
|
|
375
|
-
provider: null,
|
|
376
|
-
model: null,
|
|
377
|
-
latencyMs: null,
|
|
378
|
-
tokens: 0,
|
|
379
|
-
fallbackAttempts: 0,
|
|
380
|
-
}
|
|
381
|
-
while (true) {
|
|
382
|
-
const { value, done } = await reader.read()
|
|
383
|
-
if (done) break
|
|
384
|
-
buffer += decoder.decode(value, { stream: true })
|
|
385
|
-
// 📖 Split on blank-line SSE boundaries.
|
|
386
|
-
const events = buffer.split(/\n\n/)
|
|
387
|
-
buffer = events.pop() || ''
|
|
388
|
-
for (const event of events) {
|
|
389
|
-
const lines = event.split(/\n/)
|
|
390
|
-
for (const line of lines) {
|
|
391
|
-
if (!line.startsWith('data:')) continue
|
|
392
|
-
const payload = line.slice(5).trim()
|
|
393
|
-
if (payload === '[DONE]') continue
|
|
394
|
-
try {
|
|
395
|
-
const json = JSON.parse(payload)
|
|
396
|
-
const delta = json?.choices?.[0]?.delta?.content
|
|
397
|
-
if (delta) {
|
|
398
|
-
setMessages((prev) => prev.map((m) => (
|
|
399
|
-
m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m
|
|
400
|
-
)))
|
|
401
|
-
}
|
|
402
|
-
if (json?.x_routed_via) routed.provider = json.x_routed_via
|
|
403
|
-
if (json?.x_routed_model) routed.model = json.x_routed_model
|
|
404
|
-
if (json?.x_latency_ms) routed.latencyMs = json.x_latency_ms
|
|
405
|
-
if (json?.x_fallback_attempts) routed.fallbackAttempts = json.x_fallback_attempts
|
|
406
|
-
if (json?.usage?.total_tokens) routed.tokens = json.usage.total_tokens
|
|
407
|
-
} catch {
|
|
408
|
-
// 📖 Ignore non-JSON keep-alive frames.
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
setMessages((prev) => prev.map((m) => (
|
|
414
|
-
m.id === assistantId ? { ...m, meta: routed } : m
|
|
415
|
-
)))
|
|
416
|
-
} else {
|
|
417
|
-
// 📖 Non-streaming: wait for the full JSON response and append.
|
|
418
|
-
const json = await resp.json()
|
|
419
|
-
const content = json?.choices?.[0]?.message?.content || ''
|
|
420
|
-
const usage = json?.usage || {}
|
|
421
|
-
const routed = {
|
|
422
|
-
provider: json?.x_routed_via || null,
|
|
423
|
-
model: json?.x_routed_model || null,
|
|
424
|
-
latencyMs: json?.x_latency_ms || null,
|
|
425
|
-
tokens: usage?.total_tokens || 0,
|
|
426
|
-
fallbackAttempts: json?.x_fallback_attempts || 0,
|
|
427
|
-
}
|
|
428
|
-
setMessages((prev) => prev.map((m) => (
|
|
429
|
-
m.id === assistantId ? { ...m, content, meta: routed } : m
|
|
430
|
-
)))
|
|
431
|
-
}
|
|
432
|
-
} catch (err) {
|
|
433
|
-
if (err.name === 'AbortError') {
|
|
434
|
-
setMessages((prev) => prev.map((m) => (
|
|
435
|
-
m.id === assistantId ? { ...m, aborted: true } : m
|
|
436
|
-
)))
|
|
437
|
-
} else {
|
|
438
|
-
setError(err.message || String(err))
|
|
439
|
-
setMessages((prev) => prev.map((m) => (
|
|
440
|
-
m.id === assistantId ? { ...m, error: err.message || String(err), aborted: true } : m
|
|
441
|
-
)))
|
|
442
|
-
}
|
|
443
|
-
} finally {
|
|
444
|
-
setIsLoading(false)
|
|
445
|
-
abortRef.current = null
|
|
446
|
-
}
|
|
447
|
-
}, [input, isLoading, messages, model, streamOn, daemonRunning])
|
|
448
|
-
|
|
449
|
-
const handleKeyDown = useCallback((e) => {
|
|
450
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
451
|
-
e.preventDefault()
|
|
452
|
-
void sendMessage()
|
|
453
|
-
}
|
|
454
|
-
}, [sendMessage])
|
|
231
|
+
const handleClear = useCallback(() => {
|
|
232
|
+
chatRef.current?.clear()
|
|
233
|
+
setChatStats({ count: 0, totalTokens: 0 })
|
|
234
|
+
}, [])
|
|
455
235
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
setError(null)
|
|
460
|
-
}, [isLoading, stopStream])
|
|
236
|
+
// 📖 Can the user send right now? fcm needs the daemon; a pinned model
|
|
237
|
+
// 📖 routes directly to its provider and works without the daemon.
|
|
238
|
+
const canSend = !(model === 'fcm' && daemonRunning !== true)
|
|
461
239
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
}, [onToast])
|
|
471
|
-
|
|
472
|
-
const totalTokens = useMemo(
|
|
473
|
-
() => messages.reduce((sum, m) => sum + (m.meta?.tokens || 0), 0),
|
|
474
|
-
[messages]
|
|
475
|
-
)
|
|
240
|
+
// 📖 Daemon-panel gates. The panels replace the chat area entirely when the
|
|
241
|
+
// 📖 user is on the auto-router, the daemon is down/starting, and there is
|
|
242
|
+
// 📖 no conversation yet to keep on screen.
|
|
243
|
+
const hasMessages = chatStats.count > 0
|
|
244
|
+
const showDaemonStartPanel = model === 'fcm' && !hasMessages && daemonRunning === false && !daemonStarting
|
|
245
|
+
const showDaemonStartingPanel = model === 'fcm' && !hasMessages && daemonStarting
|
|
246
|
+
const showChat = !showDaemonStartPanel && !showDaemonStartingPanel
|
|
476
247
|
|
|
477
248
|
return (
|
|
478
249
|
<div className={styles.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
|
@@ -484,15 +255,15 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
484
255
|
Playground
|
|
485
256
|
</div>
|
|
486
257
|
<div className={styles.headerSubtitle}>
|
|
487
|
-
Chat with the FCM router · {
|
|
258
|
+
Chat with the FCM router · {chatStats.count} message{chatStats.count === 1 ? '' : 's'} · {chatStats.totalTokens} tokens
|
|
488
259
|
</div>
|
|
489
260
|
</div>
|
|
490
261
|
<div className={styles.headerActions}>
|
|
491
262
|
<button
|
|
492
263
|
className={styles.iconBtn}
|
|
493
|
-
onClick={
|
|
264
|
+
onClick={handleClear}
|
|
494
265
|
title="Clear conversation"
|
|
495
|
-
disabled={
|
|
266
|
+
disabled={!hasMessages}
|
|
496
267
|
>
|
|
497
268
|
<IconTrash size={16} />
|
|
498
269
|
</button>
|
|
@@ -510,9 +281,9 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
510
281
|
<span className={styles.modelLabel}>Model:</span>
|
|
511
282
|
<select
|
|
512
283
|
className={styles.modelSelect}
|
|
513
|
-
value={model}
|
|
284
|
+
value={model ?? 'fcm'}
|
|
514
285
|
onChange={(e) => setModel(e.target.value)}
|
|
515
|
-
disabled={
|
|
286
|
+
disabled={!showChat}
|
|
516
287
|
>
|
|
517
288
|
{modelOptions.map((opt) => (
|
|
518
289
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
@@ -545,8 +316,8 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
545
316
|
</div>
|
|
546
317
|
)}
|
|
547
318
|
|
|
548
|
-
<div className={styles.
|
|
549
|
-
{
|
|
319
|
+
<div className={styles.chatBody}>
|
|
320
|
+
{showDaemonStartPanel ? (
|
|
550
321
|
<div className={styles.daemonStartPanel}>
|
|
551
322
|
<IconAlertTriangle size={32} style={{ color: '#fbbf24', opacity: 0.8 }} />
|
|
552
323
|
<div className={styles.daemonStartTitle}>Router daemon is not running</div>
|
|
@@ -568,7 +339,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
568
339
|
Or run <code>free-coding-models --daemon-bg</code> in your terminal
|
|
569
340
|
</div>
|
|
570
341
|
</div>
|
|
571
|
-
) :
|
|
342
|
+
) : showDaemonStartingPanel ? (
|
|
572
343
|
<div className={styles.daemonStartPanel}>
|
|
573
344
|
<IconLoader size={32} className={styles.spin} style={{ color: 'var(--accent, #22c55e)' }} />
|
|
574
345
|
<div className={styles.daemonStartTitle}>Starting router daemon…</div>
|
|
@@ -576,133 +347,24 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
|
|
|
576
347
|
The daemon is being launched. This usually takes a few seconds. You'll be able to chat as soon as it's ready.
|
|
577
348
|
</div>
|
|
578
349
|
</div>
|
|
579
|
-
) :
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
</button>
|
|
596
|
-
))}
|
|
597
|
-
</div>
|
|
598
|
-
</div>
|
|
599
|
-
) : (
|
|
600
|
-
messages.map((m, idx) => (
|
|
601
|
-
<div key={m.id || idx} className={`${styles.message} ${styles[m.role] || ''}`}>
|
|
602
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
|
|
603
|
-
<div className={styles.messageRole}>{m.role}</div>
|
|
604
|
-
{m.role === 'assistant' && m.content && (
|
|
605
|
-
<button
|
|
606
|
-
className={styles.iconBtn}
|
|
607
|
-
style={{ padding: 2, border: 'none' }}
|
|
608
|
-
onClick={() => copyMessage(idx, m.content)}
|
|
609
|
-
title="Copy reply"
|
|
610
|
-
>
|
|
611
|
-
{copiedIdx === idx ? <IconCheck size={11} /> : <IconCopy size={11} />}
|
|
612
|
-
</button>
|
|
613
|
-
)}
|
|
614
|
-
</div>
|
|
615
|
-
<div className={styles.bubble}>
|
|
616
|
-
{m.role === 'assistant' ? (
|
|
617
|
-
<>
|
|
618
|
-
{renderAssistantText(m.content)}
|
|
619
|
-
{isLoading && idx === messages.length - 1 && !m.aborted && (
|
|
620
|
-
<span className={styles.cursor} />
|
|
621
|
-
)}
|
|
622
|
-
</>
|
|
623
|
-
) : (
|
|
624
|
-
m.content
|
|
625
|
-
)}
|
|
626
|
-
</div>
|
|
627
|
-
{m.role === 'assistant' && m.meta && (m.meta.provider || m.meta.model) && (
|
|
628
|
-
<div className={styles.meta}>
|
|
629
|
-
{m.meta.provider && (
|
|
630
|
-
<MetaChip
|
|
631
|
-
icon={<IconBolt size={11} />}
|
|
632
|
-
label={`routed via ${m.meta.provider}/${m.meta.model || '?'}`}
|
|
633
|
-
tone="provider"
|
|
634
|
-
/>
|
|
635
|
-
)}
|
|
636
|
-
{m.meta.latencyMs != null && (
|
|
637
|
-
<MetaChip label={`${m.meta.latencyMs} ms`} />
|
|
638
|
-
)}
|
|
639
|
-
{m.meta.tokens > 0 && (
|
|
640
|
-
<MetaChip label={`${m.meta.tokens} tok`} />
|
|
641
|
-
)}
|
|
642
|
-
{m.meta.fallbackAttempts > 0 && (
|
|
643
|
-
<MetaChip
|
|
644
|
-
icon={<IconRefresh size={11} />}
|
|
645
|
-
label={`${m.meta.fallbackAttempts} fallback${m.meta.fallbackAttempts > 1 ? 's' : ''}`}
|
|
646
|
-
/>
|
|
647
|
-
)}
|
|
648
|
-
</div>
|
|
649
|
-
)}
|
|
650
|
-
{m.role === 'assistant' && m.error && (
|
|
651
|
-
<div className={styles.meta}>
|
|
652
|
-
<MetaChip
|
|
653
|
-
icon={<IconAlertTriangle size={11} />}
|
|
654
|
-
label={m.error}
|
|
655
|
-
tone="error"
|
|
656
|
-
/>
|
|
657
|
-
</div>
|
|
658
|
-
)}
|
|
659
|
-
{m.role === 'assistant' && m.aborted && !m.error && (
|
|
660
|
-
<div className={styles.meta}>
|
|
661
|
-
<MetaChip label="stopped" />
|
|
662
|
-
</div>
|
|
663
|
-
)}
|
|
664
|
-
</div>
|
|
665
|
-
))
|
|
350
|
+
) : null}
|
|
351
|
+
|
|
352
|
+
{showChat && (
|
|
353
|
+
<PlaygroundChat
|
|
354
|
+
ref={chatRef}
|
|
355
|
+
model={model || 'fcm'}
|
|
356
|
+
variant="full"
|
|
357
|
+
stream={streamOn}
|
|
358
|
+
disabled={!canSend}
|
|
359
|
+
onTurnComplete={handleTurnComplete}
|
|
360
|
+
suggestions={SUGGESTIONS}
|
|
361
|
+
emptyTitle="Try the FCM router in 10 seconds"
|
|
362
|
+
emptyHint="Each request is auto-routed to the healthiest free coding model in your active set, or sent directly to the model you picked. Every reply shows the served model, latency, and TPS below it."
|
|
363
|
+
emptyIcon={<IconMessageChatbot size={42} style={{ opacity: 0.5 }} />}
|
|
364
|
+
placeholder="Ask anything. Enter to send, Shift+Enter for a newline."
|
|
365
|
+
/>
|
|
666
366
|
)}
|
|
667
367
|
</div>
|
|
668
|
-
|
|
669
|
-
{error && (
|
|
670
|
-
<div className={styles.errorBar}>
|
|
671
|
-
<IconAlertTriangle size={14} />
|
|
672
|
-
{error}
|
|
673
|
-
</div>
|
|
674
|
-
)}
|
|
675
|
-
|
|
676
|
-
{isLoading && (
|
|
677
|
-
<div className={styles.stopBar}>
|
|
678
|
-
<span>Streaming response…</span>
|
|
679
|
-
<button className={styles.stopBtn} onClick={stopStream}>
|
|
680
|
-
Stop
|
|
681
|
-
</button>
|
|
682
|
-
</div>
|
|
683
|
-
)}
|
|
684
|
-
|
|
685
|
-
<div className={styles.inputBar}>
|
|
686
|
-
<textarea
|
|
687
|
-
className={styles.textarea}
|
|
688
|
-
placeholder="Ask the FCM router anything. Enter to send, Shift+Enter for newline."
|
|
689
|
-
value={input}
|
|
690
|
-
onChange={(e) => setInput(e.target.value)}
|
|
691
|
-
onKeyDown={handleKeyDown}
|
|
692
|
-
disabled={isLoading || (model === 'fcm' && daemonRunning !== true)}
|
|
693
|
-
rows={1}
|
|
694
|
-
data-testid="playground-input"
|
|
695
|
-
/>
|
|
696
|
-
<button
|
|
697
|
-
className={styles.sendBtn}
|
|
698
|
-
onClick={sendMessage}
|
|
699
|
-
disabled={isLoading || !input.trim() || (model === 'fcm' && daemonRunning !== true)}
|
|
700
|
-
data-testid="playground-send"
|
|
701
|
-
>
|
|
702
|
-
<IconSend size={14} />
|
|
703
|
-
Send
|
|
704
|
-
</button>
|
|
705
|
-
</div>
|
|
706
368
|
</div>
|
|
707
369
|
</div>
|
|
708
370
|
)
|