diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index 42a5c677d..af48f675c 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -174,6 +174,17 @@ test.describe('Home model chip', () => { await expect(chip).toBeFocused() }) + test('keeps Home auto-selection and remembers an explicit model', async ({ page }) => { + await ready(page) + const chip = page.getByTestId('home-model-chip') + await expect(chip).toContainText('qwen3-8b-instruct') + await chip.click() + await page.getByRole('option', { name: /gemma-4-e4b/ }).click() + expect(await page.evaluate(() => localStorage.getItem('localai_last_model:FLAG_CHAT'))).toBe('gemma-4-e4b-it-qat-q4_0') + await page.reload() + await expect(chip).toContainText('gemma-4-e4b-it-qat-q4_0') + }) + test('Escape closes the list and returns to the chip', async ({ page }) => { await ready(page) const chip = page.getByTestId('home-model-chip') @@ -479,7 +490,7 @@ test.describe('Home first run', () => { await mockFirstRun(page) await ready(page) const guide = page.getByTestId('home-first-run') - await expect(guide.getByRole('listitem')).toHaveCount(3) + await expect(guide.locator('.home-step')).toHaveCount(3) await expect(guide.locator('.home-step[data-state="now"]')).toContainText('Browse the Model Gallery') await expect(guide.locator('.home-step').nth(1).locator('.home-starters')).toContainText('tiny-chat') await expect(guide.getByRole('button', { name: /browse model gallery/i })).toBeVisible() diff --git a/core/http/react-ui/e2e/media-history.spec.js b/core/http/react-ui/e2e/media-history.spec.js index 216506d89..1b9c84fd0 100644 --- a/core/http/react-ui/e2e/media-history.spec.js +++ b/core/http/react-ui/e2e/media-history.spec.js @@ -230,7 +230,8 @@ test.describe('Media History - Sound Generation', () => { await page.goto('/app/sound') await expect(page.getByRole('button', { name: 'test-sound-model' })).toBeVisible({ timeout: 10_000 }) - await page.getByLabel('Prompt expansion model', { exact: true }).selectOption('__backend_default__') + await page.getByRole('button', { name: 'Prompt expansion model', exact: true }).click() + await page.getByRole('option', { name: 'Backend default', exact: true }).click() await page.locator('.textarea').first().fill('upbeat jazz') await page.locator('button[type="submit"]').click() diff --git a/core/http/react-ui/e2e/sound-expansion.spec.js b/core/http/react-ui/e2e/sound-expansion.spec.js index b1dbef445..cfbba10f7 100644 --- a/core/http/react-ui/e2e/sound-expansion.spec.js +++ b/core/http/react-ui/e2e/sound-expansion.spec.js @@ -6,7 +6,12 @@ const preference = 'localai_sound_expansion_model' const chatPreference = 'localai_last_model:FLAG_CHAT' const audio = { id: 'music', capabilities: ['FLAG_SOUND_GENERATION'] } const chat = id => ({ id, capabilities: ['FLAG_CHAT'] }) -const picker = page => page.getByLabel('Prompt expansion model', { exact: true }) +const picker = page => page.getByRole('button', { name: 'Prompt expansion model', exact: true }) +const choiceLabel = value => value === '__backend_default__' ? 'Backend default' : value || 'Choose a model or backend default' +async function choose(page, value) { + await picker(page).click() + await page.getByRole('listbox', { name: 'Prompt expansion model' }).getByRole('option').filter({ has: page.locator('code').getByText(choiceLabel(value), { exact: true }) }).click() +} const submit = page => page.locator('[data-testid="ws-compose"] button[type="submit"]') async function setup(page, models = [audio, chat('writer'), chat('poet')], saved) { await page.route('**/api/**', route => route.fulfill({ json: {} })) @@ -33,13 +38,15 @@ test('requires a choice, filters installed chat models and remembers only the So const requests = await setup(page, [audio, chat('writer'), chat('poet'), { id: 'embedding', capabilities: ['FLAG_EMBEDDINGS'] }, { ...chat('disabled'), disabled: true }]) await prompt(page) await expect(submit(page)).toBeDisabled() - await expect(picker(page).locator('option')).toHaveText(['Choose a model or backend default', 'Backend default', 'writer', 'poet']) - await picker(page).selectOption('poet') + await picker(page).click() + await expect(page.getByRole('listbox', { name: 'Prompt expansion model' }).getByRole('option').locator('code')).toHaveText(['Backend default', 'writer', 'poet']) + await page.keyboard.press('Escape') + await choose(page, 'poet') await submit(page).click() await expect.poll(() => requests.length).toBe(1) expect(requests[0]).toMatchObject({ model_id: 'music', text: 'A quiet piano instrumental', instrumental: false, expansion_model: 'poet' }) await page.reload() - await expect(picker(page)).toHaveValue('poet') + await expect(picker(page)).toContainText(choiceLabel('poet')) expect(await page.evaluate(key => localStorage.getItem(key), chatPreference)).toBe('general-chat') expect(await page.evaluate(key => localStorage.getItem(key), preference)).toBe('poet') }) @@ -47,7 +54,7 @@ for (const stale of ['removed', 'embedding']) { test(`clears unavailable remembered model ${stale} and asks for a choice`, async ({ page }) => { await setup(page, [audio, chat('writer'), { id: 'embedding', capabilities: ['FLAG_EMBEDDINGS'] }], stale) await prompt(page) - await expect(picker(page)).toHaveValue('') + await expect(picker(page)).toContainText(choiceLabel('')) await expect(submit(page)).toBeDisabled() await expect(page.getByText('Choose a chat model or Backend default to continue.', { exact: true })).toBeVisible() expect(await page.evaluate(key => localStorage.getItem(key), preference)).toBeNull() @@ -55,10 +62,10 @@ for (const stale of ['removed', 'embedding']) { } test('revalidates a selected model when the installed list changes', async ({ page }) => { await setup(page, [audio, chat('writer'), chat('poet')], 'writer') - await expect(picker(page)).toHaveValue('writer') + await expect(picker(page)).toContainText(choiceLabel('writer')) await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [audio, chat('poet')] } })) await page.evaluate(() => window.dispatchEvent(new Event('focus'))) - await expect(picker(page)).toHaveValue('') + await expect(picker(page)).toContainText(choiceLabel('')) await prompt(page) await expect(submit(page)).toBeDisabled() expect(await page.evaluate(key => localStorage.getItem(key), preference)).toBeNull() @@ -68,17 +75,17 @@ test('Backend default works without chat models, omits expansion_model and survi await prompt(page) await expect(submit(page)).toBeDisabled() await expect(page.getByText('No installed chat models. Choose Backend default to use the audio backend.', { exact: true })).toBeVisible() - await picker(page).selectOption('__backend_default__') + await choose(page, '__backend_default__') await submit(page).click() await expect.poll(() => requests.length).toBe(1) expect(requests[0]).not.toHaveProperty('expansion_model') await page.reload() - await expect(picker(page)).toHaveValue('__backend_default__') + await expect(picker(page)).toContainText(choiceLabel('__backend_default__')) expect(await page.evaluate(key => localStorage.getItem(key), chatPreference)).toBe('general-chat') }) test('Advanced preserves all fields and never sends the remembered expansion model', async ({ page }) => { const requests = await setup(page, undefined, 'writer') - await expect(picker(page)).toHaveValue('writer') + await expect(picker(page)).toContainText(choiceLabel('writer')) await page.getByRole('tab', { name: 'Advanced', exact: true }).click() await expect(picker(page)).toHaveCount(0) await page.locator('#sound-caption').fill('Warm acoustic folk') @@ -117,15 +124,15 @@ test('Re-run with edits restores the original expansion choice and tracks edits' contentType: 'audio/wav', headers: { 'Content-Disposition': 'attachment; filename="piano.wav"' }, body: silentWav(), })) await prompt(page) - await picker(page).selectOption('writer') + await choose(page, 'writer') await submit(page).click() const tile = page.getByTestId('media-history-item').first() await expect(tile).toBeVisible() - await picker(page).selectOption('poet') + await choose(page, 'poet') await tile.locator('.ws-tile__open').click() await page.getByTestId('ws-rerun').click() - await expect(picker(page)).toHaveValue('writer') - await picker(page).selectOption('poet') + await expect(picker(page)).toContainText(choiceLabel('writer')) + await choose(page, 'poet') await expect(page.getByTestId('ws-changes')).toContainText('Prompt expansion model writer to poet') }) @@ -139,8 +146,127 @@ for (const historical of [undefined, 'removed']) { await page.reload() await page.getByTestId('media-history-item').first().locator('.ws-tile__open').click() await page.getByTestId('ws-rerun').click() - await expect(picker(page)).toHaveValue(historical ? '' : '__backend_default__') + await expect(picker(page)).toContainText(choiceLabel(historical ? '' : '__backend_default__')) if (historical) await expect(submit(page)).toBeDisabled() else await expect(submit(page)).toBeEnabled() }) } + +test('shared chip supports keyboard, focus return and dismissal without choosing', async ({ page }) => { + await setup(page) + const chip = picker(page) + await expect(chip).toHaveAttribute('id', 'sound-expansion-model') + await expect(chip).toHaveAttribute('aria-describedby', /(?:^| )sound-expansion-help(?: |$)/) + await chip.press('ArrowDown') + const list = page.getByRole('listbox', { name: 'Prompt expansion model' }) + await expect(list).toBeVisible() + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await expect(chip).toContainText('writer') + await expect(chip).toBeFocused() + await chip.click() + await page.keyboard.press('Escape') + await expect(list).toHaveCount(0) + await expect(chip).toBeFocused() + await chip.click() + await page.keyboard.press('Tab') + await expect(list).toHaveCount(0) + await chip.click() + await page.locator('[data-testid="ws-compose"] textarea').first().click() + await expect(list).toHaveCount(0) + await expect(chip).toContainText('writer') +}) + +for (const theme of ['dark', 'light']) { + test(`searches long IDs without clipping on a narrow ${theme} viewport`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await setup(page, [audio, ...Array.from({ length: 8 }, (_, i) => chat(`writer-${i}-${'long-model-name-'.repeat(5)}`))]) + await page.evaluate(theme => document.documentElement.setAttribute('data-theme', theme), theme) + await picker(page).click() + const search = page.getByRole('combobox') + await search.fill('no-such-model') + await expect(page.getByRole('option')).toHaveCount(0) + await expect(page.getByText('No model matches.', { exact: false })).toBeVisible() + await search.fill('writer-7') + await expect(page.getByRole('option')).toHaveCount(1) + const menu = page.locator('.home-menu--models') + const box = await menu.boundingBox() + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + await page.keyboard.press('Enter') + await expect(picker(page)).toContainText('writer-7') + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + if (process.env.SOUND_PICKER_EVIDENCE) { + await picker(page).click() + await page.screenshot({ path: `${process.env.SOUND_PICKER_EVIDENCE}/sound-${theme}-narrow.png`, fullPage: true }) + } + }) +} + +test('Backend default remains usable after a model list error', async ({ page }) => { + const requests = await setup(page) + await page.route('**/api/models/capabilities', route => route.fulfill({ status: 500, json: { error: 'offline' } })) + await page.route('**/v1/models', route => route.fulfill({ status: 500, json: { error: 'offline' } })) + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(page.locator('#sound-expansion-help')).toHaveText('Cannot load chat models. Choose Backend default or return to this tab to retry.') + await choose(page, '__backend_default__') + await prompt(page) + await expect(submit(page)).toBeEnabled() + await submit(page).click() + await expect.poll(() => requests.length).toBe(1) + expect(requests[0]).not.toHaveProperty('expansion_model') +}) + + +test('initial model loading disables the picker until the response resolves', async ({ page }) => { + const requests = await setup(page, undefined, 'writer') + let release + const responseReady = new Promise(resolve => { release = resolve }) + await page.route('**/api/models/capabilities', async route => { + await responseReady + await route.fulfill({ json: { data: [audio, chat('writer'), chat('poet')] } }) + }) + await page.reload() + try { + await expect(picker(page)).toBeDisabled() + await expect(page.locator('#sound-expansion-help')).toHaveText('Loading installed chat models…') + await prompt(page) + await expect(submit(page)).toBeDisabled() + } finally { + release() + } + await expect(picker(page)).toBeEnabled() + await expect(picker(page)).toContainText('writer') + await choose(page, 'poet') + await expect(submit(page)).toBeEnabled() + await submit(page).click() + await expect.poll(() => requests.length).toBe(1) + expect(requests[0]).toHaveProperty('expansion_model', 'poet') +}) + +test('Sound shows no unverified load status on any expansion model row or the chip', async ({ page }) => { + await setup(page) + await picker(page).click() + const options = page.getByRole('listbox', { name: 'Prompt expansion model' }).getByRole('option') + await expect(options.locator('code')).toHaveText(['Backend default', 'writer', 'poet']) + await expect(options.locator('.home-dot')).toHaveCount(0) + await expect(options.locator('small')).toHaveCount(0) + await page.keyboard.press('Escape') + await choose(page, 'writer') + await expect(picker(page).locator('.home-dot')).toHaveCount(0) +}) + +test('the stable picker label describes its current choice and retains the help association', async ({ page }) => { + await setup(page) + const chip = picker(page) + const help = 'A chat model expands your description into style and lyrics. Backend default uses the audio backend’s existing prompt behavior.' + await expect(chip).toHaveAccessibleName('Prompt expansion model') + await expect(chip).toHaveAccessibleDescription(`${choiceLabel('')} ${help}`) + await choose(page, 'writer') + await expect(chip).toHaveAccessibleName('Prompt expansion model') + await expect(chip).toHaveAccessibleDescription(`writer ${help}`) + await choose(page, '__backend_default__') + await expect(chip).toHaveAccessibleName('Prompt expansion model') + await expect(chip).toHaveAccessibleDescription(`Backend default ${help}`) + await expect(chip).toHaveAttribute('aria-describedby', /(?:^| )sound-expansion-help(?: |$)/) +}) diff --git a/core/http/react-ui/src/components/home/HomeModelPicker.jsx b/core/http/react-ui/src/components/home/HomeModelPicker.jsx index d65ca82e3..ccd92ca3f 100644 --- a/core/http/react-ui/src/components/home/HomeModelPicker.jsx +++ b/core/http/react-ui/src/components/home/HomeModelPicker.jsx @@ -1,5 +1,5 @@ // eslint-disable-next-line no-unused-vars -import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useModels } from '../../hooks/useModels' import { readLastModel, writeLastModel } from '../../utils/lastModel' @@ -25,7 +25,9 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( { value, onChange, capability, loadedIds, disabled = false, placeholder, models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer, - showWarm = true, labels, + showWarm = true, labels, autoSelect = true, rememberSelection = true, + defaultChoice, defaultSelected = false, onChooseDefault, id, describedBy, + }, ref, ) { @@ -34,6 +36,10 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( const models = givenModels || own.models const loading = givenModels ? !!givenLoading : own.loading const names = useMemo(() => models.map(m => m.id), [models]) + const instanceId = useId() + const menuId = `${instanceId}-menu` + const listId = `${instanceId}-list` + // A null row is a non-model action, never an installed model ID. const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [active, setActive] = useState(0) @@ -46,19 +52,19 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( // there, otherwise the first one. Auto-select is not a user choice, so it is // not written back. useEffect(() => { - if (names.length > 0 && (!value || !names.includes(value))) { + if (autoSelect && !defaultSelected && names.length > 0 && (!value || !names.includes(value))) { const remembered = readLastModel(capability) onChange(remembered && names.includes(remembered) ? remembered : names[0]) } - }, [names, value, onChange, capability]) + }, [names, value, onChange, capability, autoSelect, defaultSelected]) // Loaded models first when the list is grouped; each group keeps its order. const shown = useMemo(() => { const q = query.trim().toLowerCase() const found = q ? names.filter(n => n.toLowerCase().includes(q)) : names - if (!grouped) return found - return [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] - }, [names, query, grouped, loadedIds]) + const ordered = grouped ? [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] : found + return defaultChoice && (!q || defaultChoice.toLowerCase().includes(q)) ? [null, ...ordered] : ordered + }, [names, query, grouped, loadedIds, defaultChoice]) const close = useCallback((restoreFocus = false) => { setOpen(false) @@ -67,12 +73,12 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( }, []) const openMenu = useCallback(() => { - if (disabled || names.length === 0) return - setActive(Math.max(0, names.indexOf(value))) + if (disabled || (names.length === 0 && !defaultChoice)) return + setActive(defaultSelected ? 0 : Math.max(0, names.indexOf(value) + (defaultChoice ? 1 : 0))) setQuery('') setOpen(true) onOpen?.() - }, [disabled, names, value, onOpen]) + }, [disabled, names, value, onOpen, defaultChoice, defaultSelected]) useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu]) @@ -97,10 +103,13 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( }, [open, active, shown]) const pick = useCallback((name) => { - writeLastModel(capability, name) - onChange(name) + if (name === null) onChooseDefault?.() + else { + if (rememberSelection) writeLastModel(capability, name) + onChange(name) + } close(true) - }, [capability, onChange, close]) + }, [capability, onChange, close, rememberSelection, onChooseDefault]) const onMenuKey = (e) => { const n = shown.length @@ -108,24 +117,27 @@ const HomeModelPicker = forwardRef(function HomeModelPicker( else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) } else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) } else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) } - else if (e.key === 'Enter') { e.preventDefault(); if (shown[active]) pick(shown[active]) } + else if (e.key === 'Enter') { e.preventDefault(); if (active < n) pick(shown[active]) } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) } else if (e.key === 'Tab') close() } const isWarm = (name) => loadedIds?.has(name) - const label = value || placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none'))) - const activeId = shown[active] ? `home-model-opt-${active}` : undefined + const label = (defaultSelected ? defaultChoice : value) || placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none'))) + const activeId = active < shown.length ? `${instanceId}-opt-${active}` : undefined return (
{open && ( -
+
{names.length >= FILTER_FROM && ( { setQuery(e.target.value); setActive(0) }} role="combobox" aria-expanded="true" - aria-controls="home-model-list" + aria-controls={listId} aria-activedescendant={activeId} /> )}
    {t('picker.noMatch')}} {shown.map((name, i) => { const warm = isWarm(name) - const info = describe ? describe(name, warm) : null + const info = name !== null && describe ? describe(name, warm) : null + const selected = name === null ? defaultSelected : !defaultSelected && name === value const first = grouped && (i === 0 || isWarm(shown[i - 1]) !== warm) return ( - + {first && (
  • {warm ? t('picker.loadedNow') : t('picker.installed')}
  • )}
  • setActive(i)} onClick={() => pick(name)} > - {showWarm &&
  • diff --git a/core/http/react-ui/src/pages/Sound.jsx b/core/http/react-ui/src/pages/Sound.jsx index 0877c9335..15095fa91 100644 --- a/core/http/react-ui/src/pages/Sound.jsx +++ b/core/http/react-ui/src/pages/Sound.jsx @@ -1,5 +1,7 @@ import { useState } from 'react' // eslint-disable-next-line no-unused-vars +import HomeModelPicker from '../components/home/HomeModelPicker' +// eslint-disable-next-line no-unused-vars import RequestPanel from '../components/RequestPanel' import { useParams, useOutletContext } from 'react-router-dom' import { CAP_SOUND_GENERATION } from '../utils/capabilities' @@ -218,11 +220,23 @@ export default function Sound() { /> {!text && } - + expansion.choose(BACKEND_DEFAULT)} + />

    {expansion.loading ? t('sound.expansion.loading') : expansion.error ? t('sound.expansion.error') diff --git a/docs/content/features/text-to-audio.md b/docs/content/features/text-to-audio.md index 17d013630..f73e1e50d 100644 --- a/docs/content/features/text-to-audio.md +++ b/docs/content/features/text-to-audio.md @@ -1170,7 +1170,10 @@ tool call, multiple calls, or invalid arguments returns a request error. Expansi Cancellation propagates to model loading, text inference, and audio generation. In **Studio → Sound → Simple**, choose a **Prompt expansion model** before -submitting. The list contains installed, enabled chat models. **Backend default** +submitting. Sound uses the same model picker as Home, with search for longer +model lists and keyboard navigation. The list contains installed, enabled chat +models. You must explicitly choose a model or **Backend default**; opening or +dismissing the picker does not select a model. **Backend default** omits `expansion_model` and uses the audio backend's existing prompt behavior. Sound remembers either choice in this browser, separately from your chat model. If a remembered model disappears or loses chat support, choose again.