mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(ui): reuse Home picker for Sound expansion (#12790)
* fix(ui): reuse Home picker for Sound expansion Use the shared model chip and listbox for Sound expansion instead of a native select. Keep explicit Sound selection and Backend default independent from Home model preferences. Preserve keyboard navigation and expose the selected value through an accessible description. Hide runtime badges without loaded-model data. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): select Sound expansion through shared picker Update media history coverage to use the model button and listbox. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
560527e2e1
commit
e684fdf084
6 files changed
+223
-55
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(?: |$)/)
|
||||
})
|
||||
@@ -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 (
|
||||
<div className="home-pick" ref={rootRef}>
|
||||
<button
|
||||
ref={chipRef}
|
||||
id={id}
|
||||
aria-label={labels?.label}
|
||||
aria-describedby={[labels?.label && `${instanceId}-selection`, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
type="button"
|
||||
className="home-chip home-chip--model"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls="home-model-menu"
|
||||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
title={labels?.title || t('picker.title')}
|
||||
data-testid="home-model-chip"
|
||||
@@ -133,12 +145,12 @@ const HomeModelPicker = forwardRef(function HomeModelPicker(
|
||||
onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
|
||||
>
|
||||
<Icon name="cube" />
|
||||
{showWarm && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
<span className="home-chip__text">{label}</span>
|
||||
{showWarm && !defaultSelected && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
<span id={labels?.label ? `${instanceId}-selection` : undefined} className="home-chip__text">{label}</span>
|
||||
<Icon name="chevron-down" className="home-chip__caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="home-menu home-menu--models" id="home-model-menu" onKeyDown={onMenuKey}>
|
||||
<div className="home-menu home-menu--models" id={menuId} onKeyDown={onMenuKey}>
|
||||
{names.length >= FILTER_FROM && (
|
||||
<input
|
||||
ref={filterRef}
|
||||
@@ -150,13 +162,13 @@ const HomeModelPicker = forwardRef(function HomeModelPicker(
|
||||
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="home-model-list"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId}
|
||||
/>
|
||||
)}
|
||||
<ul
|
||||
ref={menuRef}
|
||||
id="home-model-list"
|
||||
id={listId}
|
||||
className="home-menu__list"
|
||||
role="listbox"
|
||||
aria-label={labels?.label || t('picker.label')}
|
||||
@@ -167,29 +179,30 @@ const HomeModelPicker = forwardRef(function HomeModelPicker(
|
||||
{shown.length === 0 && <li className="home-menu__empty" role="presentation">{t('picker.noMatch')}</li>}
|
||||
{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 (
|
||||
<Fragment key={name}>
|
||||
<Fragment key={name === null ? `${instanceId}-default` : name}>
|
||||
{first && (
|
||||
<li className="home-menu__head" role="presentation">
|
||||
{warm ? t('picker.loadedNow') : t('picker.installed')}
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
id={`home-model-opt-${i}`}
|
||||
id={`${instanceId}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={name === value}
|
||||
aria-selected={selected}
|
||||
data-active={i === active}
|
||||
className={`home-menu__item${info ? ' home-menu__item--rich' : ''}`}
|
||||
onMouseMove={() => setActive(i)}
|
||||
onClick={() => pick(name)}
|
||||
>
|
||||
{showWarm && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
{showWarm && name !== null && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
{info ? (
|
||||
<>
|
||||
<span className="home-menu__main">
|
||||
<code>{name}</code>
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{(info.size || info.vision) && (
|
||||
<small className="home-menu__caps">
|
||||
{info.size && <span>{info.size}</span>}
|
||||
@@ -198,16 +211,16 @@ const HomeModelPicker = forwardRef(function HomeModelPicker(
|
||||
)}
|
||||
</span>
|
||||
<span className="home-menu__state" data-tone={info.fit?.tone}>
|
||||
<b>{name === value ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
|
||||
<b>{selected ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
|
||||
{info.fit && <small>{info.fit.text}</small>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<code>{name}</code>
|
||||
{name === value
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{selected
|
||||
? <Icon name="check" className="home-menu__check" />
|
||||
: (showWarm && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
|
||||
: (showWarm && name !== null && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -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 && <Starters type="sound" onPick={setText} />}
|
||||
<Field label={t('sound.expansion.label')} htmlFor="sound-expansion-model" changed={changed.has('expansionModel')}>
|
||||
<select id="sound-expansion-model" className="dk-input" value={expansion.value} onChange={e => expansion.choose(e.target.value)} disabled={expansion.loading} aria-describedby="sound-expansion-help">
|
||||
<option value="" disabled>{t('sound.expansion.placeholder')}</option>
|
||||
<option value={BACKEND_DEFAULT}>{t('sound.expansion.backendDefault')}</option>
|
||||
{expansion.models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
|
||||
</select>
|
||||
<HomeModelPicker
|
||||
id="sound-expansion-model"
|
||||
describedBy="sound-expansion-help"
|
||||
labels={{ label: t('sound.expansion.label'), heading: t('sound.expansion.label') }}
|
||||
placeholder={t('sound.expansion.placeholder')}
|
||||
value={expansion.value === BACKEND_DEFAULT ? '' : expansion.value}
|
||||
onChange={expansion.choose}
|
||||
models={expansion.models}
|
||||
loading={expansion.loading}
|
||||
disabled={expansion.loading}
|
||||
autoSelect={false}
|
||||
showWarm={false}
|
||||
rememberSelection={false}
|
||||
defaultChoice={t('sound.expansion.backendDefault')}
|
||||
defaultSelected={expansion.value === BACKEND_DEFAULT}
|
||||
onChooseDefault={() => expansion.choose(BACKEND_DEFAULT)}
|
||||
/>
|
||||
<p id="sound-expansion-help" className="text-note" role="status">{expansion.loading
|
||||
? t('sound.expansion.loading')
|
||||
: expansion.error ? t('sound.expansion.error')
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user