mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
23 files changed
+169
-463
No files matched your search
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 @@
|
||||
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
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user