free-coding-models 0.5.9 → 0.5.11
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 +37 -0
- package/bin/free-coding-models.js +6 -1
- package/changelog/v0.5.10.md +7 -0
- package/changelog/v0.5.11.md +56 -0
- package/package.json +1 -1
- package/src/core/config.js +44 -1
- package/src/core/playground.js +502 -0
- package/src/core/router-daemon.js +113 -3
- package/src/core/utils.js +7 -0
- package/src/tui/app.js +9 -1
- package/src/tui/cli-help.js +2 -0
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +22 -0
- package/src/tui/overlays.js +11 -0
- package/src/tui/theme.js +3 -0
- package/web/dist/assets/index-DeEJErFO.js +39 -0
- package/web/dist/assets/{index-DKmmiDip.css → index-Z24EXUEe.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/server.js +87 -0
- package/web/src/App.jsx +15 -1
- package/web/src/components/install/InstallEndpointsView.jsx +39 -9
- package/web/src/components/install/InstallEndpointsView.module.css +86 -10
- package/web/src/components/layout/Header.jsx +2 -1
- package/web/src/components/palette/CommandPalette.jsx +1 -0
- package/web/src/components/playground/PlaygroundView.jsx +528 -0
- package/web/src/components/playground/PlaygroundView.module.css +413 -0
- package/web/dist/assets/index-27IYGKp2.js +0 -39
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file playground.js
|
|
3
|
+
* @description TUI Playground — a chat overlay that talks to the local FCM
|
|
4
|
+
* router through its `/v1/chat/completions` endpoint. Streams responses via
|
|
5
|
+
* SSE so the user sees the answer appear token-by-token.
|
|
6
|
+
*
|
|
7
|
+
* 📖 The TUI Playground is intentionally simpler than the Web Playground:
|
|
8
|
+
* 📖 one chat at a time, no copy-paste, no theme overrides. It's a quick
|
|
9
|
+
* 📖 way to test the router without leaving the TUI.
|
|
10
|
+
*
|
|
11
|
+
* @functions
|
|
12
|
+
* → openPlaygroundOverlay — reset state and mark the overlay open
|
|
13
|
+
* → closePlaygroundOverlay — cancel any in-flight request, mark closed
|
|
14
|
+
* → renderPlayground — render the full-screen chat overlay
|
|
15
|
+
* → handlePlaygroundKeypress — handle Enter / Esc / arrow keys
|
|
16
|
+
* → playgroundSubmit — POST the current draft to the router
|
|
17
|
+
* → parsePlaygroundSseFrame — parse one SSE frame (defensive, mirrors router-dashboard)
|
|
18
|
+
*
|
|
19
|
+
* @see ./router-daemon.js — chat-completions endpoint we POST to
|
|
20
|
+
* @see ./config.js — pre-prompt lives under `router.prePrompt`
|
|
21
|
+
* @see ../tui/overlays.js — overlay factory that mounts this renderer
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
25
|
+
import { ROUTER_PORT_PATH } from './router-daemon.js'
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { themeColors } from '../tui/theme.js'
|
|
28
|
+
|
|
29
|
+
// 📖 Width budget for the wrapped input + transcript columns inside the
|
|
30
|
+
// 📖 overlay. Slightly tighter than the full terminal so borders have room
|
|
31
|
+
// 📖 and long assistant messages wrap nicely.
|
|
32
|
+
const STREAM_TIMEOUT_MS = 90000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 📖 Extract a human-readable message from an OpenAI-style error payload.
|
|
36
|
+
* 📖 Mirrors the helper in `web/src/components/playground/PlaygroundView.jsx`
|
|
37
|
+
* 📖 so the TUI and the Web show the same error string. Without this, a
|
|
38
|
+
* 📖 router error like `{ error: { message, type, code, ... } }` would be
|
|
39
|
+
* 📖 stored as a React child and crash the playground on the next render.
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} errBody
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export function extractErrorMessage(errBody) {
|
|
45
|
+
if (!errBody || typeof errBody !== 'object') {
|
|
46
|
+
return typeof errBody === 'string' ? errBody : null
|
|
47
|
+
}
|
|
48
|
+
if (typeof errBody.error === 'string') return errBody.error
|
|
49
|
+
if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
|
|
50
|
+
return errBody.error.message
|
|
51
|
+
}
|
|
52
|
+
if (typeof errBody.message === 'string') return errBody.message
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const PLAYGROUND_OVERLAY_STATE = {
|
|
57
|
+
open: false,
|
|
58
|
+
messages: [], // { role, content, meta? }
|
|
59
|
+
draft: '',
|
|
60
|
+
busy: false,
|
|
61
|
+
model: 'fcm',
|
|
62
|
+
streamOn: true,
|
|
63
|
+
prePrompt: null, // { enabled, text } hydrated on open
|
|
64
|
+
statusMessage: null,
|
|
65
|
+
abortController: null,
|
|
66
|
+
scrollOffset: 0,
|
|
67
|
+
cursor: 0, // line cursor inside the textarea draft
|
|
68
|
+
lastError: null,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MAX_TRANSCRIPT_LINES = 200
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 📖 Reset the playground to a clean state and mark the overlay open.
|
|
75
|
+
* 📖 Fetches the pre-prompt from the router (if reachable) so the chat
|
|
76
|
+
* 📖 shows the real persona.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} state - global TUI state
|
|
79
|
+
* @param {object} deps - { fetchFn, loadConfig }
|
|
80
|
+
*/
|
|
81
|
+
export async function openPlaygroundOverlay(state, deps = {}) {
|
|
82
|
+
PLAYGROUND_OVERLAY_STATE.open = true
|
|
83
|
+
PLAYGROUND_OVERLAY_STATE.messages = []
|
|
84
|
+
PLAYGROUND_OVERLAY_STATE.draft = ''
|
|
85
|
+
PLAYGROUND_OVERLAY_STATE.busy = false
|
|
86
|
+
PLAYGROUND_OVERLAY_STATE.streamOn = true
|
|
87
|
+
PLAYGROUND_OVERLAY_STATE.model = 'fcm'
|
|
88
|
+
PLAYGROUND_OVERLAY_STATE.statusMessage = null
|
|
89
|
+
PLAYGROUND_OVERLAY_STATE.scrollOffset = 0
|
|
90
|
+
PLAYGROUND_OVERLAY_STATE.cursor = 0
|
|
91
|
+
PLAYGROUND_OVERLAY_STATE.lastError = null
|
|
92
|
+
PLAYGROUND_OVERLAY_STATE.abortController = null
|
|
93
|
+
|
|
94
|
+
// 📖 Best-effort fetch of the pre-prompt so the persona pill in the
|
|
95
|
+
// 📖 header is accurate. Never fail the overlay over a missing read.
|
|
96
|
+
try {
|
|
97
|
+
const port = await readDaemonPort()
|
|
98
|
+
if (port) {
|
|
99
|
+
const fetchFn = deps.fetchFn || globalThis.fetch
|
|
100
|
+
const resp = await fetchFn(`http://127.0.0.1:${port}/api/router/preprompt`, { signal: AbortSignal.timeout(2000) })
|
|
101
|
+
if (resp.ok) {
|
|
102
|
+
const data = await resp.json().catch(() => null)
|
|
103
|
+
if (data && typeof data === 'object') {
|
|
104
|
+
PLAYGROUND_OVERLAY_STATE.prePrompt = {
|
|
105
|
+
enabled: data.enabled === true,
|
|
106
|
+
text: typeof data.text === 'string' ? data.text : '',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
PLAYGROUND_OVERLAY_STATE.prePrompt = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 📖 Mark the overlay closed. Cancels any in-flight streaming request so we
|
|
118
|
+
* 📖 don't leave dangling fetch handles behind.
|
|
119
|
+
*/
|
|
120
|
+
export function closePlaygroundOverlay() {
|
|
121
|
+
if (PLAYGROUND_OVERLAY_STATE.abortController) {
|
|
122
|
+
try { PLAYGROUND_OVERLAY_STATE.abortController.abort() } catch {}
|
|
123
|
+
PLAYGROUND_OVERLAY_STATE.abortController = null
|
|
124
|
+
}
|
|
125
|
+
PLAYGROUND_OVERLAY_STATE.open = false
|
|
126
|
+
PLAYGROUND_OVERLAY_STATE.busy = false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 📖 Append a delta token to the last assistant message in the transcript.
|
|
131
|
+
* 📖 Mutates in place so streaming updates don't trigger expensive array
|
|
132
|
+
* 📖 recreations.
|
|
133
|
+
*/
|
|
134
|
+
function appendAssistantDelta(delta) {
|
|
135
|
+
const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
|
|
136
|
+
if (last && last.role === 'assistant') {
|
|
137
|
+
last.content = (last.content || '') + delta
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 📖 Finalize the last assistant message with provider/latency/tokens
|
|
143
|
+
* 📖 metadata so the next render can show the routed-via chip.
|
|
144
|
+
*/
|
|
145
|
+
function finalizeAssistantMeta(meta) {
|
|
146
|
+
const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
|
|
147
|
+
if (last && last.role === 'assistant') {
|
|
148
|
+
last.meta = { ...(last.meta || {}), ...meta }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 📖 POST the current draft to the router and stream the response back.
|
|
154
|
+
* 📖 Cancellation: aborting the in-flight controller keeps the partial
|
|
155
|
+
* 📖 answer visible but flags it with `aborted: true` so the renderer
|
|
156
|
+
* 📖 can show "stopped" instead of pretending the response completed.
|
|
157
|
+
*/
|
|
158
|
+
export async function playgroundSubmit(state, deps = {}) {
|
|
159
|
+
if (PLAYGROUND_OVERLAY_STATE.busy) return
|
|
160
|
+
const text = PLAYGROUND_OVERLAY_STATE.draft.trim()
|
|
161
|
+
if (!text) return
|
|
162
|
+
|
|
163
|
+
const port = await readDaemonPort()
|
|
164
|
+
if (!port) {
|
|
165
|
+
PLAYGROUND_OVERLAY_STATE.lastError = 'Router is not running. Press R or run `free-coding-models --daemon-bg`.'
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const userMessage = { role: 'user', content: text, ts: Date.now() }
|
|
170
|
+
PLAYGROUND_OVERLAY_STATE.messages.push(userMessage)
|
|
171
|
+
PLAYGROUND_OVERLAY_STATE.messages.push({ role: 'assistant', content: '', ts: Date.now() })
|
|
172
|
+
PLAYGROUND_OVERLAY_STATE.draft = ''
|
|
173
|
+
PLAYGROUND_OVERLAY_STATE.cursor = 0
|
|
174
|
+
PLAYGROUND_OVERLAY_STATE.busy = true
|
|
175
|
+
PLAYGROUND_OVERLAY_STATE.lastError = null
|
|
176
|
+
PLAYGROUND_OVERLAY_STATE.statusMessage = 'Sending…'
|
|
177
|
+
|
|
178
|
+
const controller = new AbortController()
|
|
179
|
+
PLAYGROUND_OVERLAY_STATE.abortController = controller
|
|
180
|
+
|
|
181
|
+
const transcript = PLAYGROUND_MESSAGES_SAFE()
|
|
182
|
+
|
|
183
|
+
const body = {
|
|
184
|
+
model: PLAYGROUND_OVERLAY_STATE.model || 'fcm',
|
|
185
|
+
messages: transcript.map(({ role, content }) => ({ role, content })),
|
|
186
|
+
stream: PLAYGROUND_OVERLAY_STATE.streamOn,
|
|
187
|
+
temperature: 0.7,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const url = `http://127.0.0.1:${port}/v1/chat/completions`
|
|
191
|
+
const fetchFn = deps.fetchFn || globalThis.fetch
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const resp = await fetchFn(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify(body),
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
})
|
|
200
|
+
if (!resp.ok) {
|
|
201
|
+
const errBody = await resp.json().catch(() => null)
|
|
202
|
+
const msg = extractErrorMessage(errBody)
|
|
203
|
+
PLAYGROUND_OVERLAY_STATE.lastError = `HTTP ${resp.status}: ${msg || 'request failed'}`
|
|
204
|
+
finalizeAssistantMeta({ error: PLAYGROUND_OVERLAY_STATE.lastError, aborted: true })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (PLAYGROUND_OVERLAY_STATE.streamOn) {
|
|
208
|
+
await readSseStream(resp, fetchFn, controller)
|
|
209
|
+
} else {
|
|
210
|
+
const json = await resp.json().catch(() => null)
|
|
211
|
+
const content = json?.choices?.[0]?.message?.content || ''
|
|
212
|
+
const usage = json?.usage || {}
|
|
213
|
+
// 📖 Some upstreams do not echo the `X-Routed-Via` header in the body;
|
|
214
|
+
// 📖 fall back to the `x-routed-via` snake-case field if present.
|
|
215
|
+
finalizeAssistantMeta({
|
|
216
|
+
provider: json?.x_routed_via || null,
|
|
217
|
+
model: json?.x_routed_model || null,
|
|
218
|
+
latencyMs: json?.x_latency_ms || null,
|
|
219
|
+
tokens: usage?.total_tokens || 0,
|
|
220
|
+
fallbackAttempts: json?.x_fallback_attempts || 0,
|
|
221
|
+
})
|
|
222
|
+
// 📖 Direct content replacement for non-streaming responses.
|
|
223
|
+
const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
|
|
224
|
+
if (last && last.role === 'assistant') last.content = content
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (err?.name === 'AbortError') {
|
|
228
|
+
finalizeAssistantMeta({ aborted: true })
|
|
229
|
+
} else {
|
|
230
|
+
PLAYGROUND_OVERLAY_STATE.lastError = err?.message || String(err)
|
|
231
|
+
finalizeAssistantMeta({ error: PLAYGROUND_OVERLAY_STATE.lastError, aborted: true })
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
PLAYGROUND_OVERLAY_STATE.busy = false
|
|
235
|
+
PLAYGROUND_OVERLAY_STATE.abortController = null
|
|
236
|
+
PLAYGROUND_OVERLAY_STATE.statusMessage = null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function PLAYGROUND_MESSAGES_SAFE() {
|
|
241
|
+
// 📖 Trim the transcript to the last 20 turns so long sessions do not blow
|
|
242
|
+
// 📖 the request body budget. We always keep the pre-prompt implicit on
|
|
243
|
+
// 📖 the server side, so this is the user-facing history only.
|
|
244
|
+
return PLAYGROUND_OVERLAY_STATE.messages.slice(-20)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function readSseStream(resp, fetchFn, controller) {
|
|
248
|
+
const reader = resp.body?.getReader()
|
|
249
|
+
if (!reader) {
|
|
250
|
+
const json = await resp.json().catch(() => null)
|
|
251
|
+
const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
|
|
252
|
+
if (last && last.role === 'assistant') last.content = json?.choices?.[0]?.message?.content || ''
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
const decoder = new TextDecoder()
|
|
256
|
+
let buffer = ''
|
|
257
|
+
let streamTimer = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
|
|
258
|
+
try {
|
|
259
|
+
while (true) {
|
|
260
|
+
const { value, done } = await reader.read()
|
|
261
|
+
if (done) break
|
|
262
|
+
clearTimeout(streamTimer)
|
|
263
|
+
streamTimer = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
|
|
264
|
+
buffer += decoder.decode(value, { stream: true })
|
|
265
|
+
const events = buffer.split(/\n\n/)
|
|
266
|
+
buffer = events.pop() || ''
|
|
267
|
+
for (const event of events) {
|
|
268
|
+
for (const line of event.split(/\n/)) {
|
|
269
|
+
if (!line.startsWith('data:')) continue
|
|
270
|
+
const payload = line.slice(5).trim()
|
|
271
|
+
if (payload === '[DONE]') continue
|
|
272
|
+
try {
|
|
273
|
+
const json = JSON.parse(payload)
|
|
274
|
+
const delta = json?.choices?.[0]?.delta?.content
|
|
275
|
+
if (delta) appendAssistantDelta(delta)
|
|
276
|
+
if (json?.x_routed_via) finalizeAssistantMeta({ provider: json.x_routed_via })
|
|
277
|
+
if (json?.x_routed_model) finalizeAssistantMeta({ model: json.x_routed_model })
|
|
278
|
+
if (json?.x_latency_ms) finalizeAssistantMeta({ latencyMs: json.x_latency_ms })
|
|
279
|
+
if (json?.x_fallback_attempts) finalizeAssistantMeta({ fallbackAttempts: json.x_fallback_attempts })
|
|
280
|
+
if (json?.usage?.total_tokens) finalizeAssistantMeta({ tokens: json.usage.total_tokens })
|
|
281
|
+
} catch {
|
|
282
|
+
// 📖 Non-JSON keep-alive frames; ignore.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
clearTimeout(streamTimer)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 📖 Read the daemon port from disk. Returns null when the daemon is not
|
|
294
|
+
* 📖 running. Mirrors the helper in `router-dashboard.js` so the playground
|
|
295
|
+
* 📖 can stand alone in the TUI process.
|
|
296
|
+
*/
|
|
297
|
+
async function readDaemonPort() {
|
|
298
|
+
// 📖 Try the recorded port file first.
|
|
299
|
+
try {
|
|
300
|
+
if (existsSync(ROUTER_PORT_PATH)) {
|
|
301
|
+
const raw = readFileSync(ROUTER_PORT_PATH, 'utf8').trim()
|
|
302
|
+
if (/^\d+$/.test(raw)) return Number(raw)
|
|
303
|
+
}
|
|
304
|
+
} catch {}
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 📖 Handle a keypress inside the playground overlay. Returns true if the
|
|
310
|
+
* 📖 key was consumed so the main key handler can skip it.
|
|
311
|
+
*/
|
|
312
|
+
export function handlePlaygroundKeypress(key, deps = {}) {
|
|
313
|
+
if (!PLAYGROUND_OVERLAY_STATE.open) return false
|
|
314
|
+
// 📖 Esc always closes.
|
|
315
|
+
if (key === 'Escape' || key === '\u001b') {
|
|
316
|
+
closePlaygroundOverlay()
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
if (PLAYGROUND_OVERLAY_STATE.busy) {
|
|
320
|
+
// 📖 While streaming, only Esc and Ctrl+C are accepted.
|
|
321
|
+
if (key === 'C-c' || key === '\u0003') {
|
|
322
|
+
if (PLAYGROUND_OVERLAY_STATE.abortController) PLAYGROUND_OVERLAY_STATE.abortController.abort()
|
|
323
|
+
return true
|
|
324
|
+
}
|
|
325
|
+
return key === 'Escape'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (key === 'C-c' || key === '\u0003') {
|
|
329
|
+
if (PLAYGROUND_OVERLAY_STATE.abortController) PLAYGROUND_OVERLAY_STATE.abortController.abort()
|
|
330
|
+
PLAYGROUND_OVERLAY_STATE.busy = false
|
|
331
|
+
return true
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (key === 'Enter' || key === '\r' || key === '\n') {
|
|
335
|
+
void playgroundSubmit(null, deps)
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
if (key === 'Backspace' || key === '\b' || key === '\u007f') {
|
|
339
|
+
PLAYGROUND_OVERLAY_STATE.draft = PLAYGROUND_OVERLAY_STATE.draft.slice(0, -1)
|
|
340
|
+
return true
|
|
341
|
+
}
|
|
342
|
+
if (key === 'Tab' || key === '\t') {
|
|
343
|
+
// 📖 Cycle the model between fcm and a random catalog entry for quick testing.
|
|
344
|
+
PLAYGROUND_OVERLAY_STATE.model = PLAYGROUND_OVERLAY_STATE.model === 'fcm' ? 'groq/llama-3.3-70b-versatile' : 'fcm'
|
|
345
|
+
return true
|
|
346
|
+
}
|
|
347
|
+
if (key === 'C-l' || key === '\u000c') {
|
|
348
|
+
// 📖 Clear transcript
|
|
349
|
+
PLAYGROUND_OVERLAY_STATE.messages = []
|
|
350
|
+
PLAYGROUND_OVERLAY_STATE.scrollOffset = 0
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
if (key === 'C-s' || key === '\u0013') {
|
|
354
|
+
PLAYGROUND_OVERLAY_STATE.streamOn = !PLAYGROUND_OVERLAY_STATE.streamOn
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 📖 Arrow keys for transcript scrolling (page up/down style).
|
|
359
|
+
if (key === 'PageUp' || key === '\u001b[5~') {
|
|
360
|
+
PLAYGROUND_OVERLAY_STATE.scrollOffset = Math.max(0, PLAYGROUND_OVERLAY_STATE.scrollOffset - 4)
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
if (key === 'PageDown' || key === '\u001b[6~') {
|
|
364
|
+
PLAYGROUND_OVERLAY_STATE.scrollOffset = PLAYGROUND_OVERLAY_STATE.scrollOffset + 4
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 📖 Regular printable characters: append to draft.
|
|
369
|
+
if (key.length === 1 && key >= ' ' && key <= '~') {
|
|
370
|
+
PLAYGROUND_OVERLAY_STATE.draft += key
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
// 📖 Multi-byte (UTF-8) printable characters: still treat as one grapheme.
|
|
374
|
+
if (key && key.length > 1 && !key.startsWith('\u001b')) {
|
|
375
|
+
PLAYGROUND_OVERLAY_STATE.draft += key
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 📖 Render the playground overlay. Returns the painted buffer string ready
|
|
383
|
+
* 📖 to write to the alt-screen. Wraps long lines and tints the overlay
|
|
384
|
+
* 📖 background for visual separation.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} state - global TUI state
|
|
387
|
+
* @param {number} terminalRows
|
|
388
|
+
* @param {number} terminalCols
|
|
389
|
+
* @returns {string}
|
|
390
|
+
*/
|
|
391
|
+
export function renderPlayground(state, terminalRows, terminalCols) {
|
|
392
|
+
if (!PLAYGROUND_OVERLAY_STATE.open) return ''
|
|
393
|
+
const lines = []
|
|
394
|
+
const innerWidth = Math.max(40, terminalCols - 8)
|
|
395
|
+
|
|
396
|
+
// 📖 Header
|
|
397
|
+
lines.push(themeColors.accentBold(' 💬 Playground — chat with the FCM router'))
|
|
398
|
+
lines.push(themeColors.dim(' Press Enter to send · Shift+Tab cycles model · Ctrl+S toggles streaming · Esc closes · Ctrl+L clears'))
|
|
399
|
+
lines.push('')
|
|
400
|
+
|
|
401
|
+
// 📖 Persona pill (one liner preview of the pre-prompt)
|
|
402
|
+
const pre = PLAYGROUND_OVERLAY_STATE.prePrompt
|
|
403
|
+
if (pre && pre.enabled && pre.text) {
|
|
404
|
+
const preview = pre.text.length > innerWidth - 16 ? `${pre.text.slice(0, innerWidth - 19)}…` : pre.text
|
|
405
|
+
lines.push(themeColors.dim(` Persona: ${preview.replace(/\n+/g, ' ')}`))
|
|
406
|
+
} else {
|
|
407
|
+
lines.push(themeColors.dim(' Persona: (none)'))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 📖 Model + mode row
|
|
411
|
+
const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? 'streaming' : 'one-shot'
|
|
412
|
+
lines.push(themeColors.dim(` Model: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
|
|
413
|
+
if (PLAYGROUND_OVERLAY_STATE.lastError) {
|
|
414
|
+
lines.push(themeColors.errorBold(` ⚠ ${PLAYGROUND_OVERLAY_STATE.lastError}`))
|
|
415
|
+
}
|
|
416
|
+
lines.push('')
|
|
417
|
+
|
|
418
|
+
// 📖 Transcript
|
|
419
|
+
const transcriptLines = []
|
|
420
|
+
for (const msg of PLAYGROUND_OVERLAY_STATE.messages) {
|
|
421
|
+
const role = msg.role === 'user'
|
|
422
|
+
? themeColors.accentBold(' ❯ you')
|
|
423
|
+
: msg.role === 'assistant'
|
|
424
|
+
? themeColors.dim(' ✦ fcm')
|
|
425
|
+
: themeColors.dim(` · ${msg.role}`)
|
|
426
|
+
transcriptLines.push(role)
|
|
427
|
+
const wrapped = wrapMessage(msg.content || '', innerWidth)
|
|
428
|
+
for (const line of wrapped) {
|
|
429
|
+
transcriptLines.push(` ${line}`)
|
|
430
|
+
}
|
|
431
|
+
if (msg.role === 'assistant' && msg.meta) {
|
|
432
|
+
const metaChips = []
|
|
433
|
+
if (msg.meta.provider) metaChips.push(`routed ${msg.meta.provider}/${msg.meta.model || '?'}`)
|
|
434
|
+
if (msg.meta.latencyMs != null) metaChips.push(`${msg.meta.latencyMs}ms`)
|
|
435
|
+
if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} tok`)
|
|
436
|
+
if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} fallback`)
|
|
437
|
+
if (metaChips.length) {
|
|
438
|
+
transcriptLines.push(themeColors.dim(` [ ${metaChips.join(' · ')} ]`))
|
|
439
|
+
}
|
|
440
|
+
if (msg.meta.aborted) {
|
|
441
|
+
transcriptLines.push(themeColors.dim(' [ stopped ]'))
|
|
442
|
+
}
|
|
443
|
+
if (msg.meta.error) {
|
|
444
|
+
transcriptLines.push(themeColors.error(` [ error: ${msg.meta.error} ]`))
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
transcriptLines.push('')
|
|
448
|
+
}
|
|
449
|
+
lines.push(...transcriptLines)
|
|
450
|
+
|
|
451
|
+
// 📖 Pad the transcript up to a stable minimum height so the input box
|
|
452
|
+
// 📖 doesn't jump around between renders.
|
|
453
|
+
const minTranscriptLines = Math.max(8, terminalRows - 12)
|
|
454
|
+
while (lines.length < minTranscriptLines) lines.push('')
|
|
455
|
+
|
|
456
|
+
// 📖 Input box
|
|
457
|
+
lines.push(themeColors.dim(' ─'.repeat(Math.max(8, Math.floor(innerWidth / 4)))))
|
|
458
|
+
if (PLAYGROUND_OVERLAY_STATE.busy) {
|
|
459
|
+
lines.push(themeColors.accent(' ⏳ waiting for response — Esc to stop'))
|
|
460
|
+
} else {
|
|
461
|
+
const draft = PLAYGROUND_OVERLAY_STATE.draft || 'Type your message and press Enter…'
|
|
462
|
+
const draftDisplay = PLAYGROUND_OVERLAY_STATE.draft ? draft : themeColors.dim(draft)
|
|
463
|
+
const wrappedDraft = wrapMessage(draftDisplay, innerWidth - 4)
|
|
464
|
+
for (const line of wrappedDraft) {
|
|
465
|
+
lines.push(` ❯ ${line}`)
|
|
466
|
+
}
|
|
467
|
+
if (!PLAYGROUND_OVERLAY_STATE.draft) {
|
|
468
|
+
lines.push(themeColors.dim(' (Enter to send · Shift+Tab for a pinned model)'))
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 📖 Slice for the terminal height so we never overflow.
|
|
473
|
+
const { visible, offset } = sliceOverlayLines(lines, PLAYGROUND_OVERLAY_STATE.scrollOffset, terminalRows)
|
|
474
|
+
PLAYGROUND_OVERLAY_STATE.scrollOffset = offset
|
|
475
|
+
const tinted = tintOverlayLines(visible, themeColors.overlayBgPlayground, terminalCols)
|
|
476
|
+
return tinted.map((l) => l + '\x1b[0m').join('\n')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 📖 Wrap a string into lines of at most `width` display columns. Respects
|
|
481
|
+
* 📖 existing newlines so the user can paste multi-line content.
|
|
482
|
+
*/
|
|
483
|
+
function wrapMessage(text, width) {
|
|
484
|
+
if (!text) return ['']
|
|
485
|
+
const out = []
|
|
486
|
+
for (const paragraph of text.split(/\n/)) {
|
|
487
|
+
if (!paragraph) { out.push(''); continue }
|
|
488
|
+
const words = paragraph.split(/(\s+)/)
|
|
489
|
+
let current = ''
|
|
490
|
+
for (const word of words) {
|
|
491
|
+
const candidate = current + word
|
|
492
|
+
if (displayWidth(candidate) > width && current) {
|
|
493
|
+
out.push(current)
|
|
494
|
+
current = word.trimStart()
|
|
495
|
+
} else {
|
|
496
|
+
current = candidate
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (current) out.push(current)
|
|
500
|
+
}
|
|
501
|
+
return out
|
|
502
|
+
}
|
|
@@ -394,7 +394,17 @@ function getWebConfigPayload(runtime) {
|
|
|
394
394
|
cliOnly: src.cliOnly || false,
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
|
-
|
|
397
|
+
const router = runtime.routerConfig()
|
|
398
|
+
return {
|
|
399
|
+
providers,
|
|
400
|
+
totalModels: MODELS.length,
|
|
401
|
+
prePrompt: {
|
|
402
|
+
enabled: router.prePrompt?.enabled === true,
|
|
403
|
+
text: router.prePrompt?.text || '',
|
|
404
|
+
isDefault: router.prePrompt?.text === DEFAULT_ROUTER_SETTINGS.prePrompt.text
|
|
405
|
+
&& router.prePrompt?.enabled === DEFAULT_ROUTER_SETTINGS.prePrompt.enabled,
|
|
406
|
+
},
|
|
407
|
+
}
|
|
398
408
|
}
|
|
399
409
|
|
|
400
410
|
const WEB_DIST_DIR = resolvePath(__dirname, '..', '..', 'web', 'dist')
|
|
@@ -616,6 +626,54 @@ function readJsonBody(req) {
|
|
|
616
626
|
})
|
|
617
627
|
}
|
|
618
628
|
|
|
629
|
+
/**
|
|
630
|
+
* 📖 Inject the configured router pre-prompt as the first `system` message
|
|
631
|
+
* 📖 of the request, ahead of any user-provided messages. The pre-prompt is
|
|
632
|
+
* 📖 always prepended (not appended) so it takes precedence over the
|
|
633
|
+
* 📖 per-conversation tone; user `system` messages after the pre-prompt can
|
|
634
|
+
* 📖 still override specific instructions.
|
|
635
|
+
*
|
|
636
|
+
* 📖 If the pre-prompt is disabled or empty, the messages array is returned
|
|
637
|
+
* 📖 as-is. The function is pure: it never mutates the input.
|
|
638
|
+
*
|
|
639
|
+
* @param {unknown} messages
|
|
640
|
+
* @param {{ enabled?: boolean, text?: string }|null|undefined} prePrompt
|
|
641
|
+
* @returns {Array}
|
|
642
|
+
*/
|
|
643
|
+
export function injectPrePrompt(messages, prePrompt) {
|
|
644
|
+
if (!Array.isArray(messages)) return messages
|
|
645
|
+
if (!prePrompt || prePrompt.enabled !== true) return messages
|
|
646
|
+
const text = typeof prePrompt.text === 'string' ? prePrompt.text.trim() : ''
|
|
647
|
+
if (!text) return messages
|
|
648
|
+
// 📖 Skip injection if the very first message is already an exact match —
|
|
649
|
+
// 📖 prevents duplicate system messages when the client retries a request
|
|
650
|
+
// 📖 or the Playground already sent the pre-prompt itself.
|
|
651
|
+
const first = messages[0]
|
|
652
|
+
if (first && first.role === 'system' && typeof first.content === 'string' && first.content.trim() === text) {
|
|
653
|
+
return messages
|
|
654
|
+
}
|
|
655
|
+
return [{ role: 'system', content: text }, ...messages]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 📖 Apply the pre-prompt to a chat-completion body. Returns a new body so
|
|
660
|
+
* 📖 we never mutate the client's payload. Used by both the streaming and
|
|
661
|
+
* 📖 non-streaming proxy paths.
|
|
662
|
+
*
|
|
663
|
+
* @param {Record<string, unknown>|null|undefined} body
|
|
664
|
+
* @param {{ enabled?: boolean, text?: string }|null|undefined} prePrompt
|
|
665
|
+
* @returns {Record<string, unknown>}
|
|
666
|
+
*/
|
|
667
|
+
export function applyPrePromptToBody(body, prePrompt) {
|
|
668
|
+
const safeBody = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {}
|
|
669
|
+
// 📖 If the body is missing `messages`, start with an empty array so
|
|
670
|
+
// 📖 downstream code that always expects `messages` does not have to
|
|
671
|
+
// 📖 special-case the pre-prompt path.
|
|
672
|
+
const baseMessages = Array.isArray(safeBody.messages) ? safeBody.messages : []
|
|
673
|
+
const messages = injectPrePrompt(baseMessages, prePrompt)
|
|
674
|
+
return { ...safeBody, messages }
|
|
675
|
+
}
|
|
676
|
+
|
|
619
677
|
class RouterLogger {
|
|
620
678
|
constructor(logPath, level = 'info') {
|
|
621
679
|
this.logPath = logPath
|
|
@@ -1547,8 +1605,12 @@ class RouterRuntime {
|
|
|
1547
1605
|
const controller = new AbortController()
|
|
1548
1606
|
const timeout = setTimeout(() => controller.abort(), this.routerConfig().failover.requestTimeoutMs)
|
|
1549
1607
|
const started = performance.now()
|
|
1608
|
+
// 📖 Pre-prompt is injected server-side so every client (OpenAI SDK,
|
|
1609
|
+
// 📖 curl, custom Playground) gets the FCM persona without any client
|
|
1610
|
+
// 📖 change. Non-streaming path.
|
|
1611
|
+
const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
|
|
1550
1612
|
const upstreamBody = {
|
|
1551
|
-
...
|
|
1613
|
+
...bodyWithPrePrompt,
|
|
1552
1614
|
model: getApiModelId(candidate.provider, candidate.model),
|
|
1553
1615
|
stream: false,
|
|
1554
1616
|
}
|
|
@@ -1681,8 +1743,12 @@ class RouterRuntime {
|
|
|
1681
1743
|
}
|
|
1682
1744
|
const controller = new AbortController()
|
|
1683
1745
|
const started = performance.now()
|
|
1746
|
+
// 📖 Pre-prompt is injected server-side so every client (OpenAI SDK,
|
|
1747
|
+
// 📖 curl, custom Playground) gets the FCM persona without any client
|
|
1748
|
+
// 📖 change. Streaming path.
|
|
1749
|
+
const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
|
|
1684
1750
|
const upstreamBody = {
|
|
1685
|
-
...
|
|
1751
|
+
...bodyWithPrePrompt,
|
|
1686
1752
|
model: getApiModelId(candidate.provider, candidate.model),
|
|
1687
1753
|
stream: true,
|
|
1688
1754
|
}
|
|
@@ -2030,6 +2096,50 @@ class RouterRuntime {
|
|
|
2030
2096
|
sendJson(res, 200, getWebConfigPayload(this), { 'x-request-id': requestId })
|
|
2031
2097
|
return
|
|
2032
2098
|
}
|
|
2099
|
+
if (url.pathname === '/api/router/preprompt') {
|
|
2100
|
+
// 📖 Pre-prompt lives in `~/.free-coding-models.json` under
|
|
2101
|
+
// 📖 `router.prePrompt`. The GET returns the effective value so the
|
|
2102
|
+
// 📖 Playground can render it next to the input box, and the PUT
|
|
2103
|
+
// 📖 updates the persisted config and triggers a hot reload so the
|
|
2104
|
+
// 📖 next proxied request uses the new pre-prompt without restart.
|
|
2105
|
+
if (req.method === 'GET') {
|
|
2106
|
+
const router = this.routerConfig()
|
|
2107
|
+
const fallback = DEFAULT_ROUTER_SETTINGS.prePrompt
|
|
2108
|
+
const isDefault = router.prePrompt?.text === fallback.text && router.prePrompt?.enabled === fallback.enabled
|
|
2109
|
+
sendJson(res, 200, {
|
|
2110
|
+
enabled: router.prePrompt?.enabled === true,
|
|
2111
|
+
text: router.prePrompt?.text || '',
|
|
2112
|
+
isDefault,
|
|
2113
|
+
defaultText: fallback.text,
|
|
2114
|
+
}, { 'x-request-id': requestId })
|
|
2115
|
+
return
|
|
2116
|
+
}
|
|
2117
|
+
if (req.method === 'PUT') {
|
|
2118
|
+
if (!isSameOriginOrLocal(req)) {
|
|
2119
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
const body = await readJsonBody(req)
|
|
2123
|
+
const nextEnabled = body?.enabled === true
|
|
2124
|
+
const nextText = typeof body?.text === 'string' ? body.text.slice(0, 4000) : ''
|
|
2125
|
+
const nextRouter = {
|
|
2126
|
+
...this.routerConfig(),
|
|
2127
|
+
prePrompt: { enabled: nextEnabled, text: nextText },
|
|
2128
|
+
}
|
|
2129
|
+
this.setRouterConfig(nextRouter)
|
|
2130
|
+
this.saveRouterConfig()
|
|
2131
|
+
this.broadcast('config', { activeSet: this.routerConfig().activeSet, prePrompt: this.routerConfig().prePrompt })
|
|
2132
|
+
sendJson(res, 200, {
|
|
2133
|
+
ok: true,
|
|
2134
|
+
enabled: nextEnabled,
|
|
2135
|
+
text: nextText,
|
|
2136
|
+
isDefault: nextText === DEFAULT_ROUTER_SETTINGS.prePrompt.text && nextEnabled === DEFAULT_ROUTER_SETTINGS.prePrompt.enabled,
|
|
2137
|
+
}, { 'x-request-id': requestId })
|
|
2138
|
+
return
|
|
2139
|
+
}
|
|
2140
|
+
sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['GET', 'PUT'] })
|
|
2141
|
+
return
|
|
2142
|
+
}
|
|
2033
2143
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
2034
2144
|
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
|
|
2035
2145
|
sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
|
package/src/core/utils.js
CHANGED
|
@@ -417,6 +417,7 @@ export function findBestModel(results) {
|
|
|
417
417
|
// --xcode, --gemini, --jcode, --copilot, --forgecode,
|
|
418
418
|
// --daemon, --daemon-bg, --daemon-stop,
|
|
419
419
|
// --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
|
|
420
|
+
// --playground / playground subcommand (open the in-TUI chat playground)
|
|
420
421
|
// - Value flag: --tier <letter> (the next non-flag arg is the tier value)
|
|
421
422
|
//
|
|
422
423
|
// Returns:
|
|
@@ -519,6 +520,11 @@ export function parseArgs(argv) {
|
|
|
519
520
|
// 📖 --web / --gui / web subcommand — launch the web dashboard instead of the TUI
|
|
520
521
|
const webMode = flags.includes('--web') || flags.includes('--gui') || args[0] === 'web'
|
|
521
522
|
|
|
523
|
+
// 📖 --playground / playground subcommand — boot the TUI directly into the
|
|
524
|
+
// 📖 Playground chat overlay (assumes the router daemon is running or can
|
|
525
|
+
// 📖 be started with `free-coding-models --daemon-bg` first).
|
|
526
|
+
const playgroundMode = flags.includes('--playground') || args[0] === 'playground'
|
|
527
|
+
|
|
522
528
|
// New boolean flags
|
|
523
529
|
const sortDesc = flags.includes('--desc')
|
|
524
530
|
const sortAscFlag = flags.includes('--asc')
|
|
@@ -574,6 +580,7 @@ export function parseArgs(argv) {
|
|
|
574
580
|
showUnconfigured,
|
|
575
581
|
premiumMode,
|
|
576
582
|
webMode,
|
|
583
|
+
playgroundMode,
|
|
577
584
|
daemonMode,
|
|
578
585
|
daemonBackgroundMode,
|
|
579
586
|
daemonStopMode,
|