Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions core/http/react-ui/e2e/chat-history.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ async function openMenu(page) {
}

test.describe('Chat conversations menu', () => {
const now = new Date('2026-10-09T12:00:00Z').getTime()
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(now)
await mockChat(page, { models: [
{ id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] },
], loaded: ['qwen3-8b-instruct'] })
await openChat(page, sampleChats())
await openChat(page, sampleChats(now))
await expect(page.getByTestId('chat-message').first()).toBeVisible()
})

Expand Down Expand Up @@ -153,7 +155,7 @@ test.describe('Chat conversations menu', () => {
})

test('the undo time runs out by itself after six seconds', async ({ page }) => {
await page.clock.install()
await page.clock.install({ time: now })
await page.reload()
await page.getByTestId('chat-composer').waitFor()
await openMenu(page)
Expand Down
10 changes: 6 additions & 4 deletions core/http/react-ui/e2e/home-console.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,11 @@ test.describe('Home in a cluster', () => {
})

test.describe('Home resume list', () => {
const now = new Date('2026-10-09T12:00:00Z').getTime()
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(now)
await mockHome(page)
await seedChats(page)
await seedChats(page, sampleChats(now))
})

test('groups conversations by day, one card per day', async ({ page }) => {
Expand Down Expand Up @@ -408,7 +410,7 @@ test.describe('Home resume list', () => {
})

test('without undo the delete becomes final when the time ends', async ({ page }) => {
await page.clock.install()
await page.clock.install({ time: now })
await ready(page)
const row = page.getByTestId('home-conversation').filter({ hasText: 'invoice' })
await row.hover()
Expand All @@ -430,12 +432,12 @@ test.describe('Home resume list', () => {
})

test('old conversations wait behind one button', async ({ page }) => {
const chats = sampleChats()
const chats = sampleChats(now)
const old = Array.from({ length: 8 }, (_, i) => ({
...chats[0],
id: `old-${i}`,
name: `Old chat ${i}`,
updatedAt: Date.now() - (10 + i) * 24 * 60 * 60 * 1000,
updatedAt: now - (10 + i) * 24 * 60 * 60 * 1000,
}))
await page.addInitScript(([key, data]) => localStorage.setItem(key, JSON.stringify(data)),
[CHATS_KEY, { chats: [...chats, ...old], activeChatId: chats[0].id }])
Expand Down
8 changes: 5 additions & 3 deletions core/http/react-ui/e2e/model-page.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,17 @@ test.describe('Model page - walking and going back', () => {
const { state } = await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(500)
const before = state.listingRequests.length
// Facet counts load in the background; they do not reload the list.
const listings = () => state.listingRequests.filter(search => new URLSearchParams(search).get('items') !== '1')
const before = listings().length
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await expect(name(page)).toHaveText('qwen3-8b-instruct')
await page.keyboard.press('Escape')
await expect(rows(page).first()).toBeVisible()
await page.waitForTimeout(500)
// The page itself looks its own entry up once; the list asks for nothing.
expect(state.listingRequests.length - before).toBeLessThanOrEqual(1)
expect(state.listingRequests.slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true)
expect(listings().length - before).toBeLessThanOrEqual(1)
expect(listings().slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true)
})

test('changing tabs keeps the walker, and Back still leaves for the list in one step', async ({ page }) => {
Expand Down
9 changes: 4 additions & 5 deletions core/http/react-ui/e2e/models-cleanup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,10 @@ test.describe('Cleanup sheet - what it knows and what it does not', () => {

test('each tier is ranked by size and a model with no known size goes last', async ({ page }) => {
await openSheet(page)
const names = await tier(page, 'call').locator('.ledger-cr__name').evaluateAll(
(els) => els.map((e) => e.firstChild.textContent.trim()),
)
expect(names[0]).toBe('llama-3.3-70b-instruct-iq2')
expect(names[names.length - 1]).toBe('my-finetune-q4')
// Estimates arrive after the sheet becomes interactive and can reorder it.
const names = tier(page, 'call').locator('.ledger-cr__name')
await expect(names.first()).toContainText('llama-3.3-70b-instruct-iq2')
await expect(names.last()).toContainText('my-finetune-q4')
await expect(tier(page, 'call').locator('li').last()).toContainText('size unknown')
await expect(tier(page, 'call').locator('li').first()).toContainText(`${gb1(sizeOf('llama-3.3-70b-instruct-iq2'))} GB`)
})
Expand Down
3 changes: 1 addition & 2 deletions core/http/react-ui/e2e/studio-work.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ test.describe('Your work', () => {

test('the masonry uses fewer columns on a narrow screen', async ({ page }) => {
await open(page)
const wide = await page.locator('.studio-masonry').getAttribute('data-columns')
await expect.poll(async () => Number(await page.locator('.studio-masonry').getAttribute('data-columns'))).toBeGreaterThan(1)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => page.locator('.studio-masonry').getAttribute('data-columns')).toBe('1')
expect(Number(wide)).toBeGreaterThan(1)
})
})
3 changes: 2 additions & 1 deletion core/http/react-ui/e2e/traffic-models-host.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ test.describe('Traffic models', () => {
await expect(bge.locator('td.dk-num').last()).toHaveText('-')
// The failed load of flux-dev is in the backend-operation buffer.
await expect(page.locator('tr[data-entity="flux-dev"]')).toContainText('1')
await expect(page.getByText(/p50|p95|p99/)).toHaveCount(0)
// A row's concatenated text can contain llama-cpp followed by 99 requests.
await expect(page.getByText(/\bp(?:50|95|99)\b/i)).toHaveCount(0)
})

test('a row opens in place with the split, the buffer and the links', async ({ page }) => {
Expand Down
124 changes: 124 additions & 0 deletions core/http/react-ui/e2e/voice-library.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const profile = {

async function mockVoiceAPIs(page) {
const state = { createdMultipart: null }
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false } }))
await page.route('**/api/models/capabilities', route => route.fulfill({
status: 200,
contentType: 'application/json',
Expand Down Expand Up @@ -216,3 +217,126 @@ test.describe('Speech voices', () => {
expect(apiState.createdMultipart.readUInt16LE(wavOffset + 34)).toBe(16)
})
})

test.describe('Voice design', () => {
let apiState
test.beforeEach(async ({ page }) => { apiState = await mockVoiceAPIs(page) })

async function openDesign(page) {
await page.goto('/app/voice-library/new')
await page.getByRole('button', { name: 'Design a voice', exact: true }).click()
}

test('previews generated audio and saves its matching transcript', async ({ page }) => {
let speechRequest
await page.route('**/tts', route => {
speechRequest = route.request().postDataJSON()
return route.fulfill({ status: 200, contentType: 'audio/wav', body: pcmWav(2) })
})
await openDesign(page)
const generate = page.getByRole('button', { name: 'Generate reference', exact: true })
await expect(generate).toBeDisabled()
await page.getByLabel('Voice instructions').fill(' A warm narrator. ')
await expect(generate).toBeDisabled()
await page.getByLabel('Sample text').fill(' Welcome to the documentary. ')
await generate.click()
await expect(page.getByText('Normalized reference')).toBeVisible()
expect(speechRequest).toEqual({ model: 'qwen-base', input: 'Welcome to the documentary.', instructions: 'A warm narrator.' })
await expect(page.getByLabel('Exact transcript')).toHaveValue(speechRequest.input)
expect(apiState.createdMultipart).toBeNull()
await page.getByLabel('Voice name', { exact: true }).fill('Designed narrator')
await expect(page.getByRole('button', { name: 'Save voice', exact: true })).toBeDisabled()
await page.getByText('I confirm that this voice may be cloned').click()
await page.getByRole('button', { name: 'Save voice', exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/app/voice-library\\?selected=${VOICE_ID}$`))
expect(apiState.createdMultipart.toString()).toContain('name="transcript"\r\n\r\nWelcome to the documentary.')
const wavOffset = apiState.createdMultipart.indexOf(Buffer.from('RIFF'))
expect(wavOffset).toBeGreaterThanOrEqual(0)
expect(apiState.createdMultipart.readUInt32LE(wavOffset + 24)).toBe(24000)
expect(apiState.createdMultipart.readUInt16LE(wavOffset + 22)).toBe(1)
expect(apiState.createdMultipart.readUInt16LE(wavOffset + 34)).toBe(16)
})

test('requires regeneration after edits and disables save during generation', async ({ page }) => {
let releaseSpeech
await page.route('**/tts', async route => {
await new Promise(resolve => { releaseSpeech = resolve })
await route.fulfill({ status: 200, contentType: 'audio/wav', body: pcmWav(2) })
})
await openDesign(page)
await page.getByLabel('Voice instructions').fill('Warm narrator')
await page.getByLabel('Sample text').fill('First sample.')
await page.getByLabel('Voice name', { exact: true }).fill('Narrator')
await page.getByText('I confirm that this voice may be cloned').click()
const generate = page.getByRole('button', { name: 'Generate reference', exact: true })
const save = page.getByRole('button', { name: 'Save voice', exact: true })
await generate.click()
await expect.poll(() => !!releaseSpeech).toBe(true)
await expect(save).toBeDisabled()
await expect(page.getByLabel('Sample text')).toBeDisabled()
releaseSpeech()
await expect(save).toBeEnabled()
await page.getByLabel('Sample text').fill('Second sample.')
await expect(save).toBeDisabled()
await expect(page.getByText('Normalized reference')).toBeHidden()
releaseSpeech = null
await generate.click()
await expect.poll(() => !!releaseSpeech).toBe(true)
releaseSpeech()
await expect(save).toBeEnabled()
await expect(page.getByLabel('Exact transcript')).toHaveValue('Second sample.')
await page.getByRole('group', { name: 'Voice design model', exact: true }).getByRole('button').click()
await page.getByRole('option', { name: 'piper-default', exact: true }).click()
await expect(save).toBeDisabled()
await expect(page.getByText('Normalized reference')).toBeHidden()
releaseSpeech = null
await generate.click()
await expect.poll(() => !!releaseSpeech).toBe(true)
releaseSpeech()
await expect(save).toBeEnabled()
await page.getByLabel('Voice instructions').fill('Bright narrator')
await expect(save).toBeDisabled()
await page.getByRole('button', { name: 'Upload or record', exact: true }).click()
await expect(page.locator('#voice-profile-audio-file')).toBeAttached()
await expect(save).toBeDisabled()
})

test('reports generation failures and permits retry', async ({ page }) => {
let attempts = 0
await page.route('**/tts', route => {
attempts += 1
return attempts === 1
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Voice design unavailable' } }) })
: route.fulfill({ status: 200, contentType: 'audio/wav', body: pcmWav(2) })
})
await openDesign(page)
await page.getByLabel('Voice instructions').fill('Warm narrator')
await page.getByLabel('Sample text').fill('Try this sample.')
await page.getByRole('button', { name: 'Generate reference', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('Voice design unavailable')
await expect(page.getByRole('button', { name: 'Save voice', exact: true })).toBeDisabled()
await page.getByRole('button', { name: 'Generate reference', exact: true }).click()
await expect(page.getByText('Normalized reference')).toBeVisible()
await expect(page.getByLabel('Exact transcript')).toHaveValue('Try this sample.')
})

test('cannot generate without an installed TTS model', async ({ page }) => {
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [] } }))
await openDesign(page)
await page.getByLabel('Voice instructions').fill('Warm narrator')
await page.getByLabel('Sample text').fill('Hello there.')
await expect(page.getByRole('button', { name: 'Generate reference', exact: true })).toBeDisabled()
})

test('rejects generated audio outside the reference duration limit', async ({ page }) => {
await page.route('**/tts', route => route.fulfill({ status: 200, contentType: 'audio/wav', body: pcmWav(0.5) }))
await openDesign(page)
await page.getByLabel('Voice instructions').fill('Warm narrator')
await page.getByLabel('Sample text').fill('Hi.')
await page.getByRole('button', { name: 'Generate reference', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('Reference audio must be between 1 second and 2 minutes.')
await expect(page.getByText('Normalized reference')).toBeHidden()
await expect(page.getByRole('button', { name: 'Save voice', exact: true })).toBeDisabled()
})

})
14 changes: 13 additions & 1 deletion core/http/react-ui/public/locales/en/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@
"sections": {
"reference": {
"title": "Reference audio",
"body": "Record in the browser or upload an existing clip. Speech should be clean, natural, and from one speaker."
"body": "Record, upload, or generate a reference clip. Speech should be clean, natural, and from one speaker."
},
"details": {
"title": "Identity and transcript",
Expand Down Expand Up @@ -690,6 +690,18 @@
},
"toasts": {
"created": "{{name}} is ready to use"
},
"design": {
"source": "Reference source",
"upload": "Upload or record",
"title": "Design a voice",
"model": "Voice design model",
"hint": "Choose a TTS model that supports voice design. The list includes all TTS models; some do not support voice instructions. Describe the voice, generate a sample, and listen before saving.",
"instructions": "Voice instructions",
"text": "Sample text",
"generate": "Generate reference",
"generating": "Generating reference…",
"error": "Could not generate the voice reference."
}
},
"sound": {
Expand Down
6 changes: 4 additions & 2 deletions core/http/react-ui/src/components/models/page/model-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,17 @@ a.modelpage-chip { cursor: pointer; text-decoration: none; color: inherit; }
.modelpage-keys { display: none; }
}

.modelpage-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }
.modelpage-table .dk-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }

/* A grid child is as wide as its content unless told otherwise; a table inside
one would push the whole page sideways instead of scrolling in its own box. */
.modelpage-variants > section, .modelpage-usage > section, .modelpage-columns > *, .modelpage-fit > * { min-width: 0; }
.modelpage-variants__size { display: none; }
@media (max-width: 640px) {
.modelpage-variants__size { display: inline; }
/* Leave room for Fit and Install while long build names wrap. */
.modelpage-table .dk-table .dk-table-sub { max-width: 14ch; }
.modelpage-table .dk-table-actions .dk-btn { padding-inline: var(--dk-space-2); }
}

Expand Down
Loading
Loading