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.
@@ -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
- return { providers, totalModels: MODELS.length }
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
- ...body,
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
- ...body,
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,