From 6ffc9e82c1b35fc37919bd58e7d014e515cc702f Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 08:34:17 +0000 Subject: [PATCH] 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 --- core/http/react-ui/e2e/agent-tasks.spec.js | 3 +- core/http/react-ui/e2e/agents-edit.spec.js | 23 ++ .../http/react-ui/e2e/failover-editor.spec.js | 45 ++-- core/http/react-ui/e2e/model-picker.spec.js | 46 ++++ .../http/react-ui/e2e/router-template.spec.js | 18 +- core/http/react-ui/e2e/scheduling.spec.js | 10 +- .../react-ui/e2e/settings-chats-real.spec.js | 1 + core/http/react-ui/e2e/settings-page.spec.js | 2 + core/http/react-ui/inline-style-baseline.txt | 2 +- .../src/components/ConfigFieldRenderer.jsx | 11 +- .../src/components/FailoverTargetsEditor.jsx | 7 +- .../src/components/ModelMultiSelect.jsx | 63 ------ .../react-ui/src/components/ModelPicker.jsx | 5 +- .../react-ui/src/components/ModelSelector.jsx | 63 ++---- .../src/components/RouterCandidatesEditor.jsx | 5 +- .../src/components/SearchableModelSelect.jsx | 199 ------------------ .../src/components/agents/TaskSheets.jsx | 12 +- .../src/components/settings/SettingField.jsx | 5 +- .../src/components/swarm/ModelPicker.jsx | 93 -------- .../src/components/swarm/RuleSheet.jsx | 5 +- core/http/react-ui/src/hooks/useModels.js | 7 +- .../react-ui/src/pages/AgentTaskDetails.jsx | 4 +- core/http/react-ui/src/pages/swarm.css | 3 - 23 files changed, 169 insertions(+), 463 deletions(-) create mode 100644 core/http/react-ui/e2e/model-picker.spec.js delete mode 100644 core/http/react-ui/src/components/ModelMultiSelect.jsx delete mode 100644 core/http/react-ui/src/components/SearchableModelSelect.jsx delete mode 100644 core/http/react-ui/src/components/swarm/ModelPicker.jsx diff --git a/core/http/react-ui/e2e/agent-tasks.spec.js b/core/http/react-ui/e2e/agent-tasks.spec.js index 555e1299f5..919d4f9d54 100644 --- a/core/http/react-ui/e2e/agent-tasks.spec.js +++ b/core/http/react-ui/e2e/agent-tasks.spec.js @@ -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() diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js index 7a53b5c61d..1372e2897b 100644 --- a/core/http/react-ui/e2e/agents-edit.spec.js +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -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 diff --git a/core/http/react-ui/e2e/failover-editor.spec.js b/core/http/react-ui/e2e/failover-editor.spec.js index da66227ebe..3a6abe24df 100644 --- a/core/http/react-ui/e2e/failover-editor.spec.js +++ b/core/http/react-ui/e2e/failover-editor.spec.js @@ -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 }) diff --git a/core/http/react-ui/e2e/model-picker.spec.js b/core/http/react-ui/e2e/model-picker.spec.js new file mode 100644 index 0000000000..bf433b6da5 --- /dev/null +++ b/core/http/react-ui/e2e/model-picker.spec.js @@ -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') + }) +}) diff --git a/core/http/react-ui/e2e/router-template.spec.js b/core/http/react-ui/e2e/router-template.spec.js index 94140b66d4..014dd47857 100644 --- a/core/http/react-ui/e2e/router-template.spec.js +++ b/core/http/react-ui/e2e/router-template.spec.js @@ -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() }) diff --git a/core/http/react-ui/e2e/scheduling.spec.js b/core/http/react-ui/e2e/scheduling.spec.js index 59bce57343..4df9f79f86 100644 --- a/core/http/react-ui/e2e/scheduling.spec.js +++ b/core/http/react-ui/e2e/scheduling.spec.js @@ -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() diff --git a/core/http/react-ui/e2e/settings-chats-real.spec.js b/core/http/react-ui/e2e/settings-chats-real.spec.js index 218f926bbb..d5027f86ac 100644 --- a/core/http/react-ui/e2e/settings-chats-real.spec.js +++ b/core/http/react-ui/e2e/settings-chats-real.spec.js @@ -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) diff --git a/core/http/react-ui/e2e/settings-page.spec.js b/core/http/react-ui/e2e/settings-page.spec.js index f250c537e5..6a1e639ad4 100644 --- a/core/http/react-ui/e2e/settings-page.spec.js +++ b/core/http/react-ui/e2e/settings-page.spec.js @@ -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) diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index 415196e47f..194b81caae 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -118 +112 diff --git a/core/http/react-ui/src/components/ConfigFieldRenderer.jsx b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx index 276d948e0c..61d87d41a2 100644 --- a/core/http/react-ui/src/components/ConfigFieldRenderer.jsx +++ b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx @@ -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 ( } description={description}> - {description} - + ) } diff --git a/core/http/react-ui/src/components/FailoverTargetsEditor.jsx b/core/http/react-ui/src/components/FailoverTargetsEditor.jsx index 2eca81d9a8..0c07f19b2a 100644 --- a/core/http/react-ui/src/components/FailoverTargetsEditor.jsx +++ b/core/http/react-ui/src/components/FailoverTargetsEditor.jsx @@ -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
- onChange((r) => ({ ...r, model: v }))} placeholder="target model..." diff --git a/core/http/react-ui/src/components/ModelMultiSelect.jsx b/core/http/react-ui/src/components/ModelMultiSelect.jsx deleted file mode 100644 index 4f58d09eec..0000000000 --- a/core/http/react-ui/src/components/ModelMultiSelect.jsx +++ /dev/null @@ -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 ( -
- {items.length === 0 ? ( -
- No detectors — PII is enabled but nothing scans requests. Add a token-classification - (NER) model below; its pii_detection block supplies the policy. -
- ) : ( -
- {items.map((name, i) => ( - - {name} - - - ))} -
- )} - - {/* 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. */} - -
- ) -} diff --git a/core/http/react-ui/src/components/ModelPicker.jsx b/core/http/react-ui/src/components/ModelPicker.jsx index 24f47959d9..00d63b043d 100644 --- a/core/http/react-ui/src/components/ModelPicker.jsx +++ b/core/http/react-ui/src/components/ModelPicker.jsx @@ -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 &&
  • {labels?.heading || t('picker.heading')}
  • } + {!grouped && (labels?.heading || capability === 'FLAG_CHAT') &&
  • {labels?.heading || t('picker.heading')}
  • } {loading &&
  • {t('picker.loading')}
  • } {!loading && shown.length === 0 && (
  • {names.length === 0 ? t('picker.empty') : t('picker.noMatch')}
  • diff --git a/core/http/react-ui/src/components/ModelSelector.jsx b/core/http/react-ui/src/components/ModelSelector.jsx index d81f4b61af..73747b2e43 100644 --- a/core/http/react-ui/src/components/ModelSelector.jsx +++ b/core/http/react-ui/src/components/ModelSelector.jsx @@ -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 ( - +
    + +
    ) } diff --git a/core/http/react-ui/src/components/RouterCandidatesEditor.jsx b/core/http/react-ui/src/components/RouterCandidatesEditor.jsx index e3116da133..37af981dcf 100644 --- a/core/http/react-ui/src/components/RouterCandidatesEditor.jsx +++ b/core/http/react-ui/src/components/RouterCandidatesEditor.jsx @@ -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,
    - onChange((r) => ({ ...r, model: v }))} placeholder="downstream model..." diff --git a/core/http/react-ui/src/components/SearchableModelSelect.jsx b/core/http/react-ui/src/components/SearchableModelSelect.jsx deleted file mode 100644 index 2a383d3594..0000000000 --- a/core/http/react-ui/src/components/SearchableModelSelect.jsx +++ /dev/null @@ -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 ( -
    - - { - 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 && ( -
    - {filtered.length === 0 ? ( -
    - {query ? 'No matching models — value will be used as-is' : 'No models available'} -
    - ) : ( - filtered.map((m, i) => { - const isEnterTarget = i === enterTargetIndex - return ( -
    setFocusIndex(i)} - onMouseDown={(e) => { - e.preventDefault() - commit(m.id) - }} - > - {m.id} - {hints[m.id] && ( - {hints[m.id]} - )} - {isEnterTarget && ( - ↵ - )} -
    - ) - }) - )} -
    - )} -
    - ) -} diff --git a/core/http/react-ui/src/components/agents/TaskSheets.jsx b/core/http/react-ui/src/components/agents/TaskSheets.jsx index 86f78bd41c..980e8146b2 100644 --- a/core/http/react-ui/src/components/agents/TaskSheets.jsx +++ b/core/http/react-ui/src/components/agents/TaskSheets.jsx @@ -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 {form.runner === 'model' ? ( - - - + setForm({ model })} describedBy={inv('model')['aria-describedby']} + /> ) : ( diff --git a/core/http/react-ui/src/components/settings/SettingField.jsx b/core/http/react-ui/src/components/settings/SettingField.jsx index 7c85e38278..598299a5aa 100644 --- a/core/http/react-ui/src/components/settings/SettingField.jsx +++ b/core/http/react-ui/src/components/settings/SettingField.jsx @@ -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 (
    - diff --git a/core/http/react-ui/src/components/swarm/ModelPicker.jsx b/core/http/react-ui/src/components/swarm/ModelPicker.jsx deleted file mode 100644 index be6cabd929..0000000000 --- a/core/http/react-ui/src/components/swarm/ModelPicker.jsx +++ /dev/null @@ -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 ( -
    - = 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 && ( -
    - {matches.map((name, index) => ( -
    { event.preventDefault(); pick(name) }} - onMouseEnter={() => setActive(index)} - > - - {name} - {hints[name] && {hints[name]}} - -
    - ))} - {matches.length === 0 &&
    {t('sheet.pickerEmpty', { name: value })}
    } -
    - )} -
    - ) -} diff --git a/core/http/react-ui/src/components/swarm/RuleSheet.jsx b/core/http/react-ui/src/components/swarm/RuleSheet.jsx index 5bc51c2e18..b4e6a73cf5 100644 --- a/core/http/react-ui/src/components/swarm/RuleSheet.jsx +++ b/core/http/react-ui/src/components/swarm/RuleSheet.jsx @@ -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 ? ( ) : ( - + )} {/* 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. */} diff --git a/core/http/react-ui/src/hooks/useModels.js b/core/http/react-ui/src/hooks/useModels.js index 3cb4c74c0c..492f6487e4 100644 --- a/core/http/react-ui/src/hooks/useModels.js +++ b/core/http/react-ui/src/hooks/useModels.js @@ -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). diff --git a/core/http/react-ui/src/pages/AgentTaskDetails.jsx b/core/http/react-ui/src/pages/AgentTaskDetails.jsx index 09c91ad17e..30be94351a 100644 --- a/core/http/react-ui/src/pages/AgentTaskDetails.jsx +++ b/core/http/react-ui/src/pages/AgentTaskDetails.jsx @@ -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 && (
    {t('jobs.edit.model')} - set('model', model)} capability={CAP_CHAT} /> + set('model', model)} capability={CAP_CHAT} />
    )}
    diff --git a/core/http/react-ui/src/pages/swarm.css b/core/http/react-ui/src/pages/swarm.css index 8d55975521..f0125f1e31 100644 --- a/core/http/react-ui/src/pages/swarm.css +++ b/core/http/react-ui/src/pages/swarm.css @@ -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); }