Tesseract / studio /src /components /ModelPicker.jsx
rkbosamia's picture
Tesseract major version upgrade - v2
9c49532
Raw
History Blame Contribute Delete
5.2 kB
/**
* ModelPicker — inference-model dropdown that sits in the TopBar right
* next to the Run pill.
*
* Lives in the TopBar (not the StatusBar, not the composer) because:
* - Model is a session-level control, not a chat-message-level one — putting
* it near Run groups it with the other "what's running right now"
* affordances (run / stop / reload / open-in-browser).
* - The composer area is already busy (pacing pill, hints, pinned chips).
* - The StatusBar runs the full width including under the preview pane;
* the model has nothing to do with what's rendering in the preview.
*
* Saved to the user's profile via PATCH /api/auth/me/preferred-model so the
* choice persists across sessions, reloads, and chat turns.
*
* Visual: h-7 pill matching the rest of the TopBar action cluster. Accent
* caps "MODEL" label, monospace model name, ▾ caret. Native <select> sits
* at opacity 0 over the pill so keyboard nav / a11y come for free.
*/
import useAppStore from '../store/appStore'
// Some model names are long ("openai/gpt-oss-120b", "llama-3.3-70b-versatile").
// Strip provider prefix and shorten common suffixes so the pill stays compact
// in the TopBar. Hover title still shows the full identifier.
function displayName(id) {
if (!id) return '—'
// Strip "openai/", "qwen/" provider prefix
const noProvider = id.includes('/') ? id.split('/').pop() : id
return noProvider
}
export default function ModelPicker() {
const model = useAppStore((s) => s.model)
const supportedModels = useAppStore((s) => s.supportedModels)
const setModel = useAppStore((s) => s.setModel)
const isStreaming = useAppStore((s) => s.isStreaming)
const setCurrentUser = useAppStore((s) => s.setCurrentUser)
const showToast = useAppStore((s) => s.showToast)
const list = supportedModels && supportedModels.length > 0
? supportedModels
: (model ? [model] : [])
if (list.length === 0) return null
async function onChange(next) {
if (!next || next === model) return
try {
const r = await fetch('/api/auth/me/preferred-model', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: next }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const user = await r.json()
setCurrentUser(user)
setModel(next)
showToast?.(`Model switched to ${next}`)
} catch (e) {
showToast?.(`Model switch failed: ${e.message}`)
}
}
const disabled = isStreaming
const current = model || list[0]
return (
<span
title={disabled
? 'Wait for the current turn to finish before switching models'
: `Inference model — ${current} · saved to your profile`
}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 28, // matches h-7 chrome of sibling TopBar controls
padding: '0 10px',
borderRadius: 6,
background: 'var(--surface-2)',
border: '1px solid var(--border-2)',
fontSize: 11,
color: 'var(--text-dim)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.55 : 1,
transition: 'border-color 0.15s, background 0.15s',
}}
onMouseEnter={(e) => {
if (disabled) return
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.background = 'var(--surface-3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-2)'
e.currentTarget.style.background = 'var(--surface-2)'
}}
>
<span
style={{
color: 'var(--accent-hi)',
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
fontSize: 9.5,
pointerEvents: 'none',
}}
>
Model
</span>
<span
style={{
fontFamily: 'ui-monospace, "JetBrains Mono", monospace',
color: 'var(--text)',
fontSize: 11,
pointerEvents: 'none',
maxWidth: 180,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayName(current)}
</span>
<span style={{ color: 'var(--text-faint)', pointerEvents: 'none', fontSize: 9 }}></span>
<select
value={current}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
aria-label="Inference model"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: disabled ? 'not-allowed' : 'pointer',
appearance: 'none',
border: 'none',
padding: 0,
font: 'inherit',
}}
>
{list.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</span>
)
}