feat(ui): use the shared model picker everywhere

Settings, config fields, the failover and router editors, the agent task
form, the scheduling rule sheet and the agent job form now use
ModelPicker. SearchableModelSelect, ModelMultiSelect and the scheduling
ModelPicker are removed; ModelSelector stays as the name add-ons use and
draws the shared picker. Aliases come from the capabilities listing, so
the scheduling sheet no longer builds its own hints.

A broken alias is listed, marked and not selectable. The picker's style
sheet now loads with the picker, so the e2e specs that read a settings
group right after opening it wait for its first field.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:40:07 +00:00
1 parent dbb8e8a022
commit 6ffc9e82c1
23 files changed
+169 -463

No files matched your search

+2 -1
View File
@@ -51,7 +51,8 @@ test.describe('Agent tasks', () => {
await expect(form.getByText('Choose a model.')).toBeVisible()
await expect(form.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true')
await form.getByLabel('Name').fill('weekly-summary')
await form.getByLabel('Model').selectOption('qwen3-8b')
await form.getByRole('button', { name: 'Model' }).click()
await page.getByRole('option', { name: /qwen3-8b/ }).click()
await form.getByLabel('Prompt').fill('Summarise the week of {{.team}}')
await expect(page.getByTestId('agent-task-inputs')).toContainText('team')
await page.getByRole('button', { name: 'Save task' }).click()
@@ -384,6 +384,29 @@ test('create uses the chat picker, filters unusable models and persists the alia
expect(aliasRequests).toEqual([])
})
test('create can pick an alias, shows what it stands for, and saves the alias name', async ({ page }) => {
const models = [
{ id: 'production', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp' },
{ id: 'daily', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp', alias_of: 'production' },
{ id: 'lost', capabilities: [], alias_of: 'gone', alias_error: 'alias "lost" points to unknown model "gone"' },
]
const s = await installAgents(page, { models })
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Alias picker')
const chip = await openModel(page)
await chip.click()
const options = page.getByRole('listbox').getByRole('option')
await expect(options.filter({ hasText: 'daily' })).toContainText('alias of production')
await expect(options.filter({ hasText: 'lost' })).toHaveAttribute('aria-disabled', 'true')
if (process.env.PICKER_SHOTS) await page.waitForTimeout(600)
if (process.env.PICKER_SHOTS) await page.screenshot({ path: `${process.env.PICKER_SHOTS}/agent-create-picker.png` })
await options.filter({ hasText: 'daily' }).click()
await expect(chip).toContainText('daily')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/agents').body.model).toBe('daily')
})
test('edit retains the stored alias while capabilities load and through unrelated saves, then can change or clear it', async ({ page }) => {
const s = await installAgents(page, { models: aliasModels, agents: [{ ...AGENTS[0], model: 'production' }] })
let release
+27 -18
View File
@@ -47,6 +47,15 @@ async function mockCommon(page) {
})
}
// A target row is the shared model picker: a chip that opens a list with a
// filter box, where a name that is not listed is committed as typed.
const ROWS = '.fte-row__body [data-testid="home-model-chip"]'
async function setModel(page, row, name) {
await row.click()
await page.getByRole('combobox', { name: 'Filter models' }).fill(name)
await page.getByRole('combobox', { name: 'Filter models' }).press('Enter')
}
test.describe('Failover Chain template - create flow', () => {
test.beforeEach(async ({ page }) => {
await mockCommon(page)
@@ -64,51 +73,51 @@ test.describe('Failover Chain template - create flow', () => {
await expect(page.getByText('Failover targets').first()).toBeVisible()
// Two empty {model: ''} rows seeded by the template.
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(2)
await expect(page.locator(ROWS)).toHaveCount(2)
await expect(page.getByRole('button', { name: /Add target/i }).first()).toBeVisible()
})
test('Add target adds a row', async ({ page }) => {
await page.goto('/app/model-editor?template=failover')
await page.getByRole('button', { name: /Add target/i }).first().click()
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(3)
await expect(page.locator(ROWS)).toHaveCount(3)
})
test('removing a row removes it', async ({ page }) => {
await page.goto('/app/model-editor?template=failover')
await page.locator('button[title="Remove target"]').first().click()
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(1)
await expect(page.locator(ROWS)).toHaveCount(1)
})
test('move down/up reorders the target rows', async ({ page }) => {
await page.goto('/app/model-editor?template=failover')
const rows = page.locator('input[placeholder="target model..."]')
await rows.nth(0).fill('model-a')
await rows.nth(1).fill('model-b')
const rows = page.locator(ROWS)
await setModel(page, rows.nth(0), 'model-a')
await setModel(page, rows.nth(1), 'model-b')
// Move the first row down — it should now be the second row's value.
await page.locator('button[title="Move down"]').first().click()
await expect(rows.nth(0)).toHaveValue('model-b')
await expect(rows.nth(1)).toHaveValue('model-a')
await expect(rows.nth(0)).toContainText('model-b')
await expect(rows.nth(1)).toContainText('model-a')
// Move it back up with the second row's "Move up" control.
await page.locator('button[title="Move up (tried earlier)"]').nth(1).click()
await expect(rows.nth(0)).toHaveValue('model-a')
await expect(rows.nth(1)).toHaveValue('model-b')
await expect(rows.nth(0)).toContainText('model-a')
await expect(rows.nth(1)).toContainText('model-b')
})
test('a single remaining target shows a too-few warning', async ({ page }) => {
await page.goto('/app/model-editor?template=failover')
await page.locator('button[title="Remove target"]').first().click()
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(1)
await expect(page.locator(ROWS)).toHaveCount(1)
await expect(page.getByText(/nothing to fail over to/i)).toBeVisible()
})
test('duplicate target models flag both rows', async ({ page }) => {
await page.goto('/app/model-editor?template=failover')
const rows = page.locator('input[placeholder="target model..."]')
await rows.nth(0).fill('same-model')
await rows.nth(1).fill('same-model')
const rows = page.locator(ROWS)
await setModel(page, rows.nth(0), 'same-model')
await setModel(page, rows.nth(1), 'same-model')
await expect(page.getByText(/Duplicate target/i)).toHaveCount(2)
})
@@ -118,8 +127,8 @@ test.describe('Failover Chain template - create flow', () => {
// generic field renderer), whose placeholder comes from the modelEditor
// i18n namespace rather than the field registry.
await page.locator('input[placeholder="my-model-name"]').fill('my-chain')
const rows = page.locator('input[placeholder="target model..."]')
await rows.nth(0).fill('my-chain')
const rows = page.locator(ROWS)
await setModel(page, rows.nth(0), 'my-chain')
await expect(page.getByText(/chain's own name/i)).toBeVisible()
})
})
@@ -155,12 +164,12 @@ test.describe('Failover Chain - saving an edited chain', () => {
await expect(page.locator('h1', { hasText: 'Model Editor' })).toBeVisible({ timeout: 10_000 })
// Existing targets loaded from YAML.
const rows = page.locator('input[placeholder="target model..."]')
const rows = page.locator(ROWS)
await expect(rows).toHaveCount(2)
// Add a third target, then save.
await page.getByRole('button', { name: /Add target/i }).first().click()
await rows.nth(2).fill('model-c')
await setModel(page, rows.nth(2), 'model-c')
await page.locator('button', { hasText: 'Save Changes' }).click()
await expect(page.locator('text=Configuration saved')).toBeVisible({ timeout: 5_000 })
@@ -0,0 +1,46 @@
import { test, expect } from './coverage-fixtures.js'
// The shared model picker, reached through the settings and scheduling pages
// with the model list stubbed: aliases, broken aliases and disabled models.
const MODELS = [
{ id: 'qwen3-8b', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp' },
{ id: 'fast', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp', alias_of: 'qwen3-8b' },
{ id: 'lost', capabilities: [], alias_of: 'gone', alias_error: 'alias "lost" points to unknown model "gone"' },
{ id: 'old', capabilities: ['FLAG_CHAT'], disabled: true },
{ id: 'pinned-one', capabilities: ['FLAG_CHAT'], pinned: true },
]
async function stub(page) {
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: MODELS } }))
await page.route('**/api/nodes/scheduling', route => route.fulfill({ json: [] }))
await page.route('**/api/nodes', route => route.fulfill({ json: [] }))
}
test.describe('Model picker', () => {
test('lists an alias with its target and a broken alias as unusable', async ({ page }) => {
await stub(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'New rule' }).click()
await page.locator('#sched-model').click()
const list = page.getByRole('listbox')
await expect(list.getByRole('option', { name: /fast.*alias of qwen3-8b/ })).toBeVisible()
await expect(list.getByRole('option', { name: /lost.*broken alias/ })).toHaveAttribute('aria-disabled', 'true')
await expect(list.getByRole('option', { name: /old.*disabled/ })).toHaveAttribute('aria-disabled', 'true')
await expect(list.getByRole('option', { name: /pinned-one.*pinned/ })).toBeVisible()
if (process.env.PICKER_SHOTS) await page.waitForTimeout(600)
if (process.env.PICKER_SHOTS) await page.screenshot({ path: `${process.env.PICKER_SHOTS}/scheduling-picker.png` })
await list.getByRole('option', { name: /fast/ }).click()
await expect(page.locator('#sched-model')).toContainText('fast')
})
test('a typed name that is not listed can be used as it is', async ({ page }) => {
await stub(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'New rule' }).click()
await page.locator('#sched-model').click()
await page.getByRole('combobox', { name: 'Filter models' }).fill('later-model')
await page.getByRole('option', { name: /Use "later-model"/ }).click()
await expect(page.locator('#sched-model')).toContainText('later-model')
})
})
+12 -6
View File
@@ -237,15 +237,21 @@ test.describe('Router template — create flow', () => {
await page.route('**/api/models/edit/smart-router', route => route.fulfill({ json: { config: YAML.stringify(saved) } }))
await page.goto('/app/model-editor?template=router')
const modelRow = page.locator('.form-row').filter({ has: page.locator('.form-row__label-text', { hasText: /^Classifier Model$/ }) })
const chip = modelRow.getByTestId('home-model-chip')
const typeModel = async (name) => {
await chip.click()
await page.getByRole('combobox', { name: 'Filter models' }).fill(name)
await page.getByRole('combobox', { name: 'Filter models' }).press('Enter')
}
const threshold = page.locator('.form-row').filter({ hasText: 'Activation Threshold' }).locator('input[type="range"]')
await modelRow.locator('input').fill('arch-score')
await typeModel('arch-score')
await threshold.focus()
for (let i = 0; i < 5; i++) await threshold.press('ArrowRight')
await page.getByText('Score (Arch-Router-style)', { exact: true }).click()
await page.getByRole('option', { name: 'Decisions (native probabilities)' }).click()
await expect(modelRow.locator('input')).toHaveValue('')
await expect(chip.locator('.home-chip__text')).not.toHaveText(/arch-score|NimbleCLM-vllm/)
await expect(threshold).toHaveValue('0.65')
await modelRow.locator('input').click()
await chip.click()
for (const id of ['openjev-llama', 'LayaGLiNERDecide-vllm', 'TevKev-vllm', 'NimbleCLM-vllm']) {
await expect(page.getByRole('option', { name: id })).toBeVisible()
}
@@ -257,11 +263,11 @@ test.describe('Router template — create flow', () => {
expect(saved.router).toMatchObject({ classifier: 'decisions', classifier_model: 'NimbleCLM-vllm', activation_threshold: 0.65 })
await page.reload()
await expect(page.getByText('Decisions (native probabilities)', { exact: true })).toBeVisible()
await expect(modelRow.locator('input')).toHaveValue('NimbleCLM-vllm')
await expect(chip).toContainText('NimbleCLM-vllm')
await expect(threshold).toHaveValue('0.65')
await page.getByText('Decisions (native probabilities)', { exact: true }).click()
await page.getByRole('option', { name: 'Colbert (reranker)' }).click()
await expect(modelRow.locator('input')).toHaveValue('')
await expect(chip.locator('.home-chip__text')).not.toHaveText(/arch-score|NimbleCLM-vllm/)
await expect(threshold).toHaveValue('0.65')
await page.getByText('Colbert (reranker)', { exact: true }).click()
await page.getByRole('option', { name: 'KNN (labelled corpus)' }).click()
@@ -269,7 +275,7 @@ test.describe('Router template — create flow', () => {
await expect(threshold).toHaveValue('0.65')
await page.getByText('KNN (labelled corpus)', { exact: true }).click()
await page.getByRole('option', { name: 'Decisions (native probabilities)' }).click()
await modelRow.locator('input').fill('generic-ner')
await typeModel('generic-ner')
await page.getByRole('button', { name: /Save Changes$/ }).click()
await expect(page.getByText('Save failed: Select an eligible native Decisions classifier model')).toBeVisible()
})
+5 -5
View File
@@ -158,8 +158,8 @@ test.describe('Placement rules page', () => {
await expect(page.getByRole('button', { name: 'Edit llama-3.3' })).toBeFocused()
await page.getByRole('button', { name: 'New rule' }).click()
await expect(page.getByTestId('rule-sheet')).toHaveCount(1)
await expect(page.getByRole('combobox', { name: 'Model' })).toHaveValue('')
await expect(page.getByRole('combobox', { name: 'Model' })).toBeEnabled()
await expect(page.getByRole('button', { name: 'Model' })).toContainText('Type to search')
await expect(page.getByRole('button', { name: 'Model' })).toBeEnabled()
})
// The roster feeds suggestions and nothing else now, so failing to load it
@@ -202,7 +202,7 @@ test.describe('Placement rules page', () => {
await page.route('**/api/models/capabilities', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ object: 'list', data: [{ id: 'llama-3.3' }, { id: 'production' }] }),
body: JSON.stringify({ object: 'list', data: [{ id: 'llama-3.3' }, { id: 'production', alias_of: 'llama-3.3' }] }),
}))
}
@@ -240,9 +240,9 @@ test.describe('Placement rules page', () => {
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'New rule' }).click()
const picker = page.getByRole('combobox', { name: 'Model' })
const picker = page.getByRole('button', { name: 'Model' })
await picker.click()
await expect(page.locator('.dk-cmd-desc')).toHaveText('alias of llama-3.3')
await expect(page.getByRole('option', { name: /production/ }).locator('.home-menu__tag')).toHaveText('alias of llama-3.3')
await page.getByRole('option', { name: /production/ }).click()
await expect(page.getByText(/production is an alias for llama-3\.3/)).toBeVisible()
@@ -64,6 +64,7 @@ test.describe('Chat settings against the real server', () => {
await page.getByRole('button', { name: /Chats and collections/ }).click()
await expect(page.locator('main')).not.toContainText(/AGENT POOL|AGENT JOBS/i)
// Each caption heads one run of fields: the collections, then the chats.
await expect(page.locator('main .st-h3').first()).toBeVisible()
const heads = await page.locator('main .st-h3').allTextContents()
expect(heads.slice(0, 2)).toEqual(['Collections', 'Chats'])
expect(new Set(heads).size).toBe(heads.length)
@@ -31,6 +31,8 @@ test.describe('Settings: groups and search', () => {
const seen = new Set()
for (const name of await page.locator('.st-group .st-group__label').allTextContents()) {
await group(page, name).click()
// A group's fields can wait for a style sheet, so wait for the first one.
await expect(page.locator('[data-field]').first()).toBeVisible()
for (const key of await page.locator('[data-field]').evaluateAll(els => els.map(e => e.getAttribute('data-field')))) {
expect(seen.has(key), `${key} is listed twice`).toBe(false)
seen.add(key)
+1 -1
View File
@@ -1 +1 @@
118
112
@@ -3,13 +3,12 @@ import { useState } from 'react'
import SettingRow from './SettingRow'
import Toggle from './Toggle'
import SearchableSelect from './SearchableSelect'
import SearchableModelSelect from './SearchableModelSelect'
import ModelPicker from './ModelPicker'
import AutocompleteInput from './AutocompleteInput'
import CodeEditor from './CodeEditor'
import StructuredCodeEditor from './StructuredCodeEditor'
import EntityActionListEditor from './EntityActionListEditor'
import PatternListEditor from './PatternListEditor'
import ModelMultiSelect from './ModelMultiSelect'
import RouterCandidatesEditor from './RouterCandidatesEditor'
import RouterPoliciesEditor from './RouterPoliciesEditor'
import FailoverTargetsEditor from './FailoverTargetsEditor'
@@ -203,8 +202,12 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
const cap = PROVIDER_TO_CAPABILITY[provider] || undefined
return (
<SettingRow label={<FieldLabel field={field} />} description={description}>
<SearchableModelSelect
<ModelPicker
key={provider}
variant="field"
allowCustom
clearable
searchable
value={value || ''}
onChange={handleChange}
capability={cap}
@@ -420,7 +423,7 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
<div className="text-meta mt-xs">{description}</div>
</div>
</div>
<ModelMultiSelect value={value} onChange={handleChange} capability={cap} placeholder={field.placeholder} />
<ModelPicker variant="field" multiple searchable value={Array.isArray(value) ? value : []} onChange={handleChange} capability={cap} placeholder={field.placeholder} />
</div>
)
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useFormContext } from '../contexts/FormContext'
import SearchableModelSelect from './SearchableModelSelect'
import ModelPicker from './ModelPicker'
import Toggle from './Toggle'
import Icon from './Icon'
@@ -20,7 +20,7 @@ import Icon from './Icon'
// - a target equal to the chain's OWN name: the alias would point at
// itself and never resolve.
//
// The model list from useModels() (what SearchableModelSelect searches)
// The model list from useModels() (what ModelPicker searches)
// does not carry each entry's backend, so a remote/proxy target can't be
// told apart from a local one here to disable warm for it. The server
// already rejects/warns on warm for a backend that can't be kept loaded,
@@ -130,7 +130,8 @@ function TargetRow({ index, total, row, duplicate, isOwnName, onChange, onRemove
</div>
<div className="hstack hstack--md fte-row__body">
<SearchableModelSelect
<ModelPicker
variant="field" allowCustom searchable
value={row?.model || ''}
onChange={(v) => onChange((r) => ({ ...r, model: v }))}
placeholder="target model..."
@@ -1,63 +0,0 @@
import SearchableModelSelect from './SearchableModelSelect'
import Icon from './Icon'
// Editor for a list of model names (value is []string). Selected models render
// as compact removable chips; a single capability-filtered, commit-only picker
// adds new ones. Used for pii.detectors / the instance-wide default detector,
// where every entry must be a token_classify model. Already-selected models are
// guarded against so each appears at most once.
//
// The picker is commit-only on purpose: typing a partial query must never be
// treated as a chosen model (otherwise each keystroke would add a bogus entry),
// and selecting one input box per detector wastes vertical space.
export default function ModelMultiSelect({ value, onChange, capability, placeholder }) {
const items = Array.isArray(value) ? value : []
const remove = (index) => onChange(items.filter((_, i) => i !== index))
const add = (v) => {
if (!v || items.includes(v)) return
onChange([...items, v])
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
{items.length === 0 ? (
<div className="text-meta">
No detectors — PII is enabled but nothing scans requests. Add a token-classification
(NER) model below; its <code>pii_detection</code> block supplies the policy.
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{items.map((name, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '2px 4px 2px 10px', fontSize: '0.8125rem',
fontFamily: 'var(--font-mono)', background: 'var(--color-bg-tertiary)',
borderRadius: 'var(--radius-md)',
}}>
{name}
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => remove(i)}
style={{ padding: '0 6px', fontSize: '0.75rem', lineHeight: 1.6 }}
aria-label={`Remove ${name}`}>
<Icon name="close" />
</button>
</span>
))}
</div>
)}
{/* Size by width only. The container is a flex column, so a flex-basis
here would set the wrapper's HEIGHT — which the dropdown anchors to
(top: 100%), opening it far below the input. */}
<SearchableModelSelect
value=""
onChange={add}
commitOnly
capability={capability}
placeholder={placeholder || '+ Add detector model...'}
style={{ width: '100%', maxWidth: 360 }}
/>
</div>
)
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useModels } from '../hooks/useModels'
import { readLastModel, writeLastModel, readPreferredModel } from '../utils/lastModel'
import Icon from './Icon'
import './home/composer.css'
// How many models before the list gets a filter box, when `searchable` is left
// to decide.
@@ -38,7 +39,7 @@ const ModelPicker = forwardRef(function ModelPicker(
ref,
) {
const { t } = useTranslation('home')
const own = useModels(capability, { enabled: !givenModels })
const own = useModels(capability, { enabled: !givenModels, keepBroken: true })
const models = givenModels || own.models
const loading = givenModels ? !!givenLoading : own.loading
const byId = useMemo(() => new Map(models.map(m => [m.id, m])), [models])
@@ -249,7 +250,7 @@ const ModelPicker = forwardRef(function ModelPicker(
tabIndex={-1}
aria-activedescendant={activeId}
>
{!grouped && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
{!grouped && (labels?.heading || capability === 'FLAG_CHAT') && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
{loading && <li className="home-menu__empty" role="presentation">{t('picker.loading')}</li>}
{!loading && shown.length === 0 && (
<li className="home-menu__empty" role="presentation">{names.length === 0 ? t('picker.empty') : t('picker.noMatch')}</li>
@@ -1,54 +1,21 @@
import { useEffect, useMemo, useCallback } from 'react'
import { useModels } from '../hooks/useModels'
import SearchableSelect from './SearchableSelect'
import { useTranslation } from 'react-i18next'
import { readLastModel, writeLastModel } from '../utils/lastModel'
import { useMemo } from 'react'
import ModelPicker from './ModelPicker'
// The name add-ons know the model picker by. It keeps the props they were
// given (a plain list of names in `options`, `searchPlaceholder`, `style`) and
// draws the shared ModelPicker.
export default function ModelSelector({
value, onChange, capability, className = '',
options: externalOptions, loading: externalLoading,
disabled: externalDisabled, searchPlaceholder, style, triggerClassName, id,
value, onChange, capability, className = '', options, loading,
disabled, style, id,
}) {
const { t } = useTranslation('models')
// Skip capability fetch when external options are provided (capability will be undefined)
const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
const modelNames = useMemo(
() => externalOptions || hookModels.map(m => m.id),
[externalOptions, hookModels]
)
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
const isDisabled = isLoading || (externalDisabled || false)
// Persist genuine selections so the next visit can restore them.
const handleChange = useCallback((next) => {
writeLastModel(capability, next)
onChange(next)
}, [capability, onChange])
useEffect(() => {
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
// Prefer the remembered model when it's still available; otherwise fall
// back to the first option. Don't re-persist here — auto-select is not a
// user choice, and writing back the stored value would be a harmless but
// pointless round-trip.
const remembered = readLastModel(capability)
onChange(remembered && modelNames.includes(remembered) ? remembered : modelNames[0])
}
}, [modelNames, value, onChange, capability])
const models = useMemo(() => options?.map(name => ({ id: name })), [options])
return (
<SearchableSelect
id={id}
value={value || ''}
onChange={handleChange}
options={modelNames}
placeholder={isLoading ? t('selector.loading') : (modelNames.length === 0 ? t('selector.noModels') : t('selector.selectModel'))}
searchPlaceholder={searchPlaceholder || t('selector.searchPlaceholder')}
disabled={isDisabled}
className={className}
style={style}
triggerClassName={triggerClassName}
/>
<div style={style}>
<ModelPicker
id={id} variant="field" searchable className={className}
value={value || ''} onChange={onChange} capability={capability}
models={models} loading={loading} disabled={disabled}
/>
</div>
)
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useFormContext } from '../contexts/FormContext'
import SearchableModelSelect from './SearchableModelSelect'
import ModelPicker from './ModelPicker'
import Icon from './Icon'
// RouterCandidatesEditor renders the routing table for a router model.
@@ -122,7 +122,8 @@ function CandidateRow({ index, total, row, knownLabels, knownLabelSet, onChange,
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 'var(--spacing-sm)', alignItems: 'center' }}>
<SearchableModelSelect
<ModelPicker
variant="field" allowCustom searchable
value={row?.model || ''}
onChange={(v) => onChange((r) => ({ ...r, model: v }))}
placeholder="downstream model..."
@@ -1,199 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useModels } from '../hooks/useModels'
// commitOnly: when true, onChange fires only on an explicit commit (selecting an
// item, or Enter) — never on each keystroke. Use it where each onChange is a
// final selection (e.g. the ModelMultiSelect "add" picker), so a partial typed
// query isn't treated as a chosen value. After a commit the field is cleared,
// matching the add-and-clear flow. Default false keeps the as-you-type
// behaviour single-value editors rely on.
// hints: optional { [modelId]: string } shown as muted text beside an entry and
// searchable along with the name. Used to mark aliases with the model they
// point at, so a picker that lists both can tell them apart.
export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style, commitOnly = false, hints = {} }) {
const { models, loading } = useModels(capability)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [focusIndex, setFocusIndex] = useState(-1)
const wrapperRef = useRef(null)
const listRef = useRef(null)
// Sync external value into the input
useEffect(() => {
setQuery(value || '')
}, [value])
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const needle = query.toLowerCase()
const filtered = models.filter(m =>
m.id.toLowerCase().includes(needle) ||
(hints[m.id] || '').toLowerCase().includes(needle)
)
// Which item Enter will select — matches SearchableSelect behavior
const enterTargetIndex = focusIndex >= 0 ? focusIndex
: filtered.length > 0 ? 0
: -1
const commit = useCallback((val) => {
// In commitOnly mode the field is an "add" box — clear it after a pick so
// the next selection starts fresh; otherwise reflect the chosen value.
setQuery(commitOnly ? '' : val)
onChange(val)
setOpen(false)
setFocusIndex(-1)
}, [onChange, commitOnly])
const handleKeyDown = (e) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
setOpen(true)
return
}
if (!open && e.key === 'Enter') {
e.preventDefault()
commit(query)
return
}
if (!open) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setFocusIndex(i => Math.min(i + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusIndex(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (enterTargetIndex >= 0) {
commit(filtered[enterTargetIndex].id)
} else {
commit(query)
}
} else if (e.key === 'Escape') {
setOpen(false)
setFocusIndex(-1)
}
}
// Scroll focused item into view
useEffect(() => {
if (focusIndex >= 0 && listRef.current) {
const item = listRef.current.children[focusIndex]
if (item) item.scrollIntoView({ block: 'nearest' })
}
}, [focusIndex])
return (
<div ref={wrapperRef} className="searchable-model-select" style={style}>
<style>{`
.searchable-model-select {
position: relative;
width: 280px;
}
.searchable-model-select input {
width: 100%;
}
.sms-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
max-height: 220px;
overflow-y: auto;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
animation: dropdownIn 120ms ease-out;
margin-top: 2px;
}
.sms-item {
padding: 6px 10px;
font-size: 0.8125rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.sms-item:hover, .sms-item.sms-focused {
background: var(--color-bg-tertiary);
}
.sms-item.sms-active {
color: var(--color-primary);
font-weight: 600;
}
.sms-hint {
color: var(--color-text-muted);
font-size: 0.75rem;
flex-shrink: 0;
}
.sms-empty {
padding: 8px 10px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
`}</style>
<input
className="input"
aria-haspopup="listbox"
aria-expanded={open}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
setFocusIndex(-1)
// Single-value editors want the parent updated as you type; an
// "add" picker (commitOnly) must wait for an explicit commit so a
// partial query is never mistaken for a chosen model.
if (!commitOnly) onChange(e.target.value)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={loading ? 'Loading models...' : placeholder}
/>
{open && !loading && (
<div className="sms-dropdown" ref={listRef} role="listbox">
{filtered.length === 0 ? (
<div className="sms-empty">
{query ? 'No matching models — value will be used as-is' : 'No models available'}
</div>
) : (
filtered.map((m, i) => {
const isEnterTarget = i === enterTargetIndex
return (
<div
key={m.id}
role="option"
aria-selected={m.id === value}
className={`sms-item${i === focusIndex || isEnterTarget ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
onMouseEnter={() => setFocusIndex(i)}
onMouseDown={(e) => {
e.preventDefault()
commit(m.id)
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.id}</span>
{hints[m.id] && (
<span className="sms-hint">{hints[m.id]}</span>
)}
{isEnterTarget && (
<span className="text-meta shrink-0">↵</span>
)}
</div>
)
})
)}
</div>
)}
</div>
)
}
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import SideSheet from '../SideSheet'
import ModelPicker from '../ModelPicker'
import { Button, Callout, Switch } from '../ui'
import { promptParams, taskErrors } from '../../utils/agentTasks'
@@ -81,12 +82,11 @@ export function TaskSheet({ editing, setForm, agents, models, onSave, onClose, s
</Field>
{form.runner === 'model' ? (
<Field id="at-model" label={t('tasks.model')} error={err('model')}>
<span className="dk-select-wrap">
<select id="at-model" className="dk-select" value={form.model} onChange={(e) => setForm({ model: e.target.value })} {...inv('model')}>
<option value="">{t('tasks.pick')}</option>
{models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</select>
</span>
<ModelPicker
id="at-model" variant="field" searchable autoSelect={false} rememberSelection={false}
models={models} value={form.model} placeholder={t('tasks.pick')}
onChange={(model) => setForm({ model })} describedBy={inv('model')['aria-describedby']}
/>
</Field>
) : (
<Field id="at-agent" label={t('tasks.agent')} error={err('agent_id')}>
@@ -1,5 +1,5 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import SearchableModelSelect from '../SearchableModelSelect'
import ModelPicker from '../ModelPicker'
import Icon from '../Icon'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_VISION } from '../../utils/capabilities'
import {
@@ -98,7 +98,8 @@ function Control({ field, settings, onChange, id, labelId, disabled, assets }) {
case 'model':
return (
<div className="st-model">
<SearchableModelSelect
<ModelPicker
variant="field" allowCustom clearable searchable
value={value} onChange={set} placeholder={field.placeholder}
capability={MODEL_CAPABILITY[field.key]}
/>
@@ -1,93 +0,0 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModels } from '../../hooks/useModels'
const LIMIT = 50
// A model name, typed or picked. A rule may be written before its model is
// installed, so what is typed is kept as it is: the list only helps. Aliases
// are offered beside models, each with the model it resolves to.
//
// hints { [name]: 'alias of x' } shown under a name and searched with it
export default function ModelPicker({ id, value, onChange, hints = {} }) {
const { t } = useTranslation('swarm')
const { models } = useModels()
const listId = useId()
const [open, setOpen] = useState(false)
const [active, setActive] = useState(-1)
const wrapRef = useRef(null)
useEffect(() => {
const away = event => { if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false) }
document.addEventListener('mousedown', away)
return () => document.removeEventListener('mousedown', away)
}, [])
const needle = (value || '').toLowerCase()
const matches = useMemo(() => {
const names = [...new Set([...models.map(m => m.id), ...Object.keys(hints)])]
return names.filter(name => name.toLowerCase().includes(needle) || (hints[name] || '').toLowerCase().includes(needle)).slice(0, LIMIT)
}, [models, hints, needle])
const pick = name => { onChange(name); setOpen(false); setActive(-1) }
const onKeyDown = event => {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!open) { setOpen(true); return }
setActive(i => Math.min(i + 1, matches.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActive(i => Math.max(i - 1, 0))
} else if (event.key === 'Enter' && open) {
event.preventDefault()
pick(active >= 0 ? matches[active] : (matches.length > 0 && !matches.includes(value) ? matches[0] : value))
} else if (event.key === 'Escape' && open) {
event.preventDefault()
setOpen(false)
setActive(-1)
}
}
return (
<div className="sw-picker" ref={wrapRef}>
<input
id={id}
className="dk-input dk-input--mono"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
autoComplete="off"
spellCheck={false}
placeholder={t('sheet.modelPlaceholder')}
value={value}
onChange={event => { onChange(event.target.value); setOpen(true); setActive(-1) }}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
{open && (
<div className="dk-cmdlist sw-picker__list" role="listbox" id={listId} aria-label={t('sheet.model')}>
{matches.map((name, index) => (
<div
key={name}
id={`${listId}-${index}`}
className="dk-cmd"
role="option"
aria-selected={index === active}
onMouseDown={event => { event.preventDefault(); pick(name) }}
onMouseEnter={() => setActive(index)}
>
<span className="dk-cmd-main">
<span className="dk-cmd-name dk-mono">{name}</span>
{hints[name] && <span className="dk-cmd-desc">{hints[name]}</span>}
</span>
</div>
))}
{matches.length === 0 && <div className="dk-cmd-empty">{t('sheet.pickerEmpty', { name: value })}</div>}
</div>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import ModelPicker from './ModelPicker'
import ModelPicker from '../ModelPicker'
import KeyValueChips from '../nodes/KeyValueChips'
import { rulePlan, selectorOf } from '../../utils/swarm'
import Icon from '../Icon'
@@ -110,7 +110,6 @@ export default function RuleSheet({ initial, editing, labels, aliases, nodes, on
}, [])
const hasSelector = Object.keys(selector).length > 0
const aliasHints = Object.fromEntries(Object.entries(aliases || {}).map(([name, target]) => [name, t('sheet.aliasOf', { target })]))
const aliasTarget = (aliases || {})[modelName]
const valid = () => {
@@ -165,7 +164,7 @@ export default function RuleSheet({ initial, editing, labels, aliases, nodes, on
{editing ? (
<input id="sched-model" className="dk-input dk-input--mono" value={modelName} readOnly />
) : (
<ModelPicker id="sched-model" value={modelName} onChange={setModelName} hints={aliasHints} />
<ModelPicker id="sched-model" variant="field" allowCustom searchable autoSelect={false} rememberSelection={false} value={modelName} onChange={setModelName} placeholder={t('sheet.modelPlaceholder')} />
)}
{/* An alias is a stable name for whichever model serves it, so a rule
on one is a rule on a slot. Say so where it is chosen. */}
+5 -2
View File
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import { modelsApi } from '../utils/api'
// `enabled: false` skips the request, for a caller that already holds the list.
export function useModels(capability, { enabled = true } = {}) {
// `keepBroken: true` keeps an alias that cannot be resolved (it has no
// capabilities) in a filtered list, for a picker that shows it as broken.
export function useModels(capability, { enabled = true, keepBroken = false } = {}) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -14,6 +16,7 @@ export function useModels(capability, { enabled = true } = {}) {
let items = (data?.data || []).filter(m => !capability || !m.disabled)
if (capability) {
items = items.filter(m =>
(keepBroken && m.alias_error) ||
m.capabilities?.includes(capability) || m.capabilities?.includes(capability.replace(/^FLAG_/, '').toLowerCase()) ||
// Models without config (loose files) have no capabilities — show them only when no filter
false
@@ -33,7 +36,7 @@ export function useModels(capability, { enabled = true } = {}) {
} finally {
if (!silent) setLoading(false)
}
}, [capability])
}, [capability, keepBroken])
// Subsequent refetches stay silent so consumers don't blank their tables
// (e.g. the Manage page auto-refreshes every 10s in distributed mode).
@@ -4,7 +4,7 @@ import { Link, useParams, useNavigate, useOutletContext, useLocation } from 'rea
import { useTranslation } from 'react-i18next'
import { chatAgentsApi, chatTriggersApi, filesApi } from '../utils/api'
// eslint-disable-next-line no-unused-vars
import ModelSelector from '../components/ModelSelector'
import ModelPicker from '../components/ModelPicker'
// eslint-disable-next-line no-unused-vars
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
// eslint-disable-next-line no-unused-vars
@@ -514,7 +514,7 @@ function TaskForm({ id, isNew }) {
{!picked?.model && (
<div className="dk-field">
<span className="dk-label" id="aj-model-l">{t('jobs.edit.model')}</span>
<ModelSelector value={task.model} onChange={(model) => set('model', model)} capability={CAP_CHAT} />
<ModelPicker variant="field" searchable value={task.model} onChange={(model) => set('model', model)} capability={CAP_CHAT} />
</div>
)}
<div className="dk-field aj-enabled">
-3
View File
@@ -288,9 +288,6 @@
/* ---- Rule sheet ---------------------------------------------------------------- */
.sw-picker { position: relative; }
.sw-picker .dk-input { width: 100%; }
.sw-picker__list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: var(--dk-z-popover); max-height: 240px; overflow-y: auto; }
.sw-sheet__body { display: flex; flex-direction: column; gap: var(--dk-space-4); }
.sw-two { display: grid; grid-template-columns: 1fr 1fr; gap: var(--dk-space-3); }