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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/playground/PlaygroundChat.jsx
|
|
3
|
+
* @description The SINGLE shared playground chat core used by all three
|
|
4
|
+
* playgrounds in the web app:
|
|
5
|
+
* 1. PlaygroundView (full-screen modal, multi-turn) — variant="full"
|
|
6
|
+
* 2. RouterView ("Test Router" section) — variant="mini"
|
|
7
|
+
* 3. ExpandedDetailRow ("Mini Playground" column) — variant="mini"
|
|
8
|
+
*
|
|
9
|
+
* 📖 Why this exists: the three playgrounds used to each ship their own copy
|
|
10
|
+
* 📖 of the input/transcript/SSE-parsing/metadata code, which drifted apart —
|
|
11
|
+
* 📖 ExpandedDetailRow showed NO metadata at all, RouterView showed a partial
|
|
12
|
+
* 📖 badge, and PlaygroundView read body fields (x_routed_via / x_latency_ms)
|
|
13
|
+
* 📖 that the daemon never actually emits, so its chips were dead code. This
|
|
14
|
+
* 📖 component is the single source of truth: identical look + identical
|
|
15
|
+
* 📖 metadata on every surface.
|
|
16
|
+
*
|
|
17
|
+
* 📖 Metadata contract (the thing the user asked to harmonize):
|
|
18
|
+
* 📖 • Under every USER bubble → the addressed model (`provider/model`),
|
|
19
|
+
* 📖 or the router's Primary pick when routing through `fcm`.
|
|
20
|
+
* 📖 • Under every ASSISTANT bubble →
|
|
21
|
+
* 📖 `provider/model · 1234ms · 42 tok · 34.1 t/s`
|
|
22
|
+
* 📖 The served model is read from the response header `x-fcm-router-model`
|
|
23
|
+
* 📖 (forwarded by the daemon for `fcm`); for direct routing the served
|
|
24
|
+
* 📖 model is simply the requested model. Latency is measured client-side
|
|
25
|
+
* 📖 (send → stream end); tokens come from the final chunk's `usage`;
|
|
26
|
+
* 📖 TPS = tokens / (latencyMs / 1000).
|
|
27
|
+
*
|
|
28
|
+
* 📖 All traffic goes through `/api/playground/chat` (the proxy in server.js)
|
|
29
|
+
* 📖 so the browser never touches providers directly (no CORS, no exposed keys).
|
|
30
|
+
*
|
|
31
|
+
* @functions
|
|
32
|
+
* → PlaygroundChat — self-contained chat core (input + transcript + meta)
|
|
33
|
+
* → parseModelKey — split "provider/modelId" into { provider, model }
|
|
34
|
+
* → extractErrorMessage — normalize OpenAI-style error payloads to a string
|
|
35
|
+
* → renderAssistantText — render triple-backtick code blocks without a dep
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef,
|
|
39
|
+
} from 'react'
|
|
40
|
+
import {
|
|
41
|
+
IconSend, IconX, IconCopy, IconCheck, IconBolt, IconClock,
|
|
42
|
+
IconAlertTriangle, IconRefresh, IconLoader,
|
|
43
|
+
} from '@tabler/icons-react'
|
|
44
|
+
import styles from './PlaygroundChat.module.css'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split a "provider/modelId" key into its parts. Returns { provider, model }.
|
|
48
|
+
* Tolerant: "fcm" → { provider: 'fcm', model: '' }; "a/b/c" → provider='a', model='b/c'.
|
|
49
|
+
* @param {string|null|undefined} key
|
|
50
|
+
* @returns {{provider:string, model:string}|null}
|
|
51
|
+
*/
|
|
52
|
+
function parseModelKey(key) {
|
|
53
|
+
if (!key) return null
|
|
54
|
+
const idx = key.indexOf('/')
|
|
55
|
+
if (idx <= 0) return { provider: key, model: '' }
|
|
56
|
+
return { provider: key.slice(0, idx), model: key.slice(idx + 1) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 📖 Normalize an OpenAI-style error body to a plain string. Both shapes are
|
|
61
|
+
* 📖 accepted: `{ error: "msg" }` (custom) and `{ error: { message, type, code } }`
|
|
62
|
+
* 📖 (the OpenAI wire format). Returning a string is critical because React
|
|
63
|
+
* 📖 throws if a non-string sneaks into JSX.
|
|
64
|
+
* @param {unknown} errBody
|
|
65
|
+
* @returns {string|null}
|
|
66
|
+
*/
|
|
67
|
+
function extractErrorMessage(errBody) {
|
|
68
|
+
if (!errBody || typeof errBody !== 'object') {
|
|
69
|
+
return typeof errBody === 'string' ? errBody : null
|
|
70
|
+
}
|
|
71
|
+
if (typeof errBody.error === 'string') return errBody.error
|
|
72
|
+
if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
|
|
73
|
+
return errBody.error.message
|
|
74
|
+
}
|
|
75
|
+
if (typeof errBody.message === 'string') return errBody.message
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 📖 Render assistant text with triple-backtick code blocks rendered as <pre>.
|
|
81
|
+
* 📖 No markdown dependency — keeps the dashboard zero-dep. Shared by every
|
|
82
|
+
* 📖 playground so code looks identical everywhere.
|
|
83
|
+
* @param {string} text
|
|
84
|
+
* @returns {React.ReactNode}
|
|
85
|
+
*/
|
|
86
|
+
function renderAssistantText(text) {
|
|
87
|
+
if (!text) return null
|
|
88
|
+
const parts = text.split(/(```[\s\S]*?```)/g)
|
|
89
|
+
return parts.map((part, idx) => {
|
|
90
|
+
const codeMatch = part.match(/^```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```$/)
|
|
91
|
+
if (codeMatch) {
|
|
92
|
+
return (
|
|
93
|
+
<pre key={idx} className={styles.codeBlock}>
|
|
94
|
+
<code>{codeMatch[2].replace(/\n$/, '')}</code>
|
|
95
|
+
</pre>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return <span key={idx}>{part}</span>
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Round to one decimal, returns '—' for non-finite numbers. */
|
|
103
|
+
function round1(n) {
|
|
104
|
+
return Number.isFinite(n) ? (Math.round(n * 10) / 10).toFixed(1) : '—'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shared playground chat core. See file header.
|
|
109
|
+
*
|
|
110
|
+
* @param {Object} props
|
|
111
|
+
* @param {string} props.model — 'fcm' (auto-router) or 'providerKey/modelId' (direct).
|
|
112
|
+
* @param {boolean} [props.disabled=false] — disable the input + send button (e.g. daemon down).
|
|
113
|
+
* @param {'full'|'mini'} [props.variant='mini'] — density/size of the UI.
|
|
114
|
+
* @param {boolean} [props.stream=true] — request SSE streaming vs. one-shot JSON.
|
|
115
|
+
* @param {string} [props.placeholder] — input placeholder.
|
|
116
|
+
* @param {string} [props.targetLabel] — override the "addressed model" label shown under the
|
|
117
|
+
* user bubble. The RouterView passes its Primary pick here so the user sees which model the
|
|
118
|
+
* priority-first router will try first. When omitted, the addressed model is derived from `model`.
|
|
119
|
+
* @param {string[]} [props.suggestions] — clickable chips in the empty state.
|
|
120
|
+
* @param {string} [props.emptyTitle] — empty-state title.
|
|
121
|
+
* @param {string} [props.emptyHint] — empty-state hint line.
|
|
122
|
+
* @param {React.ReactNode} [props.emptyIcon] — empty-state icon.
|
|
123
|
+
* @param {React.ReactNode} [props.children] — optional extra node rendered above the transcript
|
|
124
|
+
* (used by PlaygroundView to host its daemon-start panel / pre-prompt hint).
|
|
125
|
+
* @param {(summary:{count:number,totalTokens:number,lastMeta?:object}) => void} [props.onTurnComplete]
|
|
126
|
+
* — fired after every turn so hosts can update their own chrome (token counters, etc.).
|
|
127
|
+
* @param {React.Ref} ref — imperative handle exposing { clear(), focus(), getMessages() }.
|
|
128
|
+
*/
|
|
129
|
+
const PlaygroundChat = forwardRef(function PlaygroundChat({
|
|
130
|
+
model,
|
|
131
|
+
disabled = false,
|
|
132
|
+
variant = 'mini',
|
|
133
|
+
stream = true,
|
|
134
|
+
placeholder,
|
|
135
|
+
targetLabel,
|
|
136
|
+
suggestions = [],
|
|
137
|
+
emptyTitle,
|
|
138
|
+
emptyHint,
|
|
139
|
+
emptyIcon,
|
|
140
|
+
children,
|
|
141
|
+
onTurnComplete,
|
|
142
|
+
}, ref) {
|
|
143
|
+
// 📖 Each message: { id, role, content, target?, meta?, error?, aborted?, pending? }
|
|
144
|
+
// 📖 meta = { provider, model, latencyMs, tokens, tps }
|
|
145
|
+
const [messages, setMessages] = useState([])
|
|
146
|
+
const [input, setInput] = useState('')
|
|
147
|
+
const [loading, setLoading] = useState(false)
|
|
148
|
+
const [copiedIdx, setCopiedIdx] = useState(null)
|
|
149
|
+
const abortRef = useRef(null)
|
|
150
|
+
const inputRef = useRef(null)
|
|
151
|
+
const transcriptRef = useRef(null)
|
|
152
|
+
// 📖 Ref mirror of `messages` so the imperative handle (below) can read the
|
|
153
|
+
// 📖 live transcript without being recreated on every message change.
|
|
154
|
+
const messagesRef = useRef([])
|
|
155
|
+
|
|
156
|
+
useImperativeHandle(ref, () => ({
|
|
157
|
+
/** Stop any in-flight stream and clear the transcript. */
|
|
158
|
+
clear: () => {
|
|
159
|
+
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null }
|
|
160
|
+
setLoading(false)
|
|
161
|
+
setMessages([])
|
|
162
|
+
},
|
|
163
|
+
focus: () => inputRef.current?.focus(),
|
|
164
|
+
// 📖 `getMessages` is a stable ref read, so the handle never has to be
|
|
165
|
+
// 📖 recreated when messages change.
|
|
166
|
+
getMessages: () => messagesRef.current,
|
|
167
|
+
}), [])
|
|
168
|
+
|
|
169
|
+
// 📖 Keep the ref mirror in sync with the state transcript.
|
|
170
|
+
useEffect(() => { messagesRef.current = messages }, [messages])
|
|
171
|
+
|
|
172
|
+
// 📖 Notify the host after a turn SETTLES (token counters etc.). Gated on
|
|
173
|
+
// 📖 the last message being finalized (meta / error / aborted) so it doesn't
|
|
174
|
+
// 📖 fire on every streamed token — only once per completed exchange.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (typeof onTurnComplete !== 'function') return
|
|
177
|
+
const last = messages[messages.length - 1]
|
|
178
|
+
if (!last || last.pending) return // still streaming
|
|
179
|
+
const settled = last.role === 'assistant' && (!!last.meta || last.error || last.aborted)
|
|
180
|
+
if (!settled) return
|
|
181
|
+
const totalTokens = messages.reduce((sum, m) => sum + (m.meta?.tokens || 0), 0)
|
|
182
|
+
onTurnComplete({ count: messages.length, totalTokens, lastMeta: last.meta })
|
|
183
|
+
}, [messages, onTurnComplete])
|
|
184
|
+
|
|
185
|
+
// 📖 Auto-scroll the transcript to the bottom as content streams in.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const el = transcriptRef.current
|
|
188
|
+
if (el) el.scrollTop = el.scrollHeight
|
|
189
|
+
}, [messages, loading])
|
|
190
|
+
|
|
191
|
+
const stop = useCallback(() => {
|
|
192
|
+
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null }
|
|
193
|
+
setLoading(false)
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const sendMessage = useCallback(async (overrideText) => {
|
|
197
|
+
const text = (typeof overrideText === 'string' ? overrideText : input).trim()
|
|
198
|
+
if (!text || loading || disabled) return
|
|
199
|
+
|
|
200
|
+
setInput('')
|
|
201
|
+
|
|
202
|
+
// 📖 Build the addressed-model label for the USER bubble.
|
|
203
|
+
const targetKey = targetLabel || (model === 'fcm' ? 'fcm' : model)
|
|
204
|
+
|
|
205
|
+
const userMsg = { id: `u-${Date.now()}`, role: 'user', content: text, ts: Date.now(), target: targetKey }
|
|
206
|
+
const assistantId = `a-${Date.now()}`
|
|
207
|
+
const assistantMsg = { id: assistantId, role: 'assistant', content: '', pending: true }
|
|
208
|
+
const transcript = [...messages, userMsg]
|
|
209
|
+
setMessages([...transcript, assistantMsg])
|
|
210
|
+
setLoading(true)
|
|
211
|
+
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
abortRef.current = controller
|
|
214
|
+
const startedAt = Date.now()
|
|
215
|
+
|
|
216
|
+
// 📖 Patch helpers: update the in-flight assistant bubble in place.
|
|
217
|
+
const patchAssistant = (patch) => setMessages((prev) => prev.map((m) => (
|
|
218
|
+
m.id === assistantId ? { ...m, ...patch } : m
|
|
219
|
+
)))
|
|
220
|
+
|
|
221
|
+
// 📖 Served-model resolution. The daemon (fcm route) forwards the real
|
|
222
|
+
// 📖 served model through the `x-fcm-router-model` response header; for
|
|
223
|
+
// 📖 direct routing there is no such header, so the served model is the
|
|
224
|
+
// 📖 requested one. We resolve the header lazily once we have the resp.
|
|
225
|
+
let servedKey = null
|
|
226
|
+
const resolveServed = (resp) => {
|
|
227
|
+
const headerKey = resp.headers.get('x-fcm-router-model')
|
|
228
|
+
servedKey = headerKey || (model !== 'fcm' ? model : null)
|
|
229
|
+
return parseModelKey(servedKey) || { provider: model, model: '' }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const body = {
|
|
233
|
+
model: model || 'fcm',
|
|
234
|
+
messages: transcript.map(({ role, content }) => ({ role, content })),
|
|
235
|
+
stream,
|
|
236
|
+
temperature: 0.7,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const resp = await fetch('/api/playground/chat', {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: { 'Content-Type': 'application/json' },
|
|
243
|
+
body: JSON.stringify(body),
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if (!resp.ok) {
|
|
248
|
+
const errBody = await resp.json().catch(() => null)
|
|
249
|
+
const errMsg = extractErrorMessage(errBody) || `Request failed (${resp.status})`
|
|
250
|
+
patchAssistant({ pending: false, error: errMsg })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (stream) {
|
|
255
|
+
// ── SSE streaming path ─────────────────────────────────────────
|
|
256
|
+
const reader = resp.body?.getReader()
|
|
257
|
+
if (!reader) throw new Error('No stream reader available')
|
|
258
|
+
const decoder = new TextDecoder()
|
|
259
|
+
let buffer = ''
|
|
260
|
+
let acc = ''
|
|
261
|
+
let served = resolveServed(resp)
|
|
262
|
+
let tokens = 0
|
|
263
|
+
let servedKnown = !!servedKey
|
|
264
|
+
|
|
265
|
+
while (true) {
|
|
266
|
+
const { value, done } = await reader.read()
|
|
267
|
+
if (done) break
|
|
268
|
+
buffer += decoder.decode(value, { stream: true })
|
|
269
|
+
// 📖 Split on blank-line SSE boundaries; keep the trailing partial.
|
|
270
|
+
const events = buffer.split(/\n\n/)
|
|
271
|
+
buffer = events.pop() || ''
|
|
272
|
+
for (const event of events) {
|
|
273
|
+
const lines = event.split(/\n/)
|
|
274
|
+
for (const line of lines) {
|
|
275
|
+
if (!line.startsWith('data:')) continue
|
|
276
|
+
const payload = line.slice(5).trim()
|
|
277
|
+
if (payload === '[DONE]') continue
|
|
278
|
+
try {
|
|
279
|
+
const json = JSON.parse(payload)
|
|
280
|
+
const delta = json?.choices?.[0]?.delta?.content
|
|
281
|
+
if (typeof delta === 'string' && delta) {
|
|
282
|
+
acc += delta
|
|
283
|
+
patchAssistant({ content: acc, pending: false })
|
|
284
|
+
}
|
|
285
|
+
// 📖 Tokens: the final chunk carries usage. Prefer completion
|
|
286
|
+
// 📖 tokens (matches what was generated) over total.
|
|
287
|
+
const usage = json?.usage
|
|
288
|
+
if (usage) {
|
|
289
|
+
if (typeof usage.completion_tokens === 'number') tokens = usage.completion_tokens
|
|
290
|
+
else if (typeof usage.total_tokens === 'number') tokens = usage.total_tokens
|
|
291
|
+
}
|
|
292
|
+
// 📖 Some daemons/proxies also forward the served model in the
|
|
293
|
+
// 📖 body — accept it as a fallback to the header.
|
|
294
|
+
if (!servedKnown && json?.x_routed_via) {
|
|
295
|
+
served = { provider: json.x_routed_via, model: json.x_routed_model || '' }
|
|
296
|
+
servedKnown = true
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// 📖 Ignore non-JSON keep-alive frames.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const latencyMs = Date.now() - startedAt
|
|
306
|
+
const tps = tokens > 0 && latencyMs > 0 ? tokens / (latencyMs / 1000) : null
|
|
307
|
+
patchAssistant({
|
|
308
|
+
pending: false,
|
|
309
|
+
meta: {
|
|
310
|
+
provider: served.provider,
|
|
311
|
+
model: served.model,
|
|
312
|
+
latencyMs,
|
|
313
|
+
tokens,
|
|
314
|
+
tps,
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
} else {
|
|
318
|
+
// ── One-shot JSON path ─────────────────────────────────────────
|
|
319
|
+
const json = await resp.json().catch(() => null)
|
|
320
|
+
const content = json?.choices?.[0]?.message?.content || ''
|
|
321
|
+
const usage = json?.usage || {}
|
|
322
|
+
const tokens = typeof usage.completion_tokens === 'number'
|
|
323
|
+
? usage.completion_tokens
|
|
324
|
+
: (usage.total_tokens || 0)
|
|
325
|
+
const served = resolveServed(resp)
|
|
326
|
+
const latencyMs = Date.now() - startedAt
|
|
327
|
+
const tps = tokens > 0 && latencyMs > 0 ? tokens / (latencyMs / 1000) : null
|
|
328
|
+
patchAssistant({
|
|
329
|
+
pending: false,
|
|
330
|
+
content,
|
|
331
|
+
meta: {
|
|
332
|
+
provider: served.provider,
|
|
333
|
+
model: served.model,
|
|
334
|
+
latencyMs,
|
|
335
|
+
tokens,
|
|
336
|
+
tps,
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (err?.name === 'AbortError') {
|
|
343
|
+
patchAssistant({ pending: false, aborted: true })
|
|
344
|
+
} else {
|
|
345
|
+
patchAssistant({ pending: false, error: err?.message || String(err) })
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
setLoading(false)
|
|
349
|
+
abortRef.current = null
|
|
350
|
+
}
|
|
351
|
+
}, [input, loading, disabled, messages, model, stream, targetLabel, onTurnComplete])
|
|
352
|
+
|
|
353
|
+
const handleKeyDown = useCallback((e) => {
|
|
354
|
+
// 📖 Enter sends, Shift+Enter inserts a newline (full variant only).
|
|
355
|
+
if (e.key === 'Enter' && !e.shiftKey && variant === 'full') {
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
void sendMessage()
|
|
358
|
+
} else if (e.key === 'Enter' && variant === 'mini') {
|
|
359
|
+
e.preventDefault()
|
|
360
|
+
void sendMessage()
|
|
361
|
+
}
|
|
362
|
+
}, [sendMessage, variant])
|
|
363
|
+
|
|
364
|
+
const copyMessage = useCallback(async (idx, content) => {
|
|
365
|
+
try {
|
|
366
|
+
await navigator.clipboard.writeText(content || '')
|
|
367
|
+
setCopiedIdx(idx)
|
|
368
|
+
setTimeout(() => setCopiedIdx(null), 1500)
|
|
369
|
+
} catch {}
|
|
370
|
+
}, [])
|
|
371
|
+
|
|
372
|
+
// 📖 Tiny auto-grow for the textarea so 'full' expands with long prompts.
|
|
373
|
+
const handleInputChange = useCallback((e) => {
|
|
374
|
+
setInput(e.target.value)
|
|
375
|
+
if (variant === 'full') {
|
|
376
|
+
const el = e.target
|
|
377
|
+
el.style.height = 'auto'
|
|
378
|
+
el.style.height = `${Math.min(el.scrollHeight, 180)}px`
|
|
379
|
+
}
|
|
380
|
+
}, [variant])
|
|
381
|
+
|
|
382
|
+
const variantClass = variant === 'full' ? styles.full : styles.mini
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div className={`${styles.root} ${variantClass}`}>
|
|
386
|
+
{children}
|
|
387
|
+
|
|
388
|
+
<div className={styles.transcript} ref={transcriptRef}>
|
|
389
|
+
{messages.length === 0 ? (
|
|
390
|
+
<div className={styles.empty}>
|
|
391
|
+
{emptyIcon && <div className={styles.emptyIcon}>{emptyIcon}</div>}
|
|
392
|
+
{emptyTitle && <div className={styles.emptyTitle}>{emptyTitle}</div>}
|
|
393
|
+
{emptyHint && <div className={styles.emptyHint}>{emptyHint}</div>}
|
|
394
|
+
{suggestions.length > 0 && (
|
|
395
|
+
<div className={styles.suggestions}>
|
|
396
|
+
{suggestions.map((s) => (
|
|
397
|
+
<button key={s} className={styles.chip} onClick={() => void sendMessage(s)} type="button">
|
|
398
|
+
{s}
|
|
399
|
+
</button>
|
|
400
|
+
))}
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
) : (
|
|
405
|
+
messages.map((m, idx) => (
|
|
406
|
+
<div key={m.id || idx} className={`${styles.message} ${styles[m.role] || ''}`}>
|
|
407
|
+
<div className={styles.bubbleRow}>
|
|
408
|
+
<span className={styles.role}>{m.role}</span>
|
|
409
|
+
{m.role === 'assistant' && m.content && (
|
|
410
|
+
<button
|
|
411
|
+
className={styles.copyBtn}
|
|
412
|
+
onClick={() => copyMessage(idx, m.content)}
|
|
413
|
+
title="Copy reply"
|
|
414
|
+
type="button"
|
|
415
|
+
>
|
|
416
|
+
{copiedIdx === idx ? <IconCheck size={11} /> : <IconCopy size={11} />}
|
|
417
|
+
</button>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className={styles.bubble}>
|
|
422
|
+
{m.role === 'assistant' ? (
|
|
423
|
+
<>
|
|
424
|
+
{m.content ? renderAssistantText(m.content) : null}
|
|
425
|
+
{loading && idx === messages.length - 1 && m.pending && !m.aborted && (
|
|
426
|
+
<span className={styles.cursor} />
|
|
427
|
+
)}
|
|
428
|
+
{!m.content && m.pending && <span className={styles.typing}>…</span>}
|
|
429
|
+
</>
|
|
430
|
+
) : (
|
|
431
|
+
m.content
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{/* ── Metadata row under the USER bubble: addressed model ── */}
|
|
436
|
+
{m.role === 'user' && m.target && (
|
|
437
|
+
<div className={styles.meta}>
|
|
438
|
+
<span className={`${styles.metaChip} ${styles.target}`} title="Addressed model">
|
|
439
|
+
<IconBolt size={11} />
|
|
440
|
+
{m.target}
|
|
441
|
+
</span>
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{/* ── Metadata row under the ASSISTANT bubble: served model + ms + tok + t/s ── */}
|
|
446
|
+
{m.role === 'assistant' && !m.pending && m.meta && (
|
|
447
|
+
<div className={styles.meta}>
|
|
448
|
+
{m.meta.provider && (
|
|
449
|
+
<span className={`${styles.metaChip} ${styles.modelChip}`} title="Model that served this reply">
|
|
450
|
+
<IconBolt size={11} />
|
|
451
|
+
{m.meta.provider}{m.meta.model ? `/${m.meta.model}` : ''}
|
|
452
|
+
</span>
|
|
453
|
+
)}
|
|
454
|
+
{m.meta.latencyMs != null && (
|
|
455
|
+
<span className={`${styles.metaChip} ${styles.latencyChip}`} title="Response time (send → end)">
|
|
456
|
+
<IconClock size={11} />
|
|
457
|
+
{Math.round(m.meta.latencyMs)}ms
|
|
458
|
+
</span>
|
|
459
|
+
)}
|
|
460
|
+
{m.meta.tokens > 0 && (
|
|
461
|
+
<span className={styles.metaChip} title="Completion tokens">
|
|
462
|
+
{m.meta.tokens} tok
|
|
463
|
+
</span>
|
|
464
|
+
)}
|
|
465
|
+
{m.meta.tps != null && m.meta.tps > 0 && (
|
|
466
|
+
<span className={`${styles.metaChip} ${styles.tpsChip}`} title="Tokens per second">
|
|
467
|
+
<IconRefresh size={11} />
|
|
468
|
+
{round1(m.meta.tps)} t/s
|
|
469
|
+
</span>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
|
|
474
|
+
{m.role === 'assistant' && m.error && (
|
|
475
|
+
<div className={styles.meta}>
|
|
476
|
+
<span className={`${styles.metaChip} ${styles.errorChip}`} title="Error">
|
|
477
|
+
<IconAlertTriangle size={11} />
|
|
478
|
+
{m.error}
|
|
479
|
+
</span>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
{m.role === 'assistant' && m.aborted && !m.error && (
|
|
483
|
+
<div className={styles.meta}>
|
|
484
|
+
<span className={styles.metaChip}>stopped</span>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
))
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<div className={styles.inputBar}>
|
|
493
|
+
<textarea
|
|
494
|
+
ref={inputRef}
|
|
495
|
+
className={styles.input}
|
|
496
|
+
placeholder={placeholder || (variant === 'full'
|
|
497
|
+
? 'Ask anything. Enter to send, Shift+Enter for a newline.'
|
|
498
|
+
: 'Send a message…')}
|
|
499
|
+
value={input}
|
|
500
|
+
onChange={handleInputChange}
|
|
501
|
+
onKeyDown={handleKeyDown}
|
|
502
|
+
disabled={disabled}
|
|
503
|
+
rows={1}
|
|
504
|
+
/>
|
|
505
|
+
{loading ? (
|
|
506
|
+
<button className={styles.stopBtn} onClick={stop} title="Stop generating" type="button">
|
|
507
|
+
<IconLoader size={14} className={styles.spin} />
|
|
508
|
+
{variant === 'full' ? 'Stop' : ''}
|
|
509
|
+
</button>
|
|
510
|
+
) : (
|
|
511
|
+
<button
|
|
512
|
+
className={styles.sendBtn}
|
|
513
|
+
onClick={() => void sendMessage()}
|
|
514
|
+
disabled={disabled || !input.trim()}
|
|
515
|
+
title="Send"
|
|
516
|
+
type="button"
|
|
517
|
+
>
|
|
518
|
+
<IconSend size={14} />
|
|
519
|
+
{variant === 'full' ? 'Send' : ''}
|
|
520
|
+
</button>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
export default PlaygroundChat
|