diff --git a/addons/fine-tuning/ui/test/page.test.mjs b/addons/fine-tuning/ui/test/page.test.mjs
index c14464dcf..29554797f 100644
--- a/addons/fine-tuning/ui/test/page.test.mjs
+++ b/addons/fine-tuning/ui/test/page.test.mjs
@@ -207,14 +207,6 @@ test('plural: the job count picks the singular and the plural key', async () =>
assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server')
})
-test('parity: every data-testid of the core page and chart is in the add-on or the shared views', () => {
- const core = ['pages/FineTune.jsx', 'components/tools/LossChart.jsx'].map(f => read(join(hostUi, 'src', f))).join('\n')
- const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1]))
- assert.ok(ids.size >= 20, `found only ${ids.size} test ids in the core sources`)
- const mine = ownText + sharedText
- for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`)
-})
-
test('i18n: every literal key the add-on and the shared views read is in en.js', () => {
const keys = new Set()
for (const text of [ownText, sharedText]) for (const m of text.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'/g)) keys.add(m[1])
diff --git a/addons/quantization/ui/test/page.test.mjs b/addons/quantization/ui/test/page.test.mjs
index 16e629641..ec0a8c885 100644
--- a/addons/quantization/ui/test/page.test.mjs
+++ b/addons/quantization/ui/test/page.test.mjs
@@ -170,14 +170,6 @@ test('plural: the job count picks the singular and the plural key', async () =>
assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server')
})
-test('parity: every data-testid of the core page is in the add-on or the shared views', () => {
- const core = read(join(hostUi, 'src/pages/Quantize.jsx'))
- const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1]))
- assert.ok(ids.size >= 10, `found only ${ids.size} test ids in the core source`)
- const mine = ownText + sharedText
- for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`)
-})
-
test('icons: every icon name the page draws is one the SDK knows', async () => {
const { ICON_NAMES } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
const known = new Set(ICON_NAMES)
diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js
index d2e615ea9..011cfd5dc 100644
--- a/core/http/react-ui/e2e/addon-navigation.spec.js
+++ b/core/http/react-ui/e2e/addon-navigation.spec.js
@@ -387,14 +387,6 @@ async function asAdmin(page, features = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, voice_recognition: true, ...features }) }))
}
-async function expectVoicesPage(page) {
- await expect(page.getByTestId('voices-page')).toBeVisible()
- await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
- for (const name of [/Speakers/, /Speech voices/, /From a recording/]) {
- await expect(page.getByRole('tab', { name })).toBeVisible()
- }
-}
-
// The tabs of the one Voice page. An administrator sees all four.
async function expectVoiceTabs(page, active) {
const tabs = page.getByTestId('voice-tabs')
@@ -426,13 +418,12 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
})
- test('the core page, with the add-on absent, shows the same test ids and tabs', async ({ page }) => {
+ test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
- await mockIdentity(page)
await page.goto('/app/voice')
- await expect(page).toHaveURL(/\/app\/voice$/)
- await expectVoicesPage(page)
+ await expect(page).toHaveURL(/\/app\/addons\/voice\//)
+ await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old model link ends in the add-on with the model in the query', async ({ page }) => {
@@ -667,13 +658,12 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
})
- test('the core page, with the add-on absent, shows the same test id and title', async ({ page }) => {
+ test('an old Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
- await asFaceAdmin(page)
- await mockIdentity(page)
+ await asAdmin(page)
await page.goto('/app/face')
- await expect(page).toHaveURL(/\/app\/face$/)
- await expectFacesPage(page)
+ await expect(page).toHaveURL(/\/app\/addons\/face\//)
+ await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old model link ends in the add-on with the model in the query', async ({ page }) => {
@@ -705,10 +695,12 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.getByTestId('tool-off')).toBeVisible()
})
- test('an old fine-tuning link opens the core page when the add-on is not installed', async ({ page }) => {
+ test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
+ await asAdmin(page)
await page.goto('/app/fine-tune')
- await expect(page).toHaveURL(/\/app\/fine-tune$/)
+ await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//)
+ await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old fine-tuning link opens the add-on when it is installed and running', async ({ page }) => {
@@ -718,10 +710,12 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/)
})
- test('an old quantization link opens the core page when the add-on is not installed', async ({ page }) => {
+ test('an old quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
+ await asAdmin(page)
await page.goto('/app/quantize')
- await expect(page).toHaveURL(/\/app\/quantize$/)
+ await expect(page).toHaveURL(/\/app\/addons\/quantization\//)
+ await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old quantization link opens the add-on when it is installed and running', async ({ page }) => {
diff --git a/core/http/react-ui/e2e/build-tools.spec.js b/core/http/react-ui/e2e/build-tools.spec.js
deleted file mode 100644
index e63047f24..000000000
--- a/core/http/react-ui/e2e/build-tools.spec.js
+++ /dev/null
@@ -1,513 +0,0 @@
-import { test, expect } from './coverage-fixtures.js'
-import { CPU_HOST, GPU_HOST, ftEvents, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js'
-
-// Fine-tune and Quantize share one pattern: set up, check before you start (the
-// list redraws as the form changes), run with progress and a log, then a result
-// with real next steps. Every number on these pages comes from the stubs below.
-
-const MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'
-const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state)
-const check = (page, id) => page.locator(`[data-testid="tool-checks"] [data-check="${id}"]`)
-
-async function boot(page, { host = GPU_HOST, ft = {}, qz = {} } = {}) {
- await mockMachine(page, { host })
- const ftState = { jobs: [], ...ft }
- const qzState = { jobs: [], ...qz }
- await mockFineTune(page, ftState)
- await mockQuantize(page, qzState)
- return { ftState, qzState }
-}
-
-async function fillFineTune(page) {
- await page.fill('#ft-model', MODEL)
- await page.fill('#ft-dataset', 'tatsu-lab/alpaca')
-}
-
-test.describe('Fine-tune: set up and check', () => {
- test('starts empty: Set up is current, Start is off, and the check says what is missing', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- await expectStep(page, 'setup', 'current')
- await expect(page.getByTestId('ft-start')).toBeDisabled()
- await expect(check(page, 'model')).toHaveAttribute('data-tone', 'fail')
- await expect(check(page, 'data')).toContainText('Add a dataset')
- await expect(page.getByTestId('ft-bar')).toContainText('Enter the model to train.')
- await expect(page.getByTestId('ft-recipe')).toContainText('Choose a model and a dataset')
- })
-
- test('a model and a dataset move to Check and switch Start on', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await expectStep(page, 'check', 'current')
- await expect(page.getByTestId('ft-start')).toBeEnabled()
- await expect(check(page, 'model')).toHaveAttribute('data-tone', 'ok')
- await expect(check(page, 'data')).toContainText('Data comes from tatsu-lab/alpaca')
- await expect(check(page, 'backend')).toContainText('trl is installed')
- await expect(check(page, 'memory')).toContainText('RTX 4070: 9.8 GB of 12 GB free')
- await expect(check(page, 'memory')).toContainText('LocalAI does not estimate it before the job starts')
- await expect(check(page, 'memory').getByRole('img')).toHaveAttribute('aria-label', /18 percent in use/)
- await expect(check(page, 'disk')).toContainText('118 GB free on the models disk')
- await expect(page.getByTestId('ft-recipe')).toContainText('Train a LoRA adapter of TinyLlama-1.1B-Chat-v1.0 with SFT, 3 epochs over tatsu-lab/alpaca.')
- await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.')
- })
-
- test('the check list redraws as the form changes', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await expect(check(page, 'memory')).toContainText('Batch size 2')
- await page.fill('#ft-batch', '4')
- await expect(check(page, 'memory')).toContainText('Batch size 4')
- await page.fill('#ft-epochs', '5')
- await expect(page.getByTestId('ft-recipe')).toContainText('5 epochs')
- await page.getByRole('radio', { name: /Full model/ }).check()
- await expect(page.getByTestId('ft-recipe')).toContainText('full model')
-
- // GRPO needs a reward function: a warning that goes when one is chosen.
- await page.getByTestId('ft-more-toggle').click()
- await page.selectOption('#ft-method', 'grpo')
- await expect(check(page, 'reward')).toHaveAttribute('data-tone', 'warn')
- await expect(page.getByTestId('ft-bar')).toContainText('1 warning. You can still start.')
- await expect(page.getByTestId('ft-start')).toBeEnabled()
- await page.getByLabel('format_reward').check()
- await expect(check(page, 'reward')).toHaveCount(0)
- await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.')
- })
-
- test('with no GPU the memory check warns and names the RAM', async ({ page }) => {
- await boot(page, { host: CPU_HOST })
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await expect(check(page, 'memory')).toHaveAttribute('data-tone', 'warn')
- await expect(check(page, 'memory')).toContainText('No GPU found. Training uses the CPU and 20 GB of 32 GB RAM is free.')
- })
-
- test('with no backend installed it warns, links to Backends, and still lets you start', async ({ page }) => {
- await boot(page, { ft: { backends: [] } })
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn')
- await expect(check(page, 'backend')).toContainText('No fine-tuning backend is installed')
- await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toHaveAttribute('href', '/app/backends')
- await expect(page.getByTestId('ft-start')).toBeEnabled()
- })
-
- test('a user who cannot read the machine still gets a check list', async ({ page }) => {
- await boot(page, { host: null })
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await expect(check(page, 'memory')).toContainText('This account cannot read the machine')
- await expect(check(page, 'disk')).toHaveCount(0)
- })
-
- test('Start sends the request the form describes and opens the job', async ({ page }) => {
- const { ftState } = await boot(page)
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await page.fill('#ft-split', 'train')
- await page.fill('#ft-lr', '5e-5')
- await page.getByTestId('ft-start').click()
- await expect(page.getByTestId('job-view')).toBeVisible()
- expect(ftState.started).toHaveLength(1)
- expect(ftState.started[0]).toMatchObject({
- model: MODEL, backend: 'trl', training_method: 'sft', training_type: 'lora', dataset_source: 'tatsu-lab/alpaca',
- dataset_split: 'train', num_epochs: 3, batch_size: 2, learning_rate: 0.00005, adapter_rank: 16,
- })
- expect(ftState.started[0].extra_options).toMatchObject({ max_seq_length: '2048', eval_strategy: 'no' })
- await expectStep(page, 'run', 'current')
- })
-
- test('an uploaded file is the dataset, and is sent to the upload endpoint first', async ({ page }) => {
- const { ftState } = await boot(page)
- await page.goto('/app/fine-tune')
- await page.fill('#ft-model', MODEL)
- await page.setInputFiles('#ft-dataset-file', { name: 'pairs.jsonl', mimeType: 'application/json', buffer: Buffer.from('{"text":"a"}\n') })
- await expect(check(page, 'data')).toContainText('pairs.jsonl')
- await page.getByTestId('ft-start').click()
- await expect(page.getByTestId('job-view')).toBeVisible()
- expect(ftState.started[0].dataset_source).toBe('/data/datasets/upload.jsonl')
- })
-
- test('a refused start shows the server message and keeps the form', async ({ page }) => {
- await boot(page, { ft: { startError: 'failed to load backend trl: not found' } })
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await page.getByTestId('ft-start').click()
- await expect(page.getByTestId('tool-error')).toContainText('failed to load backend trl: not found')
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- await expect(page.locator('#ft-model')).toHaveValue(MODEL)
- })
-
- test('More options carries the method, adapter and evaluation settings', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await expect(page.locator('#ft-method')).toHaveCount(0)
- await page.getByTestId('ft-more-toggle').click()
- for (const id of ['#ft-backend', '#ft-method', '#ft-type', '#ft-rank', '#ft-optimizer', '#ft-hf-token', '#ft-seq-len']) await expect(page.locator(id)).toBeVisible()
- await page.getByRole('switch', { name: 'Evaluate while training' }).click()
- await expect(page.locator('#ft-eval-strategy')).toBeVisible()
- })
-})
-
-test.describe('Fine-tune: run, fail, finish', () => {
- const open = async (page) => {
- await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
- await expect(page.getByTestId('job-view')).toBeVisible()
- }
-
- test('a running job shows progress, stages, chart and log from its events', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } })
- await page.goto('/app/fine-tune')
- await open(page)
- await expectStep(page, 'run', 'current')
- const view = page.getByTestId('job-view')
- await expect(view.locator('.bt-job__percent')).toContainText('60')
- await expect(view).toContainText('step 540 of 900')
- await expect(view).toContainText('epoch 1.8 of 3')
- await expect(view).toContainText('about 12 min left')
- await expect(view).toContainText('312 tokens/s')
- await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '60')
- await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Training')
- // The chart: a line, the evaluation loss as hollow dots, the last value.
- await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible()
- await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(5)
- await expect(page.getByTestId('job-chart')).toContainText('Hollow dots are the evaluation loss')
- await page.getByRole('tab', { name: 'Learning rate' }).click()
- await expect(page.getByTestId('job-chart').getByRole('heading', { name: 'Learning rate' })).toBeVisible()
- await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(0)
- // The log is what the events said, and the filter shows warnings only.
- await expect(page.getByTestId('job-log')).toContainText('step 530/900 loss')
- await page.getByTestId('job-log-filter').check()
- await expect(page.getByTestId('job-log')).toContainText('No warnings or errors.')
- })
-
- test('Stop asks whether to keep a checkpoint and sends the answer', async ({ page }) => {
- const { ftState } = await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } })
- await page.goto('/app/fine-tune')
- await open(page)
- await page.getByTestId('job-stop').click()
- await expect(page.getByTestId('stop-dialog')).toContainText('Stop training?')
- await page.getByTestId('stop-keep').click()
- await expect(page.getByTestId('stop-dialog')).toHaveCount(0)
- expect(ftState.stopped).toEqual([{ id: 'job-run', save: 'true' }])
- await page.getByTestId('job-stop').click()
- await page.getByTestId('stop-discard').click()
- expect(ftState.stopped[1]).toEqual({ id: 'job-run', save: 'false' })
- await page.getByTestId('job-stop').click()
- await page.getByRole('button', { name: 'Keep training' }).click()
- expect(ftState.stopped).toHaveLength(2)
- })
-
- test('a failed job shows the server message, offers the fixes for a memory failure, and applies them to a copy', async ({ page }) => {
- const message = 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.34 GiB.'
- await boot(page, {
- ft: {
- jobs: [ftJob('job-f', 'failed', { message })],
- checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: '2026-10-08 10:10' }] },
- },
- })
- await page.goto('/app/fine-tune')
- await open(page)
- await expectStep(page, 'run', 'failed')
- await expect(page.getByTestId('job-failed')).toHaveAttribute('role', 'alert')
- await expect(page.getByTestId('job-failed-message')).toHaveText(message)
- await expect(page.getByTestId('job-try')).toContainText('Lower the batch size to 1')
- await expect(page.getByRole('button', { name: 'Resume from step 200' })).toBeVisible()
- await page.getByTestId('job-retry').click()
- // Back on the form, with the setup restored and the two fixes applied.
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- await expect(page.locator('#ft-model')).toHaveValue(MODEL)
- await expect(page.locator('#ft-batch')).toHaveValue('1')
- await page.getByTestId('ft-more-toggle').click()
- await expect(page.getByLabel(/Gradient checkpointing/)).toBeChecked()
- })
-
- test('a failure that is not about memory gets no invented advice', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-f', 'failed', { message: 'dataset not found: tatsu-lab/alpaca' })] } })
- await page.goto('/app/fine-tune')
- await open(page)
- await expect(page.getByTestId('job-failed-message')).toHaveText('dataset not found: tatsu-lab/alpaca')
- await expect(page.getByTestId('job-try')).not.toContainText('batch size')
- await expect(page.getByTestId('job-try')).toContainText('change what the message points at')
- })
-
- test('Resume from a checkpoint puts it in the form', async ({ page }) => {
- await boot(page, {
- ft: {
- jobs: [ftJob('job-f', 'failed', { message: 'stopped' })],
- checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: 'x' }] },
- },
- })
- await page.goto('/app/fine-tune')
- await open(page)
- await page.getByRole('button', { name: 'Resume from step 200' }).click()
- await expect(page.getByText('Resuming from checkpoint:')).toBeVisible()
- await expect(page.getByTestId('fine-tune-form')).toContainText('/data/finetune/job-f/checkpoint-200')
- await expect(page.getByTestId('ft-start')).toContainText('Resume training')
- })
-
- test('a finished job offers the export, and an exported model leads to Chat and Models', async ({ page }) => {
- const { ftState } = await boot(page, {
- ft: {
- jobs: [ftJob('job-d', 'completed')],
- checkpoints: { 'job-d': [{ path: '/data/finetune/job-d/checkpoint-500', step: 500, epoch: 1.66, loss: 0.8911, created_at: 'x' }] },
- },
- })
- await page.goto('/app/fine-tune')
- await open(page)
- await expectStep(page, 'result', 'current')
- await expect(page.getByTestId('job-finished')).toContainText('Training finished.')
- await expect(page.getByTestId('job-checkpoints')).toContainText('1.4012'.replace('1.4012', '0.8911'))
- await page.fill('#ft-export-name', 'tinyllama-ft')
- await page.selectOption('#ft-export-format', 'gguf')
- await page.fill('#ft-export-quant', 'q5_k_m')
- await page.getByRole('region', { name: 'Export as a model' }).getByRole('button', { name: 'Export', exact: true }).click()
- expect(ftState.exported[0]).toMatchObject({ name: 'tinyllama-ft', export_format: 'gguf', quantization_method: 'q5_k_m', checkpoint_path: '/data/finetune/job-d', model: MODEL })
- })
-
- test('an exported job links to Chat, Models and the archive', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-d', 'completed', { export_status: 'completed', export_model_name: 'tinyllama-ft' })] } })
- await page.goto('/app/fine-tune')
- await open(page)
- const next = page.getByTestId('export-next')
- await expect(next).toContainText('tinyllama-ft is a model now')
- await expect(next.getByRole('link', { name: 'Chat with tinyllama-ft' })).toHaveAttribute('href', '/app/chat/tinyllama-ft')
- await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed')
- await expect(next.getByRole('link', { name: 'Download archive' })).toHaveAttribute('href', /jobs\/job-d\/download/)
- })
-
- test('New job returns to the form', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-d', 'completed')] } })
- await page.goto('/app/fine-tune')
- await open(page)
- await page.getByTestId('job-back').click()
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- })
-})
-
-test.describe('Fine-tune: earlier jobs', () => {
- test('lists jobs with kind and status, reuses a setup and deletes after a confirmation', async ({ page }) => {
- const { ftState } = await boot(page, {
- ft: { jobs: [ftJob('job-1', 'completed', { model: 'Qwen2.5-0.5B', training_method: 'dpo', config: { model: 'Qwen2.5-0.5B', dataset_source: 'x/y', training_method: 'dpo', batch_size: 3 } }), ftJob('job-2', 'failed', { model: 'gemma-2-2b', message: 'unsupported' })] },
- })
- await page.goto('/app/fine-tune')
- const jobs = page.getByTestId('ft-jobs')
- await expect(jobs).toContainText('2 kept on this server')
- await expect(jobs.locator('tbody tr')).toHaveCount(2)
- await expect(jobs.locator('tbody tr').first()).toContainText('lora, dpo')
- await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported')
- await jobs.locator('tbody tr').first().getByRole('button', { name: 'Reuse' }).click()
- await expect(page.locator('#ft-model')).toHaveValue('Qwen2.5-0.5B')
- await expect(page.locator('#ft-dataset')).toHaveValue('x/y')
- await expect(page.locator('#ft-batch')).toHaveValue('3')
- await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
- await expect(page.getByRole('alertdialog')).toContainText('Delete this job?')
- await page.getByRole('button', { name: 'Cancel' }).click()
- expect(ftState.deleted).toEqual([])
- await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
- await page.getByTestId('delete-confirm').click()
- await expect.poll(() => ftState.deleted).toEqual(['job-2'])
- })
-
- test('with no jobs it names the next step', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await expect(page.getByTestId('ft-jobs').locator('.dk-empty-title')).toHaveText('No fine-tuning jobs yet')
- })
-})
-
-test.describe('Quantize', () => {
- test('setup: a model is needed, the type can be custom, and the check lists the facts', async ({ page }) => {
- await boot(page)
- await page.goto('/app/quantize')
- await expectStep(page, 'setup', 'current')
- await expect(page.getByTestId('qz-start')).toBeDisabled()
- await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
- await expectStep(page, 'check', 'current')
- await expect(page.getByTestId('qz-start')).toContainText('Quantize (q4_k_m)')
- await expect(check(page, 'backend')).toContainText('llama-cpp-quantization is installed')
- await expect(check(page, 'memory')).toContainText('20 GB of 32 GB RAM is free. Conversion runs on the CPU.')
- await expect(check(page, 'disk')).toContainText('118 GB free on the models disk')
- await expect(page.getByTestId('qz-recipe')).toContainText('Convert Llama-3.2-1B to GGUF at q4_k_m.')
- await page.selectOption('#qz-type', 'q8_0')
- await expect(page.getByTestId('qz-recipe')).toContainText('at q8_0')
- await page.selectOption('#qz-type', '__custom__')
- await expect(check(page, 'type')).toHaveAttribute('data-tone', 'fail')
- await expect(page.getByTestId('qz-start')).toBeDisabled()
- await page.fill('#qz-custom', 'iq4_nl')
- await expect(page.getByTestId('qz-start')).toContainText('Quantize (iq4_nl)')
- await expect(page.getByTestId('qz-start')).toBeEnabled()
- })
-
- test('no backend: a warning and a link, and the start stays possible', async ({ page }) => {
- await boot(page, { qz: { backends: [] } })
- await page.goto('/app/quantize')
- await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
- await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn')
- await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toBeVisible()
- await expect(page.getByTestId('qz-start')).toBeEnabled()
- })
-
- test('Start sends the request, with the token under extra options, and opens the job', async ({ page }) => {
- const { qzState } = await boot(page)
- await page.goto('/app/quantize')
- await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
- await page.getByRole('button', { name: /More options/ }).click()
- await page.fill('#qz-token', 'hf_secret')
- await page.getByTestId('qz-start').click()
- await expect(page.getByTestId('job-view')).toBeVisible()
- expect(qzState.started[0]).toEqual({ model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', quantization_type: 'q4_k_m', extra_options: { hf_token: 'hf_secret' } })
- await expectStep(page, 'run', 'current')
- })
-
- test('a running job shows the stage, progress and log, and Stop sends the stop', async ({ page }) => {
- const events = [{ job_id: 'qz1', status: 'downloading', progress_percent: 10, message: 'Downloading model files' }, { job_id: 'qz1', status: 'converting', progress_percent: 42, message: 'converting tensor 84 of 201' }]
- const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz1', 'converting')], events: { qz1: events } } })
- await page.goto('/app/quantize')
- await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
- const view = page.getByTestId('job-view')
- await expect(view.locator('.bt-job__percent')).toContainText('42')
- await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '42')
- await expect(view).toContainText('converting tensor 84 of 201')
- await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Converting')
- await expect(page.locator('[data-testid="job-stages"] [data-state="done"]')).toHaveCount(2)
- await expect(page.getByTestId('job-log')).toContainText('Downloading model files')
- await page.getByTestId('job-stop').click()
- expect(qzState.stopped).toEqual(['qz1'])
- })
-
- test('a failed job shows the server message and reuses its setup', async ({ page }) => {
- await boot(page, { qz: { jobs: [qzJob('qz9', 'failed', { model: 'acme/odd-model', quantization_type: 'q5_k_m', message: 'unsupported architecture: OddForCausalLM' })] } })
- await page.goto('/app/quantize')
- await page.getByTestId('qz-jobs').getByRole('button', { name: 'acme/odd-model' }).click()
- await expectStep(page, 'run', 'failed')
- await expect(page.getByTestId('job-failed-message')).toHaveText('unsupported architecture: OddForCausalLM')
- await page.getByTestId('job-retry').click()
- await expect(page.locator('#qz-model')).toHaveValue('acme/odd-model')
- await expect(page.locator('#qz-type')).toHaveValue('q5_k_m')
- })
-
- test('a finished job imports under a name and then leads to Chat and Models', async ({ page }) => {
- const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/model-q4_k_m.gguf' })] } })
- await page.goto('/app/quantize')
- await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
- await expectStep(page, 'result', 'current')
- await expect(page.getByTestId('job-finished')).toContainText('Quantization finished: q4_k_m.')
- await expect(page.getByTestId('quantize-output')).toContainText('/data/quant/qz2/model-q4_k_m.gguf')
- await expect(page.getByRole('link', { name: 'Download GGUF' })).toHaveAttribute('href', /jobs\/qz2\/download/)
- await page.fill('#qz-import-name', 'llama-1b-q4')
- await page.getByTestId('quantize-import').click()
- const next = page.getByTestId('quantize-next')
- await expect(next).toContainText('llama-1b-q4 is a model now')
- expect(qzState.imported).toEqual([{ name: 'llama-1b-q4' }])
- await expect(next.getByRole('link', { name: 'Chat with llama-1b-q4' })).toHaveAttribute('href', '/app/chat/llama-1b-q4')
- await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed')
- })
-
- test('earlier jobs: imported mark, failure line, delete after a confirmation', async ({ page }) => {
- const { qzState } = await boot(page, {
- qz: { jobs: [qzJob('qz0', 'completed', { import_status: 'completed', import_model_name: 'llama-3.2-1b-q4_k_m' }), qzJob('qz9', 'failed', { model: 'acme/odd-model', message: 'unsupported architecture' })] },
- })
- await page.goto('/app/quantize')
- const jobs = page.getByTestId('qz-jobs')
- await expect(jobs.locator('tbody tr').first()).toContainText('imported as llama-3.2-1b-q4_k_m')
- await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported architecture')
- await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
- await page.getByTestId('delete-confirm').click()
- await expect.poll(() => qzState.deleted).toEqual(['qz9'])
- })
-
- test('with no jobs it names the next step', async ({ page }) => {
- await boot(page)
- await page.goto('/app/quantize')
- await expect(page.getByTestId('qz-jobs').locator('.dk-empty-title')).toHaveText('No quantization jobs yet')
- })
-})
-
-test.describe('Disabled: the account lacks the permission', () => {
- const member = (permissions) => (page) => page.route('**/api/auth/status', route => route.fulfill({
- json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } },
- }))
-
- test('Fine-tune says the account cannot use it, who can change that, and where to go', async ({ page }) => {
- await boot(page)
- await member({ chat: true })(page)
- await page.goto('/app/fine-tune')
- const off = page.getByTestId('tool-off')
- await expect(off).toHaveAttribute('data-cause', 'account')
- await expect(off).toContainText('Your account cannot fine-tune')
- await expect(off).toContainText('Ask an administrator')
- await expect(off.getByRole('link', { name: 'Go to Home' })).toHaveAttribute('href', '/app')
- await expect(page.getByTestId('fine-tune-form')).toHaveCount(0)
- })
-
- test('Quantize has its own words', async ({ page }) => {
- await boot(page)
- await member({ chat: true, fine_tuning: true })(page)
- await page.goto('/app/quantize')
- await expect(page.getByTestId('tool-off')).toContainText('Your account cannot quantize')
- await page.goto('/app/fine-tune')
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- })
-
- test('an administrator is never shown the page', async ({ page }) => {
- await boot(page)
- await page.goto('/app/fine-tune')
- await expect(page.getByTestId('fine-tune-form')).toBeVisible()
- await expect(page.getByTestId('tool-off')).toHaveCount(0)
- })
-})
-
-test.describe('Build tools: phone', () => {
- test.use({ viewport: { width: 390, height: 844 } })
- const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
-
- test('Fine-tune setup and check fit the screen', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-1', 'completed'), ftJob('job-2', 'failed', { message: 'x' })] } })
- await page.goto('/app/fine-tune')
- await fillFineTune(page)
- await page.getByTestId('ft-more-toggle').click()
- await expect(check(page, 'memory')).toBeVisible()
- expect(await noOverflow(page)).toBe(false)
- await expect(page.getByTestId('ft-start')).toBeVisible()
- })
-
- test('a running Fine-tune job fits the screen', async ({ page }) => {
- await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } })
- await page.goto('/app/fine-tune')
- await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
- await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible()
- expect(await noOverflow(page)).toBe(false)
- })
-
- test('Quantize finished fits the screen', async ({ page }) => {
- await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/a-very-long-file-name-q4_k_m.gguf' })] } })
- await page.goto('/app/quantize')
- await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
- await expect(page.getByTestId('quantize-output')).toBeVisible()
- expect(await noOverflow(page)).toBe(false)
- })
-
- test('the disabled page fits the screen', async ({ page }) => {
- await boot(page)
- await page.route('**/api/auth/status', route => route.fulfill({
- json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } },
- }))
- await page.goto('/app/quantize')
- await expect(page.getByTestId('tool-off')).toBeVisible()
- expect(await noOverflow(page)).toBe(false)
- })
-})
-
-test.describe('Build tools: reduced motion', () => {
- test('the job card does not animate', async ({ page }) => {
- await page.emulateMedia({ reducedMotion: 'reduce' })
- await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } })
- await page.goto('/app/fine-tune')
- await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
- const duration = await page.getByTestId('job-view').locator('.bt-job').evaluate(el => getComputedStyle(el).animationDuration)
- expect(parseFloat(duration)).toBeLessThan(0.01)
- })
-})
diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js
index 1a85ec249..891e0285f 100644
--- a/core/http/react-ui/e2e/chrome-audit.spec.js
+++ b/core/http/react-ui/e2e/chrome-audit.spec.js
@@ -8,10 +8,9 @@ import { installStudioAddons } from './studio-addon.js'
const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
- '/app/fine-tune', '/app/quantize', '/app/face', '/app/voice',
'/app/models?view=installed', '/app/backends', '/app/activity', '/app/operate',
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
- '/app/voice-library', '/app/voice-library/new', '/app/account',
+ '/app/account',
]
test('no page renders a dead icon or a default-chrome control', async ({ page }) => {
diff --git a/core/http/react-ui/e2e/identity.spec.js b/core/http/react-ui/e2e/identity.spec.js
deleted file mode 100644
index 792cdbe07..000000000
--- a/core/http/react-ui/e2e/identity.spec.js
+++ /dev/null
@@ -1,530 +0,0 @@
-import { test, expect } from './coverage-fixtures.js'
-import {
- FACE_KEY, VOICE_KEY, clipFile, faces, match, mockIdentity, people, photoFile,
-} from './identity-fixtures.js'
-
-const marta = match('v-marta', 'Marta Rossi', 0.18)
-const jonas = match('v-jonas', 'Jonas Weber', 0.41)
-const ilse = match('v-ilse', 'Ilse Janssen', 0.52)
-const closeSet = { matches: [marta, jonas, ilse] }
-const farSet = { matches: [match('v-jonas', 'Jonas Weber', 0.31), match('v-ilse', 'Ilse Janssen', 0.47), match('v-marta', 'Marta Rossi', 0.55)] }
-
-async function addProbe(page, name = 'board-sync-clip.wav', seconds = 8) {
- await page.locator('#voice-probe-audio-file').setInputFiles(clipFile(name, seconds))
- await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled')
-}
-
-test.describe('Voices: Speakers', () => {
- test('empty: says nobody is enrolled and that the list lives in the browser', async ({ page }) => {
- await mockIdentity(page)
- await page.goto('/app/voice')
- await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
- await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
- await expect(page.getByTestId('registry-note')).toContainText('kept in this browser')
- await expect(page.getByTestId('registry-note')).toContainText('no list call')
- await expect(page.getByTestId('identify-go')).toBeDisabled()
- // The Build bar has no Voices tab and no second row.
- await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveCount(0)
- await expect(page.locator('.hub-subnav')).toHaveCount(0)
- })
-
- test('who is this: a match is explained in words, on a scale, with the real numbers', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: people, identify: closeSet })
- await page.goto('/app/voice')
- await addProbe(page)
- await expect(page.getByText('good length')).toBeVisible()
- await page.getByTestId('identify-go').click()
- const verdict = page.getByTestId('verdict')
- await expect(verdict).toContainText('Probably Marta Rossi')
- await expect(verdict).toContainText('Likely match')
- await expect(verdict).toContainText('0.18 away')
- await expect(verdict).toContainText('under 0.25')
- await expect(page.getByTestId('distance-scale')).toBeVisible()
- await expect(page.getByTestId('rank-row')).toHaveCount(3)
- await expect(page.getByTestId('rank-row').first()).toContainText('within cut-off')
- await expect(page.getByTestId('cutoff-value')).toHaveText('0.25')
- // The request carries the model, the clip, a top_k that covers everyone, and the cut-off.
- expect(calls.identify).toHaveLength(1)
- expect(calls.identify[0]).toMatchObject({ model: 'wespeaker-resnet34', threshold: 0.25 })
- expect(calls.identify[0].audio).toMatch(/^data:audio\//)
- expect(calls.identify[0].top_k).toBeGreaterThanOrEqual(25)
- await expect(page.getByTestId('known-row-v-marta')).toContainText('best match')
- })
-
- test('the cut-off slider re-reads the answer without another call', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: people, identify: closeSet })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
- const slider = page.locator('#voice-find-cutoff')
- await slider.fill('0.1')
- await expect(page.getByTestId('cutoff-value')).toHaveText('0.10')
- await expect(page.getByTestId('verdict')).toContainText('Nobody enrolled sounds like this')
- await expect(page.getByRole('button', { name: 'Reset to 0.25' })).toBeVisible()
- await page.getByRole('button', { name: 'Reset to 0.25' }).click()
- await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
- expect(calls.identify).toHaveLength(1)
- })
-
- test('no match: names the closest person and says why it may be wrong', async ({ page }) => {
- await mockIdentity(page, { stored: people, identify: farSet })
- await page.goto('/app/voice')
- await addProbe(page, 'hallway-recording.wav', 2.4)
- await expect(page.getByText('short', { exact: true })).toBeVisible()
- await page.getByTestId('identify-go').click()
- const verdict = page.getByTestId('verdict')
- await expect(verdict).toContainText('Nobody enrolled sounds like this')
- await expect(verdict).toContainText('closest was Jonas Weber at 0.31')
- await expect(page.getByRole('button', { name: 'Enrol this voice' })).toBeVisible()
- await page.getByText('Why this might be wrong').click()
- await expect(page.getByTestId('why-list')).toContainText('not a security check')
- await expect(page.getByTestId('why-list')).toContainText('short: 2.4 s')
- })
-
- test('working: a progress bar and a disabled button while the call is out', async ({ page }) => {
- await mockIdentity(page, { stored: people, identify: closeSet, delay: 1500 })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('identify-working')).toBeVisible()
- await expect(page.getByTestId('identify-working')).toHaveAttribute('aria-busy', 'true')
- await expect(page.getByTestId('identify-go')).toBeDisabled()
- await expect(page.getByTestId('verdict')).toBeVisible()
- })
-
- test('failed: plain words, the server message, a retry and a trace link', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: people, identify: { status: 500, body: { error: { message: 'backend crashed' } } } })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- const err = page.getByTestId('identity-error')
- await expect(err).toContainText('The call failed')
- await expect(err).toContainText('backend crashed')
- await expect(err.getByRole('link', { name: 'Open traces' })).toHaveAttribute('href', /\/app\/traces/)
- await err.getByRole('button', { name: 'Try again' }).click()
- await expect.poll(() => calls.identify.length).toBe(2)
- })
-
- test('failed: a missing model is named as such', async ({ page }) => {
- await mockIdentity(page, { stored: people, identify: { status: 404, body: { error: { message: 'model not found' } } } })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('identity-error')).toContainText('cannot find this model')
- })
-
- test('same person: uses the model cut-off from the answer and shows the pair on the scale', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: people, verify: { verified: false, distance: 0.31, threshold: 0.28, confidence: 0, model: 'x' } })
- await page.goto('/app/voice')
- await page.getByRole('tab', { name: 'Same person?' }).click()
- await page.locator('#voice-a-audio-file').setInputFiles(clipFile('a.wav'))
- await page.locator('#voice-b-audio-file').setInputFiles(clipFile('b.wav', 6))
- await page.getByTestId('compare-go').click()
- const verdict = page.getByTestId('verdict')
- await expect(verdict).toContainText('Probably different people')
- await expect(verdict).toContainText('0.31 apart')
- await expect(verdict).toContainText('under 0.28')
- expect(calls.verify[0]).toMatchObject({ model: 'wespeaker-resnet34' })
- expect(calls.verify[0]).not.toHaveProperty('threshold')
- await expect(page.getByTestId('cutoff-value')).toHaveText('0.28')
- await page.locator('#voice-pair-cutoff').fill('0.4')
- await expect(verdict).toContainText('Probably the same person')
- expect(calls.verify).toHaveLength(1)
- })
-
- test('mic blocked: says so and offers a file', async ({ page }) => {
- await page.addInitScript(() => {
- Object.defineProperty(navigator, 'mediaDevices', {
- value: { getUserMedia: () => Promise.reject(Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' })) },
- configurable: true,
- })
- })
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' }).click()
- await expect(page.getByTestId('clip-notice')).toContainText('microphone is blocked')
- await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Choose a file' })).toBeVisible()
- await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' })).toHaveCount(0)
- })
-
- test('mic allowed: shows the state when the browser reports it', async ({ page }) => {
- await page.addInitScript(() => {
- const status = { state: 'granted', onchange: null }
- Object.defineProperty(navigator, 'permissions', { value: { query: () => Promise.resolve(status) }, configurable: true })
- Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: () => Promise.reject(new Error('unused')) }, configurable: true })
- })
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await expect(page.getByTestId('mic-allowed')).toContainText('Microphone allowed')
- })
-
- test('mic on an insecure origin is explained', async ({ page }) => {
- await page.addInitScript(() => {
- Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
- Object.defineProperty(navigator, 'mediaDevices', { value: undefined, configurable: true })
- })
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await expect(page.getByTestId('clip-notice')).toContainText('https or on localhost')
- })
-})
-
-test.describe('Voices: the registry tells the truth', () => {
- const priya = { id: 'v-priya', name: 'Priya Nair', labels: { team: 'platform' }, registeredAt: new Date(Date.now() - 86400000).toISOString() }
-
- test('a person the search did not return is marked, and a stranger is listed', async ({ page }) => {
- await mockIdentity(page, {
- stored: [...people, priya],
- identify: { matches: [marta, jonas, ilse, match('v-other', 'Omar Haddad', 0.6)] },
- })
- await page.goto('/app/voice')
- // Before a search nothing is claimed.
- await expect(page.getByTestId('not-on-server')).toHaveCount(0)
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toBeVisible()
- await expect(page.getByTestId('known-row-v-priya').getByTestId('not-on-server')).toContainText('not on the server')
- await expect(page.getByTestId('known-row-v-priya')).toContainText('may have restarted')
- await expect(page.getByTestId('known-row-v-marta').getByTestId('not-on-server')).toHaveCount(0)
- await expect(page.getByTestId('known-row-v-other').getByTestId('not-in-browser')).toBeVisible()
- await expect(page.getByTestId('registry-note')).toContainText('The last search checked it')
- })
-
- test('the server returning nobody is never read as proof when the search was cut short', async ({ page }) => {
- // 25 asked, 25 returned: the search may have missed people, so nothing is marked.
- const many = Array.from({ length: 25 }, (_, i) => match(`x-${i}`, `Person ${i}`, 0.3 + i / 100))
- await mockIdentity(page, { stored: [...people, priya], identify: { matches: many } })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toBeVisible()
- await expect(page.getByTestId('not-on-server')).toHaveCount(0)
- })
-
- test('an empty answer after a restart says the voices are gone', async ({ page }) => {
- await mockIdentity(page, { stored: people, identify: { matches: [] } })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toContainText('The server returned no speakers')
- await expect(page.getByTestId('verdict')).toContainText('enrol them again')
- await expect(page.getByTestId('not-on-server')).toHaveCount(3)
- })
-
- test('what is stored, and where, is stated', async ({ page }) => {
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await page.getByTestId('stored-note').getByText('What is stored, and where').click()
- const note = page.getByTestId('stored-note')
- await expect(note).toContainText('lost when it restarts')
- await expect(note).toContainText('The recording is not kept')
- await expect(note).toContainText('only if you tick Keep a copy')
- })
-})
-
-test.describe('Voices: enrol sheet', () => {
- test('lists what is still needed, then registers and saves only the metadata', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: [] })
- await page.goto('/app/voice')
- await page.getByTestId('enrol-open').click()
- const sheet = page.getByTestId('enrol-sheet')
- await expect(sheet).toBeVisible()
- const submit = sheet.getByRole('button', { name: /^Enrol/ })
- await expect(submit).toBeDisabled()
- await expect(page.getByTestId('enrol-needs')).toContainText('a recording, a name, permission')
- await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 5))
- await page.locator('#voice-enrol-name').fill('Marta Rossi')
- await page.locator('#voice-enrol-labels').fill('team: platform')
- await expect(page.getByTestId('enrol-needs')).toContainText('permission')
- await sheet.getByText('This person agreed').click()
- await expect(page.getByTestId('enrol-needs')).toHaveText('Ready to enrol.')
- await submit.click()
- await expect(sheet).toHaveCount(0)
- expect(calls.register[0]).toMatchObject({ model: 'wespeaker-resnet34', name: 'Marta Rossi', labels: { team: 'platform' } })
- expect(calls.register[0].audio).toMatch(/^data:audio\//)
- await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
- const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
- expect(stored).toHaveLength(1)
- expect(stored[0]).toMatchObject({ name: 'Marta Rossi', labels: { team: 'platform' } })
- // The copy of the recording is opt-in; it was not ticked.
- expect(stored[0]).not.toHaveProperty('sampleUrl')
- })
-
- test('keeping a copy is opt-in and stays in the browser', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: [] })
- await page.goto('/app/voice')
- await page.getByTestId('enrol-open').click()
- await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
- await page.locator('#voice-enrol-name').fill('Ada')
- const sheet = page.getByTestId('enrol-sheet')
- await sheet.getByText('This person agreed').click()
- await sheet.getByText('Keep a copy in this browser').click()
- await sheet.getByRole('button', { name: /^Enrol/ }).click()
- await expect(sheet).toHaveCount(0)
- const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
- expect(stored[0].sampleUrl).toMatch(/^data:audio\//)
- expect(JSON.stringify(calls.register[0])).not.toContain('sampleUrl')
- })
-
- test('can also keep the recording as a speech voice (admin)', async ({ page }) => {
- const calls = await mockIdentity(page, { stored: [] })
- await page.goto('/app/voice')
- await page.getByTestId('enrol-open').click()
- const sheet = page.getByTestId('enrol-sheet')
- await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 3))
- await page.locator('#voice-enrol-name').fill('Marta Rossi')
- await sheet.getByText('Also keep it as a speech voice').click()
- await expect(page.getByTestId('enrol-needs')).toContainText('a transcript')
- await page.locator('#voice-enrol-transcript').fill('The words spoken.')
- await sheet.getByText('This person agreed').click()
- await sheet.getByRole('button', { name: /^Enrol/ }).click()
- await expect(sheet).toHaveCount(0)
- expect(calls.register).toHaveLength(1)
- expect(calls.profile).toHaveLength(1)
- })
-
- test('a failed enrol keeps the sheet and says why', async ({ page }) => {
- await mockIdentity(page, { stored: [] })
- await page.route('**/v1/voice/register', route => route.fulfill({ status: 500, json: { error: { message: 'store unavailable' } } }))
- await page.goto('/app/voice')
- await page.getByTestId('enrol-open').click()
- await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
- await page.locator('#voice-enrol-name').fill('Ada')
- const sheet = page.getByTestId('enrol-sheet')
- await sheet.getByText('This person agreed').click()
- await sheet.getByRole('button', { name: /^Enrol/ }).click()
- await expect(sheet.getByTestId('identity-error')).toContainText('store unavailable')
- await expect(page.locator('#voice-enrol-name')).toHaveValue('Ada')
- })
-
- test('Escape closes the sheet', async ({ page }) => {
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await page.getByTestId('enrol-open').click()
- await expect(page.getByTestId('enrol-sheet')).toBeVisible()
- await page.keyboard.press('Escape')
- await expect(page.getByTestId('enrol-sheet')).toHaveCount(0)
- })
-})
-
-test.describe('Voices: delete with undo', () => {
- test('nothing is sent before the window ends, and Undo sends nothing', async ({ page }) => {
- await page.clock.install()
- const calls = await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
- await expect(page.getByTestId('forget-undo-toast')).toContainText('Jonas Weber will be removed')
- await expect(page.getByTestId('known-row-v-jonas')).toContainText('removing')
- await page.clock.runFor(9000)
- expect(calls.forget).toHaveLength(0)
- await page.getByTestId('forget-undo-toast').getByRole('button', { name: 'Undo' }).click()
- await page.clock.runFor(20000)
- expect(calls.forget).toHaveLength(0)
- await expect(page.getByTestId('known-row-v-jonas')).toBeVisible()
- await expect(page.getByTestId('known-row-v-jonas')).not.toContainText('removing')
- })
-
- test('after ten seconds the forget call goes out and the row is gone', async ({ page }) => {
- await page.clock.install()
- const calls = await mockIdentity(page, { stored: people })
- await page.goto('/app/voice')
- await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
- await page.clock.runFor(9500)
- expect(calls.forget).toHaveLength(0)
- await page.clock.runFor(1500)
- await expect.poll(() => calls.forget.length).toBe(1)
- expect(calls.forget[0]).toEqual({ id: 'v-jonas' })
- await expect(page.getByTestId('known-row-v-jonas')).toHaveCount(0)
- const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)).map(x => x.id), VOICE_KEY)
- expect(stored).toEqual(['v-marta', 'v-ilse'])
- })
-
- test('a 404 from the server still removes the row', async ({ page }) => {
- await page.clock.install()
- const calls = await mockIdentity(page, { stored: people, forgetStatus: 404 })
- await page.goto('/app/voice')
- await page.getByRole('button', { name: 'Remove Ilse Janssen' }).click()
- await page.clock.runFor(10500)
- await expect.poll(() => calls.forget.length).toBe(1)
- await expect(page.getByTestId('known-row-v-ilse')).toHaveCount(0)
- })
-})
-
-test.describe('Voices: states of the page', () => {
- test('disabled: no speaker model says what turns it on and offers installs', async ({ page }) => {
- await mockIdentity(page, {
- voiceModels: [],
- stored: people,
- gallery: [{ id: 'localai@wespeaker-resnet34', name: 'wespeaker-resnet34', backend: 'wespeaker', installed: false }],
- })
- let installed = null
- await page.route('**/api/models/install/**', route => { installed = decodeURIComponent(route.request().url().split('/').pop()); return route.fulfill({ json: { jobID: 'j' } }) })
- await page.goto('/app/voice')
- const needed = page.getByTestId('model-needed')
- await expect(needed).toContainText('Voices need a speaker model')
- await expect(needed).toContainText('Not enabled on this server')
- await expect(needed.getByRole('button', { name: 'Install' })).toBeVisible()
- await needed.getByText('What turns this on').click()
- await expect(needed).toContainText('Voice recognition permission')
- await needed.getByRole('button', { name: 'Install' }).click()
- await expect.poll(() => installed).toBe('localai@wespeaker-resnet34')
- // The remembered people stay manageable without a model.
- await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
- await expect(page.getByTestId('enrol-open')).toBeDisabled()
- })
-
- test('a user without the permission sees what is off, not a redirect', async ({ page }) => {
- await mockIdentity(page)
- await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { id: 'u', role: 'user', permissions: {} } } }))
- await page.goto('/app/voice')
- await expect(page.getByTestId('feature-off')).toContainText('Voice recognition is off for your account')
- await expect(page.getByTestId('feature-off')).toContainText('Voice recognition permission')
- await page.goto('/app/face')
- await expect(page.getByTestId('feature-off')).toContainText('Face recognition is off for your account')
- })
-
- test('From a recording links to the diarization workspace', async ({ page }) => {
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice?tab=recording')
- await expect(page.getByTestId('recording-tab')).toContainText('Name the speakers in a recording')
- await page.getByRole('link', { name: /Open the diarization workspace/ }).click()
- await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/diarization/)
- })
-
- test('the route /app/voice/:model keeps working', async ({ page }) => {
- await mockIdentity(page, { stored: people })
- await page.goto('/app/voice/wespeaker-resnet34')
- await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
- await expect(page.getByTestId('known-list')).toBeVisible()
- })
-
- test('phone: no sideways scroll with a result open, and the table stays inside the screen', async ({ page }) => {
- await page.setViewportSize({ width: 390, height: 844 })
- await mockIdentity(page, { stored: [...people, { id: 'v-priya', name: 'Priya Nair', labels: {}, registeredAt: new Date().toISOString() }], identify: closeSet })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toBeVisible()
- const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
- expect(overflow).toBeLessThanOrEqual(0)
- const box = await page.getByTestId('distance-scale').boundingBox()
- expect(box.x + box.width).toBeLessThanOrEqual(390)
- await page.getByTestId('enrol-open').click()
- const sheet = await page.getByTestId('enrol-sheet').boundingBox()
- expect(sheet.width).toBeLessThanOrEqual(390)
- expect(sheet.x).toBeGreaterThanOrEqual(0)
- })
-
- test('reduced motion: the working bar and recording pulse do not animate', async ({ page }) => {
- await page.emulateMedia({ reducedMotion: 'reduce' })
- await mockIdentity(page, { stored: people, identify: closeSet, delay: 2500 })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- const bar = page.getByTestId('identify-working').locator('.dk-progress-bar')
- await expect(bar).toBeVisible()
- const name = await bar.evaluate(el => getComputedStyle(el).animationName)
- expect(name).toBe('none')
- })
-
- test('no left rail marks on rows, cards or the verdict', async ({ page }) => {
- await mockIdentity(page, { stored: people, identify: closeSet })
- await page.goto('/app/voice')
- await addProbe(page)
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('verdict')).toBeVisible()
- const widths = await page.evaluate(() => [...document.querySelectorAll('.idn-page *')]
- .map(el => getComputedStyle(el))
- .filter(cs => parseFloat(cs.borderLeftWidth) >= 3 && cs.borderLeftStyle !== 'none' && parseFloat(cs.borderTopWidth) === 0)
- .length)
- expect(widths).toBe(0)
- })
-})
-
-test.describe('Faces', () => {
- const anna = match('f-anna', 'Anna Kowalski', 0.21)
- const tomas = match('f-tomas', 'Tomas Ribeiro', 0.52)
-
- test('who is this: a match, with the faceprint worded as such', async ({ page }) => {
- const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [anna, tomas] } })
- await page.goto('/app/face')
- await expect(page.getByRole('heading', { name: 'Faces', level: 1 })).toBeVisible()
- await page.locator('#face-probe-image-file').setInputFiles(photoFile())
- await page.getByTestId('identify-go').click()
- const verdict = page.getByTestId('verdict')
- await expect(verdict).toContainText('Probably Anna Kowalski')
- await expect(verdict).toContainText('faceprint')
- await expect(verdict).toContainText('under 0.35')
- expect(calls.faceIdentify[0]).toMatchObject({ model: 'insightface-buffalo-l', threshold: 0.35 })
- expect(calls.faceIdentify[0].img).toMatch(/^data:image\//)
- await expect(page.getByTestId('known-row-f-anna')).toContainText('best match')
- })
-
- test('no face found: says what to try', async ({ page }) => {
- await mockIdentity(page, { storedFaces: faces, faceIdentify: { status: 400, body: { error: { message: 'no face detected in the image' } } } })
- await page.goto('/app/face')
- await page.locator('#face-probe-image-file').setInputFiles(photoFile())
- await page.getByTestId('identify-go').click()
- await expect(page.getByTestId('identity-error')).toContainText('No face found in this photo')
- await expect(page.getByTestId('identity-error')).toContainText('front-facing')
- })
-
- test('same person: sends both photos, optional anti-spoofing, draws the faces it found', async ({ page }) => {
- const calls = await mockIdentity(page, { storedFaces: faces })
- await page.goto('/app/face')
- await page.getByRole('tab', { name: 'Same person?' }).click()
- await page.locator('#face-a-image-file').setInputFiles(photoFile('a.png'))
- await page.locator('#face-b-image-file').setInputFiles(photoFile('b.png'))
- await page.getByText('Check for photos of photos').click()
- await page.getByTestId('compare-go').click()
- await expect(page.getByTestId('verdict')).toContainText('Probably the same person')
- expect(calls.faceVerify[0]).toMatchObject({ anti_spoofing: true })
- expect(calls.faceVerify[0].img1).toMatch(/^data:image\//)
- await expect(page.locator('.biometrics-bbox')).toHaveCount(2)
- await expect(page.getByTestId('cutoff-value')).toHaveText('0.35')
- })
-
- test('enrol a person: a photo copy is opt-in', async ({ page }) => {
- const calls = await mockIdentity(page, { storedFaces: [] })
- await page.goto('/app/face')
- await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
- await page.getByTestId('enrol-open').click()
- const sheet = page.getByTestId('enrol-sheet')
- await page.locator('#face-enrol-image-file').setInputFiles(photoFile())
- await page.locator('#face-enrol-name').fill('Anna Kowalski')
- await sheet.getByText('This person agreed').click()
- await sheet.getByRole('button', { name: /^Enrol/ }).click()
- await expect(sheet).toHaveCount(0)
- expect(calls.faceRegister[0]).toMatchObject({ model: 'insightface-buffalo-l', name: 'Anna Kowalski' })
- const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), FACE_KEY)
- expect(stored[0]).not.toHaveProperty('thumbnail')
- await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski')
- })
-
- test('delete uses the face forget call after the window', async ({ page }) => {
- await page.clock.install()
- const calls = await mockIdentity(page, { storedFaces: faces })
- await page.goto('/app/face')
- await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
- await page.clock.runFor(9000)
- expect(calls.forget).toHaveLength(0)
- await page.clock.runFor(2000)
- await expect.poll(() => calls.forget.length).toBe(1)
- expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' })
- })
-
- test('disabled: no face model', async ({ page }) => {
- await mockIdentity(page, { faceModels: [] })
- await page.goto('/app/face')
- await expect(page.getByTestId('model-needed')).toContainText('Faces need a face model')
- })
-
- test('more tools stay reachable: detect and the raw faceprint', async ({ page }) => {
- await mockIdentity(page, { storedFaces: faces })
- await page.goto('/app/face')
- await page.getByText('More tools').click()
- await expect(page.getByRole('tab', { name: /Detect and analyze/ })).toBeVisible()
- await expect(page.getByRole('tab', { name: 'Embedding' })).toBeVisible()
- })
-})
diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js
index c20baa50e..9a8e51154 100644
--- a/core/http/react-ui/e2e/no-edge-rails.spec.js
+++ b/core/http/react-ui/e2e/no-edge-rails.spec.js
@@ -12,7 +12,7 @@ const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/models?view=installed', '/app/studio',
'/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
'/app/backends', '/app/activity', '/app/operate', '/app/build', '/app/settings',
- '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/voice-library',
+ '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
'/app/account',
]
diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js
index 2d2ed8314..efb2864bc 100644
--- a/core/http/react-ui/e2e/page-render-smoke.spec.js
+++ b/core/http/react-ui/e2e/page-render-smoke.spec.js
@@ -32,12 +32,6 @@ const PAGES = [
['/app/failover', 'Failover'],
['/app/nodes/add', 'Add a node'],
['/app/p2p', 'P2P'],
- ['/app/face', 'Face recognition'],
- ['/app/voice', 'Voice recognition'],
- ['/app/fine-tune', 'Fine-tuning'],
- ['/app/quantize', 'Quantize'],
- ['/app/voice-library', 'Voice Library'],
- ['/app/voice-library/new', 'Create voice profile'],
]
test.describe('Page render smoke', () => {
diff --git a/core/http/react-ui/e2e/voice-library-empty.spec.js b/core/http/react-ui/e2e/voice-library-empty.spec.js
deleted file mode 100644
index eefa8b6d7..000000000
--- a/core/http/react-ui/e2e/voice-library-empty.spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { test, expect } from './coverage-fixtures.js'
-
-// The empty voice library must offer its action, visibly, inside the panel.
-
-test.describe('Voice library empty state', () => {
- test.beforeEach(async ({ page }) => {
- await page.route('**/api/voice-profiles', route => route.fulfill({ json: [] }))
- await page.goto('/app/voice-library')
- })
-
- test('the create action is visible and a normal size', async ({ page }) => {
- const action = page.locator('.empty-state__actions a.btn').first()
- await expect(action).toBeVisible()
- const box = await action.boundingBox()
- // It had been carrying the panel's own min-height:430px, which made it an
- // invisible box that pushed itself out of view.
- expect(box.height).toBeLessThan(80)
- })
-
- test('the action sits inside the panel, not past its edge', async ({ page }) => {
- const panel = page.locator('.empty-state').first()
- const action = page.locator('.empty-state__actions a.btn').first()
- // Measure after the layout settles; the two boxes are read one after the
- // other, and a reflow in between made the comparison race.
- await expect(action).toBeVisible()
- await page.waitForTimeout(500)
- const [p, a] = [await panel.boundingBox(), await action.boundingBox()]
- expect(a.y + a.height).toBeLessThanOrEqual(p.y + p.height + 1)
- })
-
- test('the panel renders its icon', async ({ page }) => {
- const icon = page.locator('.empty-state-icon').first()
- await expect(icon).toBeVisible()
- const box = await icon.boundingBox()
- expect(box.width).toBeGreaterThan(0)
- })
-})
diff --git a/core/http/react-ui/e2e/voice-library.spec.js b/core/http/react-ui/e2e/voice-library.spec.js
deleted file mode 100644
index bb7608209..000000000
--- a/core/http/react-ui/e2e/voice-library.spec.js
+++ /dev/null
@@ -1,226 +0,0 @@
-import { test, expect } from './coverage-fixtures.js'
-import { mockStudio, stubMedia } from './studio-fixtures.js'
-
-const VOICE_ID = '00000000-0000-0000-0000-000000000001'
-
-function pcmWav(seconds = 2) {
- const sampleRate = 16000
- const dataSize = sampleRate * seconds * 2
- const buffer = Buffer.alloc(44 + dataSize)
- buffer.write('RIFF', 0)
- buffer.writeUInt32LE(36 + dataSize, 4)
- buffer.write('WAVEfmt ', 8)
- buffer.writeUInt32LE(16, 16)
- buffer.writeUInt16LE(1, 20)
- buffer.writeUInt16LE(1, 22)
- buffer.writeUInt32LE(sampleRate, 24)
- buffer.writeUInt32LE(sampleRate * 2, 28)
- buffer.writeUInt16LE(2, 32)
- buffer.writeUInt16LE(16, 34)
- buffer.write('data', 36)
- buffer.writeUInt32LE(dataSize, 40)
- return buffer
-}
-
-const TTS_MODELS = [
- { id: 'qwen-base', capabilities: ['FLAG_TTS'], backend: 'qwen3-tts-cpp', voice_cloning: { reference_transcript_required: true, accepted_audio_formats: ['audio/wav'] } },
- { id: 'piper-default', capabilities: ['FLAG_TTS'], backend: 'piper' },
-]
-
-// The speech page is a page of the Studio add-on: install it, and serve the
-// same models through the capabilities call it reads.
-async function openStudioSpeech(page, query = '') {
- await mockStudio(page, { capabilityModels: TTS_MODELS })
- await stubMedia(page)
- await page.goto(`/app/studio/tts${query}`)
-}
-
-const profile = {
- id: VOICE_ID,
- name: 'Documentary narrator',
- description: 'Measured and clear',
- language: 'en-US',
- transcript: 'The exact words spoken in this reference.',
- voice: `localai://voice-profiles/${VOICE_ID}`,
- consent_confirmed_at: '2026-07-01T12:00:00Z',
- created_at: '2026-07-01T12:00:00Z',
- updated_at: '2026-07-01T12:00:00Z',
- audio: { duration_ms: 2000, sample_rate: 16000, channels: 1, bit_depth: 16, size_bytes: 64044, mime_type: 'audio/wav' },
-}
-
-async function mockVoiceAPIs(page) {
- const state = { createdMultipart: null }
- await page.route('**/api/models/capabilities', route => route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ data: TTS_MODELS }),
- }))
- await page.route('**/api/voice-profiles', async route => {
- if (route.request().method() === 'POST') {
- state.createdMultipart = route.request().postDataBuffer()
- await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(profile) })
- return
- }
- await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [profile] }) })
- })
- await page.route(`**/api/voice-profiles/${VOICE_ID}/audio`, route => route.fulfill({
- status: 200,
- contentType: 'audio/wav',
- body: pcmWav(),
- }))
- return state
-}
-
-test.describe('Speech voices', () => {
- let apiState
-
- test.beforeEach(async ({ page }) => {
- apiState = await mockVoiceAPIs(page)
- })
-
- test('lists the voices and opens one in a sheet', async ({ page }) => {
- await page.goto('/app/voice-library')
- await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
- await expect(page.getByRole('heading', { name: /Speech voices/i, level: 2 })).toBeVisible()
- await expect(page.locator('.idn-tabs').getByRole('tab', { name: /Speech voices/ })).toHaveAttribute('aria-current', 'page')
- const row = page.locator('.voice-row', { hasText: 'Documentary narrator' })
- await expect(row).toBeVisible()
- // Nothing opens by itself: the detail is a sheet the person asks for.
- await expect(page.getByTestId('speech-voice-sheet')).toHaveCount(0)
- await row.click()
- await expect(page.getByTestId('speech-voice-sheet')).toBeVisible()
- await expect(page.locator('.voice-library-detail')).toContainText('The exact words spoken in this reference.')
- await expect(page.locator('.voice-library-detail')).toContainText('Consent confirmed')
- await expect(page.getByRole('button', { name: /Use in Text to Speech/i })).toBeEnabled()
- })
-
- test('shows inline API usage and installed model compatibility', async ({ page }) => {
- await page.goto(`/app/voice-library?selected=${VOICE_ID}`)
- await page.getByText('API usage and compatible models').click()
- const apiHelp = page.locator('.voice-detail__api')
- await expect(apiHelp).toContainText('qwen-base')
- await expect(apiHelp).toContainText('qwen3-tts-cpp')
- await expect(apiHelp.locator('code')).toContainText('/v1/audio/speech')
- await expect(apiHelp.locator('code')).toContainText(`localai://voice-profiles/${VOICE_ID}`)
- })
-
- test('offers server-declared gallery models when none are installed', async ({ page }) => {
- let galleryCapability = null
- let installedModel = null
- await page.route('**/api/models/capabilities', route => route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ data: [] }),
- }))
- await page.route('**/api/models?**', route => {
- galleryCapability = new URL(route.request().url()).searchParams.get('capability')
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ models: [{
- id: 'localai@omnivoice-cpp',
- name: 'omnivoice-cpp',
- backend: 'omnivoice-cpp',
- installed: false,
- voice_cloning: { reference_transcript_required: true, accepted_audio_formats: ['audio/wav'] },
- }] }),
- })
- })
- await page.route('**/api/models/install/**', route => {
- installedModel = decodeURIComponent(route.request().url().split('/').pop())
- return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ jobID: 'voice-install' }) })
- })
-
- await page.goto(`/app/voice-library?selected=${VOICE_ID}`)
- await expect(page.getByRole('heading', { name: 'Install a voice-cloning model' })).toBeVisible()
- await expect(page.locator('.voice-detail__model-list')).toContainText('omnivoice-cpp')
- expect(galleryCapability).toBe('voice_cloning')
- await page.locator('.voice-detail__model-list').getByRole('button', { name: 'Install' }).click()
- await expect.poll(() => installedModel).toBe('localai@omnivoice-cpp')
- await expect(page.getByText(/Installing omnivoice-cpp/)).toBeVisible()
- })
-
- test('keeps the Build tab bar compact and scrollable on small screens', async ({ page }) => {
- await page.setViewportSize({ width: 390, height: 844 })
- await page.goto('/app/voice-library')
- const bar = page.locator('.dk-hubtabs')
- await expect(bar).toBeVisible()
- expect((await bar.boundingBox()).width).toBeLessThanOrEqual(390)
- await expect(bar.locator('[data-hub-tab="voices"]')).toHaveCount(0)
- await expect(page.locator('.idn-tabs').getByRole('tab', { name: /Speech voices/ })).toHaveAttribute('aria-current', 'page')
- await expect(page.locator('.hub-subnav')).toHaveCount(0)
- })
-
- test('passes the stable voice URI from the library into TTS', async ({ page }) => {
- let ttsBody = null
- await page.route('**/tts', async route => {
- ttsBody = route.request().postDataJSON()
- await route.fulfill({
- status: 200,
- contentType: 'audio/wav',
- headers: { 'Content-Disposition': 'attachment; filename="speech.wav"' },
- body: pcmWav(1),
- })
- })
-
- await openStudioSpeech(page, `?voice=${VOICE_ID}`)
- await expect(page.locator('#tts-voice')).toHaveValue(VOICE_ID)
- await page.getByPlaceholder('Enter text to synthesize...').fill('Hello from the saved voice.')
- await page.getByRole('button', { name: /Generate$/ }).click()
- await expect.poll(() => ttsBody?.voice).toBe(`localai://voice-profiles/${VOICE_ID}`)
- expect(ttsBody.model).toBe('qwen-base')
- })
-
- test('sends trimmed speech instructions and omits blank instructions', async ({ page }) => {
- const ttsBodies = []
- await page.route('**/tts', async route => {
- if (route.request().method() !== 'POST') {
- await route.fallback()
- return
- }
- ttsBodies.push(route.request().postDataJSON())
- await route.fulfill({
- status: 200,
- contentType: 'audio/wav',
- headers: { 'Content-Disposition': 'attachment; filename="speech.wav"' },
- body: pcmWav(1),
- })
- })
-
- await openStudioSpeech(page)
- await page.getByPlaceholder('Enter text to synthesize...').fill('Read this sentence.')
- // Delivery instructions sit in the Advanced fold of the workspace.
- await page.getByRole('button', { name: /^Instructions/ }).click()
- await page.getByLabel('Instructions').fill(' Speak slowly and warmly. ')
- await page.getByRole('button', { name: /Generate$/ }).click()
- await expect.poll(() => ttsBodies.length).toBe(1)
- expect(ttsBodies[0].instructions).toBe('Speak slowly and warmly.')
-
- await expect(page.getByRole('button', { name: /Generate$/ })).toBeEnabled()
- await page.getByLabel('Instructions').fill(' \n ')
- await page.getByRole('button', { name: /Generate$/ }).click()
- await expect.poll(() => ttsBodies.length).toBe(2)
- expect(ttsBodies[1]).not.toHaveProperty('instructions')
- })
-
- test('normalizes an upload and creates a consented profile', async ({ page }) => {
- await page.goto('/app/voice-library/new')
- await page.locator('#voice-profile-audio-file').setInputFiles({
- name: 'reference.wav',
- mimeType: 'audio/wav',
- buffer: pcmWav(2),
- })
- await expect(page.getByText('Normalized reference')).toBeVisible()
- await page.locator('#voice-profile-name').fill('Documentary narrator')
- await page.locator('#voice-profile-transcript').fill('The exact words spoken in this reference.')
- await page.getByText('I confirm that this voice may be cloned').click()
- await page.getByRole('button', { name: /Save voice/i }).click()
- await expect(page).toHaveURL(new RegExp(`/app/voice-library\\?selected=${VOICE_ID}$`))
-
- const wavOffset = apiState.createdMultipart.indexOf(Buffer.from('RIFF'))
- expect(wavOffset).toBeGreaterThanOrEqual(0)
- expect(apiState.createdMultipart.readUInt16LE(wavOffset + 22)).toBe(1)
- expect(apiState.createdMultipart.readUInt32LE(wavOffset + 24)).toBe(24000)
- expect(apiState.createdMultipart.readUInt16LE(wavOffset + 34)).toBe(16)
- })
-})
diff --git a/core/http/react-ui/public/locales/en/biometrics.json b/core/http/react-ui/public/locales/en/biometrics.json
deleted file mode 100644
index 8fdcfe068..000000000
--- a/core/http/react-ui/public/locales/en/biometrics.json
+++ /dev/null
@@ -1,292 +0,0 @@
-{
- "voices": {
- "title": "Voices",
- "lede": "Recognise who is speaking, and keep voices that text-to-speech can use.",
- "tabs": "Voices sections",
- "speakers": "Speakers",
- "speech": "Speech voices",
- "recording": "From a recording"
- },
- "faces": {
- "title": "Faces",
- "lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server."
- },
- "tool": {
- "aria": "Recognition tool",
- "sameTab": "Same person?",
- "clip": "Clip",
- "clipA": "Clip A",
- "clipB": "Clip B",
- "photo": "Photo",
- "photoA": "Photo A",
- "photoB": "Photo B",
- "compare": "Compare",
- "comparing": "Comparing",
- "workingAria": "Working on it"
- },
- "voice": {
- "speaker": "speaker",
- "print": "voiceprint",
- "whoTab": "Who is this",
- "whoLede": "Matches a clip against the people you enrolled.",
- "sameLede": "Two clips, one question. Nobody has to be enrolled.",
- "whoGo": "Identify speaker",
- "working": "Identifying",
- "whoNote": "Searches the speakers enrolled on this server. The clip you test with is not stored.",
- "sameNote": "The answer comes from one voiceprint per clip. Nothing is stored.",
- "workingText": "Comparing the clip with the voiceprints the server holds.",
- "probably": "Probably {{name}}",
- "matchText": "{{name}}'s voiceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.",
- "nobody": "Nobody enrolled sounds like this",
- "missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new speaker, or a clip that is too short or noisy.",
- "noneEnrolled": "Nobody is enrolled to compare with",
- "noneEnrolledText": "Enrol a speaker first, then identify a clip against them.",
- "noneReturned": "The server returned no speakers",
- "noneReturnedText": "It holds no voiceprints made with this model. If the server restarted, enrolled voices are gone: enrol them again.",
- "enrolThis": "Enrol this voice",
- "same": "Probably the same person",
- "different": "Probably different people",
- "knownTitle": "Known speakers",
- "enrol": "Enrol a speaker",
- "enrolTitle": "Enrol a speaker",
- "enrolIntro": "The server keeps a voiceprint and a name. It does not keep the recording.",
- "emptyTitle": "Nobody enrolled in this browser yet",
- "emptyText": "Enrol a speaker with a short recording, then test a clip against them.",
- "agreedHint": "I have their permission, or another lawful basis, to store a voiceprint of them.",
- "keepHint": "Lets you replay it and enrol again after a server restart. Off by default. It is stored in this browser only.",
- "neededTitle": "Voices need a speaker model",
- "neededText": "Voice recognition turns a recording into a voiceprint and compares voiceprints. It runs on a speaker-recognition model, and none is installed on this server.",
- "neededWhat": "Install a model that supports speaker recognition. Once one is installed, this page turns on by itself. Users also need the Voice recognition permission.",
- "moreTitle": "More tools",
- "moreHint": "guess a speaker's traits, get the raw voiceprint",
- "why": [
- "One speaker is assumed. A clip with two people mixes both voices.",
- "A different microphone or room changes how a voice sounds.",
- "This is a similarity score, not a security check. Do not use it to unlock anything."
- ],
- "stored": [
- { "where": "On the server", "what": "A {{print}} (numbers that describe the voice, not the audio), a name and your labels. They are kept in memory, are shared by everyone who uses this server, and are lost when it restarts. The recording is not kept." },
- { "where": "In this browser", "what": "The name, labels and time of each person you enrolled. A copy of the recording only if you tick Keep a copy when enrolling." },
- { "where": "Clips you test with", "what": "They go to the model on this server to be compared and are not kept." }
- ]
- },
- "face": {
- "person": "person",
- "print": "faceprint",
- "whoTab": "Who is this",
- "whoLede": "Matches a photo against the people you enrolled.",
- "sameLede": "Two photos, one question. Nobody has to be enrolled.",
- "whoGo": "Find who this is",
- "working": "Searching",
- "whoNote": "Searches the people enrolled on this server. The photo you test with is not stored.",
- "sameNote": "The answer comes from one faceprint per photo. Nothing is stored.",
- "workingText": "Comparing the photo with the faceprints the server holds.",
- "probably": "Probably {{name}}",
- "matchText": "{{name}}'s faceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.",
- "nobody": "Nobody enrolled looks like this",
- "missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new person, or a photo that is blurred, turned away or in poor light.",
- "noneEnrolled": "Nobody is enrolled to compare with",
- "noneEnrolledText": "Enrol a person first, then look up a photo against them.",
- "noneReturned": "The server returned no people",
- "noneReturnedText": "It holds no faceprints made with this model. If the server restarted, enrolled faces are gone: enrol them again.",
- "enrolThis": "Enrol this face",
- "same": "Probably the same person",
- "different": "Probably different people",
- "knownTitle": "Known people",
- "enrol": "Enrol a person",
- "enrolTitle": "Enrol a person",
- "enrolIntro": "The server keeps a faceprint and a name. It does not keep the photo.",
- "emptyTitle": "Nobody enrolled in this browser yet",
- "emptyText": "Enrol a person with one clear photo, then test a photo against them.",
- "agreedHint": "I have their permission, or another lawful basis, to store a faceprint of them.",
- "keepHint": "Lets you see the photo and enrol again after a server restart. Off by default. It is stored in this browser only.",
- "antiSpoof": "Check for photos of photos",
- "antiSpoofHint": "Needs a model that supports it.",
- "real": "Real",
- "spoof": "Spoof",
- "found": "Face in photo {{n}}",
- "neededTitle": "Faces need a face model",
- "neededText": "Face recognition turns a photo into a faceprint and compares faceprints. It runs on a face-recognition model, and none is installed on this server.",
- "neededWhat": "Install a model that supports face recognition. Once one is installed, this page turns on by itself. Users also need the Face recognition permission.",
- "moreTitle": "More tools",
- "moreHint": "detect faces, guess traits, get the raw faceprint",
- "why": [
- "The server matches one face per photo. A group photo is matched on a single face.",
- "Light, angle, glasses and blur change a face a lot.",
- "This is a similarity score, not a security check. Do not use it to unlock anything."
- ],
- "stored": [
- { "where": "On the server", "what": "A {{print}} (numbers that describe the face, not the picture), a name and your labels. They are kept in memory and are lost when the server restarts. The photo is not kept." },
- { "where": "In this browser", "what": "The name, labels and time of each person you enrolled. A copy of the photo only if you tick Keep a copy when enrolling." },
- { "where": "Photos you test with", "what": "They go to the model on this server to be compared and are not kept." }
- ]
- },
- "same": {
- "text": "The two {{print}}s are {{distance}} apart. Anything under {{cutoff}} counts as the same person.",
- "distance": "distance"
- },
- "word": {
- "strong": "Strong match",
- "likely": "Likely match",
- "close": "Close call",
- "near": "Just past the cut-off"
- },
- "scale": {
- "aria": "Distance scale with the cut-off at {{cutoff}}. Lower means more alike. {{dots}}",
- "cutoff": "cut-off {{value}}",
- "axis": "distance between {{print}}s, lower means more alike",
- "oneDot": "one dot per enrolled {{person}}",
- "onePair": "one dot for the pair"
- },
- "ranks": {
- "aria": "Closest people, nearest first",
- "within": "within cut-off",
- "far": "too far"
- },
- "cutoff": {
- "label": "Cut-off",
- "default": "model default",
- "reset": "Reset to {{value}}",
- "stricter": "Stricter",
- "looser": "Looser",
- "explain": "Stricter means fewer wrong matches and more misses. The answer above updates as you move it. The server is not asked again."
- },
- "how": {
- "title": "How to read this.",
- "body": "Distance is how far apart two {{print}}s are, 0 meaning identical. The cut-off is the line between same and different. The word Strong or Likely says how far inside the line a result sits. It is not a probability."
- },
- "why": {
- "title": "Why this might be wrong",
- "short": "The clip is short: {{s}} s. Three seconds or more of speech works better."
- },
- "clip": {
- "mic": "microphone",
- "camera": "camera",
- "record": "Record",
- "takePhoto": "Take a photo",
- "chooseAudio": "Choose a file",
- "choosePhoto": "Choose a photo",
- "readAloud": "Read this aloud, normal pace",
- "remove": "Remove",
- "blocked": "The {{thing}} is blocked. Allow it in the browser's site settings and try again, or choose a file instead.",
- "insecure": "A browser only opens the {{thing}} on https or on localhost. You are on {{origin}}. Open this page through localhost or add TLS, or choose a file.",
- "unsupported": "This browser cannot open the {{thing}}. Choose a file instead.",
- "missing": "No {{thing}} was found on this device. Choose a file instead.",
- "failed": "The {{thing}} could not be opened. Choose a file instead.",
- "allowed": "Microphone allowed.",
- "capture": "Capture",
- "cancel": "Cancel",
- "recording": "Recording, {{s}} s",
- "micReady": "Microphone ready",
- "stop": "Stop",
- "start": "Start recording",
- "recorded": "Recording",
- "photographed": "Photo",
- "uploaded": "Uploaded file",
- "goodLength": "good length",
- "short": "short",
- "checkLevel": "check level",
- "preview": "Preview",
- "photoAlt": "The photo you chose"
- },
- "known": {
- "caption": "People saved in this browser, with their {{noun}} state",
- "name": "Name",
- "added": "Added",
- "actions": "Actions",
- "best": "best match",
- "notOnServer": "not on the server",
- "notInBrowser": "not in this browser's list",
- "removing": "removing",
- "goneText": "The last search did not return this person. The server may have restarted, so the {{print}} is gone, or it was enrolled with another model.",
- "extraText": "The server returned this person but this browser has no record of them.",
- "noLabels": "no labels",
- "undo": "Undo",
- "reenrolSaved": "Re-enrol from saved copy",
- "enrolAgain": "Enrol again",
- "play": "Play the saved sample of {{name}}",
- "forget": "Remove {{name}}",
- "note": "This list is kept in this browser. The server has no list call, so it cannot be checked until you run a search.",
- "noteSearched": "This list is kept in this browser. The last search checked it against the server; a person it did not return is marked."
- },
- "stored": {
- "title": "What is stored, and where"
- },
- "enrol": {
- "close": "Close",
- "cancel": "Cancel",
- "submit": "Enrol",
- "working": "Enrolling",
- "ready": "Ready to enrol.",
- "stillNeeded": "Still needed: {{list}}.",
- "needModel": "a model",
- "needRecording": "a recording",
- "needPhoto": "a photo",
- "needName": "a name",
- "needTranscript": "a transcript",
- "needPermission": "permission",
- "recordingStep": "Recording",
- "photoStep": "Photo",
- "recordingLabel": "Recording",
- "photoLabel": "Photo",
- "whoStep": "Who is it",
- "name": "Name",
- "namePlaceholder": "e.g. Marta Rossi",
- "labels": "Labels",
- "labelsHint": "optional, one per line as key: value",
- "asSpeech": "Also keep it as a speech voice",
- "asSpeechHint": "Text-to-speech can then speak in this voice. Needs the exact transcript.",
- "transcript": "Exact transcript",
- "permissionStep": "Permission",
- "agreed": "This person agreed",
- "keep": "Keep a copy in this browser"
- },
- "toast": {
- "enrolled": "Enrolled {{name}}",
- "enrolledBoth": "Enrolled {{name}} and saved a speech voice",
- "tooBig": "The copy was too large for browser storage, so it was not kept.",
- "speechFailed": "Enrolled, but the speech voice could not be saved: {{message}}",
- "removing": "{{name}} will be removed.",
- "removed": "Removed {{name}}",
- "alreadyGone": "{{name}} was already gone from the server."
- },
- "error": {
- "retry": "Try again",
- "traces": "Open traces"
- },
- "needed": {
- "badge": "Not enabled on this server",
- "backend": "backend {{backend}}",
- "install": "Install",
- "installing": "Installing",
- "started": "Installing {{name}}. Progress is in Operations.",
- "loadFailed": "Models could not be listed.",
- "noneListed": "No matching model is listed in the galleries.",
- "browse": "Browse all models",
- "askAdmin": "Ask an administrator to install one.",
- "whatTitle": "What turns this on"
- },
- "off": {
- "badge": "Not enabled for your account",
- "voice": {
- "title": "Voice recognition is off for your account",
- "text": "An administrator has not allowed you to use voice recognition.",
- "what": "An administrator can turn on the Voice recognition permission for your user in Users and keys."
- },
- "face": {
- "title": "Face recognition is off for your account",
- "text": "An administrator has not allowed you to use face recognition.",
- "what": "An administrator can turn on the Face recognition permission for your user in Users and keys."
- }
- },
- "recording": {
- "title": "Name the speakers in a recording",
- "text": "The diarization workspace finds who spoke when in a recording. From there you can remember a speaker, and that voice joins the Speakers list here.",
- "step1": "Open the workspace, add a recording and tick Prepare speakers to remember.",
- "step2": "Run diarization. Each speaker with enough clean speech gets a Name and remember button.",
- "step3": "Name a speaker and remember it. That voice then appears under Speakers, ready for Who is this.",
- "open": "Open the diarization workspace",
- "off": "Diarization is not turned on for your account.",
- "warning": "Remembered voices are shared on this server and are lost when it restarts. Nothing is remembered automatically."
- }
-}
diff --git a/core/http/react-ui/src/components/EmptyState.jsx b/core/http/react-ui/src/components/EmptyState.jsx
deleted file mode 100644
index 1a45f3b04..000000000
--- a/core/http/react-ui/src/components/EmptyState.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import Icon from './Icon'
-// Editorial empty state: optional eyebrow + icon, serif title, lede, actions.
-// Wraps the existing .empty-state CSS so legacy callers keep working.
-export default function EmptyState({ icon, eyebrow, title, body, actions, className = '' }) {
- return (
-
- {eyebrow &&
{eyebrow} }
- {icon &&
}
- {title &&
{title} }
- {body &&
{body}
}
- {actions &&
{actions}
}
-
- )
-}
diff --git a/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx b/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx
deleted file mode 100644
index df72f7e8e..000000000
--- a/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-
-// BoundingBoxCanvas — overlay face-detection rectangles on the user-supplied image.
-// boxes: [{ x, y, w, h, label?, sublabel?, tone? }]
-// tone: 'default' | 'success' | 'warning' | 'error' | 'accent'
-export default function BoundingBoxCanvas({ src, boxes = [], alt = '' }) {
- const wrapRef = useRef(null)
- const imgRef = useRef(null)
- const [dims, setDims] = useState({ w: 0, h: 0, natW: 0, natH: 0 })
-
- useEffect(() => {
- const update = () => {
- if (!wrapRef.current || !imgRef.current) return
- const rect = imgRef.current.getBoundingClientRect()
- setDims({
- w: rect.width,
- h: rect.height,
- natW: imgRef.current.naturalWidth || 1,
- natH: imgRef.current.naturalHeight || 1,
- })
- }
- update()
- const ro = new ResizeObserver(update)
- if (imgRef.current) ro.observe(imgRef.current)
- window.addEventListener('resize', update)
- return () => {
- ro.disconnect()
- window.removeEventListener('resize', update)
- }
- }, [src])
-
- const sx = dims.natW ? dims.w / dims.natW : 1
- const sy = dims.natH ? dims.h / dims.natH : 1
-
- return (
-
- {src &&
{
- setDims({
- w: e.target.getBoundingClientRect().width,
- h: e.target.getBoundingClientRect().height,
- natW: e.target.naturalWidth,
- natH: e.target.naturalHeight,
- })
- }} />}
- {boxes.map((b, i) => (
-
- {(b.label || b.sublabel) && (
-
- {b.label && {b.label} }
- {b.sublabel && {b.sublabel} }
-
- )}
-
- ))}
-
- )
-}
diff --git a/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx b/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx
deleted file mode 100644
index 4375daf05..000000000
--- a/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useEffect, useRef } from 'react'
-import useAudioPeaks from '../../hooks/useAudioPeaks'
-
-// WaveformStrip — display-only waveform with optional colored segment
-// overlays. For a player with click-to-seek + audio controls, use
-// `components/audio/WaveformPlayer` instead. Both share the
-// `useAudioPeaks` hook for peak extraction.
-// segments: [{ start: seconds, end: seconds, label?, tone? }]
-export default function WaveformStrip({ src, segments = [], height = 120 }) {
- const canvasRef = useRef(null)
- const { peaks, duration, error } = useAudioPeaks(src)
-
- useEffect(() => {
- if (!canvasRef.current || !peaks) return
- const canvas = canvasRef.current
- const dpr = window.devicePixelRatio || 1
- const cssW = canvas.clientWidth
- const cssH = height
- canvas.width = Math.floor(cssW * dpr)
- canvas.height = Math.floor(cssH * dpr)
- const ctx = canvas.getContext('2d')
- ctx.scale(dpr, dpr)
- ctx.clearRect(0, 0, cssW, cssH)
-
- const accent = getComputedStyle(canvas).getPropertyValue('--biometrics-wave').trim() || '#e8a87c'
- ctx.fillStyle = accent
- const mid = cssH / 2
- const barW = Math.max(1, cssW / peaks.length)
- for (let i = 0; i < peaks.length; i++) {
- const h = Math.max(1, peaks[i] * (cssH * 0.9))
- ctx.fillRect(i * barW, mid - h / 2, Math.max(0.5, barW - 0.5), h)
- }
- }, [peaks, height])
-
- if (error) return {error}
- if (!src) return null
-
- return (
-
-
- {duration > 0 && segments.map((s, i) => {
- const left = (Math.max(0, s.start) / duration) * 100
- const right = (Math.min(duration, s.end) / duration) * 100
- return (
-
- {s.label && {s.label} }
-
- )
- })}
- {duration > 0 && (
-
{duration.toFixed(1)}s
- )}
- {!peaks && (
-
Decoding…
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/ClipInput.jsx b/core/http/react-ui/src/components/identity/ClipInput.jsx
deleted file mode 100644
index c86ccb487..000000000
--- a/core/http/react-ui/src/components/identity/ClipInput.jsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useMediaCapture } from '../../hooks/useMediaCapture'
-import useObjectUrl from '../../hooks/useObjectUrl'
-import { fileToBase64 } from '../../utils/api'
-import { judgeClip } from '../../utils/identity'
-// eslint-disable-next-line no-unused-vars
-import WaveformPlayer from '../audio/WaveformPlayer'
-import Icon from '../Icon'
-
-// Read a clip in the browser: how long it is and how loud. Nothing is sent.
-async function readClip(blob) {
- const Ctx = window.AudioContext || window.webkitAudioContext
- if (!Ctx || !blob) return null
- const ctx = new Ctx()
- try {
- const buf = await ctx.decodeAudioData(await blob.arrayBuffer())
- const data = buf.getChannelData(0)
- let peak = 0
- let hot = 0
- for (let i = 0; i < data.length; i += 1) {
- const v = Math.abs(data[i])
- if (v > peak) peak = v
- if (v >= 0.99) hot += 1
- }
- return { seconds: buf.duration, peak, clipped: hot / data.length > 0.001 }
- } catch {
- return null
- } finally {
- ctx.close().catch(() => {})
- }
-}
-
-// A permission state read without asking: 'denied', 'prompt', 'granted' or ''.
-function useMicState(enabled) {
- const [state, setState] = useState('')
- useEffect(() => {
- if (!enabled || !navigator.permissions?.query) return undefined
- let status
- let live = true
- navigator.permissions.query({ name: 'microphone' }).then((s) => {
- if (!live) return
- status = s
- setState(s.state)
- s.onchange = () => setState(s.state)
- }).catch(() => {})
- return () => { live = false; if (status) status.onchange = null }
- }, [enabled])
- return state
-}
-
-// One sample, in the kit's own look: a drop of choices before there is a
-// sample, a card with the facts after. It is the input for both the Voices and
-// the Faces pages.
-//
-// kind 'audio' or 'image'
-// value null or { base64, blob, dataUrl, mime, source, name }
-// onChange gets the same shape, or null on Remove
-// idPrefix the file input is `${idPrefix}-${kind}-file`
-export default function ClipInput({ kind, label, value, onChange, idPrefix, hint, prompt, onFacts }) {
- const { t } = useTranslation('biometrics')
- const fileRef = useRef(null)
- const cap = useMediaCapture(kind)
- const [facts, setFacts] = useState(null)
- const [busy, setBusy] = useState(false)
- const micState = useMicState(kind === 'audio')
- const secure = typeof window === 'undefined' || (window.isSecureContext ?? true)
- const live = kind === 'audio' ? t('clip.mic') : t('clip.camera')
-
- useEffect(() => {
- let current = true
- setFacts(null)
- onFacts?.(null)
- if (kind === 'audio' && value?.blob) readClip(value.blob).then((f) => { if (current) { setFacts(f); onFacts?.(f) } })
- return () => { current = false }
- }, [kind, value]) // eslint-disable-line react-hooks/exhaustive-deps
-
- const take = async (file, source = 'file') => {
- setBusy(true)
- try {
- const base64 = await fileToBase64(file)
- const dataUrl = await new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onerror = () => reject(reader.error)
- reader.onload = () => resolve(reader.result)
- reader.readAsDataURL(file)
- })
- const name = source === 'paste' ? `pasted-image.${(file.type.split('/')[1] || 'png').replace('+xml', '')}` : file.name
- onChange({ base64, blob: file, dataUrl, mime: file.type, source, name })
- } finally {
- setBusy(false)
- }
- }
-
- const onFile = (e) => {
- const f = e.target.files?.[0]
- if (f) take(f)
- else onChange(null)
- }
-
- const onPaste = (e) => {
- if (kind !== 'image') return
- const item = Array.from(e.clipboardData?.items || []).find(entry => entry.type.startsWith('image/'))
- const f = item?.getAsFile()
- if (!f) return
- e.preventDefault()
- take(f, 'paste')
- }
-
- const record = async () => {
- if (cap.recording) { cap.stopRecording(); return }
- const pending = cap.startRecording()
- if (!pending) return
- const result = await pending
- cap.stop()
- onChange({ ...result, source: 'live', name: 'recording.wav' })
- }
-
- const snap = () => {
- const shot = cap.snap()
- if (shot) { cap.stop(); onChange({ ...shot, source: 'live', name: 'photo.png' }) }
- }
-
- const clear = () => {
- onChange(null)
- if (fileRef.current) fileRef.current.value = ''
- }
-
- const playUrl = useObjectUrl(kind === 'audio' ? value?.blob : null)
- const verdict = facts ? judgeClip(facts) : null
- const blocked = cap.errorKind === 'denied' || micState === 'denied'
- const inputId = `${idPrefix}-${kind}-file`
-
- return (
-
-
- {label}
- {value && {t('clip.remove')} }
-
-
-
-
- {!value && !cap.active && (
-
- {prompt &&
{t('clip.readAloud')} {prompt}
}
-
- {cap.supported && !blocked && (
-
- {kind === 'audio' ? t('clip.record') : t('clip.takePhoto')}
-
- )}
- fileRef.current?.click()} disabled={busy}>
- {kind === 'audio' ? t('clip.chooseAudio') : t('clip.choosePhoto')}
-
-
- {(!cap.supported || blocked || cap.error) && (
-
-
-
- {blocked && t('clip.blocked', { thing: live })}
- {!blocked && !cap.supported && !secure && t('clip.insecure', { thing: live, origin: window.location.origin })}
- {!blocked && !cap.supported && secure && t('clip.unsupported', { thing: live })}
- {!blocked && cap.supported && cap.error && t(cap.errorKind === 'missing' ? 'clip.missing' : 'clip.failed', { thing: live })}
-
-
- )}
- {cap.supported && !blocked && !cap.error && micState === 'granted' && kind === 'audio' && (
-
{t('clip.allowed')}
- )}
- {hint &&
{hint}
}
-
- )}
-
- {!value && cap.active && kind === 'image' && (
-
-
-
- {t('clip.capture')}
- {t('clip.cancel')}
-
-
- )}
-
- {!value && cap.active && kind === 'audio' && (
-
-
{cap.recording ? t('clip.recording', { s: cap.elapsed.toFixed(1) }) : t('clip.micReady')}
-
-
- {cap.recording ? t('clip.stop') : t('clip.start')}
-
- {t('clip.cancel')}
-
-
- )}
-
- {value && (
-
-
-
- {value.source === 'live' ? t(kind === 'audio' ? 'clip.recorded' : 'clip.photographed') : value.name || t('clip.uploaded')}
- {facts && {facts.seconds.toFixed(1)} s }
- {verdict?.level === 'good' && {t('clip.goodLength')} }
- {verdict?.level === 'warn' && {facts.seconds < 3 ? t('clip.short') : t('clip.checkLevel')} }
-
- {kind === 'audio'
- ?
- :
}
- {verdict?.level === 'warn' &&
{verdict.notes.map(n => {n} )} }
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/DistanceScale.jsx b/core/http/react-ui/src/components/identity/DistanceScale.jsx
deleted file mode 100644
index 95c44f6e1..000000000
--- a/core/http/react-ui/src/components/identity/DistanceScale.jsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import { labelRows, pct, scaleMax, scaleTicks } from '../../utils/identity'
-
-const at = (value, max) => ({ '--at': pct(value, max) })
-
-// One scale for the whole answer: distance runs left to right, the cut-off is
-// a line across it, and each enrolled person (or the single pair) is a dot.
-// Lower means more alike. The text under it repeats the numbers, so the dots
-// are never the only way to read the result.
-//
-// dots [{ id, label, distance, within, best }] one per person, any order
-// cutoff where "same" ends
-export default function DistanceScale({ dots, cutoff, noun, single = false }) {
- const { t } = useTranslation('biometrics')
- const max = scaleMax(cutoff, dots.map(d => d.distance))
- const ordered = [...dots].sort((a, b) => a.distance - b.distance)
- const rows = labelRows(ordered.map(d => (d.distance / max) * 100))
- const plot = { '--rows': Math.max(...rows, 0) }
- const summary = ordered.map(d => `${d.label} ${d.distance.toFixed(2)}`).join(', ')
-
- return (
-
-
- {t('scale.cutoff', { value: cutoff.toFixed(2) })}
-
- {ordered.map((d, i) => (
-
-
- {d.label} {d.distance.toFixed(2)}
-
- ))}
-
- {scaleTicks(max).map(v => {v} )}
-
-
-
- {t('scale.axis', { print: noun })}
- {single ? t('scale.onePair') : t('scale.oneDot', { person: noun === 'faceprint' ? t('face.person') : t('voice.speaker') })}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/EnrolSheet.jsx b/core/http/react-ui/src/components/identity/EnrolSheet.jsx
deleted file mode 100644
index 6ca654848..000000000
--- a/core/http/react-ui/src/components/identity/EnrolSheet.jsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { useMemo, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import { faceApi, voiceApi, voiceProfilesApi } from '../../utils/api'
-import { labelsText, parseLabels } from '../../utils/identity'
-import { normalizeAudioSample } from '../../utils/referenceAudio'
-// eslint-disable-next-line no-unused-vars
-import ErrorNote from './ErrorNote'
-// eslint-disable-next-line no-unused-vars
-import LoadingSpinner from '../LoadingSpinner'
-// eslint-disable-next-line no-unused-vars
-import SideSheet from '../SideSheet'
-import Icon from '../Icon'
-// eslint-disable-next-line no-unused-vars
-import ClipInput from './ClipInput'
-
-// A saved copy of a sample lives in localStorage, which is small. A larger one
-// is not kept, and the sheet says so instead of failing quietly.
-const KEEP_LIMIT = 1_500_000
-
-const READ_ALOUD = 'The harbour was quiet before the ferry arrived. Two gulls argued over a crust, and somebody carried a ladder down to the water.'
-
-// Enrol one person: a sample, who it is, and permission. For a voice, an admin
-// can also keep the same recording as a speech voice, which is a different
-// store with its own transcript and consent record.
-export default function EnrolSheet({ kind, model, canSpeech, prefill, onClose, onDone }) {
- const { t } = useTranslation('biometrics')
- const isFace = kind === 'face'
- const [sample, setSample] = useState(prefill?.sample || null)
- const [name, setName] = useState(prefill?.name || '')
- const [labels, setLabels] = useState(labelsText(prefill?.labels).replaceAll(', ', '\n'))
- const [asSpeech, setAsSpeech] = useState(false)
- const [transcript, setTranscript] = useState('')
- const [agreed, setAgreed] = useState(false)
- const [keep, setKeep] = useState(false)
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState(null)
-
- const needs = useMemo(() => {
- const out = []
- if (!model) out.push(t('enrol.needModel'))
- if (!sample) out.push(t(isFace ? 'enrol.needPhoto' : 'enrol.needRecording'))
- if (!name.trim()) out.push(t('enrol.needName'))
- if (asSpeech && !transcript.trim()) out.push(t('enrol.needTranscript'))
- if (!agreed) out.push(t('enrol.needPermission'))
- return out
- }, [model, sample, name, asSpeech, transcript, agreed, isFace, t])
-
- const submit = async (e) => {
- e.preventDefault()
- if (needs.length || busy) return
- setBusy(true)
- setError(null)
- let data
- try {
- const parsed = parseLabels(labels)
- data = isFace
- ? await faceApi.register({ model, name: name.trim(), img: sample.dataUrl, labels: parsed })
- : await voiceApi.register({ model, name: name.trim(), audio: sample.dataUrl, labels: parsed })
- const small = sample.dataUrl.length <= KEEP_LIMIT
- const kept = keep && small
- const entry = {
- id: data.id,
- name: data.name,
- labels: parsed,
- model,
- registeredAt: data.registered_at || new Date().toISOString(),
- ...(kept ? (isFace ? { thumbnail: sample.dataUrl } : { sampleUrl: sample.dataUrl }) : {}),
- }
- let speechError = null
- if (asSpeech && canSpeech && !isFace) {
- try {
- const wav = await normalizeAudioSample(sample)
- const form = new FormData()
- form.append('name', name.trim())
- form.append('description', '')
- form.append('language', '')
- form.append('transcript', transcript.trim())
- form.append('consent_confirmed', 'true')
- form.append('audio', wav.blob, 'reference.wav')
- await voiceProfilesApi.create(form)
- } catch (err) {
- speechError = err
- } finally {
- // The normalised copy only exists for this call.
- }
- }
- onDone(entry, { keptTooBig: keep && !small, speechError, asSpeech })
- } catch (err) {
- setError(err)
- } finally {
- setBusy(false)
- }
- }
-
- const footer = (
- <>
- {needs.length ? t('enrol.stillNeeded', { list: needs.join(', ') }) : t('enrol.ready')}
- {t('enrol.cancel')}
- 0 || busy}>
- {busy ? <> {t('enrol.working')}> : <>{t('enrol.submit')} >}
-
- >
- )
-
- return (
-
-
-
- )
-}
-
diff --git a/core/http/react-ui/src/components/identity/ErrorNote.jsx b/core/http/react-ui/src/components/identity/ErrorNote.jsx
deleted file mode 100644
index 99732fc88..000000000
--- a/core/http/react-ui/src/components/identity/ErrorNote.jsx
+++ /dev/null
@@ -1,26 +0,0 @@
-// eslint-disable-next-line no-unused-vars
-import { Link } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { explainError } from '../../utils/identity'
-import Icon from '../Icon'
-
-// A failed call in plain words first, the server's own message second, and a
-// way to the traces. `onRetry` adds a button when trying again makes sense.
-export default function ErrorNote({ error, kind, onRetry }) {
- const { t } = useTranslation('biometrics')
- const { title, body } = explainError(error, kind)
- return (
-
-
-
-
{title}
-
{body}
- {error?.message &&
{error.message}
}
-
- {onRetry && {t('error.retry')} }
- {t('error.traces')}
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/FeatureOff.jsx b/core/http/react-ui/src/components/identity/FeatureOff.jsx
deleted file mode 100644
index 57d24e469..000000000
--- a/core/http/react-ui/src/components/identity/FeatureOff.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import Icon from '../Icon'
-
-// Shown in place of a page whose feature the signed-in user may not use. It
-// replaces a silent redirect: the person learns what is off and what turns it on.
-export default function FeatureOff({ feature }) {
- const { t } = useTranslation('biometrics')
- return (
-
-
- {t('off.badge')}
- {t(`off.${feature}.title`)}
- {t(`off.${feature}.text`)}
-
- {t('needed.whatTitle')}
- {t(`off.${feature}.what`)}
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/KnownList.jsx b/core/http/react-ui/src/components/identity/KnownList.jsx
deleted file mode 100644
index 65d7ea975..000000000
--- a/core/http/react-ui/src/components/identity/KnownList.jsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { useRef } from 'react'
-import { useTranslation } from 'react-i18next'
-import { ageText, initials, labelsText } from '../../utils/identity'
-import Icon from '../Icon'
-
-// eslint-disable-next-line no-unused-vars
-function Avatar({ entry, tone }) {
- return (
-
- {entry.thumbnail ? : initials(entry.name)}
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function PlaySample({ entry }) {
- const { t } = useTranslation('biometrics')
- const audio = useRef(null)
- if (!entry.sampleUrl) return null
- const play = () => {
- if (!audio.current) audio.current = new Audio(entry.sampleUrl)
- audio.current.currentTime = 0
- audio.current.play().catch(() => {})
- }
- return (
-
-
-
- )
-}
-
-// The registry as a table: who is saved in this browser, since when, and
-// whether the server still seems to have them. Names and labels are the only
-// facts shown; the saved sample, when the person chose to keep one, plays.
-export default function KnownList({ entries, extras, missing, waiting, bestId, onDelete, onUndo, onAgain, noun, onEnrol, kind, canEnrol = true }) {
- const { t, i18n } = useTranslation('biometrics')
- const rows = [
- ...entries.map(e => ({ ...e, local: true })),
- ...extras.map(e => ({ ...e, local: false })),
- ]
-
- if (rows.length === 0) {
- return (
-
-
-
{t(`${kind}.emptyTitle`)}
-
{t(`${kind}.emptyText`)}
-
onEnrol()} disabled={!canEnrol}> {t(`${kind}.enrol`)}
-
- )
- }
-
- return (
-
-
- {t('known.caption', { noun })}
-
-
- {t('known.name')}
- {t('known.added')}
- {t('known.actions')}
-
-
-
- {rows.map(row => {
- const gone = row.local && missing.has(row.id)
- const pending = waiting[row.id]
- const best = row.id === bestId
- return (
-
-
-
-
-
-
- {row.name}
- {best && {t('known.best')} }
- {gone && {t('known.notOnServer')} }
- {!row.local && {t('known.notInBrowser')} }
- {pending && {t('known.removing')} }
-
-
- {gone ? t('known.goneText', { print: noun }) : !row.local ? t('known.extraText') : labelsText(row.labels) || t('known.noLabels')}
-
- {gone && !pending && onAgain(row)}>{row.sampleUrl || row.thumbnail ? t('known.reenrolSaved') : t('known.enrolAgain')} }
-
-
-
- {row.registeredAt ? ageText(row.registeredAt, Date.now(), i18n.language) : ''}
-
- {pending ? (
- onUndo(row.id)}>{t('known.undo')}
- ) : (
- <>
-
- onDelete(row)} aria-label={t('known.forget', { name: row.name })}>
-
-
- >
- )}
-
-
- )
- })}
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/ModelNeeded.jsx b/core/http/react-ui/src/components/identity/ModelNeeded.jsx
deleted file mode 100644
index bf830917d..000000000
--- a/core/http/react-ui/src/components/identity/ModelNeeded.jsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-// eslint-disable-next-line no-unused-vars
-import { Link } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { useOperations } from '../../hooks/useOperations'
-import { modelsApi } from '../../utils/api'
-import Icon from '../Icon'
-
-// The disabled state: recognition runs on a model, and none is installed.
-// It says so, offers the gallery models that carry the right tag, and says
-// what turns the feature on. `kind` is 'voice' or 'face'.
-export default function ModelNeeded({ kind, onInstalled, addToast, isAdmin }) {
- const { t } = useTranslation('biometrics')
- const { operations } = useOperations()
- const [models, setModels] = useState([])
- const [loading, setLoading] = useState(true)
- const [failed, setFailed] = useState(false)
- const [started, setStarted] = useState(() => new Set())
-
- const load = useCallback(async () => {
- try {
- const res = await modelsApi.list({ tag: kind === 'face' ? 'face-recognition' : 'voice-recognition', items: 4, page: 1, sort: 'name', order: 'asc' })
- setModels((res?.models || []).filter(m => !m.installed).slice(0, 3))
- setFailed(false)
- } catch {
- setFailed(true)
- } finally {
- setLoading(false)
- }
- }, [kind])
-
- useEffect(() => { load() }, [load])
-
- // When an install finishes, tell the page to look for the model again.
- useEffect(() => {
- if (started.size === 0) return
- const done = [...started].some(id => operations.some(op => op.name === id && (op.completed || op.error)))
- if (done) { onInstalled?.(); load() }
- }, [operations, started, onInstalled, load])
-
- const install = async (model) => {
- setStarted(prev => new Set(prev).add(model.id))
- try {
- await modelsApi.install(model.id)
- addToast(t('needed.started', { name: model.name }), 'success')
- } catch (err) {
- setStarted(prev => { const next = new Set(prev); next.delete(model.id); return next })
- addToast(err.message, 'error')
- }
- }
-
- const busy = (model) => started.has(model.id) || operations.some(op => op.name === model.id && !op.completed && !op.error)
-
- return (
-
- {t('needed.badge')}
- {t(`${kind}.neededTitle`)}
- {t(`${kind}.neededText`)}
- {isAdmin && !loading && models.length > 0 && (
-
- {models.map(model => (
-
- {model.name} {model.backend ? t('needed.backend', { backend: model.backend }) : ''}
- install(model)}>
- {busy(model) ? t('needed.installing') : t('needed.install')}
-
-
- ))}
-
- )}
- {isAdmin && !loading && models.length === 0 && (
- {failed ? t('needed.loadFailed') : t('needed.noneListed')}{' '} {t('needed.browse')}
- )}
- {!isAdmin && {t('needed.askAdmin')}
}
-
- {t('needed.whatTitle')}
- {t(`${kind}.neededWhat`)}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/MoreFaceTools.jsx b/core/http/react-ui/src/components/identity/MoreFaceTools.jsx
deleted file mode 100644
index fc4af1ffe..000000000
--- a/core/http/react-ui/src/components/identity/MoreFaceTools.jsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import { useMemo, useState } from 'react'
-// eslint-disable-next-line no-unused-vars
-import LoadingSpinner from '../LoadingSpinner'
-// eslint-disable-next-line no-unused-vars
-import ErrorWithTraceLink from '../ErrorWithTraceLink'
-// eslint-disable-next-line no-unused-vars
-import TabSwitch from '../biometrics/TabSwitch'
-// eslint-disable-next-line no-unused-vars
-import MediaInput from '../biometrics/MediaInput'
-// eslint-disable-next-line no-unused-vars
-import BoundingBoxCanvas from '../biometrics/BoundingBoxCanvas'
-// eslint-disable-next-line no-unused-vars
-import DistributionBars from '../biometrics/DistributionBars'
-// eslint-disable-next-line no-unused-vars
-import EmbeddingInspector from '../biometrics/EmbeddingInspector'
-import { faceApi } from '../../utils/api'
-import Icon from '../Icon'
-
-// The developer tools behind the face workbench: detect faces and guess
-// attributes (guesses, often wrong), and the raw faceprint.
-const TABS = [
- { id: 'analyze', icon: 'chart-bar', label: 'Detect and analyze' },
- { id: 'embed', icon: 'code', label: 'Embedding' },
-]
-
-export default function MoreFaceTools({ model, addToast }) {
- const [tab, setTab] = useState('analyze')
- return (
-
-
-
- {tab === 'analyze' &&
}
- {tab === 'embed' &&
}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function AnalyzeTab({ model, addToast }) {
- const [img, setImg] = useState(null)
- const [actions, setActions] = useState({ age: false, gender: false, emotion: false, race: false })
- const [antiSpoofing, setAntiSpoofing] = useState(false)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [focusIdx, setFocusIdx] = useState(0)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!img) { addToast('Add an image to analyze', 'warning'); return }
- setLoading(true); setError(null); setResult(null); setFocusIdx(0)
- try {
- const body = {
- model,
- img: img.dataUrl,
- actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
- anti_spoofing: antiSpoofing,
- }
- const data = await faceApi.analyze(body)
- setResult(data)
- if (!data?.faces?.length) addToast('No face detected in the image', 'warning')
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- const boxes = useMemo(() => (result?.faces || []).map((f, i) => ({
- x: f.region.x, y: f.region.y, w: f.region.w, h: f.region.h,
- label: f.dominant_emotion || f.dominant_gender || `Face ${i + 1}`,
- sublabel: f.age ? `~${Math.round(f.age)}y` : null,
- tone: i === focusIdx ? 'accent' : 'default',
- })), [result, focusIdx])
-
- const faces = result?.faces || []
- const focus = faces[focusIdx]
-
- return (
-
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && img && (
- <>
-
-
-
- {faces.length > 1 && (
-
- {faces.map((_, i) => (
- setFocusIdx(i)}
- aria-pressed={i === focusIdx}>
- Face {i + 1}
-
- ))}
-
- )}
-
-
- {focus && (
- <>
-
-
-
Face {focusIdx + 1}
- {antiSpoofing && }
-
-
- {focus.age != null && <>Age ~{Math.round(focus.age)} >}
- {focus.dominant_gender && <>Gender {focus.dominant_gender} >}
- {focus.dominant_emotion && <>Emotion {focus.dominant_emotion} >}
- {focus.dominant_race && <>Race {focus.dominant_race} >}
- {focus.face_confidence != null && <>Detection {(focus.face_confidence * 100).toFixed(1)}% >}
-
-
-
-
-
- >
- )}
-
-
-
- >
- )}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function EmbedTab({ model, addToast }) {
- const [img, setImg] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [elapsedMs, setElapsedMs] = useState(null)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!img) { addToast('Add an image', 'warning'); return }
- setLoading(true); setError(null); setResult(null)
- const started = performance.now()
- try {
- const data = await faceApi.embed({ model, img: img.dataUrl })
- setElapsedMs(performance.now() - started)
- setResult(data)
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
- Get a raw embedding
-
- Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
-
-
-
- {loading ? <> Embedding…> : <> Extract vector>}
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && (
-
- )}
-
-
- )
-}
-
-// ──────────────────────────── Small shared bits ────────────────────────────
-
-// eslint-disable-next-line no-unused-vars
-function LivenessPill({ isReal, score }) {
- if (isReal == null) {
- return Not checked
- }
- return (
-
-
- {isReal ? 'Real' : 'Spoof'}
- {score != null && {(score * 100).toFixed(0)}% }
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function EmptyState({ icon, title, body }) {
- return (
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function ResponseDetails({ data }) {
- return (
-
- Raw response
- {JSON.stringify(data, null, 2)}
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx b/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx
deleted file mode 100644
index ed3f52fa5..000000000
--- a/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx
+++ /dev/null
@@ -1,222 +0,0 @@
-import { useMemo, useState } from 'react'
-// eslint-disable-next-line no-unused-vars
-import LoadingSpinner from '../LoadingSpinner'
-// eslint-disable-next-line no-unused-vars
-import ErrorWithTraceLink from '../ErrorWithTraceLink'
-// eslint-disable-next-line no-unused-vars
-import TabSwitch from '../biometrics/TabSwitch'
-// eslint-disable-next-line no-unused-vars
-import MediaInput from '../biometrics/MediaInput'
-// eslint-disable-next-line no-unused-vars
-import WaveformStrip from '../biometrics/WaveformStrip'
-// eslint-disable-next-line no-unused-vars
-import DistributionBars from '../biometrics/DistributionBars'
-// eslint-disable-next-line no-unused-vars
-import EmbeddingInspector from '../biometrics/EmbeddingInspector'
-import { voiceApi } from '../../utils/api'
-import Icon from '../Icon'
-
-// The two developer tools that sit behind the voice workbench: what a clip
-// sounds like (age, gender, emotion are guesses) and the raw voiceprint.
-const TABS = [
- { id: 'analyze', icon: 'waveform', label: 'Analyze' },
- { id: 'embed', icon: 'code', label: 'Embedding' },
-]
-
-const TONE_FOR_SEGMENT = ['accent', 'info', 'success', 'warning', 'data1', 'data2']
-
-export default function MoreVoiceTools({ model, addToast }) {
- const [tab, setTab] = useState('analyze')
- return (
-
-
-
- {tab === 'analyze' &&
}
- {tab === 'embed' &&
}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function AnalyzeTab({ model, addToast }) {
- const [audio, setAudio] = useState(null)
- const [actions, setActions] = useState({ age: false, gender: false, emotion: false })
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [focusIdx, setFocusIdx] = useState(0)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a speaker model first', 'warning'); return }
- if (!audio) { addToast('Add an audio clip', 'warning'); return }
- setLoading(true); setError(null); setResult(null); setFocusIdx(0)
- try {
- const data = await voiceApi.analyze({
- model,
- audio: audio.dataUrl,
- actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
- })
- setResult(data)
- if (!data?.segments?.length) addToast('No speech segments detected', 'warning')
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- const segments = useMemo(() => result?.segments || [], [result])
- const focus = segments[focusIdx]
- const waveformSegments = useMemo(() => segments.map((s, i) => ({
- start: s.start, end: s.end,
- label: s.dominant_emotion || s.dominant_gender || `#${i + 1}`,
- tone: i === focusIdx ? 'accent' : TONE_FOR_SEGMENT[i % TONE_FOR_SEGMENT.length],
- })), [segments, focusIdx])
-
- return (
-
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && audio && (
- <>
-
- {segments.length > 1 && (
-
- {segments.map((s, i) => (
- setFocusIdx(i)}
- aria-pressed={i === focusIdx}>
- #{i + 1} {s.start.toFixed(1)}s–{s.end.toFixed(1)}s
-
- ))}
-
- )}
- {focus && (
-
-
-
-
-
Segment {focusIdx + 1}
- · {focus.start.toFixed(2)}s – {focus.end.toFixed(2)}s
-
-
-
- {focus.age != null && <>Age ~{Math.round(focus.age)} >}
- {focus.dominant_gender && <>Gender {focus.dominant_gender} >}
- {focus.dominant_emotion && <>Emotion {focus.dominant_emotion} >}
-
-
-
-
-
-
- )}
-
- >
- )}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function EmbedTab({ model, addToast }) {
- const [audio, setAudio] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [elapsedMs, setElapsedMs] = useState(null)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a speaker model first', 'warning'); return }
- if (!audio) { addToast('Add an audio clip', 'warning'); return }
- setLoading(true); setError(null); setResult(null)
- const started = performance.now()
- try {
- const data = await voiceApi.embed({ model, audio: audio.dataUrl })
- setElapsedMs(performance.now() - started)
- setResult(data)
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
- Get a raw speaker embedding
-
- Returns a speaker-encoder vector — the same representation the backend uses internally for verify and identify.
-
-
-
- {loading ? <> Embedding…> : <> Extract vector>}
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && (
-
- )}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function EmptyState({ icon, title, body }) {
- return (
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function ResponseDetails({ data }) {
- return (
-
- Raw response
- {JSON.stringify(data, null, 2)}
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/Verdict.jsx b/core/http/react-ui/src/components/identity/Verdict.jsx
deleted file mode 100644
index a3bc3109d..000000000
--- a/core/http/react-ui/src/components/identity/Verdict.jsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import Icon from '../Icon'
-
-const WORD = { strong: 'ok', likely: 'ok', close: 'warn', near: 'warn', far: '' }
-
-// The answer in a sentence: a headline, a word for how strong it is, and one
-// plain line with the real numbers. `tone` picks the icon: match, miss, none.
-export default function Verdict({ tone, title, word, children, testId = 'verdict' }) {
- const { t } = useTranslation('biometrics')
- return (
-
-
-
-
- {title}
- {word && {t(`word.${word}`)} }
-
-
{children}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/Workbench.jsx b/core/http/react-ui/src/components/identity/Workbench.jsx
deleted file mode 100644
index 18aee5215..000000000
--- a/core/http/react-ui/src/components/identity/Workbench.jsx
+++ /dev/null
@@ -1,363 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import { faceApi, voiceApi } from '../../utils/api'
-import { FACE_CUTOFF, VOICE_CUTOFF, rankMatches, strength } from '../../utils/identity'
-import { UNDO_WINDOW_MS } from '../../hooks/useDelayedAction'
-// eslint-disable-next-line no-unused-vars
-import BoundingBoxCanvas from '../biometrics/BoundingBoxCanvas'
-// eslint-disable-next-line no-unused-vars
-import HomeUndoToast from '../home/HomeUndoToast'
-// eslint-disable-next-line no-unused-vars
-import LoadingSpinner from '../LoadingSpinner'
-import Icon from '../Icon'
-// eslint-disable-next-line no-unused-vars
-import ClipInput from './ClipInput'
-// eslint-disable-next-line no-unused-vars
-import DistanceScale from './DistanceScale'
-// eslint-disable-next-line no-unused-vars
-import EnrolSheet from './EnrolSheet'
-// eslint-disable-next-line no-unused-vars
-import ErrorNote from './ErrorNote'
-// eslint-disable-next-line no-unused-vars
-import KnownList from './KnownList'
-import { useRegistry } from './useRegistry'
-// eslint-disable-next-line no-unused-vars
-import Verdict from './Verdict'
-
-const KINDS = {
- voice: {
- api: voiceApi, mode: 'audio', cutoff: VOICE_CUTOFF, print: 'voiceprint', key: 'localai_voice_enrollments',
- probe: 'audio', pair: ['audio1', 'audio2'], minTopK: 25,
- },
- face: {
- api: faceApi, mode: 'image', cutoff: FACE_CUTOFF, print: 'faceprint', key: 'localai_face_enrollments',
- probe: 'img', pair: ['img1', 'img2'], minTopK: 25,
- },
-}
-
-// eslint-disable-next-line no-unused-vars
-function CutoffControl({ value, onChange, modelDefault, kind }) {
- const { t } = useTranslation('biometrics')
- const same = Math.abs(value - modelDefault) < 0.0005
- const fill = { '--dk-fill': `${((value - 0.05) / 0.95) * 100}%` }
- return (
-
-
- {t('cutoff.label')} {value.toFixed(2)}
- {same
- ? {t('cutoff.default')}
- : onChange(modelDefault)}>{t('cutoff.reset', { value: modelDefault.toFixed(2) })} }
-
-
- {t('cutoff.stricter')}
- onChange(parseFloat(e.target.value))} />
- {t('cutoff.looser')}
-
-
{t('cutoff.explain')}
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function HowToRead({ kind, print }) {
- const { t } = useTranslation('biometrics')
- return (
- {t('how.title')} {t('how.body', { print })}
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function WhyWrong({ kind, notes }) {
- const { t } = useTranslation('biometrics')
- const list = [...notes, ...t(`${kind}.why`, { returnObjects: true })]
- return (
-
- {t('why.title')}
-
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function Stored({ kind, print }) {
- const { t } = useTranslation('biometrics')
- const points = t(`${kind}.stored`, { returnObjects: true, print })
- return (
-
- {t('stored.title')}
-
- {points.map(p =>
{p.where} {p.what} )}
-
-
- )
-}
-
-// The whole identity tool, for voices or for faces: who is this, same person,
-// the people this browser knows, and what is stored. The two kinds differ in
-// the sample (audio or photo), the calls and the default cut-off; everything
-// else is shared so the pages read as one family.
-export default function Workbench({ kind, model, addToast, canSpeech = false, more = null, needed = null, onCount }) {
- const cfg = KINDS[kind]
- const isFace = kind === 'face'
- const { t } = useTranslation('biometrics')
- const noun = t(`${kind}.print`)
- const [tool, setTool] = useState('identify')
- const [probe, setProbe] = useState(null)
- const [probeFacts, setProbeFacts] = useState(null)
- const [clipA, setClipA] = useState(null)
- const [clipB, setClipB] = useState(null)
- const [antiSpoof, setAntiSpoof] = useState(false)
- const [find, setFind] = useState({ status: 'idle' })
- const [pair, setPair] = useState({ status: 'idle' })
- const [findCut, setFindCut] = useState(cfg.cutoff)
- const [pairCut, setPairCut] = useState(null)
- const [sheet, setSheet] = useState(null)
-
- const onForgot = useCallback((entry, outcome) => {
- if (outcome === 'gone') addToast(t('toast.removed', { name: entry.name }), 'info')
- else if (outcome === 'already-gone') addToast(t('toast.alreadyGone', { name: entry.name }), 'warning')
- else addToast(outcome.message, 'error')
- }, [addToast, t])
- const forgetCall = useCallback((body) => cfg.api.forget(body), [cfg])
- const reg = useRegistry({ storageKey: cfg.key, forget: forgetCall, onForgot })
-
- const visibleEntries = reg.entries
- const known = visibleEntries.length
- useEffect(() => { onCount?.(known) }, [known, onCount])
-
- const identify = async () => {
- if (!model || !probe) return
- setFind({ status: 'working' })
- const topK = Math.max(cfg.minTopK, known * 2)
- try {
- const data = await cfg.api.identify({ model, [cfg.probe]: probe.dataUrl, top_k: topK, threshold: findCut })
- const matches = data?.matches || []
- reg.noteSearch(matches, topK)
- setFind({ status: 'done', matches, cutoffSent: findCut, searched: known })
- } catch (err) {
- setFind({ status: 'failed', error: err })
- }
- }
-
- const compare = async () => {
- if (!model || !clipA || !clipB) return
- setPair({ status: 'working' })
- setPairCut(null)
- try {
- const body = { model, [cfg.pair[0]]: clipA.dataUrl, [cfg.pair[1]]: clipB.dataUrl }
- if (isFace) body.anti_spoofing = antiSpoof
- const data = await cfg.api.verify(body)
- setPair({ status: 'done', data })
- } catch (err) {
- setPair({ status: 'failed', error: err })
- }
- }
-
- const ranked = useMemo(() => (find.status === 'done' ? rankMatches(find.matches, findCut) : []), [find, findCut])
- const best = ranked[0]
- const nameOf = (m) => m.name || m.id
-
- const onEnrolled = (entry, extra) => {
- reg.add(entry)
- setSheet(null)
- addToast(t(extra.asSpeech && !extra.speechError ? 'toast.enrolledBoth' : 'toast.enrolled', { name: entry.name }), 'success')
- if (extra.keptTooBig) addToast(t('toast.tooBig'), 'warning')
- if (extra.speechError) addToast(t('toast.speechFailed', { message: extra.speechError.message }), 'error')
- }
-
- const again = async (row) => {
- const saved = row.sampleUrl || row.thumbnail
- if (!saved) { setSheet({ name: row.name, labels: row.labels }); return }
- try {
- const data = await cfg.api.register(isFace
- ? { model, name: row.name, img: saved, labels: row.labels || {} }
- : { model, name: row.name, audio: saved, labels: row.labels || {} })
- reg.replace(row.id, { ...row, id: data.id, registeredAt: data.registered_at || new Date().toISOString(), model })
- addToast(t('toast.enrolled', { name: row.name }), 'success')
- } catch (err) {
- addToast(err.message, 'error')
- }
- }
-
- const noteFor = (r) => {
- const d = r.distance.toFixed(2)
- const c = findCut.toFixed(2)
- const n = find.matches.length
- if (r.within) return t(`${kind}.matchText`, { name: nameOf(r), distance: d, cutoff: c, count: n })
- return t(`${kind}.missText`, { name: nameOf(r), distance: d, cutoff: c })
- }
-
- const pairResult = pair.status === 'done' ? pair.data : null
- const pairModelCut = pairResult?.threshold || cfg.cutoff
- const pairActive = pairCut ?? pairModelCut
- const pairWithin = pairResult ? pairResult.distance <= pairActive : false
- const pairWord = pairResult ? strength(pairResult.distance, pairActive) : null
-
- const working = find.status === 'working' || pair.status === 'working'
-
- return (
-
- {needed}
- {!needed &&
-
-
- setTool('identify')}>{t(`${kind}.whoTab`)}
- setTool('same')}>{t('tool.sameTab')}
-
-
{tool === 'identify' ? t(`${kind}.whoLede`) : t(`${kind}.sameLede`)}
-
-
- {tool === 'identify' && (
-
-
{ setProbe(v); setFind({ status: 'idle' }) }} idPrefix={`${kind}-probe`} onFacts={setProbeFacts} />
-
-
- {find.status === 'working' ? <> {t(`${kind}.working`)}> : <>{t(`${kind}.whoGo`)} >}
-
- {t(`${kind}.whoNote`, { count: known })}
-
-
- {find.status === 'working' && (
-
-
-
{t(`${kind}.workingText`, { count: known })}
-
- )}
-
- {find.status === 'failed' && }
-
- {find.status === 'done' && (
-
- {!best && (
-
- {t(known ? `${kind}.noneReturnedText` : `${kind}.noneEnrolledText`)}
-
- )}
- {best && best.within && (
-
{noteFor(best)}
- )}
- {best && !best.within && (
-
{noteFor(best)}
- )}
-
- {best && (
- <>
-
({ id: r.id, label: nameOf(r).split(/\s+/)[0], distance: r.distance, within: r.within, best: r.rank === 1 && r.within }))} />
-
- {ranked.slice(0, 5).map(r => (
-
- #{r.rank}
- {nameOf(r)}
- {r.distance.toFixed(2)}
- {r.within ? {t('ranks.within')} : {t('ranks.far')} }
-
- ))}
-
-
-
-
-
- >
- )}
-
- {(!best || !best.within) && (
- setSheet({ sample: probe })}>
- {t(`${kind}.enrolThis`)}
-
- )}
-
- )}
-
- )}
-
- {tool === 'same' && (
-
-
- { setClipA(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-a`} />
- { setClipB(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-b`} />
-
- {isFace && (
-
- setAntiSpoof(e.target.checked)} />
- {t('face.antiSpoof')} {t('face.antiSpoofHint')}
-
- )}
-
-
- {pair.status === 'working' ? <> {t('tool.comparing')}> : <>{t('tool.compare')} >}
-
- {t(`${kind}.sameNote`)}
-
-
- {pair.status === 'working' && (
-
- )}
- {pair.status === 'failed' &&
}
-
- {pairResult && (
-
-
- {t('same.text', { print: noun, distance: pairResult.distance.toFixed(2), cutoff: pairActive.toFixed(2) })}
-
-
- {isFace && (pairResult.img1_area || pairResult.img2_area) && (
-
- {[[clipA, pairResult.img1_area, pairResult.img1_is_real], [clipB, pairResult.img2_area, pairResult.img2_is_real]].map(([clip, area, real], i) => (
-
-
- {antiSpoof && real != null && {real ? t('face.real') : t('face.spoof')} }
-
- ))}
-
- )}
-
-
-
-
-
-
- )}
-
- )}
- }
-
-
-
-
{t(`${kind}.knownTitle`)} {known}
- setSheet({})} disabled={!model} data-testid="enrol-open">
- {t(`${kind}.enrol`)}
-
-
- {t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}
- reg.schedule(row)} onUndo={reg.undo} onAgain={again} onEnrol={() => setSheet({})} canEnrol={!!model}
- />
-
-
-
- {more}
-
- {sheet && (
-
setSheet(null)} onDone={onEnrolled} />
- )}
- {reg.latest && (
- reg.undo(reg.latest.id)}
- onExpire={() => reg.finish(reg.latest.id)}
- undoLabel={t('known.undo')}
- dismissible={false}
- testId="forget-undo-toast"
- />
- )}
- {working && {t('tool.workingAria')} }
-
- )
-}
diff --git a/core/http/react-ui/src/components/identity/useRegistry.js b/core/http/react-ui/src/components/identity/useRegistry.js
deleted file mode 100644
index 973612f39..000000000
--- a/core/http/react-ui/src/components/identity/useRegistry.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
-import { useDelayedAction } from '../../hooks/useDelayedAction'
-import { loadList, missingFromServer, saveList } from '../../utils/identity'
-
-// The people this browser enrolled, plus what the last search learned about
-// the server. The server has no list call, so the list is a local record: it
-// can be out of date after a restart, and the only evidence is a search that
-// asked for everyone and did not get someone back.
-//
-// storageKey localStorage key of the saved list
-// forget the real call, `({ id }) => Promise`
-// onForgot (entry, outcome) after the call: 'gone', 'already-gone' or an Error
-export function useRegistry({ storageKey, forget, onForgot }) {
- const [entries, setEntries] = useState(() => loadList(storageKey))
- const [search, setSearch] = useState(null)
- const [latest, setLatest] = useState(null)
-
- useEffect(() => { saveList(storageKey, entries) }, [storageKey, entries])
-
- const run = useCallback(async (_key, entry) => {
- try {
- await forget({ id: entry.id })
- setEntries(prev => prev.filter(e => e.id !== entry.id))
- setSearch(prev => (prev ? { ...prev, extras: prev.extras.filter(e => e.id !== entry.id) } : prev))
- onForgot?.(entry, 'gone')
- } catch (err) {
- if (err?.status === 404) {
- setEntries(prev => prev.filter(e => e.id !== entry.id))
- onForgot?.(entry, 'already-gone')
- } else {
- onForgot?.(entry, err)
- }
- }
- }, [forget, onForgot])
-
- const forgetting = useDelayedAction(run)
-
- const add = useCallback((entry) => setEntries(prev => [entry, ...prev.filter(e => e.id !== entry.id)]), [])
-
- // Swap a row for the same person under the id the server just made.
- const replace = useCallback((oldId, entry) => setEntries(prev => prev.map(e => (e.id === oldId ? entry : e))), [])
-
- const schedule = useCallback((entry) => {
- setLatest(entry)
- forgetting.schedule(entry.id, entry)
- }, [forgetting])
-
- const undo = useCallback((id) => {
- forgetting.cancel(id)
- setLatest(prev => (prev?.id === id ? null : prev))
- }, [forgetting])
-
- const finish = useCallback((id) => {
- forgetting.flush(id)
- setLatest(prev => (prev?.id === id ? null : prev))
- }, [forgetting])
-
- // Record what a search returned. `asked` is the top_k it sent.
- const noteSearch = useCallback((matches, asked) => {
- const ids = matches.map(m => m.id)
- const known = new Set(entries.map(e => e.id))
- setSearch({
- ids,
- complete: matches.length < asked,
- extras: matches.filter(m => !known.has(m.id)).map(m => ({ id: m.id, name: m.name || m.id, labels: m.labels || {} })),
- })
- }, [entries])
-
- const missing = useMemo(() => missingFromServer(entries, search), [entries, search])
-
- return {
- entries, add, replace, missing, searched: !!search, extras: search?.extras || [],
- waiting: forgetting.waiting, latest: latest && forgetting.waiting[latest.id] ? latest : null,
- schedule, undo, finish, noteSearch,
- }
-}
diff --git a/core/http/react-ui/src/components/tools/JobLog.jsx b/core/http/react-ui/src/components/tools/JobLog.jsx
deleted file mode 100644
index b6858c4fa..000000000
--- a/core/http/react-ui/src/components/tools/JobLog.jsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import { logText } from '../../utils/tools'
-import Icon from '../Icon'
-import './tools.css'
-
-const pad = (n) => String(n).padStart(2, '0')
-const stamp = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
-
-// What the job has reported since this page opened, one line per status change
-// or message. The server sends progress events and keeps no log, so a line from
-// before the page opened is not here, and the heading says so.
-export default function JobLog({ lines, name = 'job' }) {
- const { t } = useTranslation('tools')
- const [only, setOnly] = useState(false)
- const [copied, setCopied] = useState(false)
- const bodyRef = useRef(null)
- const shown = only ? lines.filter(l => l.level !== 'info') : lines
-
- useEffect(() => {
- const el = bodyRef.current
- if (el) el.scrollTop = el.scrollHeight
- }, [shown.length])
-
- const copy = () => {
- navigator.clipboard?.writeText(logText(shown)).then(() => {
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- }).catch(() => {})
- }
- const download = () => {
- const url = URL.createObjectURL(new Blob([logText(shown)], { type: 'text/plain' }))
- const a = document.createElement('a')
- a.href = url
- a.download = `${name}-log.txt`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-
- return (
-
-
-
- {shown.length === 0 ? (
-
{only ? t('log.noneFiltered') : t('log.empty')}
- ) : shown.map((line, i) => (
-
- {stamp(line.at)}
- {line.tag}
- {line.text}
-
- ))}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/tools/LossChart.jsx b/core/http/react-ui/src/components/tools/LossChart.jsx
deleted file mode 100644
index 886a00e57..000000000
--- a/core/http/react-ui/src/components/tools/LossChart.jsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import './tools.css'
-
-// The drawing is made in the size it is shown at, so the text stays readable: a
-// wide one on a desktop, a narrow one on a phone.
-const SIZES = {
- wide: { W: 720, H: 260, PAD: { top: 16, right: 64, bottom: 36, left: 56 } },
- compact: { W: 340, H: 240, PAD: { top: 16, right: 48, bottom: 36, left: 44 } },
-}
-
-function useCompact() {
- const query = '(max-width: 640px)'
- const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
- useEffect(() => {
- const mq = window.matchMedia(query)
- const on = () => setCompact(mq.matches)
- mq.addEventListener('change', on)
- return () => mq.removeEventListener('change', on)
- }, [])
- return compact
-}
-
-function tickText(v, exp) {
- if (!Number.isFinite(v)) return ''
- if (exp) return v.toExponential(1)
- if (Math.abs(v) >= 100) return v.toFixed(0)
- if (Math.abs(v) >= 1) return v.toFixed(2)
- return v.toFixed(3)
-}
-
-// The metrics of a fine-tuning run as one thin line, with a tab for each metric.
-// Loss also marks the evaluation loss as hollow dots when the run reports it.
-// The end of the line carries its value, the axes are labelled, and a data table
-// sits behind a disclosure for anyone who wants the numbers.
-export default function LossChart({ events, totalSteps }) {
- const { t } = useTranslation('tools')
- const [metric, setMetric] = useState('loss')
- const [hover, setHover] = useState(null)
- const svgRef = useRef(null)
- const compact = useCompact()
- const { W, H, PAD } = SIZES[compact ? 'compact' : 'wide']
-
- const series = useMemo(() => ({
- loss: events.filter(e => e.loss > 0),
- learning_rate: events.filter(e => e.learning_rate > 0),
- grad_norm: events.filter(e => e.grad_norm > 0),
- }), [events])
- const evalPoints = useMemo(() => events.filter(e => e.eval_loss > 0), [events])
- const data = series[metric]
- const exp = metric === 'learning_rate'
- const key = metric
- const tabs = ['loss', 'learning_rate', 'grad_norm']
-
- const geometry = useMemo(() => {
- if (data.length < 2) return null
- const values = data.map(e => e[key]).concat(metric === 'loss' ? evalPoints.map(e => e.eval_loss) : [])
- const lo = Math.min(...values)
- const hi = Math.max(...values)
- const span = hi - lo || Math.abs(hi) || 1
- const yMin = metric === 'loss' ? Math.max(0, lo - span * 0.08) : lo - span * 0.08
- const yMax = hi + span * 0.08
- const steps = data.map(e => e.current_step)
- const xMin = Math.min(...steps)
- const xMax = Math.max(totalSteps || 0, ...steps)
- const cw = W - PAD.left - PAD.right
- const ch = H - PAD.top - PAD.bottom
- const x = (s) => PAD.left + ((s - xMin) / (xMax - xMin || 1)) * cw
- const y = (v) => PAD.top + (1 - (v - yMin) / (yMax - yMin || 1)) * ch
- const yTicks = Array.from({ length: 4 }, (_, i) => yMin + ((yMax - yMin) * i) / 3)
- const xTicks = Array.from({ length: compact ? 3 : 5 }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / (compact ? 2 : 4)))
- return { x, y, yTicks, xTicks, xMin, xMax, cw, ch }
- }, [data, evalPoints, key, metric, totalSteps, W, H, PAD, compact])
-
- const move = (e) => {
- if (!geometry || !svgRef.current) return
- const rect = svgRef.current.getBoundingClientRect()
- const px = ((e.clientX - rect.left) / rect.width) * W
- const step = geometry.xMin + ((px - PAD.left) / geometry.cw) * (geometry.xMax - geometry.xMin)
- let best = data[0]
- for (const d of data) if (Math.abs(d.current_step - step) < Math.abs(best.current_step - step)) best = d
- setHover(best)
- }
-
- const last = data[data.length - 1]
- const first = data[0]
- const readout = hover || last
-
- return (
-
-
-
-
{t(`chart.${metric}`)}
-
- {geometry
- ? t(metric === 'loss' && evalPoints.length ? 'chart.windowEval' : 'chart.window', { from: first.current_step, to: last.current_step, total: totalSteps || last.current_step })
- : t('chart.waiting')}
-
-
-
- {tabs.map(id => (
- { setMetric(id); setHover(null) }}>
- {t(`chart.${id}`)}
-
- ))}
-
-
- {geometry ? (
- <>
- setHover(null)}
- >
- {geometry.yTicks.map((v, i) => (
-
-
- {tickText(v, exp)}
-
- ))}
- {geometry.xTicks.map((s, i) => (
- {s}
- ))}
- {t('chart.step')}
- `${geometry.x(e.current_step)},${geometry.y(e[key])}`).join(' ')} />
- {metric === 'loss' && evalPoints.map((e, i) => (
-
- ))}
-
- {tickText(last[key], exp)}
- {hover && }
-
-
- {t('chart.readout', { step: readout.current_step, value: tickText(readout[key], exp) })}
-
-
- {t('chart.dataTitle')}
-
-
- {t('chart.dataTitle')}
- {t('chart.step')} {t(`chart.${metric}`)} {metric === 'loss' && {t('chart.eval')} }
-
- {data.slice(-12).map(e => (
-
- {e.current_step}
- {tickText(e[key], exp)}
- {metric === 'loss' && {e.eval_loss > 0 ? tickText(e.eval_loss, false) : ''} }
-
- ))}
-
-
-
-
- >
- ) : (
- {t('chart.emptyText')}
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/components/tools/StageLine.jsx b/core/http/react-ui/src/components/tools/StageLine.jsx
deleted file mode 100644
index 46e184cdb..000000000
--- a/core/http/react-ui/src/components/tools/StageLine.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import './tools.css'
-
-// The stages a job passes through, as a row of segments. The current stage is
-// named in bold; stages before it are filled. `stages` are status ids and
-// `labels` maps each to its words.
-export default function StageLine({ stages, labels, status, failed = false, label }) {
- const at = stages.indexOf(status)
- return (
-
- {stages.map((stage, i) => {
- const state = i < at ? 'done' : i === at ? (failed ? 'failed' : 'current') : 'todo'
- return (
-
-
- {labels[stage]}
-
- )
- })}
-
- )
-}
diff --git a/core/http/react-ui/src/components/tools/ToolOff.jsx b/core/http/react-ui/src/components/tools/ToolOff.jsx
deleted file mode 100644
index 6df7e1719..000000000
--- a/core/http/react-ui/src/components/tools/ToolOff.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// eslint-disable-next-line no-unused-vars
-import { Link } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { useAuth } from '../../context/AuthContext'
-import Icon from '../Icon'
-import './tools.css'
-
-// Shown in place of a Build tool the signed-in person may not use. It replaces a
-// silent redirect: it says that the account lacks the permission and who can
-// grant it, and points at the tools the person can open. `tool` is 'fineTune' or
-// 'quantize'.
-export default function ToolOff({ tool }) {
- const { t } = useTranslation('tools')
- const { isAdmin } = useAuth()
- return (
-
-
- {t('off.badge')}
- {t(`off.${tool}.title`)}
- {t(`off.${tool}.text`)}
- {isAdmin ? t('off.admin') : t('off.askAdmin')}
-
- {t('off.back')}
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/i18n/index.js b/core/http/react-ui/src/i18n/index.js
index cf30a0c1e..19a4b51c1 100644
--- a/core/http/react-ui/src/i18n/index.js
+++ b/core/http/react-ui/src/i18n/index.js
@@ -28,7 +28,6 @@ export const NAMESPACES = [
'skills',
'collections',
'library',
- 'biometrics',
'media',
'tools',
'admin',
diff --git a/core/http/react-ui/src/pages/FaceRecognition.jsx b/core/http/react-ui/src/pages/FaceRecognition.jsx
deleted file mode 100644
index f34216385..000000000
--- a/core/http/react-ui/src/pages/FaceRecognition.jsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useMemo, useState } from 'react'
-import { useOutletContext, useParams } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-// eslint-disable-next-line no-unused-vars
-import ModelSelector from '../components/ModelSelector'
-// eslint-disable-next-line no-unused-vars
-import PageHeader from '../components/PageHeader'
-import Icon from '../components/Icon'
-// eslint-disable-next-line no-unused-vars
-import ModelNeeded from '../components/identity/ModelNeeded'
-// eslint-disable-next-line no-unused-vars
-import MoreFaceTools from '../components/identity/MoreFaceTools'
-// eslint-disable-next-line no-unused-vars
-import Workbench from '../components/identity/Workbench'
-import { useAuth } from '../context/AuthContext'
-import { useModels } from '../hooks/useModels'
-import { CAP_FACE_RECOGNITION } from '../utils/capabilities'
-import './identity.css'
-
-// The Faces page, in the same family as Voices: who is this, same person, the
-// people this browser knows, what is stored. Detect and analyze, and the raw
-// faceprint, sit behind a disclosure.
-export default function FaceRecognition() {
- const { t } = useTranslation('biometrics')
- const { model: urlModel } = useParams()
- const { addToast } = useOutletContext()
- const { isAdmin } = useAuth()
- const [model, setModel] = useState(urlModel || '')
- const { models, loading, refetch } = useModels(CAP_FACE_RECOGNITION)
- const names = useMemo(() => models.map(m => m.id), [models])
- const needModel = !loading && names.length === 0
-
- return (
-
-
-
-
- ) : null}
- />
- : null}
- more={needModel ? null : (
-
- {t('face.moreTitle')} {t('face.moreHint')}
-
-
- )}
- />
-
- )
-}
diff --git a/core/http/react-ui/src/pages/FineTune.jsx b/core/http/react-ui/src/pages/FineTune.jsx
deleted file mode 100644
index 5cfa4456c..000000000
--- a/core/http/react-ui/src/pages/FineTune.jsx
+++ /dev/null
@@ -1,1297 +0,0 @@
-/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { Link } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { fineTuneApi } from '../utils/api'
-import { useAuth } from '../context/AuthContext'
-import { useResources } from '../hooks/useResources'
-import { FT_STAGES, TERMINAL, appendLog, blocked, fineTuneChecks, logLines, looksLikeMemoryFailure, machineFacts, warned } from '../utils/tools'
-import PageHeader from '../components/PageHeader'
-import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
-import Dialog from '../components/Dialog'
-import Icon from '../components/Icon'
-import LoadingSpinner from '../components/LoadingSpinner'
-import ToolSteps from '../components/tools/ToolSteps'
-import Checks from '../components/tools/Checks'
-import StageLine from '../components/tools/StageLine'
-import JobLog from '../components/tools/JobLog'
-import LossChart from '../components/tools/LossChart'
-import '../components/tools/tools.css'
-
-const TRAINING_METHODS = ['sft', 'dpo', 'grpo', 'rloo', 'reward', 'kto', 'orpo']
-const ADAPTER_KINDS = ['lora', 'loha', 'lokr']
-const FALLBACK_BACKEND = 'trl'
-const OPTIMIZERS = ['adamw_torch', 'adamw_8bit', 'sgd', 'adafactor', 'prodigy']
-const MIXED_PRECISION_OPTS = ['', 'fp16', 'bf16', 'no']
-const QUANT_PRESETS = ['q4_k_m', 'q5_k_m', 'q8_0', 'f16', 'q4_0', 'q5_0']
-
-const BUILTIN_REWARDS = [
- { name: 'format_reward', params: [] },
- { name: 'reasoning_accuracy_reward', params: [] },
- { name: 'length_reward', params: [{ key: 'target_length', default: '200' }] },
- { name: 'xml_tag_reward', params: [] },
- { name: 'no_repetition_reward', params: [] },
- { name: 'code_execution_reward', params: [] },
-]
-
-const ACTIVE_STATUSES = FT_STAGES
-const BADGE = {
- queued: '', loading_model: 'dk-badge--warn', loading_dataset: 'dk-badge--warn', training: 'dk-badge--accent',
- saving: 'dk-badge--accent', completed: 'dk-badge--ok', failed: 'dk-badge--error', stopped: '',
-}
-const EXTRA_HANDLED = ['max_seq_length', 'save_total_limit', 'hf_token', 'eval_strategy', 'eval_steps', 'eval_split', 'eval_dataset_source', 'eval_split_ratio', 'voice', 'val_dataset']
-
-function StatusBadge({ status }) {
- const { t } = useTranslation('tools')
- return {t(`fineTune.status.${status}`, { defaultValue: String(status || '').replace(/_/g, ' ') })}
-}
-
-function Field({ id, label, hint, className = '', children }) {
- return (
-
-
{label}
- {children}
- {hint &&
{hint}
}
-
- )
-}
-
-function Select({ id, value, onChange, children }) {
- return (
-
- {children}
-
- )
-}
-
-function Section({ done, title, children }) {
- return (
-
-
- {done ? : null}
- {title}
-
- {children}
-
- )
-}
-
-function KeyValueEditor({ entries, onChange }) {
- const { t } = useTranslation('tools')
- const update = (i, field, val) => onChange(entries.map((e, idx) => (idx === i ? { ...e, [field]: val } : e)))
- return (
-
- )
-}
-
-function CopyButton({ text }) {
- const { t } = useTranslation('tools')
- const [copied, setCopied] = useState(false)
- const copy = (e) => {
- e.stopPropagation()
- navigator.clipboard?.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500) }).catch(() => {})
- }
- return (
-
-
-
- )
-}
-
-function etaText(t, seconds) {
- if (!seconds || seconds <= 0) return ''
- const h = Math.floor(seconds / 3600)
- const m = Math.floor((seconds % 3600) / 60)
- if (h > 0) return t('run.eta', { time: `${h}h ${m}m` })
- if (m > 0) return t('run.eta', { time: `${m} min` })
- return t('run.etaSeconds', { seconds: Math.max(1, Math.floor(seconds)) })
-}
-
-const kindOf = (job) => `${job.training_type || 'lora'}, ${job.training_method || 'sft'}`
-const startedAt = (job) => {
- const d = new Date(job.created_at)
- return Number.isNaN(d.getTime()) ? (job.created_at || '') : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
-}
-
-// A running, failed or finished job: progress, the loss chart, the log, the
-// checkpoints and what to do with the result. The server streams progress
-// events while the job is active; a finished job is read from the job record.
-function JobView({ job, onStop, onReuse, onResume, onTerminal, exportCheckpoint }) {
- const { t } = useTranslation('tools')
- const [events, setEvents] = useState([])
- const [latest, setLatest] = useState(null)
- const [log, setLog] = useState([])
- const [connecting, setConnecting] = useState(true)
- const [stopOpen, setStopOpen] = useState(false)
- const [checkpoints, setCheckpoints] = useState([])
- const previousRef = useRef(null)
- const terminalRef = useRef(onTerminal)
- terminalRef.current = onTerminal
-
- useEffect(() => {
- previousRef.current = null
- setEvents([])
- setLatest(null)
- setLog([])
- if (!job || !ACTIVE_STATUSES.includes(job.status)) { setConnecting(false); return undefined }
- setConnecting(true)
- const es = new EventSource(fineTuneApi.progressUrl(job.id))
- es.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data)
- setConnecting(false)
- setLatest(data)
- setLog(prev => appendLog(prev, logLines(data, previousRef.current)))
- previousRef.current = data
- if (data.loss > 0 || data.eval_loss > 0 || data.learning_rate > 0 || data.grad_norm > 0) {
- setEvents(prev => {
- const last = prev[prev.length - 1]
- return last && last.current_step === data.current_step ? [...prev.slice(0, -1), data] : [...prev, data]
- })
- }
- if (TERMINAL.includes(data.status)) { es.close(); terminalRef.current?.() }
- } catch (_) { /* a partial frame */ }
- }
- es.onerror = () => { setConnecting(false); es.close() }
- return () => es.close()
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [job?.id])
-
- const status = latest?.status || job.status
- const active = ACTIVE_STATUSES.includes(status)
- const failed = status === 'failed'
- const progress = Math.min(latest?.progress_percent || 0, 100)
-
- useEffect(() => {
- if (!job || active) return
- fineTuneApi.listCheckpoints(job.id).then(r => setCheckpoints(r.checkpoints || [])).catch(() => {})
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [job?.id, active])
-
- const message = latest?.message || job.message || ''
- const lastCheckpoint = checkpoints.length ? checkpoints[checkpoints.length - 1] : null
- const [fixes, setFixes] = useState({ batch: true, checkpointing: true })
-
- const stageLabels = Object.fromEntries(FT_STAGES.map(s => [s, t(`fineTune.status.${s}`)]))
-
- return (
-
- {failed && (
-
-
-
-
- {latest?.current_step > 0
- ? t('fineTune.failedAt', { step: latest.current_step, total: latest.total_steps })
- : t('fineTune.failed')}
-
-
{message || t('fineTune.noMessage')}
-
-
- )}
-
-
-
-
-
{job.model}
-
- {t('fineTune.jobMeta', { backend: job.backend || FALLBACK_BACKEND, method: job.training_method || 'sft', kind: job.training_type || 'lora', started: startedAt(job) })}
- · {job.id?.slice(0, 8)}
-
-
-
-
- {active && (
- setStopOpen(true)} data-testid="job-stop">
- {t('run.stop')}
-
- )}
-
-
-
- {connecting && !latest && active ? (
- {t('run.connecting')}
- ) : (
- <>
- {(latest || status === 'completed') && (
-
- {status === 'completed' ? '100' : progress.toFixed(0)}%
- {latest && latest.total_steps > 0 && {t('run.step')} {latest.current_step} {t('run.of')} {latest.total_steps} }
- {latest && latest.total_epochs > 0 && {t('run.epoch')} {latest.current_epoch?.toFixed(1)} {t('run.of')} {latest.total_epochs?.toFixed(0)} }
- {active && etaText(t, latest?.eta_seconds) && {etaText(t, latest.eta_seconds)} }
- {latest?.extra_metrics?.tokens_per_second > 0 && {latest.extra_metrics.tokens_per_second.toFixed(0)} {t('run.tokens')} }
-
- )}
- {(active || latest) && (
-
-
-
- )}
- {active && (
-
- )}
- {!failed && message && {message}
}
- {status === 'completed' && (
-
- {events.length && events[events.length - 1].loss > 0
- ? t('fineTune.finishedLoss', { loss: events[events.length - 1].loss.toFixed(4) })
- : t('fineTune.finished')}
-
- )}
- {status === 'stopped' && {t('fineTune.stopped')}
}
- >
- )}
-
-
- {failed && (
-
- )}
-
- {(active || events.length > 0) &&
}
- {(active || log.length > 0) &&
}
-
- {!active && checkpoints.length > 0 && (
-
- {t('checkpoints.title')}
-
-
- {t('checkpoints.title')}
- {t('checkpoints.step')} {t('checkpoints.epoch')} {t('checkpoints.loss')} {t('checkpoints.path')} {t('checkpoints.actions')}
-
- {checkpoints.map(cp => (
-
- {cp.step} {cp.created_at}
- {cp.epoch?.toFixed(2)}
- {cp.loss?.toFixed(4)}
- {cp.path}
-
-
- onResume(cp)} title={t('checkpoints.resumeTitle')}> {t('checkpoints.resume')}
- exportCheckpoint(cp)} title={t('checkpoints.exportTitle')}> {t('checkpoints.export')}
-
-
-
- ))}
-
-
-
-
- )}
-
- {stopOpen && (
-
setStopOpen(false)}
- testId="stop-dialog"
- labelId="bt-stop-title"
- foot={(
- <>
- setStopOpen(false)}>{t('stop.cancel')}
- { setStopOpen(false); onStop(job.id, false) }}>{t('stop.discard')}
- { setStopOpen(false); onStop(job.id, true) }}>{t('stop.keep')}
- >
- )}
- />
- )}
-
- )
-}
-
-// What to do with a finished job: export it as a model, then use it.
-function ExportPanel({ job, prefilledCheckpoint }) {
- const { t } = useTranslation('tools')
- const [checkpoints, setCheckpoints] = useState([])
- const [exportFormat, setExportFormat] = useState('lora')
- const [quantMethod, setQuantMethod] = useState('q4_k_m')
- const [modelName, setModelName] = useState('')
- const [selectedCheckpoint, setSelectedCheckpoint] = useState('')
- const [exporting, setExporting] = useState(false)
- const [message, setMessage] = useState('')
- const [exportedModelName, setExportedModelName] = useState('')
- const [exportFailed, setExportFailed] = useState(false)
- const pollRef = useRef(null)
-
- useEffect(() => {
- if (!job) return
- fineTuneApi.listCheckpoints(job.id).then(r => setCheckpoints(r.checkpoints || [])).catch(() => {})
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [job?.id])
-
- useEffect(() => { if (prefilledCheckpoint) setSelectedCheckpoint(prefilledCheckpoint.path || '') }, [prefilledCheckpoint])
-
- // Sync export state from the job record (first load, list refresh).
- useEffect(() => {
- if (!job) return
- if (job.export_status === 'exporting') {
- setExporting(true); setExportFailed(false)
- setMessage(job.export_message || t('export.working'))
- } else if (job.export_status === 'completed' && job.export_model_name) {
- setExporting(false); setExportFailed(false)
- setExportedModelName(job.export_model_name)
- setMessage(t('export.done', { name: job.export_model_name }))
- } else if (job.export_status === 'failed') {
- setExporting(false); setExportFailed(true)
- setMessage(t('export.failed', { message: job.export_message || t('export.unknown') }))
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [job?.export_status, job?.export_model_name, job?.export_message])
-
- useEffect(() => {
- if (!exporting || !job) return undefined
- pollRef.current = setInterval(async () => {
- try {
- const updated = await fineTuneApi.getJob(job.id)
- if (updated.export_status === 'completed') {
- setExporting(false); setExportFailed(false)
- const name = updated.export_model_name || modelName || 'exported model'
- setExportedModelName(name)
- setMessage(t('export.done', { name }))
- clearInterval(pollRef.current)
- } else if (updated.export_status === 'failed') {
- setExporting(false); setExportFailed(true)
- setMessage(t('export.failed', { message: updated.export_message || t('export.unknown') }))
- clearInterval(pollRef.current)
- } else if (updated.export_status === 'exporting' && updated.export_message) {
- setMessage(updated.export_message)
- }
- } catch (_) { /* try again at the next tick */ }
- }, 3000)
- return () => clearInterval(pollRef.current)
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [exporting, job?.id])
-
- const handleExport = async () => {
- setExporting(true); setExportFailed(false)
- setMessage(t('export.working'))
- setExportedModelName('')
- try {
- await fineTuneApi.exportModel(job.id, {
- name: modelName || undefined,
- checkpoint_path: selectedCheckpoint || job.output_dir,
- export_format: exportFormat,
- quantization_method: exportFormat === 'gguf' ? quantMethod : '',
- model: job.model,
- })
- } catch (e) {
- setMessage(t('export.failed', { message: e.message }))
- setExportFailed(true); setExporting(false)
- }
- }
-
- if (!job || !TERMINAL.includes(job.status)) return null
-
- return (
- <>
- {exportedModelName && !exportFailed && (
-
-
-
-
{t('export.nextTitle', { name: exportedModelName })}
-
{t('export.nextText')}
-
-
-
- )}
-
- {t('export.title')}
- {t('export.lede')}
-
- {checkpoints.length > 0 && (
-
- setSelectedCheckpoint(e.target.value)}>
- {t('export.final')}
- {checkpoints.map(cp => {t('export.checkpointOption', { step: cp.step, loss: cp.loss?.toFixed(4) })} )}
-
-
- )}
-
-
- setExportFormat(e.target.value)}>
- {t('export.lora')}
- {t('export.merged16')}
- {t('export.merged4')}
- GGUF
-
-
- {exportFormat === 'gguf' && (
-
- setQuantMethod(e.target.value)} placeholder="q4_k_m, bf16, f32" />
- {QUANT_PRESETS.map(q => )}
-
- )}
-
-
- setModelName(e.target.value)} placeholder="my-finetuned-model" />
-
-
-
- {exporting ? <> {t('export.exporting')}> : <> {t('export.go')}>}
-
-
- {message && (!exportedModelName || exportFailed) && (
-
- {exporting && } {message}
-
- )}
-
-
- >
- )
-}
-
-export default function FineTune() {
- const { t } = useTranslation('tools')
- const { isAdmin } = useAuth()
- const { resources } = useResources(10000)
- const facts = useMemo(() => machineFacts(resources), [resources])
-
- const [jobs, setJobs] = useState([])
- const [jobsLoaded, setJobsLoaded] = useState(false)
- const [selectedJob, setSelectedJob] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState('')
- const [backends, setBackends] = useState(null)
- const [exportCheckpoint, setExportCheckpoint] = useState(null)
- const [deleting, setDeleting] = useState(null)
- const initialConfigRef = useRef(null)
-
- // Form state
- const [model, setModel] = useState('')
- const [backend, setBackend] = useState(FALLBACK_BACKEND)
- const [trainingMethod, setTrainingMethod] = useState('sft')
- const [trainingType, setTrainingType] = useState('lora')
- const [datasetSource, setDatasetSource] = useState('')
- const [datasetFile, setDatasetFile] = useState(null)
- const [datasetSplit, setDatasetSplit] = useState('')
- const [numEpochs, setNumEpochs] = useState(3)
- const [batchSize, setBatchSize] = useState(2)
- const [learningRate, setLearningRate] = useState(0.0002)
- const [learningRateText, setLearningRateText] = useState('0.0002')
- const [adapterRank, setAdapterRank] = useState(16)
- const [adapterAlpha, setAdapterAlpha] = useState(16)
- const [adapterDropout, setAdapterDropout] = useState(0)
- const [targetModules, setTargetModules] = useState('')
- const [gradAccum, setGradAccum] = useState(4)
- const [warmupSteps, setWarmupSteps] = useState(5)
- const [maxSteps, setMaxSteps] = useState(0)
- const [saveSteps, setSaveSteps] = useState(500)
- const [weightDecay, setWeightDecay] = useState(0)
- const [maxSeqLength, setMaxSeqLength] = useState(2048)
- const [optimizer, setOptimizer] = useState('adamw_torch')
- const [gradCheckpointing, setGradCheckpointing] = useState(false)
- const [seed, setSeed] = useState(0)
- const [mixedPrecision, setMixedPrecision] = useState('')
- const [extraOptions, setExtraOptions] = useState([])
- const [liquidAudioVoice, setLiquidAudioVoice] = useState('')
- const [liquidAudioValDataset, setLiquidAudioValDataset] = useState('')
- const [hfToken, setHfToken] = useState('')
- const [showAdvanced, setShowAdvanced] = useState(false)
- const [resumeFromCheckpoint, setResumeFromCheckpoint] = useState('')
- const [saveTotalLimit, setSaveTotalLimit] = useState(0)
- const [evalEnabled, setEvalEnabled] = useState(false)
- const [evalStrategy, setEvalStrategy] = useState('steps')
- const [evalSteps, setEvalSteps] = useState(0)
- const [evalSplit, setEvalSplit] = useState('')
- const [evalDatasetSource, setEvalDatasetSource] = useState('')
- const [evalSplitRatio, setEvalSplitRatio] = useState(0.1)
- const [rewardFunctions, setRewardFunctions] = useState([])
- const [showAddCustomReward, setShowAddCustomReward] = useState(false)
- const [customRewardName, setCustomRewardName] = useState('')
- const [customRewardCode, setCustomRewardCode] = useState('')
-
- const loadJobs = useCallback(async () => {
- try {
- const data = await fineTuneApi.listJobs()
- const list = data || []
- setJobs(list)
- setSelectedJob(prev => {
- if (!prev) return prev
- const fresh = list.find(j => j.id === prev.id)
- return fresh ? { ...prev, ...fresh } : prev
- })
- } catch (_) { /* the next poll tries again */ } finally {
- setJobsLoaded(true)
- }
- }, [])
-
- useEffect(() => {
- loadJobs()
- const interval = setInterval(loadJobs, 10000)
- return () => clearInterval(interval)
- }, [loadJobs])
-
- useEffect(() => {
- fineTuneApi.listBackends()
- .then(data => {
- const list = Array.isArray(data) ? data : []
- setBackends(list)
- if (list.length > 0) setBackend(prev => (list.some(b => b.name === prev) ? prev : list[0].name))
- })
- .catch(() => setBackends(null))
- }, [])
-
- const backendNames = backends && backends.length > 0 ? backends.map(b => b.name) : [FALLBACK_BACKEND]
- const isAdapter = ADAPTER_KINDS.includes(trainingType)
-
- const handleSubmit = async (e) => {
- e.preventDefault()
- setLoading(true)
- setError('')
- try {
- let dsSource = datasetSource
- if (datasetFile) {
- const result = await fineTuneApi.uploadDataset(datasetFile)
- dsSource = result.path
- }
- const extra = {}
- if (maxSeqLength) extra.max_seq_length = String(maxSeqLength)
- if (hfToken.trim()) extra.hf_token = hfToken.trim()
- if (saveTotalLimit > 0) extra.save_total_limit = String(saveTotalLimit)
- if (evalEnabled) {
- extra.eval_strategy = evalStrategy || 'steps'
- if (evalSteps > 0) extra.eval_steps = String(evalSteps)
- if (evalSplit.trim()) extra.eval_split = evalSplit.trim()
- if (evalDatasetSource.trim()) extra.eval_dataset_source = evalDatasetSource.trim()
- if (evalSplitRatio > 0 && evalSplitRatio !== 0.1) extra.eval_split_ratio = String(evalSplitRatio)
- } else {
- extra.eval_strategy = 'no'
- }
- for (const { key, value } of extraOptions) if (key.trim()) extra[key.trim()] = value
- // The Python backend reads `voice` and `val_dataset` from extra_options.
- if (backend === 'liquid-audio') {
- if (liquidAudioVoice) extra.voice = liquidAudioVoice
- if (liquidAudioValDataset.trim()) extra.val_dataset = liquidAudioValDataset.trim()
- }
-
- const req = {
- model,
- backend,
- training_method: trainingMethod,
- training_type: trainingType,
- dataset_source: dsSource,
- dataset_split: datasetSplit || undefined,
- num_epochs: numEpochs,
- batch_size: batchSize,
- learning_rate: learningRate,
- adapter_rank: isAdapter ? adapterRank : 0,
- adapter_alpha: isAdapter ? adapterAlpha : 0,
- adapter_dropout: isAdapter && adapterDropout > 0 ? adapterDropout : undefined,
- target_modules: isAdapter && targetModules.trim() ? targetModules.split(',').map(s => s.trim()) : undefined,
- gradient_accumulation_steps: gradAccum,
- warmup_steps: warmupSteps,
- max_steps: maxSteps > 0 ? maxSteps : undefined,
- save_steps: saveSteps > 0 ? saveSteps : undefined,
- weight_decay: weightDecay > 0 ? weightDecay : undefined,
- gradient_checkpointing: gradCheckpointing,
- optimizer,
- seed: seed > 0 ? seed : undefined,
- mixed_precision: mixedPrecision || undefined,
- resume_from_checkpoint: resumeFromCheckpoint || undefined,
- extra_options: Object.keys(extra).length > 0 ? extra : undefined,
- reward_functions: trainingMethod === 'grpo' && rewardFunctions.length > 0 ? rewardFunctions : undefined,
- }
-
- const resp = await fineTuneApi.startJob(req)
- setResumeFromCheckpoint('')
- // Job submitted: rebaseline so leaving the page no longer warns.
- initialConfigRef.current = JSON.stringify(getFormConfig())
- await loadJobs()
- setSelectedJob({ ...req, id: resp.id, status: 'queued', created_at: new Date().toISOString() })
- } catch (err) {
- setError(err.message)
- }
- setLoading(false)
- }
-
- const handleStop = async (jobId, saveCheckpoint = true) => {
- try {
- await fineTuneApi.stopJob(jobId, saveCheckpoint)
- await loadJobs()
- } catch (err) {
- setError(err.message)
- }
- }
-
- const confirmDelete = async () => {
- const jobId = deleting
- setDeleting(null)
- try {
- await fineTuneApi.deleteJob(jobId)
- if (selectedJob?.id === jobId) setSelectedJob(null)
- await loadJobs()
- } catch (err) {
- setError(err.message)
- }
- }
-
- const getFormConfig = () => {
- const extra = {}
- for (const { key, value } of extraOptions) if (key.trim()) extra[key.trim()] = value
- if (backend === 'liquid-audio') {
- if (liquidAudioVoice) extra.voice = liquidAudioVoice
- if (liquidAudioValDataset.trim()) extra.val_dataset = liquidAudioValDataset.trim()
- }
- return {
- model, backend,
- training_method: trainingMethod,
- training_type: trainingType,
- adapter_rank: adapterRank,
- adapter_alpha: adapterAlpha,
- adapter_dropout: adapterDropout,
- target_modules: targetModules.trim() ? targetModules.split(',').map(s => s.trim()) : [],
- dataset_source: datasetSource,
- dataset_split: datasetSplit,
- num_epochs: numEpochs,
- batch_size: batchSize,
- learning_rate: learningRate,
- gradient_accumulation_steps: gradAccum,
- warmup_steps: warmupSteps,
- max_steps: maxSteps,
- save_steps: saveSteps,
- weight_decay: weightDecay,
- gradient_checkpointing: gradCheckpointing,
- optimizer, seed,
- mixed_precision: mixedPrecision,
- max_seq_length: maxSeqLength,
- eval_strategy: evalEnabled ? (evalStrategy || 'steps') : 'no',
- eval_steps: evalSteps,
- eval_split: evalSplit,
- eval_dataset_source: evalDatasetSource,
- eval_split_ratio: evalSplitRatio,
- extra_options: Object.keys(extra).length > 0 ? extra : {},
- reward_functions: rewardFunctions.length > 0 ? rewardFunctions : undefined,
- }
- }
-
- const applyFormConfig = (config) => {
- if (config.model != null) setModel(config.model)
- if (config.backend != null) setBackend(config.backend)
- if (config.training_method != null) setTrainingMethod(config.training_method)
- if (config.training_type != null) setTrainingType(config.training_type)
- if (config.adapter_rank != null) setAdapterRank(Number(config.adapter_rank))
- if (config.adapter_alpha != null) setAdapterAlpha(Number(config.adapter_alpha))
- if (config.adapter_dropout != null) setAdapterDropout(Number(config.adapter_dropout))
- if (config.target_modules != null) {
- setTargetModules(Array.isArray(config.target_modules) ? config.target_modules.join(', ') : String(config.target_modules))
- }
- if (config.dataset_source != null) setDatasetSource(config.dataset_source)
- if (config.dataset_split != null) setDatasetSplit(config.dataset_split)
- if (config.num_epochs != null) setNumEpochs(Number(config.num_epochs))
- if (config.batch_size != null) setBatchSize(Number(config.batch_size))
- if (config.learning_rate != null) { setLearningRate(Number(config.learning_rate)); setLearningRateText(String(config.learning_rate)) }
- if (config.gradient_accumulation_steps != null) setGradAccum(Number(config.gradient_accumulation_steps))
- if (config.warmup_steps != null) setWarmupSteps(Number(config.warmup_steps))
- if (config.max_steps != null) setMaxSteps(Number(config.max_steps))
- if (config.save_steps != null) setSaveSteps(Number(config.save_steps))
- if (config.weight_decay != null) setWeightDecay(Number(config.weight_decay))
- if (config.gradient_checkpointing != null) setGradCheckpointing(Boolean(config.gradient_checkpointing))
- if (config.optimizer != null) setOptimizer(config.optimizer)
- if (config.seed != null) setSeed(Number(config.seed))
- if (config.mixed_precision != null) setMixedPrecision(config.mixed_precision)
-
- // max_seq_length is a top-level field or sits inside extra_options.
- if (config.max_seq_length != null) {
- setMaxSeqLength(Number(config.max_seq_length))
- } else if (config.extra_options?.max_seq_length != null) {
- setMaxSeqLength(Number(config.extra_options.max_seq_length))
- }
-
- // Eval options: the strategy tells whether evaluation was on.
- const restoreEval = (strategy, steps, split, src, ratio) => {
- if (strategy != null && strategy !== 'no') {
- setEvalEnabled(true)
- setEvalStrategy(strategy)
- } else if (strategy === 'no') {
- setEvalEnabled(false)
- }
- if (steps != null) setEvalSteps(Number(steps))
- if (split != null) setEvalSplit(split)
- if (src != null) setEvalDatasetSource(src)
- if (ratio != null) setEvalSplitRatio(Number(ratio))
- }
- restoreEval(config.eval_strategy, config.eval_steps, config.eval_split, config.eval_dataset_source, config.eval_split_ratio)
- // extra_options overrides the top level when it carries them.
- const eo = config.extra_options
- if (eo) restoreEval(eo.eval_strategy, eo.eval_steps, eo.eval_split, eo.eval_dataset_source, eo.eval_split_ratio)
- if (eo?.save_total_limit != null) setSaveTotalLimit(Number(eo.save_total_limit))
-
- // liquid-audio extras; they are also kept out of the free-form list below.
- if (eo?.voice != null) setLiquidAudioVoice(String(eo.voice))
- if (eo?.val_dataset != null) setLiquidAudioValDataset(String(eo.val_dataset))
-
- if (config.extra_options && typeof config.extra_options === 'object') {
- setExtraOptions(Object.entries(config.extra_options)
- .filter(([k]) => !EXTRA_HANDLED.includes(k))
- .map(([key, value]) => ({ key, value: String(value) })))
- }
- setRewardFunctions(Array.isArray(config.reward_functions) ? config.reward_functions : [])
- }
-
- const handleExportConfig = () => {
- const url = URL.createObjectURL(new Blob([JSON.stringify(getFormConfig(), null, 2)], { type: 'application/json' }))
- const a = document.createElement('a')
- a.href = url
- a.download = 'finetune-config.json'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-
- const handleImportConfig = () => {
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = '.json'
- input.onchange = (e) => {
- const file = e.target.files[0]
- if (!file) return
- const reader = new FileReader()
- reader.onload = (ev) => {
- try {
- applyFormConfig(JSON.parse(ev.target.result))
- setSelectedJob(null)
- setError('')
- } catch {
- setError(t('fineTune.badConfig'))
- }
- }
- reader.readAsText(file)
- }
- input.click()
- }
-
- // Put a job's setup back in the form. `fixes` are the changes ticked on a
- // memory failure: they are applied to this copy and nothing starts until the
- // person presses Start.
- const handleUseConfig = (job, fixes) => {
- applyFormConfig(job.config || job)
- setResumeFromCheckpoint('')
- if (fixes?.batch) setBatchSize(1)
- if (fixes?.checkpointing) setGradCheckpointing(true)
- setSelectedJob(null)
- }
-
- const handleResumeFromCheckpoint = (checkpoint) => {
- if (!selectedJob) return
- applyFormConfig(selectedJob.config || selectedJob)
- setResumeFromCheckpoint(checkpoint.path)
- setShowAdvanced(true)
- setSelectedJob(null)
- }
-
- // Baseline for the unsaved-changes guard: lazily taken on first render.
- if (initialConfigRef.current === null) initialConfigRef.current = JSON.stringify(getFormConfig())
- const dirty = JSON.stringify(getFormConfig()) !== initialConfigRef.current
-
- const checks = useMemo(() => fineTuneChecks({
- form: {
- model, datasetSource, datasetFileName: datasetFile?.name || '', backend, batchSize, maxSeqLength, gradAccum,
- gradCheckpointing, trainingType, trainingMethod, rewardCount: rewardFunctions.length,
- resumeFrom: resumeFromCheckpoint, hfToken: hfToken.trim(),
- },
- facts,
- backends: { fineTune: backends },
- }), [model, datasetSource, datasetFile, backend, batchSize, maxSeqLength, gradAccum, gradCheckpointing, trainingType, trainingMethod, rewardFunctions.length, resumeFromCheckpoint, hfToken, facts, backends])
- const stop = blocked(checks)
- const warnings = warned(checks)
- const firstMissing = checks.find(c => c.tone === 'fail')
-
- const steps = [
- { key: 'setup', label: t('steps.setup') },
- { key: 'check', label: t('steps.check') },
- { key: 'run', label: t('steps.run') },
- { key: 'result', label: t('steps.result') },
- ]
- let current = stop ? 0 : 1
- let failed = false
- if (selectedJob) {
- if (selectedJob.status === 'failed') { current = 2; failed = true } else if (TERMINAL.includes(selectedJob.status)) current = 3
- else current = 2
- }
-
- const kindWords = trainingType === 'full' ? t('form.summaryFull') : t(`form.summary_${trainingType}`)
- const modelShort = model.split('/').pop() || model
-
- return (
-
-
-
{t('fineTune.title')} {t('landing.experimental')} >}
- supporting={t('fineTune.lede')}
- actions={(
-
- {selectedJob && (
- setSelectedJob(null)} data-testid="job-back">
- {t('fineTune.newJob')}
-
- )}
-
- {t('fineTune.importConfig')}
-
-
- )}
- />
-
-
- {error && (
-
- )}
-
- {selectedJob ? (
- <>
-
-
- >
- ) : (
-
- {resumeFromCheckpoint && (
-
-
- {t('fineTune.resuming')} {resumeFromCheckpoint}
- setResumeFromCheckpoint('')}> {t('fineTune.clear')}
-
- )}
-
-
-
-
-
-
-
- {t('form.kind')}
- {[['adapter', isAdapter], ['full', trainingType === 'full']].map(([id, on]) => (
-
- setTrainingType(id === 'full' ? 'full' : (ADAPTER_KINDS.includes(trainingType) ? trainingType : 'lora'))}
- />
- {t(`form.kinds.${id}.title`)} {t(`form.kinds.${id}.text`)}
-
- ))}
-
-
-
- setNumEpochs(Number(e.target.value))} min={1} />
-
-
- setBatchSize(Number(e.target.value))} min={1} />
-
-
- {
- setLearningRateText(e.target.value)
- const parsed = Number(e.target.value)
- if (!isNaN(parsed) && parsed > 0) setLearningRate(parsed)
- }}
- placeholder="5e-5"
- />
-
-
-
-
-
setShowAdvanced(!showAdvanced)} aria-expanded={showAdvanced} aria-controls="ft-more" data-testid="ft-more-toggle">
-
- {t('form.more')}
- {!showAdvanced && {t('form.moreHint')} }
-
- {showAdvanced && (
-
-
-
- setBackend(e.target.value)}>
- {backendNames.map(b => {b} )}
-
-
-
- setTrainingMethod(e.target.value)}>
- {TRAINING_METHODS.map(m => {m.toUpperCase()} )}
-
-
- {isAdapter && (
-
- setTrainingType(e.target.value)}>
- {ADAPTER_KINDS.map(k => {k} )}
-
-
- )}
- {isAdapter && (
- <>
-
- setAdapterRank(Number(e.target.value))} min={1} />
-
-
- setAdapterAlpha(Number(e.target.value))} min={1} />
-
-
- setAdapterDropout(Number(e.target.value))} min={0} max={1} step={0.05} />
-
- >
- )}
-
- setGradAccum(Number(e.target.value))} min={1} />
-
-
- setWarmupSteps(Number(e.target.value))} min={0} />
-
-
- setMaxSeqLength(Number(e.target.value))} min={64} />
-
-
- setOptimizer(e.target.value)}>
- {OPTIMIZERS.map(o => {o} )}
-
-
-
- setMaxSteps(Number(e.target.value))} min={0} />
-
-
- setSaveSteps(Number(e.target.value))} min={0} />
-
-
- setSaveTotalLimit(Number(e.target.value))} min={0} />
-
-
- setWeightDecay(Number(e.target.value))} min={0} step={0.01} />
-
-
- setSeed(Number(e.target.value))} min={0} />
-
-
- setMixedPrecision(e.target.value)}>
- {MIXED_PRECISION_OPTS.map(o => {o || t('form.auto')} )}
-
-
-
- {isAdapter && (
-
- setTargetModules(e.target.value)} placeholder="q_proj, v_proj, k_proj, o_proj" />
-
- )}
-
setGradCheckpointing(e.target.checked)} /> {t('form.gradCheckpointing')}
-
- setHfToken(e.target.value)} placeholder="hf_..." autoComplete="off" />
-
-
-
-
- {trainingMethod === 'grpo' && (
-
-
{t('form.rewards')}
-
{t('form.rewardsHint')}
- {BUILTIN_REWARDS.map(builtin => {
- const selected = rewardFunctions.find(rf => rf.type === 'builtin' && rf.name === builtin.name)
- return (
-
-
- {
- if (e.target.checked) setRewardFunctions(prev => [...prev, { type: 'builtin', name: builtin.name }])
- else setRewardFunctions(prev => prev.filter(rf => !(rf.type === 'builtin' && rf.name === builtin.name)))
- }}
- />
- {builtin.name} {t(`rewards.${builtin.name}`)}
-
- {selected && builtin.params.map(param => (
-
- setRewardFunctions(prev => prev.map(rf => (rf.type === 'builtin' && rf.name === builtin.name ? { ...rf, params: { ...(rf.params || {}), [param.key]: e.target.value } } : rf)))}
- />
-
- ))}
-
- )
- })}
- {rewardFunctions.filter(rf => rf.type === 'inline').map((rf, idx) => (
-
-
- {rf.name}
- setRewardFunctions(prev => prev.filter(x => x !== rf))}>
-
-
{rf.code}
-
- ))}
- {showAddCustomReward ? (
-
-
- setCustomRewardName(e.target.value)} placeholder="my_custom_reward" />
-
-
- setCustomRewardCode(e.target.value)} placeholder={"return [1.0 if '' in c else 0.0 for c in completions]"} rows={4} />
-
-
- {
- setRewardFunctions(prev => [...prev, { type: 'inline', name: customRewardName.trim(), code: customRewardCode }])
- setCustomRewardName(''); setCustomRewardCode(''); setShowAddCustomReward(false)
- }}
- > {t('rewards.add')}
- { setShowAddCustomReward(false); setCustomRewardName(''); setCustomRewardCode('') }}>{t('rewards.cancel')}
-
-
- ) : (
-
setShowAddCustomReward(true)}> {t('rewards.custom')}
- )}
-
- )}
-
- {resumeFromCheckpoint && (
-
-
- setResumeFromCheckpoint(e.target.value)} />
- setResumeFromCheckpoint('')} aria-label={t('form.clearCheckpoint')}>
-
-
- )}
-
- {backend === 'liquid-audio' && (
-
-
Liquid Audio
-
{t('form.liquidHint')}
-
-
- setLiquidAudioVoice(e.target.value)}>
- {t('form.liquidInherit')}
- {['us_male', 'us_female', 'uk_male', 'uk_female'].map(v => {v} )}
-
-
-
- setLiquidAudioValDataset(e.target.value)} placeholder="/data/jenny_tts/val" />
-
-
-
- )}
-
-
-
{t('form.extra')}
-
{t('form.extraHint')}
-
-
-
- )}
-
-
-
-
- {model.trim() && (datasetSource.trim() || datasetFile)
- ? t('form.recipe', { kind: kindWords, model: modelShort, method: trainingMethod.toUpperCase(), epochs: numEpochs, data: datasetFile?.name || datasetSource })
- : t('form.recipeEmpty')}
-
-
-
-
- {t('checks.heading')}
- {t('checks.live')}
-
-
-
-
-
-
-
-
- {stop
- ? t(`checks.${firstMissing.key}`)
- : warnings > 0
- ? t('checks.barWarn', { count: warnings })
- : t('checks.barOk')}
- {' '}{t('fineTune.barNote')}
-
-
- {t('fineTune.exportConfig')}
-
- {loading
- ? <> {t('fineTune.starting')}>
- : resumeFromCheckpoint
- ? <> {t('fineTune.resume')}>
- : <> {t('fineTune.start')}>}
-
-
-
-
- )}
-
-
-
- {jobs.length === 0 ? (
-
-
-
{jobsLoaded ? t('jobs.emptyTitle') : t('jobs.loading')}
- {jobsLoaded &&
{t('jobs.emptyText')}
}
-
- ) : (
-
-
- {t('jobs.title')}
-
-
- {t('jobs.model')}
- {t('jobs.kind')}
- {t('jobs.status')}
- {t('jobs.started')}
- {t('jobs.actions')}
-
-
-
- {jobs.map(job => (
-
-
- setSelectedJob(job)} title={t('jobs.open')}>
- {job.model}
-
- {job.backend} · {job.id?.slice(0, 8)}
- {job.status === 'failed' && job.message && {job.message} }
-
- {kindOf(job)}
-
- {startedAt(job)}
-
-
- handleUseConfig(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}
- {TERMINAL.includes(job.status) && (
- setDeleting(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}>
- )}
-
-
-
- ))}
-
-
-
- )}
-
-
- {deleting && (
- setDeleting(null)}
- testId="delete-dialog"
- labelId="bt-delete-title"
- foot={(
- <>
- setDeleting(null)}>{t('stop.cancelDelete')}
- {t('jobs.delete')}
- >
- )}
- />
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/pages/Quantize.jsx b/core/http/react-ui/src/pages/Quantize.jsx
deleted file mode 100644
index 2e0045ee5..000000000
--- a/core/http/react-ui/src/pages/Quantize.jsx
+++ /dev/null
@@ -1,558 +0,0 @@
-/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { Link } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { quantizationApi } from '../utils/api'
-import { useAuth } from '../context/AuthContext'
-import { useResources } from '../hooks/useResources'
-import { QZ_STAGES, TERMINAL, appendLog, blocked, logLines, machineFacts, quantizeChecks, warned } from '../utils/tools'
-import PageHeader from '../components/PageHeader'
-import Dialog from '../components/Dialog'
-import Icon from '../components/Icon'
-import LoadingSpinner from '../components/LoadingSpinner'
-import ToolSteps from '../components/tools/ToolSteps'
-import Checks from '../components/tools/Checks'
-import StageLine from '../components/tools/StageLine'
-import JobLog from '../components/tools/JobLog'
-import '../components/tools/tools.css'
-
-const QUANT_PRESETS = [
- 'q2_k', 'q3_k_s', 'q3_k_m', 'q3_k_l',
- 'q4_0', 'q4_k_s', 'q4_k_m',
- 'q5_0', 'q5_k_s', 'q5_k_m',
- 'q6_k', 'q8_0', 'f16',
-]
-const DEFAULT_QUANT = 'q4_k_m'
-const FALLBACK_BACKEND = 'llama-cpp-quantization'
-const BADGE = {
- queued: '', downloading: 'dk-badge--warn', converting: 'dk-badge--warn', quantizing: 'dk-badge--accent',
- completed: 'dk-badge--ok', failed: 'dk-badge--error', stopped: '',
-}
-
-function StatusBadge({ status }) {
- const { t } = useTranslation('tools')
- return {t(`quantize.status.${status}`, { defaultValue: String(status || '') })}
-}
-
-function Field({ id, label, hint, className = '', children }) {
- return (
-
-
{label}
- {children}
- {hint &&
{hint}
}
-
- )
-}
-
-function Section({ done, title, children }) {
- return (
-
-
- {done ? : null}
- {title}
-
- {children}
-
- )
-}
-
-const startedAt = (job) => {
- const d = new Date(job.created_at)
- return Number.isNaN(d.getTime()) ? (job.created_at || '') : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
-}
-
-// What to do with a finished quantization: import it as a model, then use it.
-function ResultPanel({ job, onRefresh }) {
- const { t } = useTranslation('tools')
- const [modelName, setModelName] = useState('')
- const [importing, setImporting] = useState(false)
- const [error, setError] = useState('')
-
- useEffect(() => {
- if (job?.import_status !== 'importing') return undefined
- const timer = setInterval(() => { onRefresh().catch(() => {}) }, 3000)
- return () => clearInterval(timer)
- }, [job?.import_status, onRefresh])
-
- if (!job || job.status !== 'completed') return null
-
- const handleImport = async () => {
- setImporting(true)
- setError('')
- try {
- await quantizationApi.importModel(job.id, { name: modelName || undefined })
- await onRefresh()
- } catch (e) {
- setError(e.message || t('quantize.importFailed'))
- } finally {
- setImporting(false)
- }
- }
-
- const imported = job.import_status === 'completed' && job.import_model_name
-
- return (
- <>
- {imported && (
-
-
-
-
{t('quantize.nextTitle', { name: job.import_model_name })}
-
{t('quantize.nextText')}
-
-
-
- )}
-
- {t('quantize.outputTitle')}
- {t('quantize.outputText', { type: job.quantization_type })}
- {job.output_file && {t('quantize.file')} {job.output_file}
}
- {error && {error}
}
- {job.import_status === 'failed' && {t('quantize.importFailedWith', { message: job.import_message })}
}
- {!imported && (
-
- {job.import_status === 'importing' ? (
-
{t('quantize.importing')} {job.import_message}
- ) : (
- <>
-
- setModelName(e.target.value)} />
-
-
- >
- )}
-
- )}
-
- >
- )
-}
-
-function JobView({ job, onStop, onReuse, onTerminal, onRefresh }) {
- const { t } = useTranslation('tools')
- const [latest, setLatest] = useState(null)
- const [log, setLog] = useState([])
- const previousRef = useRef(null)
- const terminalRef = useRef(onTerminal)
- terminalRef.current = onTerminal
-
- useEffect(() => {
- previousRef.current = null
- setLatest(null)
- setLog([])
- if (!job || TERMINAL.includes(job.status)) return undefined
- const es = new EventSource(quantizationApi.progressUrl(job.id))
- es.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data)
- setLatest(data)
- setLog(prev => appendLog(prev, logLines(data, previousRef.current)))
- previousRef.current = data
- if (TERMINAL.includes(data.status)) { es.close(); terminalRef.current?.() }
- } catch { /* a partial frame */ }
- }
- es.onerror = () => es.close()
- return () => es.close()
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [job?.id])
-
- const status = latest?.status || job.status
- const active = QZ_STAGES.includes(status)
- const failed = status === 'failed'
- const progress = Math.min(latest?.progress_percent ?? 0, 100)
- const message = latest?.message || job.message || ''
- const stageLabels = Object.fromEntries(QZ_STAGES.map(s => [s, t(`quantize.status.${s}`)]))
-
- return (
-
- {failed && (
-
-
-
-
{t('quantize.failed')}
-
{message || t('fineTune.noMessage')}
-
-
- )}
-
-
-
-
{job.model}
-
- {t('quantize.jobMeta', { type: job.quantization_type, backend: job.backend, started: startedAt(job) })}
- · {job.id?.slice(0, 8)}
-
-
-
-
- {active && (
- onStop(job.id)} data-testid="job-stop">
- {t('run.stop')}
-
- )}
-
-
- {(active || latest) && (
- <>
-
- {status === 'completed' ? '100' : progress.toFixed(0)}%
-
-
-
-
- >
- )}
- {active && }
- {!failed && message && {message}
}
- {status === 'completed' && {t('quantize.finished', { type: job.quantization_type })}
}
- {status === 'stopped' && {t('quantize.stopped')}
}
-
-
- {failed && (
-
- {t('fineTune.tryTitle')}
- {t('quantize.tryText')}
-
- onReuse(job)} data-testid="job-retry"> {t('fineTune.reuse')}
-
-
- )}
-
- {(active || log.length > 0) &&
}
-
-
- )
-}
-
-export default function Quantize() {
- const { t } = useTranslation('tools')
- const { isAdmin } = useAuth()
- const { resources } = useResources(10000)
- const facts = useMemo(() => machineFacts(resources), [resources])
-
- const [model, setModel] = useState('')
- const [quantType, setQuantType] = useState(DEFAULT_QUANT)
- const [customQuantType, setCustomQuantType] = useState('')
- const [useCustomQuant, setUseCustomQuant] = useState(false)
- const [backend, setBackend] = useState(FALLBACK_BACKEND)
- const [hfToken, setHfToken] = useState('')
- const [backends, setBackends] = useState(null)
- const [showMore, setShowMore] = useState(false)
-
- const [jobs, setJobs] = useState([])
- const [jobsLoaded, setJobsLoaded] = useState(false)
- const [selectedJob, setSelectedJob] = useState(null)
- const [error, setError] = useState('')
- const [submitting, setSubmitting] = useState(false)
- const [deleting, setDeleting] = useState(null)
-
- const loadJobs = useCallback(async () => {
- try {
- const data = await quantizationApi.listJobs()
- const list = Array.isArray(data) ? data : []
- setJobs(list)
- setSelectedJob(prev => {
- if (!prev) return prev
- const fresh = list.find(j => j.id === prev.id)
- return fresh || prev
- })
- } catch { /* the next poll tries again */ } finally {
- setJobsLoaded(true)
- }
- }, [])
-
- useEffect(() => {
- quantizationApi.listBackends().then(b => {
- const list = Array.isArray(b) ? b : []
- setBackends(list)
- if (list.length) setBackend(list[0].name)
- }).catch(() => setBackends(null))
- loadJobs()
- const interval = setInterval(loadJobs, 10000)
- return () => clearInterval(interval)
- }, [loadJobs])
-
- const handleSubmit = async (e) => {
- e.preventDefault()
- setError('')
- setSubmitting(true)
- try {
- const req = {
- model,
- backend: backend || FALLBACK_BACKEND,
- quantization_type: useCustomQuant ? customQuantType : quantType,
- }
- if (hfToken) req.extra_options = { hf_token: hfToken }
- const resp = await quantizationApi.startJob(req)
- setModel('')
- await loadJobs()
- setSelectedJob(await quantizationApi.getJob(resp.id))
- } catch (err) {
- setError(err.message || t('quantize.startFailed'))
- } finally {
- setSubmitting(false)
- }
- }
-
- const handleStop = async (jobId) => {
- try {
- await quantizationApi.stopJob(jobId)
- await loadJobs()
- } catch (err) {
- setError(err.message || t('quantize.stopFailed'))
- }
- }
-
- const confirmDelete = async () => {
- const jobId = deleting
- setDeleting(null)
- try {
- await quantizationApi.deleteJob(jobId)
- if (selectedJob?.id === jobId) setSelectedJob(null)
- await loadJobs()
- } catch (err) {
- setError(err.message || t('quantize.deleteFailed'))
- }
- }
-
- // Put a job's setup back in the form.
- const reuse = (job) => {
- setModel(job.model || '')
- if (job.backend) setBackend(job.backend)
- const type = job.quantization_type || DEFAULT_QUANT
- if (QUANT_PRESETS.includes(type)) { setUseCustomQuant(false); setQuantType(type) } else { setUseCustomQuant(true); setCustomQuantType(type) }
- setSelectedJob(null)
- }
-
- const effectiveQuantType = useCustomQuant ? customQuantType : quantType
- const backendNames = backends && backends.length > 0 ? backends.map(b => b.name) : [FALLBACK_BACKEND]
- const checks = useMemo(() => quantizeChecks({
- form: { model, backend, useCustom: useCustomQuant, customType: customQuantType, hfToken: hfToken.trim() },
- facts,
- backends: { quantize: backends },
- }), [model, backend, useCustomQuant, customQuantType, hfToken, facts, backends])
- const stop = blocked(checks)
- const warnings = warned(checks)
- const firstMissing = checks.find(c => c.tone === 'fail')
-
- const steps = [
- { key: 'setup', label: t('steps.setup') },
- { key: 'check', label: t('steps.check') },
- { key: 'run', label: t('steps.run') },
- { key: 'result', label: t('steps.result') },
- ]
- let current = stop ? 0 : 1
- let failed = false
- if (selectedJob) {
- if (selectedJob.status === 'failed') { current = 2; failed = true } else if (TERMINAL.includes(selectedJob.status)) current = 3
- else current = 2
- }
-
- const refreshSelected = useCallback(async () => {
- if (!selectedJob) return
- const updated = await quantizationApi.getJob(selectedJob.id)
- setSelectedJob(updated)
- await loadJobs()
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
- }, [selectedJob?.id, loadJobs])
-
- return (
-
-
{t('quantize.title')} {t('landing.experimental')} >}
- supporting={t('quantize.lede')}
- actions={selectedJob && (
- setSelectedJob(null)} data-testid="job-back">
- {t('quantize.newJob')}
-
- )}
- />
-
-
- {error && (
-
- )}
-
- {selectedJob ? (
-
- ) : (
-
-
-
-
-
-
-
- {
- if (e.target.value === '__custom__') setUseCustomQuant(true)
- else { setUseCustomQuant(false); setQuantType(e.target.value) }
- }}
- >
- {QUANT_PRESETS.map(q => {q} )}
- {t('quantize.custom')}
-
-
-
- {useCustomQuant && (
-
- setCustomQuantType(e.target.value)} required />
-
- )}
-
-
-
setShowMore(v => !v)} aria-expanded={showMore} aria-controls="qz-more">
-
- {t('form.more')}
- {!showMore && {t('quantize.moreHint')} }
-
- {showMore && (
-
-
-
-
- setBackend(e.target.value)}>
- {backendNames.map(b => {b} )}
-
-
-
-
- setHfToken(e.target.value)} autoComplete="off" />
-
-
-
- )}
-
-
-
-
- {model.trim() ? t('quantize.recipe', { model: model.split('/').pop(), type: effectiveQuantType || '?' }) : t('quantize.recipeEmpty')}
-
-
-
-
- {t('checks.heading')}
- {t('checks.live')}
-
-
-
-
-
-
-
-
- {stop ? t(`checks.${firstMissing.key}`) : warnings > 0 ? t('checks.barWarn', { count: warnings }) : t('checks.barOk')}
- {' '}{t('quantize.barNote')}
-
-
-
- {submitting
- ? <> {t('fineTune.starting')}>
- : <> {t('quantize.start', { type: effectiveQuantType || '' })}>}
-
-
-
-
- )}
-
-
-
- {jobs.length === 0 ? (
-
-
-
{jobsLoaded ? t('quantize.emptyTitle') : t('jobs.loading')}
- {jobsLoaded &&
{t('quantize.emptyText')}
}
-
- ) : (
-
-
- {t('jobs.title')}
-
-
- {t('jobs.model')}
- {t('quantize.type')}
- {t('jobs.status')}
- {t('jobs.started')}
- {t('jobs.actions')}
-
-
-
- {jobs.map(job => {
- const active = QZ_STAGES.includes(job.status)
- return (
-
-
- setSelectedJob(job)} title={t('jobs.open')}>
- {job.model}
-
- {job.import_status === 'completed' && {t('quantize.imported', { name: job.import_model_name })} }
- {job.status === 'failed' && job.message && {job.message} }
-
- {job.quantization_type}
-
- {startedAt(job)}
-
-
- {active ? (
- handleStop(job.id)}> {t('run.stop')}
- ) : (
- <>
- reuse(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}
- setDeleting(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}>
- >
- )}
-
-
-
- )
- })}
-
-
-
- )}
-
-
- {deleting && (
- setDeleting(null)}
- testId="delete-dialog"
- labelId="bt-delete-title"
- foot={(
- <>
- setDeleting(null)}>{t('stop.cancelDelete')}
- {t('jobs.delete')}
- >
- )}
- />
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/pages/VoiceLibrary.jsx b/core/http/react-ui/src/pages/VoiceLibrary.jsx
deleted file mode 100644
index 41cec1add..000000000
--- a/core/http/react-ui/src/pages/VoiceLibrary.jsx
+++ /dev/null
@@ -1,421 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-// eslint-disable-next-line no-unused-vars
-import { Link, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-// eslint-disable-next-line no-unused-vars
-import ConfirmDialog from '../components/ConfirmDialog'
-// eslint-disable-next-line no-unused-vars
-import EmptyState from '../components/EmptyState'
-// eslint-disable-next-line no-unused-vars
-import LoadingSpinner from '../components/LoadingSpinner'
-// eslint-disable-next-line no-unused-vars
-import SideSheet from '../components/SideSheet'
-// eslint-disable-next-line no-unused-vars
-import WaveformPlayer from '../components/audio/WaveformPlayer'
-import { useModels } from '../hooks/useModels'
-import { useOperations } from '../hooks/useOperations'
-import { useVoiceCloningGallery } from '../hooks/useVoiceProfiles'
-import { CAP_TTS } from '../utils/capabilities'
-import { modelsApi, voiceProfilesApi } from '../utils/api'
-import { copyToClipboard } from '../utils/clipboard'
-import { renderMarkdown } from '../utils/markdown'
-import Icon from '../components/Icon'
-
-const ROW_BARS = [35, 58, 78, 44, 68, 92, 55, 72, 38, 82, 64, 46, 74, 52, 88, 42, 66, 48]
-
-function formatDuration(milliseconds) {
- const seconds = Math.max(0, Math.round((milliseconds || 0) / 1000))
- const minutes = Math.floor(seconds / 60)
- const rest = seconds % 60
- return minutes ? `${minutes}:${String(rest).padStart(2, '0')}` : `${rest}s`
-}
-
-// eslint-disable-next-line no-unused-vars
-function CompactWaveform() {
- return (
-
- {ROW_BARS.map((height, index) => )}
-
- )
-}
-
-// eslint-disable-next-line no-unused-vars
-function VoiceModelSetup({ t, galleryLoading, installableModels, galleryError, installing, operations, onInstall }) {
- return (
-
-
-
-
-
{t('voiceLibrary.modelSetup.title')}
-
{t('voiceLibrary.modelSetup.body')}
-
-
-
- {galleryLoading && (
- {t('voiceLibrary.modelSetup.loading')}
- )}
- {!galleryLoading && installableModels.length > 0 && (
-
- {installableModels.map(model => {
- const busy = installing.has(model.id) || operations.some(operation => operation.name === model.id && !operation.completed && !operation.error)
- return (
-
-
- {model.name}
- {model.backend || t('voiceLibrary.modelSetup.backendUnknown')}
-
- onInstall(model)}>
- {' '}
- {t(busy ? 'voiceLibrary.modelSetup.installing' : 'voiceLibrary.modelSetup.install')}
-
-
- )
- })}
-
- )}
- {!galleryLoading && installableModels.length === 0 && (
-
- {galleryError ? t('voiceLibrary.modelSetup.loadFailed') : t('voiceLibrary.modelSetup.noneAvailable')}{' '}
- {t('voiceLibrary.actions.browseModels')}
-
- )}
-
- {t('voiceLibrary.modelSetup.capabilityNote')}
-
-
- )
-}
-
-// The speech voices tab of the Voices page: the reference recordings that
-// text-to-speech can use. The page hands in the profile list so the tab count
-// and the list come from one fetch.
-export default function SpeechVoices({ library }) {
- const { t, i18n } = useTranslation('media')
- const { addToast } = useOutletContext()
- const navigate = useNavigate()
- const [searchParams, setSearchParams] = useSearchParams()
- const selectedID = searchParams.get('selected') || ''
- const [search, setSearch] = useState('')
- const [language, setLanguage] = useState('all')
- const [confirmDelete, setConfirmDelete] = useState(false)
- const [deleting, setDeleting] = useState(false)
- const [installing, setInstalling] = useState(() => new Map())
- const { profiles, loading, error, refetch } = library
- const { models, loading: modelsLoading, refetch: refetchModels } = useModels(CAP_TTS)
- const { operations } = useOperations()
-
- const cloningModels = useMemo(() => models.filter(model => model.voice_cloning), [models])
- const {
- models: galleryModels,
- loading: galleryLoading,
- error: galleryError,
- refetch: refetchGalleryModels,
- } = useVoiceCloningGallery({ enabled: !modelsLoading && cloningModels.length === 0 })
- const installableModels = useMemo(() => galleryModels.filter(model => !model.installed).slice(0, 3), [galleryModels])
- const languages = useMemo(() => [...new Set(profiles.map(profile => profile.language).filter(Boolean))].sort(), [profiles])
- const filteredProfiles = useMemo(() => {
- const needle = search.trim().toLocaleLowerCase(i18n.language)
- return profiles.filter(profile => {
- if (language !== 'all' && profile.language !== language) return false
- if (!needle) return true
- return [profile.name, profile.description, profile.transcript, profile.language]
- .some(value => value?.toLocaleLowerCase(i18n.language).includes(needle))
- })
- }, [profiles, search, language, i18n.language])
-
- const selected = profiles.find(profile => profile.id === selectedID) || null
- const exampleModel = cloningModels[0]?.id || installableModels[0]?.name || ''
- const apiExample = selected ? `curl -X POST "$LOCALAI_URL/v1/audio/speech" \\
- -H "Authorization: Bearer $LOCALAI_API_KEY" \\
- -H "Content-Type: application/json" \\
- --data-binary @- \\
- --output speech.wav <<'JSON'
-${JSON.stringify({
- model: exampleModel,
- input: 'Text to synthesize with this saved voice.',
- voice: selected.voice,
- }, null, 2)}
-JSON` : ''
-
- // Installation progress lives in the shared operations feed. Refresh the
- // installed capability view as that operation advances so this page becomes
- // usable as soon as the new model is ready.
- useEffect(() => {
- if (installing.size === 0) return
- refetchModels()
- setInstalling(previous => {
- const next = new Map(previous)
- let changed = false
- for (const [modelID, startedAt] of previous) {
- const active = operations.some(operation => operation.name === modelID && !operation.completed && !operation.error)
- const finished = operations.some(operation => operation.name === modelID && (operation.completed || operation.error))
- if (finished || (!active && Date.now() - startedAt > 5000)) {
- next.delete(modelID)
- changed = true
- }
- }
- return changed ? next : previous
- })
- refetchGalleryModels()
- }, [operations, installing.size, refetchGalleryModels, refetchModels])
-
- const selectProfile = (id) => setSearchParams({ selected: id })
- const closeProfile = () => setSearchParams({}, { replace: true })
-
- const deleteSelected = async () => {
- if (!selected) return
- setDeleting(true)
- try {
- await voiceProfilesApi.delete(selected.id)
- setConfirmDelete(false)
- addToast(t('voiceLibrary.toasts.deleted', { name: selected.name }), 'success')
- closeProfile()
- await refetch()
- } catch (err) {
- addToast(err.message, 'error')
- } finally {
- setDeleting(false)
- }
- }
-
- const installModel = async (model) => {
- setInstalling(previous => new Map(previous).set(model.id, Date.now()))
- try {
- await modelsApi.install(model.id)
- addToast(t('voiceLibrary.toasts.installStarted', { name: model.name }), 'success')
- } catch (err) {
- setInstalling(previous => {
- const next = new Map(previous)
- next.delete(model.id)
- return next
- })
- addToast(t('voiceLibrary.toasts.installFailed', { message: err.message }), 'error')
- }
- }
-
- const copyAPIExample = async () => {
- const copied = await copyToClipboard(apiExample)
- addToast(t(copied ? 'voiceLibrary.toasts.apiCopied' : 'voiceLibrary.toasts.copyFailed'), copied ? 'success' : 'error')
- }
-
- const dateFmt = (value, dateStyle) => new Intl.DateTimeFormat(i18n.language, { dateStyle }).format(new Date(value))
-
- return (
-
-
-
-
{t('voiceLibrary.title')} {profiles.length}
-
{t('voiceLibrary.subtitle')}
-
-
- {cloningModels.length > 0
- ? {t('voiceLibrary.summary.modelsReady', { count: cloningModels.length })}
- : {t('voiceLibrary.summary.noModels')} }
-
- {t('voiceLibrary.actions.create')}
-
-
-
-
- {error && (
-
-
-
-
{t('voiceLibrary.loadFailed')}
-
{error}
-
refetch()}>{t('voiceLibrary.actions.retry')}
-
-
- )}
-
- {profiles.length > 0 && (
-
-
-
- setSearch(event.target.value)} placeholder={t('voiceLibrary.search.placeholder')} />
-
-
- setLanguage(event.target.value)}>
- {t('voiceLibrary.filters.allLanguages')}
- {languages.map(value => {value} )}
-
-
-
- )}
-
-
- {loading &&
{t('voiceLibrary.loading')}
}
- {!loading && !error && profiles.length === 0 && (
-
{t('voiceLibrary.actions.createFirst')}}
- />
- )}
- {!loading && !error && profiles.length > 0 && filteredProfiles.length === 0 && (
- { setSearch(''); setLanguage('all') }}>{t('voiceLibrary.actions.clearFilters')}}
- />
- )}
- {!loading && !error && filteredProfiles.length > 0 && (
-
- {filteredProfiles.map(profile => (
- selectProfile(profile.id)}
- >
- {profile.name.slice(0, 2).toLocaleUpperCase(i18n.language)}
-
- {profile.name}
- {profile.description || t('voiceLibrary.metadata.languageUnknown')}
-
-
-
-
- {profile.language || t('voiceLibrary.metadata.languageUnknown')}
- ·
- {formatDuration(profile.audio?.duration_ms)}
-
-
-
-
- ))}
-
- )}
-
-
- {!loading && !error && profiles.length > 0 && (
- {t('voiceCreate.privacy.body')}
- )}
-
- {!loading && !error && profiles.length === 0 && !modelsLoading && cloningModels.length === 0 && (
-
- )}
-
- {selected && (
-
- setConfirmDelete(true)}>
- {t('voiceLibrary.actions.delete')}
-
- navigate(`/app/tts?voice=${encodeURIComponent(selected.id)}`)}
- >
- {t('voiceLibrary.actions.useInTTS')}
-
- >
- )}
- >
-
- {selected.description && (
-
- )}
-
-
-
-
-
-
-
{t('voiceLibrary.detail.transcript')}
-
{selected.transcript}
-
-
-
- {t('voiceLibrary.metadata.language')} {selected.language || t('voiceLibrary.metadata.languageUnknown')}
- {t('voiceLibrary.metadata.duration')} {formatDuration(selected.audio?.duration_ms)}
- {t('voiceLibrary.metadata.sampleRate')} {Math.round((selected.audio?.sample_rate || 0) / 1000)} kHz
- {t('voiceLibrary.metadata.created')} {dateFmt(selected.created_at, 'long')}
- {t('voiceLibrary.consent.label')}
- {t('voiceLibrary.consent.confirmed')} {t('voiceLibrary.consent.confirmedAt', { date: dateFmt(selected.consent_confirmed_at, 'medium') })}
-
-
- {!modelsLoading && cloningModels.length === 0 && (
-
- )}
-
-
-
-
- {t('voiceLibrary.api.title')} {t('voiceLibrary.api.summary')}
-
-
-
{t('voiceLibrary.api.body')}
-
-
{t('voiceLibrary.api.compatibleModels')}
- {cloningModels.length > 0 ? (
-
- {cloningModels.map(model => (
- {model.id} {model.backend}
- ))}
-
- ) : (
-
{t('voiceLibrary.api.noInstalledModels')}
- )}
-
-
- {t('voiceLibrary.api.curlExample')}
-
- {t('voiceLibrary.api.copy')}
-
-
-
{apiExample}
-
{t('voiceLibrary.api.endpointNote')}
-
-
-
-
- )}
-
- !deleting && setConfirmDelete(false)}
- />
-
- )
-}
diff --git a/core/http/react-ui/src/pages/VoiceProfileCreate.jsx b/core/http/react-ui/src/pages/VoiceProfileCreate.jsx
deleted file mode 100644
index eecf9b830..000000000
--- a/core/http/react-ui/src/pages/VoiceProfileCreate.jsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-import { Link, useNavigate, useOutletContext } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import LoadingSpinner from '../components/LoadingSpinner'
-import PageHeader from '../components/PageHeader'
-import './identity.css'
-import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
-import ClipInput from '../components/identity/ClipInput'
-import WaveformPlayer from '../components/audio/WaveformPlayer'
-import { MAX_AUDIO_BYTES, normalizeAudioSample } from '../utils/referenceAudio'
-import { voiceProfilesApi } from '../utils/api'
-import Icon from '../components/Icon'
-
-function ReadinessItem({ ready, warning, children }) {
- const tone = ready ? 'ready' : warning ? 'warning' : 'pending'
- return (
-
-
- {children}
-
- )
-}
-
-export default function VoiceProfileCreate() {
- const { t } = useTranslation('media')
- const { addToast } = useOutletContext()
- const navigate = useNavigate()
- const [audio, setAudio] = useState(null)
- const [audioProcessing, setAudioProcessing] = useState(false)
- const [audioError, setAudioError] = useState('')
- const [name, setName] = useState('')
- const [description, setDescription] = useState('')
- const [language, setLanguage] = useState('')
- const [transcript, setTranscript] = useState('')
- const [additionalReferences, setAdditionalReferences] = useState([])
- const [consent, setConsent] = useState(false)
- const [submitting, setSubmitting] = useState(false)
-
- useEffect(() => () => {
- if (audio?.objectUrl) URL.revokeObjectURL(audio.dataUrl)
- }, [audio])
-
- const durationValid = audio?.duration >= 1 && audio?.duration <= 120
- const durationRecommended = audio?.duration >= 6 && audio?.duration <= 30
- const additionalReady = additionalReferences.every(reference => reference.audio && reference.transcript.trim())
- const formReady = !!audio && durationValid && name.trim() && transcript.trim() && additionalReady && consent && !audioProcessing
-
- const readiness = useMemo(() => ({
- audio: !!audio && durationValid,
- quality: !!audio && durationRecommended,
- name: !!name.trim(),
- transcript: !!transcript.trim(),
- consent,
- }), [audio, durationValid, durationRecommended, name, transcript, consent])
-
- const handleAudio = async (sample) => {
- if (!sample) {
- setAudio(null)
- setAudioError('')
- return
- }
- if (sample.blob?.size > MAX_AUDIO_BYTES) {
- setAudio(null)
- setAudioError(t('voiceCreate.audio.tooLarge'))
- return
- }
- setAudioProcessing(true)
- setAudioError('')
- try {
- const normalized = await normalizeAudioSample(sample)
- if (normalized.duration < 1 || normalized.duration > 120) {
- if (normalized.objectUrl) URL.revokeObjectURL(normalized.dataUrl)
- throw new Error(t('voiceCreate.audio.durationError'))
- }
- setAudio(normalized)
- } catch (err) {
- setAudio(null)
- setAudioError(err.message || t('voiceCreate.audio.decodeError'))
- } finally {
- setAudioProcessing(false)
- }
- }
-
- const handleAdditionalAudio = async (index, sample) => {
- if (!sample) {
- setAdditionalReferences(current => current.map((reference, itemIndex) => itemIndex === index ? { ...reference, audio: null } : reference))
- return
- }
- setAudioProcessing(true)
- try {
- const normalized = await normalizeAudioSample(sample)
- if (normalized.duration < 1 || normalized.duration > 120) throw new Error(t('voiceCreate.audio.durationError'))
- setAdditionalReferences(current => current.map((reference, itemIndex) => itemIndex === index ? { ...reference, audio: normalized } : reference))
- } catch (err) {
- addToast(err.message || t('voiceCreate.audio.decodeError'), 'error')
- } finally {
- setAudioProcessing(false)
- }
- }
-
- const submit = async (event) => {
- event.preventDefault()
- if (!formReady) return
- setSubmitting(true)
- try {
- const formData = new FormData()
- formData.append('name', name.trim())
- formData.append('description', description.trim())
- formData.append('language', language.trim())
- formData.append('transcript', transcript.trim())
- formData.append('consent_confirmed', 'true')
- formData.append('audio', audio.blob, 'reference.wav')
- additionalReferences.forEach((reference, index) => {
- formData.append('transcript', reference.transcript.trim())
- formData.append('audio', reference.audio.blob, `reference-${index + 2}.wav`)
- })
- const profile = await voiceProfilesApi.create(formData)
- addToast(t('voiceCreate.toasts.created', { name: profile.name }), 'success')
- navigate(`/app/voice-library?selected=${encodeURIComponent(profile.id)}`)
- } catch (err) {
- addToast(err.message, 'error')
- } finally {
- setSubmitting(false)
- }
- }
-
- return (
-
-
- {t('voiceCreate.title')}>}
- supporting={t('voiceCreate.subtitle')}
- actions={ {t('voiceCreate.actions.back')}}
- />
-
-
-
-
-
-
01
-
{t('voiceCreate.sections.reference.title')} {t('voiceCreate.sections.reference.body')}
-
-
- {audioProcessing && {t('voiceCreate.audio.normalizing')}
}
- {audioError && {audioError}
}
- {audio && (
-
-
-
- {audio.duration.toFixed(1)}s
- {Math.round(audio.sampleRate / 1000)} kHz · PCM WAV
-
- {durationRecommended ? t('voiceCreate.audio.qualityReady') : t('voiceCreate.audio.qualityHint')}
-
-
-
- )}
- {additionalReferences.map((reference, index) => (
-
-
- {t('voiceCreate.references.additional', { number: index + 2 })}
- {
- if (reference.audio?.objectUrl) URL.revokeObjectURL(reference.audio.dataUrl)
- setAdditionalReferences(current => current.filter((_, itemIndex) => itemIndex !== index))
- }}>{t('voiceCreate.references.remove')}
-
-
handleAdditionalAudio(index, sample)} idPrefix={`voice-profile-${index + 2}`} />
- {t('voiceCreate.fields.transcript')}
- setAdditionalReferences(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, transcript: event.target.value } : item))} required />
-
- ))}
- {additionalReferences.length < 9 && setAdditionalReferences(current => [...current, { audio: null, transcript: '' }])}> {t('voiceCreate.references.add')} }
-
-
-
-
-
02
-
{t('voiceCreate.sections.details.title')} {t('voiceCreate.sections.details.body')}
-
-
-
- {t('voiceCreate.fields.description')}
- setDescription(event.target.value)} placeholder={t('voiceCreate.fields.descriptionPlaceholder')} />
-
-
-
- {t('voiceCreate.fields.transcript')}
- {transcript.length}/4000
-
-
setTranscript(event.target.value)} placeholder={t('voiceCreate.fields.transcriptPlaceholder')} required />
- {t('voiceCreate.fields.transcriptHint')}
-
-
-
-
-
-
03
-
{t('voiceCreate.sections.consent.title')} {t('voiceCreate.sections.consent.body')}
-
-
- setConsent(event.target.checked)} />
- {t('voiceCreate.consent.title')} {t('voiceCreate.consent.body')}
-
-
-
-
- {t('voiceCreate.actions.cancel')}
-
- {submitting ? <> {t('voiceCreate.actions.saving')}> : <> {t('voiceCreate.actions.save')}>}
-
-
-
-
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/pages/VoiceRecognition.jsx b/core/http/react-ui/src/pages/VoiceRecognition.jsx
deleted file mode 100644
index e988a13fd..000000000
--- a/core/http/react-ui/src/pages/VoiceRecognition.jsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useMemo, useState } from 'react'
-// eslint-disable-next-line no-unused-vars
-import { Link, useOutletContext, useParams, useSearchParams } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-// eslint-disable-next-line no-unused-vars
-import ModelSelector from '../components/ModelSelector'
-// eslint-disable-next-line no-unused-vars
-import PageHeader from '../components/PageHeader'
-import Icon from '../components/Icon'
-// eslint-disable-next-line no-unused-vars
-import MoreVoiceTools from '../components/identity/MoreVoiceTools'
-// eslint-disable-next-line no-unused-vars
-import ModelNeeded from '../components/identity/ModelNeeded'
-// eslint-disable-next-line no-unused-vars
-import Workbench from '../components/identity/Workbench'
-import { useAuth } from '../context/AuthContext'
-import { useModels } from '../hooks/useModels'
-import { useVoiceProfiles } from '../hooks/useVoiceProfiles'
-import { CAP_SPEAKER_RECOGNITION } from '../utils/capabilities'
-import { loadList } from '../utils/identity'
-// eslint-disable-next-line no-unused-vars
-import SpeechVoices from './VoiceLibrary'
-import './identity.css'
-
-// The Voices page. Two unrelated things carry the word voice and are kept
-// apart: Speakers (voiceprints used to recognise who is speaking) and Speech
-// voices (reference recordings text-to-speech speaks in). The tab is the route:
-// /app/voice is Speakers, /app/voice?tab=recording the diarization link, and
-// /app/voice-library Speech voices.
-export default function VoiceRecognition({ tab: routeTab }) {
- const { t } = useTranslation('biometrics')
- const { model: urlModel } = useParams()
- const { addToast } = useOutletContext()
- const { isAdmin, hasFeature } = useAuth()
- const [params] = useSearchParams()
- const tab = routeTab || (params.get('tab') === 'recording' ? 'recording' : 'speakers')
- const [model, setModel] = useState(urlModel || '')
- const { models, loading: modelsLoading, refetch } = useModels(CAP_SPEAKER_RECOGNITION)
- const names = useMemo(() => models.map(m => m.id), [models])
- const library = useVoiceProfiles({ enabled: isAdmin })
- const [savedCount, setSavedCount] = useState(() => loadList('localai_voice_enrollments').length)
- const needModel = !modelsLoading && names.length === 0
-
- return (
-
-
-
-
- ) : null}
- />
-
-
- {t('voices.speakers')} {savedCount}
- {isAdmin && (
-
- {t('voices.speech')} {library.loading ? '' : library.profiles.length}
-
- )}
- {t('voices.recording')}
-
-
- {tab === 'speakers' && (
- : null}
- more={needModel ? null : (
-
- {t('voice.moreTitle')} {t('voice.moreHint')}
-
-
- )}
- />
- )}
-
- {tab === 'speech' && }
-
- {tab === 'recording' && (
-
- {t('recording.title')}
- {t('recording.text')}
-
- {t('recording.step1')}
- {t('recording.step2')}
- {t('recording.step3')}
-
- {hasFeature('audio_diarization')
- ? {t('recording.open')}
- : {t('recording.off')}
}
- {t('recording.warning')}
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/pages/identity.css b/core/http/react-ui/src/pages/identity.css
deleted file mode 100644
index 210160c32..000000000
--- a/core/http/react-ui/src/pages/identity.css
+++ /dev/null
@@ -1,296 +0,0 @@
-/* ==========================================================================
- Voices and Faces. Built on the kit's table, sheet, hub tab, range, field,
- chip, badge and toast classes. What is here is what the kit does not say:
- the distance scale, the verdict, the clip card, the registry's person cell
- and the speech voice rows. Colours, radii and spacing are kit roles (--dk-*).
- Surfaces are flat and nothing sits on a coloured rail at the edge. Motion
- is switched off for reduced motion at the end of this file.
- ========================================================================== */
-
-.idn-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
-.idn-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
-.idn-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
-.idn-page > .page-header { margin-bottom: 0; }
-.idn-modelpick { min-width: 240px; max-width: 100%; }
-.idn-model { display: inline-flex; align-items: center; gap: var(--dk-space-2); width: 100%; height: 36px; padding: 0 var(--dk-space-3); border: 1px solid var(--dk-control-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); color: var(--dk-text); font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-mono); cursor: pointer; }
-.idn-model:hover:not(:disabled) { background: var(--dk-hover); }
-.idn-model:disabled { opacity: .5; cursor: not-allowed; }
-.idn-model .searchable-select__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
-.idn-model .searchable-select__chevron { color: var(--dk-muted); font-size: 10px; }
-.idn-tabs { align-self: stretch; max-width: 100%; }
-.idn-tabs .dk-tab { text-decoration: none; display: inline-flex; align-items: center; gap: var(--dk-space-2); }
-.idn-tabs .dk-tab[aria-selected="true"]::after { transform: scaleX(1); }
-
-/* ---- The tool card -------------------------------------------------------- */
-.idn-bench { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
-.idn-tool { padding: var(--dk-space-4) var(--dk-space-5) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
-.idn-tool__bar { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
-.idn-tool__lede { margin: 0; }
-.idn-tool__body { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
-.idn-tool__go { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
-.idn-tool__option { align-items: flex-start; }
-.idn-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
-.idn-working { display: flex; flex-direction: column; gap: var(--dk-space-2); }
-.idn-working .dk-hint { margin: 0; }
-
-/* ---- Clip input ----------------------------------------------------------- */
-.idn-clip { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); min-width: 0; }
-.idn-clip__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); min-height: 28px; }
-.idn-clip__choices { display: flex; flex-direction: column; gap: var(--dk-space-3); align-items: flex-start; }
-.idn-clip__buttons { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
-.idn-clip__prompt { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); font-size: var(--dk-text-15); line-height: 1.5; }
-.idn-clip__prompt .dk-eyebrow { display: block; margin-bottom: 4px; }
-.idn-clip__hint, .idn-clip__notice { display: flex; align-items: flex-start; gap: var(--dk-space-2); margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
-.idn-clip__hint .lai-icon, .idn-clip__notice .lai-icon { flex: none; margin-top: 2px; }
-.idn-clip__notice { padding: var(--dk-space-2) var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); color: var(--dk-text); }
-.idn-clip__notice[role="alert"] { background: var(--dk-warn-wash); border-color: transparent; color: var(--dk-warn-text); }
-.idn-clip__live { display: flex; flex-direction: column; gap: var(--dk-space-3); }
-.idn-clip__video { width: 100%; max-width: 420px; border-radius: var(--dk-radius-2); background: var(--dk-canvas); }
-.idn-clip__rec { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-15); }
-.idn-clip__card { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
-.idn-clip__meta { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; min-width: 0; }
-.idn-clip__name { min-width: 0; overflow-wrap: anywhere; font-size: var(--dk-text-15); }
-.idn-clip__img { max-width: 100%; max-height: 260px; width: auto; border-radius: var(--dk-radius-2); object-fit: contain; align-self: flex-start; background: var(--dk-canvas); }
-.idn-clip__notes { margin: 0; padding-left: 18px; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
-.idn-clip .voice-profile-player, .idn-clip .waveform-player { min-width: 0; }
-
-/* ---- Result: verdict, scale, ranks, cut-off ------------------------------- */
-.idn-result { display: flex; flex-direction: column; gap: var(--dk-space-4); padding-top: var(--dk-space-4); border-top: 1px solid var(--dk-edge); min-width: 0; }
-.idn-verdict { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: var(--dk-space-3); align-items: start; }
-.idn-verdict__icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--dk-inset); color: var(--dk-muted); font-size: 20px; }
-.idn-verdict[data-tone="match"] .idn-verdict__icon { background: var(--dk-ok-wash); color: var(--dk-ok-text); }
-.idn-verdict__title { margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-2); font: var(--dk-weight-bold) var(--dk-text-21) / 1.25 var(--dk-font-display); overflow-wrap: anywhere; }
-.idn-verdict__title .dk-badge { font-family: var(--dk-font-body); }
-.idn-verdict__text { margin: 4px 0 0; max-width: 70ch; font-size: var(--dk-text-15); line-height: 1.5; }
-
-.idn-scale { margin: 0; padding: 0 36px; }
-.idn-scale__plot { position: relative; height: calc(108px + var(--rows, 0) * 18px); }
-.idn-scale__track { position: absolute; left: 0; right: 0; top: 38px; height: 8px; border-radius: var(--dk-radius-pill); background: var(--dk-inset); overflow: hidden; border: 1px solid var(--dk-edge); box-sizing: border-box; }
-.idn-scale__inside { position: absolute; left: 0; top: 0; bottom: 0; width: var(--at); background: var(--dk-accent-wash); }
-.idn-scale__cut { position: absolute; left: var(--at); top: 22px; height: 32px; width: 2px; margin-left: -1px; background: var(--dk-text); z-index: 1; }
-.idn-scale__cut-label { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); white-space: nowrap; padding-bottom: 2px; font-size: 11px; font-weight: 600; }
-.idn-scale__dot { position: absolute; left: var(--at); top: 0; width: 0; height: 0; z-index: 2; }
-.idn-scale__mark { position: absolute; left: -8px; top: 34px; width: 16px; height: 16px; box-sizing: border-box; border-radius: 50%; background: var(--dk-card); border: 2px solid var(--dk-control-edge); }
-.idn-scale__dot[data-within="true"] .idn-scale__mark { border-color: var(--dk-accent-text); }
-.idn-scale__dot[data-best="true"] .idn-scale__mark { background: var(--dk-accent); border-color: var(--dk-accent); box-shadow: 0 0 0 3px var(--dk-card); }
-.idn-scale__name { position: absolute; left: 0; transform: translateX(-50%); top: 58px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
-.idn-scale__dot[data-row="1"] .idn-scale__name { top: 76px; }
-.idn-scale__dot[data-row="2"] .idn-scale__name { top: 94px; }
-.idn-scale__ticks { position: absolute; left: 0; right: 0; top: calc(86px + var(--rows, 0) * 18px); height: 16px; }
-.idn-scale__tick { position: absolute; left: var(--at); transform: translateX(-50%); font-size: 11px; color: var(--dk-muted); }
-.idn-scale__tick::before { content: ""; position: absolute; left: 50%; bottom: 100%; height: 5px; width: 1px; background: var(--dk-control-edge); margin-bottom: 1px; }
-.idn-scale__caption { display: flex; justify-content: space-between; gap: var(--dk-space-3); margin: var(--dk-space-2) -36px 0; padding: 0 4px; font-size: 12px; color: var(--dk-muted); }
-
-.idn-ranks { margin: 0; padding: 0; list-style: none; }
-.idn-ranks li { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto auto; align-items: center; gap: var(--dk-space-3); min-height: 40px; border-bottom: 1px solid var(--dk-edge); font-size: var(--dk-text-15); }
-.idn-ranks li:last-child { border-bottom: 0; }
-.idn-ranks li[data-within="true"] .idn-ranks__name { font-weight: var(--dk-weight-semibold); }
-.idn-ranks__n { color: var(--dk-muted); font-size: 12px; }
-.idn-ranks__name { min-width: 0; overflow-wrap: anywhere; }
-
-.idn-result__tune { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: var(--dk-space-5); align-items: start; }
-.idn-cutoff { display: flex; flex-direction: column; gap: var(--dk-space-2); min-width: 0; }
-.idn-cutoff__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); flex-wrap: wrap; }
-.idn-cutoff__row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); }
-.idn-cutoff .dk-hint, .idn-how { margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.5; }
-.idn-how strong { color: var(--dk-text); }
-.idn-result__enrol { align-self: flex-start; }
-.idn-face { margin: 0; display: flex; flex-direction: column; gap: var(--dk-space-2); }
-
-/* ---- Disclosures ---------------------------------------------------------- */
-.idn-disclosure > summary { display: flex; align-items: center; gap: var(--dk-space-2); min-height: 40px; cursor: pointer; font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); list-style: none; }
-.idn-disclosure > summary::-webkit-details-marker { display: none; }
-.idn-disclosure > summary .lai-icon { color: var(--dk-muted); transition: transform var(--dk-dur-1) var(--dk-ease-out); }
-.idn-disclosure[open] > summary .lai-icon:first-child { transform: rotate(90deg); }
-.idn-disclosure > summary .dk-hint { font-weight: 400; }
-.idn-disclosure__list { margin: 0 0 var(--dk-space-3); padding-left: 38px; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.6; }
-.idn-disclosure__text { margin: 0 0 var(--dk-space-3); padding-left: 24px; font-size: var(--dk-text-13); color: var(--dk-muted); max-width: 70ch; }
-.idn-disclosure--page { border-top: 1px solid var(--dk-edge); }
-.idn-stored { margin: 0 0 var(--dk-space-3); padding-left: 24px; display: grid; gap: var(--dk-space-3); }
-.idn-stored div { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: var(--dk-space-3); }
-.idn-stored dt { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-13); }
-.idn-stored dd { margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.55; max-width: 70ch; }
-.idn-more__body { padding: 0 0 var(--dk-space-4) 24px; }
-
-/* ---- Known people --------------------------------------------------------- */
-.idn-known { display: flex; flex-direction: column; gap: var(--dk-space-2); min-width: 0; }
-.idn-known__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
-.idn-known__note { margin: 0; }
-.idn-known .dk-table-wrap { border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); }
-.idn-known__empty { padding: var(--dk-space-6) var(--dk-space-4); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); }
-.idn-who { display: flex; align-items: center; gap: var(--dk-space-3); min-width: 0; }
-.idn-who__text { display: flex; flex-direction: column; min-width: 0; }
-.idn-who .dk-table-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-2); }
-.idn-avatar { display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; background: var(--dk-inset); border: 1px solid var(--dk-edge); font: var(--dk-weight-bold) 12px var(--dk-font-mono); color: var(--dk-text); }
-.idn-avatar img { width: 100%; height: 100%; object-fit: cover; }
-.idn-avatar[data-tone="warn"] { background: var(--dk-warn-wash); color: var(--dk-warn-text); border-color: transparent; }
-.idn-when { white-space: nowrap; font-size: 12px; color: var(--dk-muted); }
-.idn-actions { text-align: right; white-space: nowrap; }
-.idn-actions > * { vertical-align: middle; }
-.idn-known tr[data-pending="true"] .idn-who { opacity: .55; }
-
-/* ---- Enrol sheet ---------------------------------------------------------- */
-.idn-sheet { display: flex; flex-direction: column; gap: var(--dk-space-5); }
-.idn-sheet__step { display: flex; flex-direction: column; gap: var(--dk-space-3); }
-.idn-sheet__h { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
-.idn-sheet__n { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--dk-accent-text); color: var(--dk-accent-text); font: 600 12px var(--dk-font-mono); }
-.idn-sheet__choice { align-items: flex-start; gap: var(--dk-space-3); }
-.idn-sheet__choice > span { display: flex; flex-direction: column; gap: 2px; }
-.idn-sheet__choice .dk-hint { margin: 0; }
-.idn-sheet__need { margin-right: auto; font-size: var(--dk-text-13); color: var(--dk-muted); }
-
-/* ---- Errors, disabled state, recording tab -------------------------------- */
-.idn-error { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-error-wash); color: var(--dk-error-text); }
-.idn-error__icon { font-size: 22px; padding-top: 2px; }
-.idn-error__title { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
-.idn-error__text, .idn-error__raw { margin: 4px 0 0; font-size: var(--dk-text-13); }
-.idn-error__raw { opacity: .85; overflow-wrap: anywhere; }
-.idn-error__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; margin-top: var(--dk-space-3); }
-.idn-error .dk-btn { color: inherit; }
-.idn-needed { padding: var(--dk-space-5); display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); }
-.idn-needed__text { margin: 0; max-width: 62ch; font-size: var(--dk-text-15); line-height: 1.5; }
-.idn-needed__list { margin: 0; padding: 0; list-style: none; width: min(100%, 560px); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); }
-.idn-needed__list li { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-bottom: 1px solid var(--dk-edge); }
-.idn-needed__list li:last-child { border-bottom: 0; }
-.idn-needed__list li > span { display: flex; flex-direction: column; min-width: 0; }
-.idn-needed__list .dk-hint { margin: 0; }
-.idn-off { min-height: 40vh; }
-.idn-recording { padding: var(--dk-space-5); display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); }
-.idn-recording__text { margin: 0; max-width: 62ch; line-height: 1.5; }
-.idn-recording__steps { margin: 0; padding-left: 20px; display: grid; gap: 4px; color: var(--dk-muted); font-size: var(--dk-text-15); }
-.idn-recording .dk-hint { margin: 0; max-width: 62ch; }
-
-/* ---- Speech voices -------------------------------------------------------- */
-.voice-library-page { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
-.idn-speech__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; }
-.idn-speech__lede { margin: 4px 0 0; max-width: 56ch; }
-.idn-speech__acts { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
-.voice-library-toolbar { display: flex; gap: var(--dk-space-3); flex-wrap: wrap; }
-.voice-library-search { flex: 1 1 260px; min-width: 0; }
-.voice-library-language { min-width: 160px; }
-.voice-library-loading { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-5); color: var(--dk-muted); }
-.idn-speech__list { border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
-.idn-speech__list .dk-row { text-align: left; }
-.voice-row__end { display: flex; align-items: center; gap: var(--dk-space-3); }
-.voice-row__meta { font-size: 12px; color: var(--dk-muted); white-space: nowrap; }
-.voice-row__waveform { display: inline-flex; align-items: center; gap: 2px; width: 120px; height: 22px; }
-.voice-row__waveform > span { flex: 1; min-width: 2px; border-radius: 2px; background: var(--dk-control-edge); }
-.voice-row__chevron { color: var(--dk-muted); }
-.voice-library-empty { min-height: 0; }
-.voice-library-detail { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
-.voice-detail__player { min-width: 0; }
-.voice-detail__section { display: flex; flex-direction: column; gap: var(--dk-space-2); }
-.voice-detail__section h3 { margin: 0; }
-.voice-detail__section blockquote { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); border: 1px solid var(--dk-edge); font-size: var(--dk-text-15); line-height: 1.55; overflow-wrap: anywhere; }
-.voice-detail__metadata { margin: 0; }
-.voice-detail__model-setup { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); }
-.voice-detail__model-setup-heading { display: flex; gap: var(--dk-space-3); align-items: flex-start; }
-.voice-detail__model-setup-heading h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
-.voice-detail__model-setup-heading p, .voice-detail__model-fallback, .voice-detail__capability-note { margin: 4px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
-.voice-detail__model-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--dk-space-2); }
-.voice-detail__model-list li { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); }
-.voice-detail__model-list li > span { display: flex; flex-direction: column; min-width: 0; }
-.voice-detail__model-list small { color: var(--dk-muted); }
-.voice-detail__model-loading { display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); color: var(--dk-muted); }
-.voice-detail__model-fallback a { color: var(--dk-accent-text); }
-.voice-detail__api { border-top: 1px solid var(--dk-edge); }
-.voice-detail__api-body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: 0 0 var(--dk-space-3) 24px; font-size: var(--dk-text-13); color: var(--dk-muted); }
-.voice-detail__api-body > p { margin: 0; }
-.voice-detail__compatible-models { display: flex; flex-direction: column; gap: var(--dk-space-2); }
-.voice-detail__compatible-models ul { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); margin: 0; padding: 0; list-style: none; }
-.voice-detail__compatible-models li { display: inline-flex; align-items: center; gap: var(--dk-space-2); padding: 2px 10px; border-radius: var(--dk-radius-pill); background: var(--dk-ok-wash); color: var(--dk-ok-text); }
-.voice-detail__code-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
-.voice-detail__api pre { margin: 0; padding: var(--dk-space-3); overflow-x: auto; border-radius: var(--dk-radius-2); background: var(--dk-inset); border: 1px solid var(--dk-edge); color: var(--dk-text); font-size: 12px; line-height: 1.55; }
-.voice-delete { margin-right: auto; }
-
-/* ---- Phone ---------------------------------------------------------------- */
-@media (max-width: 720px) {
- .idn-tool { padding: var(--dk-space-3); }
- .idn-pair { grid-template-columns: minmax(0, 1fr); }
- .idn-result__tune { grid-template-columns: minmax(0, 1fr); gap: var(--dk-space-4); }
- .idn-scale { padding: 0 26px; }
- .idn-scale__caption { margin: var(--dk-space-2) -26px 0; flex-direction: column; gap: 2px; }
- .idn-stored div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
- .idn-verdict { grid-template-columns: 36px minmax(0, 1fr); }
- .idn-verdict__icon { width: 36px; height: 36px; font-size: 17px; }
- .idn-verdict__title { font-size: var(--dk-text-17); }
- .idn-modelpick { min-width: 0; width: 100%; }
- .voice-row__waveform { display: none; }
- .idn-ranks li { grid-template-columns: 20px minmax(0, 1fr) auto auto; gap: var(--dk-space-2); font-size: var(--dk-text-13); }
- .idn-known .dk-table td { padding-inline: var(--dk-space-2); }
- .idn-actions { white-space: normal; }
- .idn-actions .dk-btn--secondary { margin-bottom: 4px; }
- .idn-more__body { padding-left: 0; }
- .idn-more > summary .dk-hint { display: none; }
- .idn-needed__list li { flex-direction: column; align-items: flex-start; }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- .idn-clip__rec .dk-dot[data-live="true"] { animation: idn-pulse 1.1s ease-in-out infinite; }
-}
-@keyframes idn-pulse { 50% { opacity: .35; } }
-@media (prefers-reduced-motion: reduce) {
- .idn-disclosure > summary .lai-icon { transition: none; }
- /* The kit's indeterminate bar keeps sliding; here it sits still at its width. */
- .idn-working .dk-progress[data-indeterminate] .dk-progress-bar { animation: none; }
-}
-
-.idn-who .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; max-width: 62ch; }
-
-/* ---- Add a speech voice (the create page) --------------------------------- */
-.voice-create-page { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--dk-space-5); }
-.voice-create-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: var(--dk-space-5); align-items: start; }
-.voice-create-form { display: flex; flex-direction: column; background: var(--dk-card); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-4); min-width: 0; }
-.voice-create-section { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-5); border-bottom: 1px solid var(--dk-edge); min-width: 0; }
-.voice-create-section__heading { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: var(--dk-space-3); }
-.voice-create-section__heading > span { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--dk-accent-text); color: var(--dk-accent-text); font: 600 11px var(--dk-font-mono); }
-.voice-create-section__heading h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
-.voice-create-section__heading p { margin: 2px 0 0; max-width: 62ch; color: var(--dk-muted); font-size: var(--dk-text-13); line-height: 1.5; }
-.voice-create-processing { display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); }
-.voice-create-preview { display: flex; flex-direction: column; gap: var(--dk-space-2); padding: var(--dk-space-3); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); }
-.voice-create-preview__meta { display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); font-size: var(--dk-text-12); color: var(--dk-muted); }
-.voice-create-preview__meta span { display: inline-flex; align-items: center; gap: 5px; }
-.voice-create-preview__meta .tone-success { color: var(--dk-ok-text); }
-.voice-create-preview__meta .tone-warning { color: var(--dk-warn-text); }
-.voice-create-label-row { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); }
-.voice-create-label-row > span { color: var(--dk-muted); font: 11px var(--dk-font-mono); }
-.voice-consent-check { align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); }
-.voice-consent-check > span { display: flex; flex-direction: column; gap: 2px; }
-.voice-consent-check .dk-hint { margin: 0; }
-.voice-create-actions { display: flex; justify-content: flex-end; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
-.voice-create-form .form-grid-2col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
-.voice-readiness { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4); background: var(--dk-card); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-4); position: sticky; top: var(--dk-space-4); }
-.voice-readiness__header { display: flex; gap: var(--dk-space-3); align-items: flex-start; padding-bottom: var(--dk-space-3); border-bottom: 1px solid var(--dk-edge); }
-.voice-readiness__icon { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: var(--dk-radius-2); background: var(--dk-accent-wash); color: var(--dk-accent-text); }
-.voice-readiness__header h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
-.voice-readiness__header p { margin: 2px 0 0; font-size: var(--dk-text-12); color: var(--dk-muted); line-height: 1.5; }
-.voice-readiness ul { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--dk-space-2); }
-.voice-readiness__item { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); color: var(--dk-muted); }
-.voice-readiness__item .lai-icon { box-sizing: content-box; width: 1em; height: 1em; padding: 4px; border-radius: 50%; background: var(--dk-inset); font-size: 11px; }
-.voice-readiness__item--ready { color: var(--dk-text); }
-.voice-readiness__item--ready .lai-icon { background: var(--dk-ok-wash); color: var(--dk-ok-text); }
-.voice-readiness__item--warning .lai-icon { background: var(--dk-warn-wash); color: var(--dk-warn-text); }
-.voice-readiness__privacy { display: flex; gap: var(--dk-space-2); padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); color: var(--dk-muted); font-size: var(--dk-text-12); line-height: 1.5; }
-.voice-readiness__privacy p { margin: 0; }
-.voice-readiness__privacy strong { color: var(--dk-text); }
-@media (max-width: 900px) {
- .voice-create-grid { grid-template-columns: minmax(0, 1fr); }
- .voice-readiness { position: static; order: -1; }
-}
-@media (max-width: 720px) {
- .voice-create-section { padding: var(--dk-space-4); }
- .voice-create-form .form-grid-2col { grid-template-columns: minmax(0, 1fr); }
- .voice-create-actions { padding: var(--dk-space-3) var(--dk-space-4); }
-}
-
-/* Faces found (same person) stay a sensible size. */
-.idn-pair--boxes .idn-face { max-width: 340px; }
-.voice-library-page .empty-state { align-items: center; text-align: center; margin: 0 auto; min-height: 0; padding: var(--dk-space-6) var(--dk-space-4); }
-.idn-sheet__need { flex: 1 1 160px; min-width: 0; margin-right: 0; }
-.idn-privacy { display: flex; gap: var(--dk-space-2); margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); max-width: 70ch; }
-.voice-create-page > .page-header { margin-bottom: 0; }
-.voice-library-language { height: 40px; font-size: var(--dk-text-15); }
-
-.idn-again { align-self: flex-start; margin-top: var(--dk-space-2); }
-.idn-speech__list .dk-row-title, .idn-speech__list .dk-row-meta { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
-@media (max-width: 720px) { .voice-row__end { gap: var(--dk-space-2); } }
diff --git a/core/http/react-ui/src/router.jsx b/core/http/react-ui/src/router.jsx
index 29f892e28..3bf785d4d 100644
--- a/core/http/react-ui/src/router.jsx
+++ b/core/http/react-ui/src/router.jsx
@@ -1,15 +1,11 @@
-import { lazy, useEffect } from 'react'
+import { lazy } from 'react'
import { createBrowserRouter, Navigate, Outlet, useLocation, useOutletContext, useParams } from 'react-router-dom'
import { routerBasename } from './utils/basePath'
-import { useAddonRegistry } from './addons/useAddonRegistry'
-import { appStatus } from './addons/registryState'
import App from './App'
import RequireAdmin from './components/RequireAdmin'
import RequireAuth from './components/RequireAuth'
import RequireAuthEnabled from './components/RequireAuthEnabled'
import RequireFeature from './components/RequireFeature'
-import FeatureOff from './components/identity/FeatureOff'
-import ToolOff from './components/tools/ToolOff'
// Pages are code-split: each becomes its own chunk loaded on demand, so a route
// no longer drags every other page (and its heavy deps — CodeMirror, the MCP
@@ -118,11 +114,6 @@ const ImportModel = page(null, () => import('./pages/ImportModel'))
const BackendLogs = page(null, () => import('./pages/BackendLogs'))
const Explorer = page(null, () => import('./pages/Explorer'))
const Login = page(null, () => import('./pages/Login'))
-const FineTune = page('fine-tune', () => import('./pages/FineTune'))
-const Quantize = page('quantize', () => import('./pages/Quantize'))
-const FaceRecognition = page('face', () => import('./pages/FaceRecognition'))
-const VoiceRecognition = page('voice', () => import('./pages/VoiceRecognition'))
-const VoiceProfileCreate = page(null, () => import('./pages/VoiceProfileCreate'))
const Nodes = page('nodes', () => import('./pages/Nodes'))
const Scheduling = page('scheduling', () => import('./pages/Scheduling'))
const NodeBackendLogs = page(null, () => import('./pages/NodeBackendLogs'))
@@ -161,7 +152,7 @@ import HubLayout from './components/hub/HubLayout'
import { AddonRegistryProvider } from './addons/registry'
import { useAuth } from './context/AuthContext'
import { NavigationProvider, useNavigation } from './context/NavigationContext'
-import { studioAddress, studioRedirect, talkAddress } from './utils/legacyRoutes'
+import { addonPageAddress, studioAddress, studioRedirect, talkAddress } from './utils/legacyRoutes'
import { buildHub, operateHub } from './components/hub/hubConfig'
import { useHubAuth } from './components/hub/useHubAuth'
@@ -201,25 +192,16 @@ function BrowseRedirect() {
}
-// A moved biometric page opens in its add-on while the add-on is usable and in
-// the core page otherwise, so a user who has not installed the add-on loses
-// nothing. withModel carries the :model segment of the old route to the
-// add-on as ?model=: an add-on path segment refuses "/", ":" and "%" and
-// long ids (resolveSubpath), so a model id cannot travel in the path.
-function MovedPage({ addon, page, withModel, children }) {
- const { apps, acquire, allowed } = useAddonRegistry()
+// The pages of Face, Voice, Fine-tuning and Quantization live only in their
+// add-ons. The old address opens the add-on page; when the add-on is not
+// installed or not ready, the add-on shell says so. withModel carries the
+// :model segment of the old route as ?model=: an add-on path segment
+// refuses "/", ":" and "%" and long ids (resolveSubpath), so a model id cannot
+// travel in the path.
+function AddonRedirect({ addon, page, withModel }) {
const { search, hash } = useLocation()
const { model } = useParams()
- // The list is read only for a user the server lets see add-ons.
- useEffect(() => (allowed ? acquire() : undefined), [acquire, allowed])
- const app = apps.find(a => a.id === addon)
- if (appStatus(app) === 'ready' && app.ui.pages.includes(page.split('/')[0])) {
- const params = new URLSearchParams(search)
- if (withModel && model) params.set('model', model)
- const query = params.toString()
- return
- }
- return children
+ return
}
function Admin({ children }) {
@@ -302,13 +284,13 @@ const appChildren = [
{ path: 'collections/:name', element: },
{ path: 'agent-jobs', element: },
{ path: 'agent-jobs/tasks/:id', element: },
- { path: 'fine-tune', element: }> },
- { path: 'quantize', element: }> },
- { path: 'face', element: }> },
- { path: 'face/:model', element: }> },
- { path: 'voice', element: }> },
- { path: 'voice/:model', element: }> },
- { path: 'voice-library', element: },
+ { path: 'fine-tune', element: },
+ { path: 'quantize', element: },
+ { path: 'face', element: },
+ { path: 'face/:model', element: },
+ { path: 'voice', element: },
+ { path: 'voice/:model', element: },
+ { path: 'voice-library', element: },
{ path: 'import-model', element: },
],
},
@@ -363,7 +345,7 @@ const appChildren = [
children: [{ path: ':id', element: }],
},
{ path: 'manage', element: },
- { path: 'voice-library/new', element: },
+ { path: 'voice-library/new', element: },
{ path: 'model-editor', element: },
{ path: 'model-editor/:name', element: },
{ path: '*', element: },
diff --git a/core/http/react-ui/src/utils/legacyRoutes.js b/core/http/react-ui/src/utils/legacyRoutes.js
index 7080d8b3f..fd9d86793 100644
--- a/core/http/react-ui/src/utils/legacyRoutes.js
+++ b/core/http/react-ui/src/utils/legacyRoutes.js
@@ -25,6 +25,12 @@ function addonAddress(base, { tab, model, search = '', hash = '' } = {}) {
return `${base}${tab ? `/${encodeURIComponent(tab)}` : ''}${query ? `?${query}` : ''}${hash}`
}
+// Where an old address of a page that moved to an add-on opens now: the page
+// path is kept as is (it may hold "/"), the model travels as ?model=.
+export function addonPageAddress({ addon, page, model, search = '', hash = '' }) {
+ return addonAddress(`/app/addons/${addon}/${page}`, { model, search, hash })
+}
+
// Where an old /app/studio[/tab[/model]] link opens now. The model, the query
// string and the hash are kept.
export function studioAddress(args) {
diff --git a/core/http/react-ui/src/utils/referenceAudio.js b/core/http/react-ui/src/utils/referenceAudio.js
deleted file mode 100644
index 28c1a174a..000000000
--- a/core/http/react-ui/src/utils/referenceAudio.js
+++ /dev/null
@@ -1,39 +0,0 @@
-// A reference recording for a speech voice: decoded and written back as 24 kHz
-// 16-bit mono WAV, the form the voice library stores.
-import { audioBufferToWavBlob } from '../hooks/useMediaCapture'
-
-export const MAX_AUDIO_BYTES = 50 * 1024 * 1024
-const REFERENCE_SAMPLE_RATE = 24000
-
-function base64ToArrayBuffer(value) {
- const binary = window.atob(value)
- const bytes = new Uint8Array(binary.length)
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
- return bytes.buffer
-}
-
-export async function normalizeAudioSample(sample) {
- const source = sample.blob?.arrayBuffer
- ? await sample.blob.arrayBuffer()
- : base64ToArrayBuffer(sample.base64)
- const AudioCtx = window.AudioContext || window.webkitAudioContext
- if (!AudioCtx) throw new Error('Web Audio API is not available in this browser')
- const context = new AudioCtx()
- try {
- const decoded = await context.decodeAudioData(source.slice(0))
- const blob = audioBufferToWavBlob(decoded, REFERENCE_SAMPLE_RATE)
- if (blob.size > MAX_AUDIO_BYTES) throw new Error('Normalized audio is larger than 50 MiB')
- return {
- ...sample,
- blob,
- dataUrl: URL.createObjectURL(blob),
- objectUrl: true,
- mime: 'audio/wav',
- duration: decoded.duration,
- sampleRate: REFERENCE_SAMPLE_RATE,
- name: sample.name || 'recording.wav',
- }
- } finally {
- await context.close().catch(() => {})
- }
-}
diff --git a/core/http/react-ui/tests/unit/legacyroutes.test.mjs b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
index 2cfbf177f..c8967c43a 100644
--- a/core/http/react-ui/tests/unit/legacyroutes.test.mjs
+++ b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { STUDIO_TABS, studioAddress, studioRedirect, talkAddress, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
+import { addonPageAddress, STUDIO_TABS, studioAddress, studioRedirect, talkAddress, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
const here = dirname(fileURLToPath(import.meta.url))
const router = readFileSync(join(here, '../../src/router.jsx'), 'utf8')
@@ -65,13 +65,20 @@ test('the same-path routes need no redirect', () => {
for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'same')) assert.equal(r.from, r.to)
})
-test('moved biometric routes are wrapped in MovedPage in the router', () => {
- // Studio and Talk have no core page to fall back to; they redirect (see above).
+test('moved routes redirect to their add-on page, with no built-in page behind them', () => {
+ // Studio and Talk have their own redirects (see above).
for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page' && !['/app/studio', '/app/talk'].includes(x.from))) {
const [, , , addon, ...rest] = r.to.split('/')
const page = rest.join('/')
- assert.ok(router.includes(``) || router.includes(``), `router.jsx does not wrap ${r.from}`)
+ assert.ok(router.includes(` `) || router.includes(` `), `router.jsx does not redirect ${r.from}`)
}
+ assert.ok(!router.includes('MovedPage'))
+ assert.ok(!/pages\/(FaceRecognition|VoiceRecognition|VoiceLibrary|VoiceProfileCreate|FineTune|Quantize)'/.test(router))
+})
+
+test('addonPageAddress keeps the page path, the model, the query and the hash', () => {
+ assert.equal(addonPageAddress({ addon: 'voice', page: 'voice/recognition' }), '/app/addons/voice/voice/recognition')
+ assert.equal(addonPageAddress({ addon: 'face', page: 'recognition', model: 'org/name', search: '?x=1', hash: '#h' }), '/app/addons/face/recognition?x=1&model=org%2Fname#h')
})
test('a moved route with a :model segment carries the model as ?model=, not in the path', () => {
@@ -84,8 +91,8 @@ test('the face route and the face model route both open the Face add-on, the mod
const lines = router.split('\n')
const plain = lines.find(l => l.includes("{ path: 'face', element:"))
const withModel = lines.find(l => l.includes("{ path: 'face/:model', element:"))
- assert.ok(plain?.includes(''), 'face is not wrapped')
- assert.ok(withModel?.includes(''), 'face/:model is not wrapped with withModel')
+ assert.ok(plain?.includes(' '), 'face does not redirect')
+ assert.ok(withModel?.includes(' '), 'face/:model does not redirect with withModel')
})
test('the Build hub has no Faces tab', async () => {
@@ -93,7 +100,7 @@ test('the Build hub has no Faces tab', async () => {
assert.ok(!buildHub.tabs.some(t => t.id === 'faces' || t.id === 'voices'))
})
-test('the fine-tune and quantize routes open their add-on pages, around the existing Feature and ToolOff', () => {
+test('the fine-tune and quantize routes redirect to their add-on pages', () => {
const lines = router.split('\n')
const rows = [
{ from: '/app/fine-tune', to: '/app/addons/fine-tuning/jobs', addon: 'fine-tuning', tool: 'fineTune', feature: 'fine_tuning', path: 'fine-tune' },
@@ -102,8 +109,7 @@ test('the fine-tune and quantize routes open their add-on pages, around the exis
for (const r of rows) {
assert.ok(MIGRATION_ROUTES.some(m => m.from === r.from && m.to === r.to && m.kind === 'addon-page'), `no row for ${r.from}`)
const entry = lines.find(l => l.includes(`{ path: '${r.path}', element:`))
- assert.ok(entry?.includes(``), `${r.path} is not wrapped`)
- assert.ok(entry.includes(` }>`), `${r.path} lost its Feature and ToolOff`)
+ assert.ok(entry?.includes(` `), `${r.path} does not redirect`)
}
})
diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md
index 55c1cb03e..02040e0c3 100644
--- a/docs/content/features/addons.md
+++ b/docs/content/features/addons.md
@@ -2040,10 +2040,9 @@ query string and the hash:
| `/app/voice-library` | `/app/addons/voice/voice/library` |
| `/app/voice-library/new` | `/app/addons/voice/voice/library/new` |
-The two face links and the four voice links open their add-on only while it is installed and usable. Without
-it, they open the same pages in the core web UI, so nobody loses a page they can open today.
-The Studio and Talk links have no core page to fall back to: without the add-on they open
-the add-on page, which says the add-on is not available.
+None of these links has a core page behind it: the face, voice, Studio and Talk pages exist
+only in their add-ons. Without the add-on, a link opens the add-on page, which says the
+add-on is not available.
A model id can hold `/` or `:`, which an add-on path segment refuses, so the model travels
as `?model=` (for example `/app/voice/org%2Fname` opens `/app/addons/voice/voice/recognition?model=org%2Fname`; the face links and the
Studio links do the same).
@@ -2161,8 +2160,8 @@ The Build hub has no Fine-Tune or Quantize tab.
| `fine-tuning` | Fine-Tune | `/app/addons/fine-tuning/jobs` | users with the `fine_tuning` feature; the page shows an off notice for anyone else |
| `quantization` | Quantize | `/app/addons/quantization/jobs` | users with the `quantization` feature; the page shows an off notice for anyone else |
-The old links `/app/fine-tune` and `/app/quantize` open the add-on page while the add-on
-is installed and usable, and the same page in the core web UI otherwise. The pages call
+The old links `/app/fine-tune` and `/app/quantize` open the add-on page. Without the add-on,
+they show the add-on page that says the add-on is not available. The pages call
`/api/fine-tuning/*` and `/api/quantization/*` with the signed-in user's own session (see
"Core routes from add-ons"); the core keeps these routes and checks every call again.
Both add-ons offer chat tools that propose a job and wait for a person to confirm. They