model selector: cached dropdown filtered by backend

This commit is contained in:
Levi Neely 2026-05-21 18:49:38 +02:00
parent c61a674289
commit 23a2de8fac
3 changed files with 100 additions and 19 deletions

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from 'preact/hooks' import { useState, useEffect } from 'preact/hooks'
import { getBackends, getAgents } from '../lib/api' import { getBackends, getAgents, fetchModels, loadModelsCache } from '../lib/api'
interface CreateParams { export interface CreateParams {
name?: string
cwd: string cwd: string
backend: string backend: string
model: string model: string
agent: string agent: string
[key: string]: string | undefined
} }
interface Props { interface Props {
@ -13,29 +15,33 @@ interface Props {
onCreate: (p: CreateParams) => Promise<void> onCreate: (p: CreateParams) => Promise<void>
} }
const DEFAULT_MODELS: Record<string, string> = {
anthropic: 'claude-opus-4-5',
openai: 'gpt-4o', const ADVANCED_FIELDS = [
ollama: 'llama3', 'maxTokens', 'maxCompletionTokens', 'temperature', 'topP', 'topK',
codewhisperer: 'anthropic.claude-3-5-sonnet-20241022-v2:0', 'minP', 'topA', 'frequencyPenalty', 'presencePenalty', 'repetitionPenalty',
copilot: 'gpt-4o', 'reasoning', 'reasoningEffort', 'includeReasoning', 'responseFormat',
} 'stop', 'verbosity',
] as const
export function NewSession({ onClose, onCreate }: Props) { export function NewSession({ onClose, onCreate }: Props) {
const [name, setName] = useState('')
const [cwd, setCwd] = useState('~') const [cwd, setCwd] = useState('~')
const [backend, setBackend] = useState('anthropic') const [backend, setBackend] = useState('anthropic')
const [model, setModel] = useState(DEFAULT_MODELS['anthropic']) const [model, setModel] = useState('')
const [agent, setAgent] = useState('default') const [agent, setAgent] = useState('default')
const [backends, setBackends] = useState<string[]>([]) const [backends, setBackends] = useState<string[]>([])
const [agents, setAgents] = useState<string[]>(['default']) const [agents, setAgents] = useState<string[]>(['default'])
const [modelCache, setModelCache] = useState(loadModelsCache)
const [advanced, setAdvanced] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
fetchModels().then(setModelCache).catch(() => {})
getBackends().then(bs => { getBackends().then(bs => {
if (bs.length > 0) { if (bs.length > 0) {
setBackends(bs) setBackends(bs)
setBackend(bs[0]) setBackend(bs[0])
setModel(DEFAULT_MODELS[bs[0]] ?? '')
} }
}).catch(() => {}) }).catch(() => {})
getAgents().then(as => { getAgents().then(as => {
@ -43,17 +49,36 @@ export function NewSession({ onClose, onCreate }: Props) {
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
// Models for selected backend, sorted
const models = (modelCache[backend] ?? []).slice().sort()
// Auto-select first model when backend or cache changes
useEffect(() => {
if (models.length) setModel(models[0])
}, [backend, modelCache])
const handleBackend = (b: string) => { const handleBackend = (b: string) => {
setBackend(b) setBackend(b)
setModel(DEFAULT_MODELS[b] ?? '') const cached = modelCache[b]
if (cached?.length) setModel(cached[0])
} }
const setAdv = (key: string, val: string) =>
setAdvanced(prev => ({ ...prev, [key]: val }))
const handleSubmit = async (e: Event) => { const handleSubmit = async (e: Event) => {
e.preventDefault() e.preventDefault()
if (!cwd.trim() || !model.trim()) return if (!cwd.trim() || !model.trim()) return
setLoading(true) setLoading(true)
try { try {
await onCreate({ cwd: cwd.trim(), backend, model: model.trim(), agent }) const params: CreateParams = {
...(name.trim() ? { name: name.trim() } : {}),
cwd: cwd.trim(), backend, model: model.trim(), agent,
}
for (const [k, v] of Object.entries(advanced)) {
if (v.trim()) params[k] = v.trim()
}
await onCreate(params)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -72,6 +97,17 @@ export function NewSession({ onClose, onCreate }: Props) {
</div> </div>
<form onSubmit={handleSubmit} style="display:flex;flex-direction:column;gap:18px;"> <form onSubmit={handleSubmit} style="display:flex;flex-direction:column;gap:18px;">
<div class="field">
<label>name</label>
<input
type="text"
value={name}
onInput={e => setName((e.target as HTMLInputElement).value)}
placeholder="optional"
autofocus
/>
</div>
<div class="field"> <div class="field">
<label>working directory</label> <label>working directory</label>
<input <input
@ -80,7 +116,6 @@ export function NewSession({ onClose, onCreate }: Props) {
onInput={e => setCwd((e.target as HTMLInputElement).value)} onInput={e => setCwd((e.target as HTMLInputElement).value)}
placeholder="~/src/myproject" placeholder="~/src/myproject"
required required
autofocus
/> />
</div> </div>
@ -109,15 +144,33 @@ export function NewSession({ onClose, onCreate }: Props) {
<div class="field"> <div class="field">
<label>model</label> <label>model</label>
<input <select
type="text"
value={model} value={model}
onInput={e => setModel((e.target as HTMLInputElement).value)} onChange={e => setModel((e.target as HTMLSelectElement).value)}
placeholder="model name"
required required
/> >
{models.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div> </div>
<details class="advanced-section">
<summary>Advanced</summary>
<div style="display:flex;flex-direction:column;gap:14px;padding-top:12px;">
{ADVANCED_FIELDS.map(key => (
<div class="field" key={key}>
<label>{key}</label>
<input
type="text"
value={advanced[key] ?? ''}
onInput={e => setAdv(key, (e.target as HTMLInputElement).value)}
/>
</div>
))}
</div>
</details>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" onClick={onClose}>cancel</button> <button type="button" class="btn btn-ghost" onClick={onClose}>cancel</button>
<button <button

View File

@ -48,10 +48,12 @@ export async function sendCtl(id: string, ctl: string): Promise<void> {
} }
export async function createSession(params: { export async function createSession(params: {
name?: string
cwd: string cwd: string
backend: string backend: string
model: string model: string
agent: string agent: string
[key: string]: string | undefined
}): Promise<void> { }): Promise<void> {
await fetch('/s/new', { await fetch('/s/new', {
method: 'POST', method: 'POST',
@ -60,6 +62,31 @@ export async function createSession(params: {
}) })
} }
// Fetch models grouped by backend. Persisted in localStorage.
const MODELS_CACHE_KEY = 'ollie:models'
export function loadModelsCache(): Record<string, string[]> {
try {
const raw = localStorage.getItem(MODELS_CACHE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return {}
}
export async function fetchModels(): Promise<Record<string, string[]>> {
const r = await fetch('/models')
if (!r.ok) return loadModelsCache()
const data: {backend: string, models: string[]}[] = await r.json()
const out: Record<string, string[]> = {}
for (const entry of data) {
out[entry.backend] = entry.models
}
localStorage.setItem(MODELS_CACHE_KEY, JSON.stringify(out))
return out
}
// Kick off fetch in background to refresh cache.
fetchModels()
export async function getBackends(): Promise<string[]> { export async function getBackends(): Promise<string[]> {
const r = await fetch('/backends') const r = await fetch('/backends')
if (!r.ok) return [] if (!r.ok) return []

View File

@ -12,6 +12,7 @@ export default defineConfig({
'/t/': 'http://localhost:8080', '/t/': 'http://localhost:8080',
'/sk/': 'http://localhost:8080', '/sk/': 'http://localhost:8080',
'/backends': 'http://localhost:8080', '/backends': 'http://localhost:8080',
'/models': 'http://localhost:8080',
'/open': 'http://localhost:8080', '/open': 'http://localhost:8080',
'/openapi.json': 'http://localhost:8080', '/openapi.json': 'http://localhost:8080',
}, },