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); }