refactor(ui): remove the built-in copies of the add-on pages (#12852)

The Face recognition, Voice recognition, Voice library, Fine-tune and
Quantize pages now live only in their add-ons. The router still kept a
built-in copy of each one and showed it when the add-on was not
installed, which meant two pages to maintain for the same job.

Replace the MovedPage wrapper with a plain redirect. The old addresses
(/app/face, /app/voice, /app/voice-library, /app/fine-tune and the
rest) keep working and always open the add-on route. When the add-on is
not installed or not ready, the add-on shell shows its "not available"
page. The model of /app/face/:model and /app/voice/:model still travels
as ?model=.

Delete the six page files, the components, styles and helpers only they
used, the biometrics translation namespace, and the end-to-end specs
that tested the built-in copies. Drop the two add-on parity tests that
compared the add-on page with the deleted core page. Server APIs and
feature flags are unchanged.


Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-23 11:54:53 +02:00
1 parent 756b019ce4
commit 60254e582d
43 files changed
+61 -6735

No files matched your search

-8
View File
@@ -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])
@@ -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)
+15 -21
View File
@@ -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 }) => {
-513
View File
@@ -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)
})
})
+1 -2
View File
@@ -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 }) => {
-530
View File
@@ -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()
})
})
+1 -1
View File
@@ -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',
]
@@ -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', () => {
@@ -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)
})
})
@@ -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)
})
})
@@ -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."
}
}
@@ -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 (
<div className={`empty-state ${className}`.trim()}>
{eyebrow && <span className="empty-state__eyebrow">{eyebrow}</span>}
{icon && <Icon name={icon} className="empty-state-icon" />}
{title && <h2 className="empty-state-title">{title}</h2>}
{body && <p className="empty-state-text">{body}</p>}
{actions && <div className="empty-state__actions">{actions}</div>}
</div>
)
}
@@ -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 (
<div ref={wrapRef} className="biometrics-bbox">
{src && <img ref={imgRef} src={src} alt={alt} onLoad={(e) => {
setDims({
w: e.target.getBoundingClientRect().width,
h: e.target.getBoundingClientRect().height,
natW: e.target.naturalWidth,
natH: e.target.naturalHeight,
})
}} />}
{boxes.map((b, i) => (
<div key={i} className={`biometrics-bbox__box tone-${b.tone || 'accent'}`}
style={{
left: `${b.x * sx}px`,
top: `${b.y * sy}px`,
width: `${b.w * sx}px`,
height: `${b.h * sy}px`,
}}>
{(b.label || b.sublabel) && (
<div className="biometrics-bbox__tag">
{b.label && <strong>{b.label}</strong>}
{b.sublabel && <span>{b.sublabel}</span>}
</div>
)}
</div>
))}
</div>
)
}
@@ -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 <div className="biometrics-waveform biometrics-waveform--error">{error}</div>
if (!src) return null
return (
<div className="biometrics-waveform" style={{ height }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
{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 (
<div key={i} className={`biometrics-waveform__segment tone-${s.tone || 'accent'}`}
style={{ left: `${left}%`, width: `${Math.max(0.5, right - left)}%` }}>
{s.label && <span className="biometrics-waveform__seglabel">{s.label}</span>}
</div>
)
})}
{duration > 0 && (
<div className="biometrics-waveform__duration" aria-hidden="true">{duration.toFixed(1)}s</div>
)}
{!peaks && (
<div className="biometrics-waveform__loading">Decoding…</div>
)}
</div>
)
}
@@ -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 (
<div className="idn-clip" onPaste={onPaste} data-state={value ? 'filled' : 'empty'} data-testid={`${idPrefix}-clip`}>
<div className="idn-clip__head">
<span className="dk-eyebrow">{label}</span>
{value && <button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={clear}><Icon name="close" /> {t('clip.remove')}</button>}
</div>
<input ref={fileRef} id={inputId} type="file" className="dk-sr-only" accept={kind === 'audio' ? 'audio/*' : 'image/*'} onChange={onFile} aria-label={label} />
{!value && !cap.active && (
<div className="idn-clip__choices">
{prompt && <p className="idn-clip__prompt"><span className="dk-eyebrow">{t('clip.readAloud')}</span>{prompt}</p>}
<div className="idn-clip__buttons">
{cap.supported && !blocked && (
<button type="button" className="dk-btn dk-btn--secondary" onClick={cap.start}>
<Icon name={kind === 'audio' ? 'mic' : 'camera'} /> {kind === 'audio' ? t('clip.record') : t('clip.takePhoto')}
</button>
)}
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => fileRef.current?.click()} disabled={busy}>
<Icon name="upload" /> {kind === 'audio' ? t('clip.chooseAudio') : t('clip.choosePhoto')}
</button>
</div>
{(!cap.supported || blocked || cap.error) && (
<p className="idn-clip__notice" role={blocked ? 'alert' : 'status'} data-testid="clip-notice">
<Icon name={blocked || !secure ? 'lock' : 'info'} />
<span>
{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 })}
</span>
</p>
)}
{cap.supported && !blocked && !cap.error && micState === 'granted' && kind === 'audio' && (
<p className="idn-clip__hint" data-testid="mic-allowed"><Icon name="check" /> {t('clip.allowed')}</p>
)}
{hint && <p className="idn-clip__hint">{hint}</p>}
</div>
)}
{!value && cap.active && kind === 'image' && (
<div className="idn-clip__live">
<video ref={cap.videoRef} autoPlay muted playsInline className="idn-clip__video" />
<div className="idn-clip__buttons">
<button type="button" className="dk-btn dk-btn--primary" onClick={snap}><Icon name="circle-dot" /> {t('clip.capture')}</button>
<button type="button" className="dk-btn dk-btn--ghost" onClick={cap.stop}>{t('clip.cancel')}</button>
</div>
</div>
)}
{!value && cap.active && kind === 'audio' && (
<div className="idn-clip__live">
<p className="idn-clip__rec" role="status"><span className="dk-dot dk-dot--error" data-live={cap.recording ? 'true' : undefined} /> {cap.recording ? t('clip.recording', { s: cap.elapsed.toFixed(1) }) : t('clip.micReady')}</p>
<div className="idn-clip__buttons">
<button type="button" className={`dk-btn ${cap.recording ? 'dk-btn--primary' : 'dk-btn--secondary'}`} onClick={record}>
<Icon name={cap.recording ? 'stop' : 'circle'} /> {cap.recording ? t('clip.stop') : t('clip.start')}
</button>
<button type="button" className="dk-btn dk-btn--ghost" onClick={cap.stop} disabled={cap.recording}>{t('clip.cancel')}</button>
</div>
</div>
)}
{value && (
<div className="idn-clip__card">
<div className="idn-clip__meta">
<Icon name={value.source === 'live' ? (kind === 'audio' ? 'mic' : 'camera') : kind === 'audio' ? 'file' : 'image'} />
<strong className="idn-clip__name">{value.source === 'live' ? t(kind === 'audio' ? 'clip.recorded' : 'clip.photographed') : value.name || t('clip.uploaded')}</strong>
{facts && <span className="dk-chip dk-chip--sm dk-mono">{facts.seconds.toFixed(1)} s</span>}
{verdict?.level === 'good' && <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('clip.goodLength')}</span>}
{verdict?.level === 'warn' && <span className="dk-badge dk-badge--warn"><Icon name="warning" /> {facts.seconds < 3 ? t('clip.short') : t('clip.checkLevel')}</span>}
</div>
{kind === 'audio'
? <WaveformPlayer src={playUrl || value.dataUrl} height={56} label={t('clip.preview')} />
: <img className="idn-clip__img" src={value.dataUrl} alt={t('clip.photoAlt')} />}
{verdict?.level === 'warn' && <ul className="idn-clip__notes">{verdict.notes.map(n => <li key={n}>{n}</li>)}</ul>}
</div>
)}
</div>
)
}
@@ -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 (
<figure className="idn-scale" data-testid="distance-scale">
<div className="idn-scale__plot" style={plot} role="img" aria-label={t('scale.aria', { cutoff: cutoff.toFixed(2), dots: summary })}>
<span className="idn-scale__cut" style={at(cutoff, max)}><span className="idn-scale__cut-label dk-mono">{t('scale.cutoff', { value: cutoff.toFixed(2) })}</span></span>
<span className="idn-scale__track"><span className="idn-scale__inside" style={at(cutoff, max)} /></span>
{ordered.map((d, i) => (
<span key={d.id} className="idn-scale__dot" data-best={d.best ? 'true' : undefined} data-within={d.within ? 'true' : 'false'} data-row={rows[i]} style={at(d.distance, max)}>
<span className="idn-scale__mark" />
<span className="idn-scale__name"><strong>{d.label}</strong> <span className="dk-mono">{d.distance.toFixed(2)}</span></span>
</span>
))}
<span className="idn-scale__ticks" aria-hidden="true">
{scaleTicks(max).map(v => <span key={v} className="idn-scale__tick dk-mono" style={at(v, max)}>{v}</span>)}
</span>
</div>
<figcaption className="idn-scale__caption">
<span>{t('scale.axis', { print: noun })}</span>
<span>{single ? t('scale.onePair') : t('scale.oneDot', { person: noun === 'faceprint' ? t('face.person') : t('voice.speaker') })}</span>
</figcaption>
</figure>
)
}
@@ -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 = (
<>
<span className="idn-sheet__need" data-testid="enrol-needs">{needs.length ? t('enrol.stillNeeded', { list: needs.join(', ') }) : t('enrol.ready')}</span>
<button type="button" className="dk-btn dk-btn--ghost" onClick={onClose}>{t('enrol.cancel')}</button>
<button type="submit" form="idn-enrol-form" className="dk-btn dk-btn--primary" disabled={needs.length > 0 || busy}>
{busy ? <><LoadingSpinner size="sm" /> {t('enrol.working')}</> : <>{t('enrol.submit')} <Icon name="check" /></>}
</button>
</>
)
return (
<SideSheet
title={t(`${kind}.enrolTitle`)}
description={t(`${kind}.enrolIntro`)}
footer={footer}
onClose={onClose}
closeLabel={t('enrol.close')}
testId="enrol-sheet"
labelId="idn-enrol-title"
>
<form id="idn-enrol-form" className="idn-sheet" onSubmit={submit}>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">1</span> {t(isFace ? 'enrol.photoStep' : 'enrol.recordingStep')}</h3>
<ClipInput kind={isFace ? 'image' : 'audio'} label={t(isFace ? 'enrol.photoLabel' : 'enrol.recordingLabel')} value={sample} onChange={setSample} idPrefix={`${kind}-enrol`} prompt={isFace ? null : READ_ALOUD} />
</section>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">2</span> {t('enrol.whoStep')}</h3>
<div className="dk-field">
<label className="dk-label" htmlFor={`${kind}-enrol-name`}>{t('enrol.name')}</label>
<input id={`${kind}-enrol-name`} className="dk-input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('enrol.namePlaceholder')} autoComplete="off" />
</div>
<div className="dk-field">
<label className="dk-label" htmlFor={`${kind}-enrol-labels`}>{t('enrol.labels')} <span className="dk-hint">{t('enrol.labelsHint')}</span></label>
<textarea id={`${kind}-enrol-labels`} className="dk-textarea" rows={2} placeholder={'team: platform'} value={labels} onChange={(e) => setLabels(e.target.value)} />
</div>
{canSpeech && !isFace && (
<div className="dk-field">
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={asSpeech} onChange={(e) => setAsSpeech(e.target.checked)} />
<span><strong>{t('enrol.asSpeech')}</strong><span className="dk-hint">{t('enrol.asSpeechHint')}</span></span>
</label>
{asSpeech && (
<>
<label className="dk-label" htmlFor="voice-enrol-transcript">{t('enrol.transcript')}</label>
<textarea id="voice-enrol-transcript" className="dk-textarea" rows={3} maxLength={4000} value={transcript} onChange={(e) => setTranscript(e.target.value)} />
</>
)}
</div>
)}
</section>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">3</span> {t('enrol.permissionStep')}</h3>
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span><strong>{t('enrol.agreed')}</strong><span className="dk-hint">{t(`${kind}.agreedHint`)}</span></span>
</label>
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={keep} onChange={(e) => setKeep(e.target.checked)} />
<span><strong>{t('enrol.keep')}</strong><span className="dk-hint">{t(`${kind}.keepHint`)}</span></span>
</label>
</section>
{error && <ErrorNote error={error} kind={kind} />}
</form>
</SideSheet>
)
}
@@ -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 (
<div className="idn-error" role="alert" data-testid="identity-error">
<span className="idn-error__icon" aria-hidden="true"><Icon name="alert-circle" /></span>
<div className="idn-error__body">
<h3 className="idn-error__title">{title}</h3>
<p className="idn-error__text">{body}</p>
{error?.message && <p className="idn-error__raw dk-mono">{error.message}</p>}
<div className="idn-error__acts">
{onRetry && <button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={onRetry}><Icon name="refresh" /> {t('error.retry')}</button>}
<Link className="dk-btn dk-btn--ghost dk-btn--sm" to="/app/traces?tab=backend"><Icon name="waveform" /> {t('error.traces')}</Link>
</div>
</div>
</div>
)
}
@@ -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 (
<main className="page idn-page idn-off" data-testid="feature-off">
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t(`off.${feature}.title`)}</h1>
<p className="idn-needed__text">{t(`off.${feature}.text`)}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
<p className="idn-disclosure__text">{t(`off.${feature}.what`)}</p>
</details>
</section>
</main>
)
}
@@ -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 (
<span className="idn-avatar" data-tone={tone} aria-hidden="true">
{entry.thumbnail ? <img src={entry.thumbnail} alt="" /> : initials(entry.name)}
</span>
)
}
// 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 (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={play} aria-label={t('known.play', { name: entry.name })}>
<Icon name="play" />
</button>
)
}
// 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 (
<div className="idn-known__empty dk-empty" data-testid="known-empty">
<div className="dk-empty-icon"><Icon name={kind === 'face' ? 'smile' : 'mic'} /></div>
<h3 className="dk-empty-title">{t(`${kind}.emptyTitle`)}</h3>
<p className="dk-empty-text">{t(`${kind}.emptyText`)}</p>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onEnrol()} disabled={!canEnrol}><Icon name="plus" /> {t(`${kind}.enrol`)}</button>
</div>
)
}
return (
<div className="dk-table-wrap dk-table-wrap--flat" role="region" aria-label={t(`${kind}.knownTitle`)} tabIndex={0}>
<table className="dk-table" data-testid="known-list">
<caption className="dk-sr-only">{t('known.caption', { noun })}</caption>
<thead>
<tr>
<th scope="col">{t('known.name')}</th>
<th scope="col" className="dk-hide-phone">{t('known.added')}</th>
<th scope="col"><span className="dk-sr-only">{t('known.actions')}</span></th>
</tr>
</thead>
<tbody>
{rows.map(row => {
const gone = row.local && missing.has(row.id)
const pending = waiting[row.id]
const best = row.id === bestId
return (
<tr key={row.id} data-row data-selected={best ? 'true' : undefined} data-pending={pending ? 'true' : undefined} data-testid={`known-row-${row.id}`}>
<td>
<span className="idn-who">
<Avatar entry={row} tone={gone ? 'warn' : undefined} />
<span className="idn-who__text">
<span className="dk-table-name">
{row.name}
{best && <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('known.best')}</span>}
{gone && <span className="dk-badge dk-badge--warn" data-testid="not-on-server"><Icon name="warning" /> {t('known.notOnServer')}</span>}
{!row.local && <span className="dk-badge" data-testid="not-in-browser">{t('known.notInBrowser')}</span>}
{pending && <span className="dk-badge dk-badge--error">{t('known.removing')}</span>}
</span>
<span className="dk-table-sub" data-error={gone ? true : undefined}>
{gone ? t('known.goneText', { print: noun }) : !row.local ? t('known.extraText') : labelsText(row.labels) || t('known.noLabels')}
</span>
{gone && !pending && <button type="button" className="dk-btn dk-btn--secondary dk-btn--sm idn-again" onClick={() => onAgain(row)}>{row.sampleUrl || row.thumbnail ? t('known.reenrolSaved') : t('known.enrolAgain')}</button>}
</span>
</span>
</td>
<td className="dk-hide-phone dk-mono idn-when">{row.registeredAt ? ageText(row.registeredAt, Date.now(), i18n.language) : ''}</td>
<td className="idn-actions">
{pending ? (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => onUndo(row.id)}>{t('known.undo')}</button>
) : (
<>
<PlaySample entry={row} />
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={() => onDelete(row)} aria-label={t('known.forget', { name: row.name })}>
<Icon name="trash" />
</button>
</>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
@@ -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 (
<section className="idn-needed dk-card" data-testid="model-needed" aria-labelledby={`${kind}-needed-title`}>
<span className="dk-badge dk-badge--warn"><Icon name="plug-off" /> {t('needed.badge')}</span>
<h2 className="idn-h2" id={`${kind}-needed-title`}>{t(`${kind}.neededTitle`)}</h2>
<p className="idn-needed__text">{t(`${kind}.neededText`)}</p>
{isAdmin && !loading && models.length > 0 && (
<ul className="idn-needed__list">
{models.map(model => (
<li key={model.id}>
<span><strong className="dk-mono">{model.name}</strong><span className="dk-hint">{model.backend ? t('needed.backend', { backend: model.backend }) : ''}</span></span>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" disabled={busy(model)} onClick={() => install(model)}>
<Icon name={busy(model) ? 'spinner' : 'download'} spin={busy(model)} /> {busy(model) ? t('needed.installing') : t('needed.install')}
</button>
</li>
))}
</ul>
)}
{isAdmin && !loading && models.length === 0 && (
<p className="dk-hint">{failed ? t('needed.loadFailed') : t('needed.noneListed')}{' '}<Link className="dk-link" to="/app/models">{t('needed.browse')}</Link></p>
)}
{!isAdmin && <p className="dk-hint">{t('needed.askAdmin')}</p>}
<details className="idn-disclosure">
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
<p className="idn-disclosure__text">{t(`${kind}.neededWhat`)}</p>
</details>
</section>
)
}
@@ -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 (
<div className="idn-more__body">
<TabSwitch tabs={TABS} value={tab} onChange={setTab} />
<div className="biometrics-page__body">
{tab === 'analyze' && <AnalyzeTab model={model} addToast={addToast} />}
{tab === 'embed' && <EmbedTab model={model} addToast={addToast} />}
</div>
</div>
)
}
// 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 (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Analyze a face</h2>
<MediaInput mode="image" label="Source image" value={img} onChange={setImg} idPrefix="face-analyze" />
<fieldset className="biometrics-fieldset">
<legend>Guess attributes <small>(off by default, often wrong)</small></legend>
<div className="biometrics-chipset" role="group">
{['age', 'gender', 'emotion', 'race'].map(k => (
<label key={k} className={`biometrics-chip ${actions[k] ? 'active' : ''}`}>
<input type="checkbox" checked={actions[k]} onChange={(e) => setActions(a => ({ ...a, [k]: e.target.checked }))} />
<span>{k}</span>
</label>
))}
</div>
</fieldset>
<div className="form-row">
<div className="form-row__label">
<span className="form-row__label-text">Anti-spoofing</span>
<span className="form-row__hint">Reject photos-of-photos (requires model support).</span>
</div>
<label className="biometrics-switch">
<input type="checkbox" checked={antiSpoofing} onChange={(e) => setAntiSpoofing(e.target.checked)} />
<span aria-hidden="true" />
</label>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><LoadingSpinner size="sm" /> Analyzing…</> : <><Icon name="sparkles" /> Analyze</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><LoadingSpinner size="lg" /></div>}
{error && <ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="smile"
title="Drop a portrait to analyze"
body="The backend will detect each face and return age, gender, emotion, and race distributions — with an optional liveness check." />
)}
{result && img && (
<>
<div className="biometrics-split">
<div className="biometrics-split__media">
<BoundingBoxCanvas src={img.dataUrl} boxes={boxes} alt="Analyzed source" />
{faces.length > 1 && (
<div className="biometrics-facepicker" role="tablist" aria-label="Select face">
{faces.map((_, i) => (
<button key={i} type="button"
className={`biometrics-facepicker__chip ${i === focusIdx ? 'active' : ''}`}
onClick={() => setFocusIdx(i)}
aria-pressed={i === focusIdx}>
Face {i + 1}
</button>
))}
</div>
)}
</div>
<div className="biometrics-split__aside">
{focus && (
<>
<div className="biometrics-summary card">
<div className="biometrics-summary__head">
<h3><Icon name="user" /> Face {focusIdx + 1}</h3>
{antiSpoofing && <LivenessPill isReal={focus.is_real} score={focus.antispoof_score} />}
</div>
<dl className="biometrics-summary__grid">
{focus.age != null && <><dt>Age</dt><dd>~{Math.round(focus.age)}</dd></>}
{focus.dominant_gender && <><dt>Gender</dt><dd>{focus.dominant_gender}</dd></>}
{focus.dominant_emotion && <><dt>Emotion</dt><dd>{focus.dominant_emotion}</dd></>}
{focus.dominant_race && <><dt>Race</dt><dd>{focus.dominant_race}</dd></>}
{focus.face_confidence != null && <><dt>Detection</dt><dd>{(focus.face_confidence * 100).toFixed(1)}%</dd></>}
</dl>
</div>
<DistributionBars title="Gender" icon="gender" distribution={focus.gender} dominant={focus.dominant_gender} />
<DistributionBars title="Emotion" icon="smile" distribution={focus.emotion} dominant={focus.dominant_emotion} />
<DistributionBars title="Race" icon="globe" distribution={focus.race} dominant={focus.dominant_race} />
</>
)}
</div>
</div>
<ResponseDetails data={result} />
</>
)}
</section>
</form>
)
}
// 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 (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Get a raw embedding</h2>
<p className="biometrics-panel__note">
Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
</p>
<MediaInput mode="image" label="Image" value={img} onChange={setImg} idPrefix="face-embed" />
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><LoadingSpinner size="sm" /> Embedding…</> : <><Icon name="code" /> Extract vector</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><LoadingSpinner size="lg" /></div>}
{error && <ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="code"
title="Get a face embedding"
body="For developers — retrieve the raw vector for a face to store, search, or cluster outside of LocalAI." />
)}
{result && (
<EmbeddingInspector embedding={result.embedding} dim={result.dim} model={result.model} elapsedMs={elapsedMs} />
)}
</section>
</form>
)
}
// ──────────────────────────── Small shared bits ────────────────────────────
// eslint-disable-next-line no-unused-vars
function LivenessPill({ isReal, score }) {
if (isReal == null) {
return <span className="biometrics-pill muted"><Icon name="help-circle" /> Not checked</span>
}
return (
<span className={`biometrics-pill ${isReal ? 'good' : 'bad'}`}>
<Icon name={isReal ? 'user-shield' : 'mask'} />
{isReal ? 'Real' : 'Spoof'}
{score != null && <small>{(score * 100).toFixed(0)}%</small>}
</span>
)
}
// eslint-disable-next-line no-unused-vars
function EmptyState({ icon, title, body }) {
return (
<div className="biometrics-empty">
<Icon name={icon} aria-hidden="true" />
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
// eslint-disable-next-line no-unused-vars
function ResponseDetails({ data }) {
return (
<details className="biometrics-response">
<summary><Icon name="chevron-right" /> Raw response</summary>
<pre>{JSON.stringify(data, null, 2)}</pre>
</details>
)
}
@@ -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 (
<div className="idn-more__body">
<TabSwitch tabs={TABS} value={tab} onChange={setTab} />
<div className="biometrics-page__body">
{tab === 'analyze' && <AnalyzeTab model={model} addToast={addToast} />}
{tab === 'embed' && <EmbedTab model={model} addToast={addToast} />}
</div>
</div>
)
}
// 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 (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Analyze a speaker</h2>
<MediaInput mode="audio" label="Audio clip" value={audio} onChange={setAudio} idPrefix="voice-analyze" />
<fieldset className="biometrics-fieldset">
<legend>Guess attributes <small>(off by default, often wrong)</small></legend>
<div className="biometrics-chipset" role="group">
{['age', 'gender', 'emotion'].map(k => (
<label key={k} className={`biometrics-chip ${actions[k] ? 'active' : ''}`}>
<input type="checkbox" checked={actions[k]} onChange={(e) => setActions(a => ({ ...a, [k]: e.target.checked }))} />
<span>{k}</span>
</label>
))}
</div>
</fieldset>
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !audio}>
{loading ? <><LoadingSpinner size="sm" /> Analyzing…</> : <><Icon name="sparkles" /> Analyze</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><LoadingSpinner size="lg" /></div>}
{error && <ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="waveform"
title="Record or upload a clip to analyze"
body="The backend will segment the audio by speaker turn and infer age, gender, and emotion per segment." />
)}
{result && audio && (
<>
<WaveformStrip src={audio.dataUrl} segments={waveformSegments} />
{segments.length > 1 && (
<div className="biometrics-facepicker" role="tablist" aria-label="Select segment">
{segments.map((s, i) => (
<button key={i} type="button"
className={`biometrics-facepicker__chip ${i === focusIdx ? 'active' : ''}`}
onClick={() => setFocusIdx(i)}
aria-pressed={i === focusIdx}>
#{i + 1} <small>{s.start.toFixed(1)}s–{s.end.toFixed(1)}s</small>
</button>
))}
</div>
)}
{focus && (
<div className="biometrics-split">
<div className="biometrics-split__aside" style={{ gridColumn: '1 / -1' }}>
<div className="biometrics-summary card">
<div className="biometrics-summary__head">
<h3><Icon name="user" /> Segment {focusIdx + 1}
<small>· {focus.start.toFixed(2)}s – {focus.end.toFixed(2)}s</small>
</h3>
</div>
<dl className="biometrics-summary__grid">
{focus.age != null && <><dt>Age</dt><dd>~{Math.round(focus.age)}</dd></>}
{focus.dominant_gender && <><dt>Gender</dt><dd>{focus.dominant_gender}</dd></>}
{focus.dominant_emotion && <><dt>Emotion</dt><dd>{focus.dominant_emotion}</dd></>}
</dl>
</div>
<DistributionBars title="Gender" icon="gender" distribution={focus.gender} dominant={focus.dominant_gender} />
<DistributionBars title="Emotion" icon="smile" distribution={focus.emotion} dominant={focus.dominant_emotion} />
</div>
</div>
)}
<ResponseDetails data={result} />
</>
)}
</section>
</form>
)
}
// 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 (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Get a raw speaker embedding</h2>
<p className="biometrics-panel__note">
Returns a speaker-encoder vector — the same representation the backend uses internally for verify and identify.
</p>
<MediaInput mode="audio" label="Audio clip" value={audio} onChange={setAudio} idPrefix="voice-embed" />
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !audio}>
{loading ? <><LoadingSpinner size="sm" /> Embedding…</> : <><Icon name="code" /> Extract vector</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><LoadingSpinner size="lg" /></div>}
{error && <ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="code"
title="Get a speaker embedding"
body="For developers — retrieve the raw vector for a voice to store, search, or cluster outside of LocalAI." />
)}
{result && (
<EmbeddingInspector embedding={result.embedding} dim={result.dim} model={result.model} elapsedMs={elapsedMs} />
)}
</section>
</form>
)
}
// eslint-disable-next-line no-unused-vars
function EmptyState({ icon, title, body }) {
return (
<div className="biometrics-empty">
<Icon name={icon} aria-hidden="true" />
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
// eslint-disable-next-line no-unused-vars
function ResponseDetails({ data }) {
return (
<details className="biometrics-response">
<summary><Icon name="chevron-right" /> Raw response</summary>
<pre>{JSON.stringify(data, null, 2)}</pre>
</details>
)
}
@@ -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 (
<div className="idn-verdict" data-tone={tone} data-testid={testId} role="status">
<span className="idn-verdict__icon" aria-hidden="true"><Icon name={tone === 'match' ? 'check' : tone === 'miss' ? 'help-circle' : 'minus'} /></span>
<div className="idn-verdict__body">
<h3 className="idn-verdict__title">
{title}
{word && <span className={`dk-badge${WORD[word] ? ` dk-badge--${WORD[word]}` : ''}`}>{t(`word.${word}`)}</span>}
</h3>
<p className="idn-verdict__text">{children}</p>
</div>
</div>
)
}
@@ -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 (
<div className="idn-cutoff">
<div className="idn-cutoff__head">
<label htmlFor={`${kind}-cutoff`}><strong>{t('cutoff.label')}</strong> <span className="dk-mono" data-testid="cutoff-value">{value.toFixed(2)}</span></label>
{same
? <span className="dk-badge">{t('cutoff.default')}</span>
: <button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => onChange(modelDefault)}>{t('cutoff.reset', { value: modelDefault.toFixed(2) })}</button>}
</div>
<div className="idn-cutoff__row">
<span className="dk-hint">{t('cutoff.stricter')}</span>
<input id={`${kind}-cutoff`} className="dk-range" type="range" min="0.05" max="1" step="0.01" value={value} style={fill}
onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="dk-hint">{t('cutoff.looser')}</span>
</div>
<p className="dk-hint">{t('cutoff.explain')}</p>
</div>
)
}
// eslint-disable-next-line no-unused-vars
function HowToRead({ kind, print }) {
const { t } = useTranslation('biometrics')
return (
<p className="idn-how"><strong>{t('how.title')}</strong> {t('how.body', { print })}</p>
)
}
// eslint-disable-next-line no-unused-vars
function WhyWrong({ kind, notes }) {
const { t } = useTranslation('biometrics')
const list = [...notes, ...t(`${kind}.why`, { returnObjects: true })]
return (
<details className="idn-disclosure">
<summary><Icon name="chevron-right" /> {t('why.title')}</summary>
<ul className="idn-disclosure__list" data-testid="why-list">{list.map(n => <li key={n}>{n}</li>)}</ul>
</details>
)
}
// eslint-disable-next-line no-unused-vars
function Stored({ kind, print }) {
const { t } = useTranslation('biometrics')
const points = t(`${kind}.stored`, { returnObjects: true, print })
return (
<details className="idn-disclosure idn-disclosure--page" data-testid="stored-note">
<summary><Icon name="chevron-right" /> {t('stored.title')}</summary>
<dl className="idn-stored">
{points.map(p => <div key={p.where}><dt>{p.where}</dt><dd>{p.what}</dd></div>)}
</dl>
</details>
)
}
// 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 (
<div className="idn-bench" data-kind={kind}>
{needed}
{!needed && <section className="idn-tool dk-card" aria-label={t('tool.aria')}>
<div className="idn-tool__bar">
<div className="dk-segmented" role="tablist" aria-label={t('tool.aria')}>
<button type="button" role="tab" className="dk-seg" aria-selected={tool === 'identify'} onClick={() => setTool('identify')}>{t(`${kind}.whoTab`)}</button>
<button type="button" role="tab" className="dk-seg" aria-selected={tool === 'same'} onClick={() => setTool('same')}>{t('tool.sameTab')}</button>
</div>
<p className="dk-hint idn-tool__lede">{tool === 'identify' ? t(`${kind}.whoLede`) : t(`${kind}.sameLede`)}</p>
</div>
{tool === 'identify' && (
<div className="idn-tool__body" role="tabpanel">
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photo' : 'tool.clip')} value={probe} onChange={(v) => { setProbe(v); setFind({ status: 'idle' }) }} idPrefix={`${kind}-probe`} onFacts={setProbeFacts} />
<div className="idn-tool__go">
<button type="button" className="dk-btn dk-btn--primary" onClick={identify} disabled={!model || !probe || find.status === 'working'} data-testid="identify-go">
{find.status === 'working' ? <><LoadingSpinner size="sm" /> {t(`${kind}.working`)}</> : <>{t(`${kind}.whoGo`)} <Icon name="arrow-right" /></>}
</button>
<span className="dk-hint">{t(`${kind}.whoNote`, { count: known })}</span>
</div>
{find.status === 'working' && (
<div className="idn-working" aria-busy="true" data-testid="identify-working">
<div className="dk-progress" role="progressbar" aria-label={t('tool.workingAria')} data-indeterminate><span className="dk-progress-bar" /></div>
<p className="dk-hint">{t(`${kind}.workingText`, { count: known })}</p>
</div>
)}
{find.status === 'failed' && <ErrorNote error={find.error} kind={kind} onRetry={identify} />}
{find.status === 'done' && (
<div className="idn-result" data-testid="identify-result">
{!best && (
<Verdict tone="none" title={t(known ? `${kind}.noneReturned` : `${kind}.noneEnrolled`)}>
{t(known ? `${kind}.noneReturnedText` : `${kind}.noneEnrolledText`)}
</Verdict>
)}
{best && best.within && (
<Verdict tone="match" title={t(`${kind}.probably`, { name: nameOf(best) })} word={best.word}>{noteFor(best)}</Verdict>
)}
{best && !best.within && (
<Verdict tone="miss" title={t(`${kind}.nobody`)} word={best.word === 'near' ? 'near' : null}>{noteFor(best)}</Verdict>
)}
{best && (
<>
<DistanceScale cutoff={findCut} noun={noun} dots={ranked.slice(0, 8).map(r => ({ id: r.id, label: nameOf(r).split(/\s+/)[0], distance: r.distance, within: r.within, best: r.rank === 1 && r.within }))} />
<ol className="idn-ranks" aria-label={t('ranks.aria')}>
{ranked.slice(0, 5).map(r => (
<li key={r.id} data-within={r.within ? 'true' : 'false'} data-testid="rank-row">
<span className="idn-ranks__n dk-mono">#{r.rank}</span>
<span className="idn-ranks__name">{nameOf(r)}</span>
<span className="dk-mono">{r.distance.toFixed(2)}</span>
{r.within ? <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('ranks.within')}</span> : <span className="dk-badge">{t('ranks.far')}</span>}
</li>
))}
</ol>
<div className="idn-result__tune">
<CutoffControl value={findCut} onChange={setFindCut} modelDefault={cfg.cutoff} kind={`${kind}-find`} />
<HowToRead kind={kind} print={noun} />
</div>
</>
)}
<WhyWrong kind={kind} notes={probeFacts ? (probeFacts.seconds < 3 ? [t('why.short', { s: probeFacts.seconds.toFixed(1) })] : []) : []} />
{(!best || !best.within) && (
<button type="button" className="dk-btn dk-btn--secondary idn-result__enrol" onClick={() => setSheet({ sample: probe })}>
<Icon name="plus" /> {t(`${kind}.enrolThis`)}
</button>
)}
</div>
)}
</div>
)}
{tool === 'same' && (
<div className="idn-tool__body" role="tabpanel">
<div className="idn-pair">
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photoA' : 'tool.clipA')} value={clipA} onChange={(v) => { setClipA(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-a`} />
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photoB' : 'tool.clipB')} value={clipB} onChange={(v) => { setClipB(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-b`} />
</div>
{isFace && (
<label className="dk-choice idn-tool__option">
<input className="dk-check" type="checkbox" checked={antiSpoof} onChange={(e) => setAntiSpoof(e.target.checked)} />
<span>{t('face.antiSpoof')} <span className="dk-hint">{t('face.antiSpoofHint')}</span></span>
</label>
)}
<div className="idn-tool__go">
<button type="button" className="dk-btn dk-btn--primary" onClick={compare} disabled={!model || !clipA || !clipB || pair.status === 'working'} data-testid="compare-go">
{pair.status === 'working' ? <><LoadingSpinner size="sm" /> {t('tool.comparing')}</> : <>{t('tool.compare')} <Icon name="arrow-right" /></>}
</button>
<span className="dk-hint">{t(`${kind}.sameNote`)}</span>
</div>
{pair.status === 'working' && (
<div className="idn-working" aria-busy="true" data-testid="compare-working">
<div className="dk-progress" role="progressbar" aria-label={t('tool.workingAria')} data-indeterminate><span className="dk-progress-bar" /></div>
</div>
)}
{pair.status === 'failed' && <ErrorNote error={pair.error} kind={kind} onRetry={compare} />}
{pairResult && (
<div className="idn-result" data-testid="compare-result">
<Verdict tone={pairWithin ? 'match' : 'miss'} title={t(pairWithin ? `${kind}.same` : `${kind}.different`)} word={pairWord === 'far' ? null : pairWord}>
{t('same.text', { print: noun, distance: pairResult.distance.toFixed(2), cutoff: pairActive.toFixed(2) })}
</Verdict>
<DistanceScale single cutoff={pairActive} noun={noun} dots={[{ id: 'pair', label: t('same.distance'), distance: pairResult.distance, within: pairWithin, best: pairWithin }]} />
{isFace && (pairResult.img1_area || pairResult.img2_area) && (
<div className="idn-pair idn-pair--boxes">
{[[clipA, pairResult.img1_area, pairResult.img1_is_real], [clipB, pairResult.img2_area, pairResult.img2_is_real]].map(([clip, area, real], i) => (
<figure key={i} className="idn-face">
<BoundingBoxCanvas src={clip?.dataUrl} boxes={area ? [{ ...area, label: real === false ? t('face.spoof') : null, tone: 'accent' }] : []} alt={t('face.found', { n: i + 1 })} />
{antiSpoof && real != null && <figcaption><span className={`dk-badge dk-badge--${real ? 'ok' : 'error'}`}>{real ? t('face.real') : t('face.spoof')}</span></figcaption>}
</figure>
))}
</div>
)}
<div className="idn-result__tune">
<CutoffControl value={pairActive} onChange={setPairCut} modelDefault={pairModelCut} kind={`${kind}-pair`} />
<HowToRead kind={kind} print={noun} />
</div>
<WhyWrong kind={kind} notes={[]} />
</div>
)}
</div>
)}
</section>}
<section className="idn-known" aria-labelledby={`${kind}-known-title`}>
<div className="idn-known__head">
<h2 className="idn-h2" id={`${kind}-known-title`}>{t(`${kind}.knownTitle`)} <span className="dk-hubtab-count">{known}</span></h2>
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setSheet({})} disabled={!model} data-testid="enrol-open">
<Icon name="plus" /> {t(`${kind}.enrol`)}
</button>
</div>
<p className="dk-hint idn-known__note" data-testid="registry-note">{t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}</p>
<KnownList
entries={visibleEntries} extras={reg.extras} missing={reg.missing} waiting={reg.waiting}
bestId={best?.within ? best.id : null} noun={noun} kind={kind}
onDelete={(row) => reg.schedule(row)} onUndo={reg.undo} onAgain={again} onEnrol={() => setSheet({})} canEnrol={!!model}
/>
</section>
<Stored kind={kind} print={noun} />
{more}
{sheet && (
<EnrolSheet kind={kind} model={model} canSpeech={canSpeech} prefill={sheet} onClose={() => setSheet(null)} onDone={onEnrolled} />
)}
{reg.latest && (
<HomeUndoToast
key={reg.latest.id}
message={t('toast.removing', { name: reg.latest.name })}
duration={UNDO_WINDOW_MS}
onUndo={() => reg.undo(reg.latest.id)}
onExpire={() => reg.finish(reg.latest.id)}
undoLabel={t('known.undo')}
dismissible={false}
testId="forget-undo-toast"
/>
)}
{working && <span className="dk-sr-only" role="status">{t('tool.workingAria')}</span>}
</div>
)
}
@@ -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,
}
}
@@ -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 (
<section className="bt-log dk-card" aria-labelledby="bt-log-title" data-testid="job-log">
<header className="bt-log__head">
<div>
<h2 className="bt-h2" id="bt-log-title">{t('log.title')}</h2>
<p className="dk-hint">{t('log.note')}</p>
</div>
<div className="bt-log__tools">
<label className="dk-choice">
<input className="dk-check" type="checkbox" checked={only} onChange={e => setOnly(e.target.checked)} data-testid="job-log-filter" />
{t('log.only')}
</label>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={copy} disabled={shown.length === 0}>
<Icon name={copied ? 'check' : 'copy'} /> {copied ? t('log.copied') : t('log.copy')}
</button>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={download} disabled={shown.length === 0}>
<Icon name="download" /> {t('log.download')}
</button>
</div>
</header>
<div className="bt-log__body" ref={bodyRef} role="log" aria-live="off" tabIndex={0} aria-label={t('log.title')}>
{shown.length === 0 ? (
<p className="bt-log__empty">{only ? t('log.noneFiltered') : t('log.empty')}</p>
) : shown.map((line, i) => (
<div key={i} className="bt-log__line" data-level={line.level}>
<span className="bt-log__time">{stamp(line.at)}</span>
<span className="bt-log__tag">{line.tag}</span>
<span className="bt-log__text">{line.text}</span>
</div>
))}
</div>
</section>
)
}
@@ -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 (
<section className="bt-chart dk-card" aria-labelledby="bt-chart-title" data-testid="job-chart">
<header className="bt-chart__head">
<div>
<h2 className="bt-h2" id="bt-chart-title">{t(`chart.${metric}`)}</h2>
<p className="dk-hint">
{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')}
</p>
</div>
<div className="dk-segmented" role="tablist" aria-label={t('chart.metric')}>
{tabs.map(id => (
<button key={id} type="button" role="tab" className="dk-seg" aria-selected={metric === id} onClick={() => { setMetric(id); setHover(null) }}>
{t(`chart.${id}`)}
</button>
))}
</div>
</header>
{geometry ? (
<>
<svg
ref={svgRef}
className="bt-chart__svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={t('chart.aria', { metric: t(`chart.${metric}`), last: tickText(last[key], exp), step: last.current_step })}
onMouseMove={move}
onMouseLeave={() => setHover(null)}
>
{geometry.yTicks.map((v, i) => (
<g key={i}>
<line className="bt-chart__grid" x1={PAD.left} x2={W - PAD.right} y1={geometry.y(v)} y2={geometry.y(v)} />
<text className="bt-chart__tick" x={PAD.left - 8} y={geometry.y(v) + 4} textAnchor="end">{tickText(v, exp)}</text>
</g>
))}
{geometry.xTicks.map((s, i) => (
<text key={i} className="bt-chart__tick" x={geometry.x(s)} y={H - PAD.bottom + 16} textAnchor="middle">{s}</text>
))}
<text className="bt-chart__axis" x={PAD.left + geometry.cw / 2} y={H - 4} textAnchor="middle">{t('chart.step')}</text>
<polyline className="bt-chart__line" points={data.map(e => `${geometry.x(e.current_step)},${geometry.y(e[key])}`).join(' ')} />
{metric === 'loss' && evalPoints.map((e, i) => (
<circle key={i} className="bt-chart__eval" cx={geometry.x(e.current_step)} cy={geometry.y(e.eval_loss)} r="4" />
))}
<circle className="bt-chart__end" cx={geometry.x(last.current_step)} cy={geometry.y(last[key])} r="4" />
<text className="bt-chart__label" x={geometry.x(last.current_step) + 10} y={geometry.y(last[key]) + 4}>{tickText(last[key], exp)}</text>
{hover && <line className="bt-chart__cursor" x1={geometry.x(hover.current_step)} x2={geometry.x(hover.current_step)} y1={PAD.top} y2={H - PAD.bottom} />}
</svg>
<p className="bt-chart__readout dk-mono" aria-live="off">
{t('chart.readout', { step: readout.current_step, value: tickText(readout[key], exp) })}
</p>
<details className="bt-chart__data">
<summary>{t('chart.dataTitle')}</summary>
<div className="dk-table-wrap bt-chart__table">
<table className="dk-table dk-table--compact">
<caption className="dk-sr-only">{t('chart.dataTitle')}</caption>
<thead><tr><th>{t('chart.step')}</th><th className="dk-num">{t(`chart.${metric}`)}</th>{metric === 'loss' && <th className="dk-num">{t('chart.eval')}</th>}</tr></thead>
<tbody>
{data.slice(-12).map(e => (
<tr key={e.current_step} data-row>
<td>{e.current_step}</td>
<td className="dk-num">{tickText(e[key], exp)}</td>
{metric === 'loss' && <td className="dk-num">{e.eval_loss > 0 ? tickText(e.eval_loss, false) : ''}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : (
<p className="bt-chart__empty">{t('chart.emptyText')}</p>
)}
</section>
)
}
@@ -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 (
<ol className="bt-stages" aria-label={label} data-testid="job-stages">
{stages.map((stage, i) => {
const state = i < at ? 'done' : i === at ? (failed ? 'failed' : 'current') : 'todo'
return (
<li key={stage} className="bt-stage" data-state={state} aria-current={state === 'current' ? 'step' : undefined}>
<span className="bt-stage__bar" aria-hidden="true" />
<span className="bt-stage__label">{labels[stage]}</span>
</li>
)
})}
</ol>
)
}
@@ -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 (
<main className="page page--medium bt-page bt-off" data-testid="tool-off" data-cause="account">
<section className="bt-off__card dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="bt-h1">{t(`off.${tool}.title`)}</h1>
<p className="bt-off__text">{t(`off.${tool}.text`)}</p>
<p className="dk-hint">{isAdmin ? t('off.admin') : t('off.askAdmin')}</p>
<div className="bt-off__acts">
<Link className="dk-btn dk-btn--primary" to="/app"><Icon name="home" /> {t('off.back')}</Link>
</div>
</section>
</main>
)
}
-1
View File
@@ -28,7 +28,6 @@ export const NAMESPACES = [
'skills',
'collections',
'library',
'biometrics',
'media',
'tools',
'admin',
@@ -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 (
<main className="page idn-page" data-testid="faces-page">
<PageHeader
title={t('faces.title')}
supporting={t('faces.lede')}
actions={!needModel ? (
<div className="idn-modelpick">
<ModelSelector value={model} onChange={setModel} capability={CAP_FACE_RECOGNITION} options={names} loading={loading} triggerClassName="idn-model" />
</div>
) : null}
/>
<Workbench
kind="face" model={model} addToast={addToast}
needed={needModel ? <ModelNeeded kind="face" addToast={addToast} isAdmin={isAdmin} onInstalled={refetch} /> : null}
more={needModel ? null : (
<details className="idn-disclosure idn-disclosure--page idn-more">
<summary><Icon name="chevron-right" /> {t('face.moreTitle')} <span className="dk-hint">{t('face.moreHint')}</span></summary>
<MoreFaceTools model={model} addToast={addToast} />
</details>
)}
/>
</main>
)
}
File diff suppressed because it is too large. Load diff
-558
View File
@@ -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 <span className={`dk-badge ${BADGE[status] || ''}`} data-status={status}>{t(`quantize.status.${status}`, { defaultValue: String(status || '') })}</span>
}
function Field({ id, label, hint, className = '', children }) {
return (
<div className={`dk-field ${className}`.trim()}>
<label className="dk-label" htmlFor={id}>{label}</label>
{children}
{hint && <p className="dk-hint">{hint}</p>}
</div>
)
}
function Section({ done, title, children }) {
return (
<section className="bt-section" data-done={done ? 'true' : 'false'}>
<h2 className="bt-section__title">
<span className="bt-section__mark" aria-hidden="true">{done ? <Icon name="check" /> : null}</span>
{title}
</h2>
<div className="bt-section__body">{children}</div>
</section>
)
}
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 && (
<section className="bt-next dk-card" data-testid="quantize-next">
<span className="bt-next__mark"><Icon name="check-circle" /></span>
<div>
<h2 className="bt-h2">{t('quantize.nextTitle', { name: job.import_model_name })}</h2>
<p className="dk-hint">{t('quantize.nextText')}</p>
<div className="bt-next__acts">
<Link className="dk-btn dk-btn--primary" to={`/app/chat/${encodeURIComponent(job.import_model_name)}`}><Icon name="chat" /> {t('quantize.chat', { name: job.import_model_name })}</Link>
<Link className="dk-btn dk-btn--secondary" to="/app/models?view=installed"><Icon name="cube" /> {t('quantize.models')}</Link>
<a className="dk-btn dk-btn--ghost" href={quantizationApi.downloadUrl(job.id)} download><Icon name="download" /> {t('quantize.download')}</a>
</div>
</div>
</section>
)}
<section className="bt-block dk-card" aria-labelledby="bt-qz-out" data-testid="quantize-output">
<h2 className="bt-h2" id="bt-qz-out">{t('quantize.outputTitle')}</h2>
<p className="dk-hint">{t('quantize.outputText', { type: job.quantization_type })}</p>
{job.output_file && <p className="bt-pathrow"><span className="dk-label">{t('quantize.file')}</span> <span className="dk-mono bt-path" title={job.output_file}>{job.output_file}</span></p>}
{error && <p className="bt-status bt-status--error" role="alert">{error}</p>}
{job.import_status === 'failed' && <p className="bt-status bt-status--error" role="alert">{t('quantize.importFailedWith', { message: job.import_message })}</p>}
{!imported && (
<div className="bt-out">
{job.import_status === 'importing' ? (
<p className="bt-status" role="status"><LoadingSpinner size="sm" /> {t('quantize.importing')} {job.import_message}</p>
) : (
<>
<Field id="qz-import-name" label={t('quantize.importName')} className="bt-out__name">
<input id="qz-import-name" className="dk-input dk-input--mono" placeholder={t('quantize.importNamePlaceholder')} value={modelName} onChange={e => setModelName(e.target.value)} />
</Field>
<div className="bt-actions">
<button type="button" className="dk-btn dk-btn--primary" onClick={handleImport} disabled={importing} aria-busy={importing || undefined} data-testid="quantize-import">
<Icon name="import" /> {t('quantize.import')}
</button>
<a className="dk-btn dk-btn--secondary" href={quantizationApi.downloadUrl(job.id)} download><Icon name="download" /> {t('quantize.download')}</a>
</div>
</>
)}
</div>
)}
</section>
</>
)
}
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 (
<div className="bt-stack" data-testid="job-view" data-status={status}>
{failed && (
<section className="bt-alert" role="alert" data-testid="job-failed">
<Icon name="alert-circle" />
<div>
<p className="bt-alert__title">{t('quantize.failed')}</p>
<p className="bt-alert__text" data-testid="job-failed-message">{message || t('fineTune.noMessage')}</p>
</div>
</section>
)}
<section className="bt-job dk-card" aria-labelledby="bt-job-title">
<div className="bt-job__top">
<div className="bt-job__who">
<h2 className="bt-job__title" id="bt-job-title">{job.model}</h2>
<p className="bt-job__meta">
{t('quantize.jobMeta', { type: job.quantization_type, backend: job.backend, started: startedAt(job) })}
<span className="dk-mono"> · {job.id?.slice(0, 8)}</span>
</p>
</div>
<div className="bt-job__acts">
<StatusBadge status={status} />
{active && (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => onStop(job.id)} data-testid="job-stop">
<Icon name="stop" /> {t('run.stop')}
</button>
)}
</div>
</div>
{(active || latest) && (
<>
<div className="bt-job__figures">
<span className="bt-job__percent">{status === 'completed' ? '100' : progress.toFixed(0)}<small>%</small></span>
</div>
<div
className={`dk-progress${failed ? ' dk-progress--error' : status === 'completed' ? ' dk-progress--ok' : ''}`}
role="progressbar" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={Math.round(status === 'completed' ? 100 : progress)} aria-label={t('run.progress')}
style={{ '--dk-value': `${status === 'completed' ? 100 : progress}%` }}
>
<span className="dk-progress-bar" />
</div>
</>
)}
{active && <StageLine stages={QZ_STAGES} labels={stageLabels} status={status} failed={failed} label={t('run.stages')} />}
{!failed && message && <p className="dk-hint bt-job__message">{message}</p>}
{status === 'completed' && <p className="bt-job__done" data-testid="job-finished"><Icon name="check-circle" /> {t('quantize.finished', { type: job.quantization_type })}</p>}
{status === 'stopped' && <p className="dk-hint">{t('quantize.stopped')}</p>}
</section>
{failed && (
<section className="bt-try dk-card" data-testid="job-try">
<h2 className="bt-h2">{t('fineTune.tryTitle')}</h2>
<p className="dk-hint">{t('quantize.tryText')}</p>
<div className="bt-try__acts">
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onReuse(job)} data-testid="job-retry"><Icon name="refresh" /> {t('fineTune.reuse')}</button>
</div>
</section>
)}
{(active || log.length > 0) && <JobLog lines={log} name={`quantize-${job.id?.slice(0, 8)}`} />}
<ResultPanel job={job} onRefresh={onRefresh} />
</div>
)
}
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 (
<div className="page page--medium bt-page quantize-page" data-testid="quantize-page">
<PageHeader
title={<>{t('quantize.title')} <span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span></>}
supporting={t('quantize.lede')}
actions={selectedJob && (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setSelectedJob(null)} data-testid="job-back">
<Icon name="plus" /> {t('quantize.newJob')}
</button>
)}
/>
<ToolSteps steps={steps} current={current} failed={failed} label={t('steps.label')} />
{error && (
<div className="bt-alert" role="alert" data-testid="tool-error">
<Icon name="warning" />
<div><p className="bt-alert__text">{error}</p></div>
</div>
)}
{selectedJob ? (
<JobView job={selectedJob} onStop={handleStop} onReuse={reuse} onTerminal={loadJobs} onRefresh={refreshSelected} />
) : (
<form onSubmit={handleSubmit} className="bt-form-page" data-testid="quantize-form">
<Section done={!!model.trim()} title={t('quantize.sectionModel')}>
<Field id="qz-model" label={t('quantize.model')} hint={t('quantize.modelHint')}>
<input id="qz-model" className="dk-input dk-input--mono" placeholder="meta-llama/Llama-3.2-1B" value={model} onChange={e => setModel(e.target.value)} required />
</Field>
</Section>
<Section done={!useCustomQuant || !!customQuantType.trim()} title={t('quantize.sectionPrecision')}>
<div className="bt-pair">
<Field id="qz-type" label={t('quantize.type')} hint={t('quantize.typeHint')}>
<span className="dk-select-wrap">
<select
id="qz-type" className="dk-select" value={useCustomQuant ? '__custom__' : quantType}
onChange={e => {
if (e.target.value === '__custom__') setUseCustomQuant(true)
else { setUseCustomQuant(false); setQuantType(e.target.value) }
}}
>
{QUANT_PRESETS.map(q => <option key={q} value={q}>{q}</option>)}
<option value="__custom__">{t('quantize.custom')}</option>
</select>
</span>
</Field>
{useCustomQuant && (
<Field id="qz-custom" label={t('quantize.customLabel')}>
<input id="qz-custom" className="dk-input dk-input--mono" placeholder={t('quantize.customPlaceholder')} value={customQuantType} onChange={e => setCustomQuantType(e.target.value)} required />
</Field>
)}
</div>
<div className="bt-more">
<button type="button" className="bt-more__toggle" onClick={() => setShowMore(v => !v)} aria-expanded={showMore} aria-controls="qz-more">
<Icon name={showMore ? 'chevron-down' : 'chevron-right'} />
<b>{t('form.more')}</b>
{!showMore && <span className="dk-hint">{t('quantize.moreHint')}</span>}
</button>
{showMore && (
<div className="bt-more__body" id="qz-more">
<div className="bt-pair">
<Field id="qz-backend" label={t('form.backend')}>
<span className="dk-select-wrap">
<select id="qz-backend" className="dk-select" value={backend} onChange={e => setBackend(e.target.value)}>
{backendNames.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</span>
</Field>
<Field id="qz-token" label={t('form.token')} hint={t('quantize.tokenHint')}>
<input id="qz-token" type="password" className="dk-input dk-input--mono" placeholder="hf_..." value={hfToken} onChange={e => setHfToken(e.target.value)} autoComplete="off" />
</Field>
</div>
</div>
)}
</div>
</Section>
<p className="bt-recipe" data-testid="qz-recipe">
{model.trim() ? t('quantize.recipe', { model: model.split('/').pop(), type: effectiveQuantType || '?' }) : t('quantize.recipeEmpty')}
</p>
<section className="bt-block" aria-labelledby="bt-check-title">
<header className="bt-block__head">
<h2 className="bt-h2" id="bt-check-title">{t('checks.heading')}</h2>
<p className="dk-hint">{t('checks.live')}</p>
</header>
<div className="dk-card bt-checks-card">
<Checks checks={checks} isAdmin={isAdmin} label={t('checks.heading')} />
</div>
</section>
<div className="bt-bar" data-testid="qz-bar">
<p className="bt-bar__text" role="status">
{stop ? t(`checks.${firstMissing.key}`) : warnings > 0 ? t('checks.barWarn', { count: warnings }) : t('checks.barOk')}
{' '}{t('quantize.barNote')}
</p>
<div className="bt-bar__acts">
<button type="submit" className="dk-btn dk-btn--primary" disabled={submitting || stop} aria-busy={submitting || undefined} data-testid="qz-start">
{submitting
? <><LoadingSpinner size="sm" /> {t('fineTune.starting')}</>
: <><Icon name="play" /> {t('quantize.start', { type: effectiveQuantType || '' })}</>}
</button>
</div>
</div>
</form>
)}
<section className="bt-jobs" aria-labelledby="bt-jobs-title" data-testid="qz-jobs">
<header className="bt-block__head">
<h2 className="bt-h2" id="bt-jobs-title">{t('jobs.title')}</h2>
{jobs.length > 0 && <p className="dk-hint">{t('jobs.count', { count: jobs.length })}</p>}
</header>
{jobs.length === 0 ? (
<div className="dk-empty bt-empty-card">
<div className="dk-empty-icon"><Icon name="minimize" /></div>
<h3 className="dk-empty-title">{jobsLoaded ? t('quantize.emptyTitle') : t('jobs.loading')}</h3>
{jobsLoaded && <p className="dk-empty-text">{t('quantize.emptyText')}</p>}
</div>
) : (
<div className="dk-table-wrap" role="region" aria-labelledby="bt-jobs-title" tabIndex={0}>
<table className="dk-table">
<caption className="dk-sr-only">{t('jobs.title')}</caption>
<thead>
<tr>
<th>{t('jobs.model')}</th>
<th className="dk-hide-phone">{t('quantize.type')}</th>
<th>{t('jobs.status')}</th>
<th className="dk-hide-phone">{t('jobs.started')}</th>
<th><span className="dk-sr-only">{t('jobs.actions')}</span></th>
</tr>
</thead>
<tbody>
{jobs.map(job => {
const active = QZ_STAGES.includes(job.status)
return (
<tr key={job.id} data-row data-selected={selectedJob?.id === job.id ? 'true' : undefined} data-error={job.status === 'failed' ? '' : undefined}>
<td>
<button type="button" className="bt-linkcell" onClick={() => setSelectedJob(job)} title={t('jobs.open')}>
<span className="dk-table-name">{job.model}</span>
</button>
{job.import_status === 'completed' && <span className="dk-table-sub">{t('quantize.imported', { name: job.import_model_name })}</span>}
{job.status === 'failed' && job.message && <span className="dk-table-sub bt-jobs__msg">{job.message}</span>}
</td>
<td className="dk-hide-phone dk-mono">{job.quantization_type}</td>
<td><StatusBadge status={job.status} /></td>
<td className="dk-hide-phone dk-mono">{startedAt(job)}</td>
<td>
<div className="bt-rowacts">
{active ? (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => handleStop(job.id)}><Icon name="stop" /> {t('run.stop')}</button>
) : (
<>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => reuse(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}</button>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={() => setDeleting(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}><Icon name="trash" /></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
{deleting && (
<Dialog
title={t('jobs.deleteDialogTitle')}
description={t('quantize.deleteDialogText')}
role="alertdialog"
onClose={() => setDeleting(null)}
testId="delete-dialog"
labelId="bt-delete-title"
foot={(
<>
<button type="button" className="dk-btn dk-btn--ghost" onClick={() => setDeleting(null)}>{t('stop.cancelDelete')}</button>
<button type="button" className="dk-btn dk-btn--danger" onClick={confirmDelete} data-testid="delete-confirm">{t('jobs.delete')}</button>
</>
)}
/>
)}
</div>
)
}
@@ -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 (
<span className="voice-row__waveform" aria-hidden="true">
{ROW_BARS.map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
</span>
)
}
// eslint-disable-next-line no-unused-vars
function VoiceModelSetup({ t, galleryLoading, installableModels, galleryError, installing, operations, onInstall }) {
return (
<section className="voice-detail__model-setup" aria-labelledby="voice-model-setup-title">
<div className="voice-detail__model-setup-heading">
<Icon name="cube" />
<div>
<h3 id="voice-model-setup-title">{t('voiceLibrary.modelSetup.title')}</h3>
<p>{t('voiceLibrary.modelSetup.body')}</p>
</div>
</div>
{galleryLoading && (
<div className="voice-detail__model-loading"><LoadingSpinner size="sm" /><span>{t('voiceLibrary.modelSetup.loading')}</span></div>
)}
{!galleryLoading && installableModels.length > 0 && (
<ul className="voice-detail__model-list">
{installableModels.map(model => {
const busy = installing.has(model.id) || operations.some(operation => operation.name === model.id && !operation.completed && !operation.error)
return (
<li key={model.id}>
<span>
<strong>{model.name}</strong>
<small>{model.backend || t('voiceLibrary.modelSetup.backendUnknown')}</small>
</span>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" disabled={busy} onClick={() => onInstall(model)}>
<Icon name={busy ? 'spinner' : 'download'} spin={Boolean(busy)} />{' '}
{t(busy ? 'voiceLibrary.modelSetup.installing' : 'voiceLibrary.modelSetup.install')}
</button>
</li>
)
})}
</ul>
)}
{!galleryLoading && installableModels.length === 0 && (
<p className="voice-detail__model-fallback">
{galleryError ? t('voiceLibrary.modelSetup.loadFailed') : t('voiceLibrary.modelSetup.noneAvailable')}{' '}
<Link to="/app/models">{t('voiceLibrary.actions.browseModels')}</Link>
</p>
)}
<p className="voice-detail__capability-note">
<Icon name="check-circle" /> {t('voiceLibrary.modelSetup.capabilityNote')}
</p>
</section>
)
}
// 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 || '<voice-cloning-model>'
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 (
<section className="voice-library-page" aria-labelledby="speech-voices-title">
<div className="idn-speech__head">
<div>
<h2 className="idn-h2" id="speech-voices-title">{t('voiceLibrary.title')} <span className="dk-hubtab-count">{profiles.length}</span></h2>
<p className="dk-hint idn-speech__lede">{t('voiceLibrary.subtitle')}</p>
</div>
<div className="idn-speech__acts">
{cloningModels.length > 0
? <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('voiceLibrary.summary.modelsReady', { count: cloningModels.length })}</span>
: <span className="dk-badge dk-badge--warn">{t('voiceLibrary.summary.noModels')}</span>}
<Link className="dk-btn dk-btn--secondary" to="/app/voice-library/new" data-testid="speech-create">
<Icon name="plus" /> {t('voiceLibrary.actions.create')}
</Link>
</div>
</div>
{error && (
<div className="idn-error" role="alert">
<span className="idn-error__icon" aria-hidden="true"><Icon name="alert-circle" /></span>
<div className="idn-error__body">
<h3 className="idn-error__title">{t('voiceLibrary.loadFailed')}</h3>
<p className="idn-error__raw dk-mono">{error}</p>
<div className="idn-error__acts"><button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => refetch()}>{t('voiceLibrary.actions.retry')}</button></div>
</div>
</div>
)}
{profiles.length > 0 && (
<div className="voice-library-toolbar">
<span className="dk-input-icon voice-library-search">
<Icon name="search" className="dk-icon" />
<input className="dk-input" type="search" aria-label={t('voiceLibrary.search.label')} value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('voiceLibrary.search.placeholder')} />
</span>
<span className="dk-select-wrap">
<select className="dk-select voice-library-language" aria-label={t('voiceLibrary.filters.language')} value={language} onChange={(event) => setLanguage(event.target.value)}>
<option value="all">{t('voiceLibrary.filters.allLanguages')}</option>
{languages.map(value => <option key={value} value={value}>{value}</option>)}
</select>
</span>
</div>
)}
<div className="voice-library-list" role="group" aria-label={t('voiceLibrary.listLabel')}>
{loading && <div className="voice-library-loading"><LoadingSpinner size="lg" /><span>{t('voiceLibrary.loading')}</span></div>}
{!loading && !error && profiles.length === 0 && (
<EmptyState
className="voice-library-empty"
icon="mic"
title={t('voiceLibrary.empty.title')}
body={t('voiceLibrary.empty.body')}
actions={<Link className="btn btn-primary" to="/app/voice-library/new">{t('voiceLibrary.actions.createFirst')}</Link>}
/>
)}
{!loading && !error && profiles.length > 0 && filteredProfiles.length === 0 && (
<EmptyState
className="voice-library-empty"
icon="filter-off"
title={t('voiceLibrary.noResults.title')}
body={t('voiceLibrary.noResults.body')}
actions={<button type="button" className="btn btn-secondary" onClick={() => { setSearch(''); setLanguage('all') }}>{t('voiceLibrary.actions.clearFilters')}</button>}
/>
)}
{!loading && !error && filteredProfiles.length > 0 && (
<div className="dk-list idn-speech__list">
{filteredProfiles.map(profile => (
<button
type="button"
className={`dk-row voice-row${profile.id === selected?.id ? ' voice-row--selected' : ''}`}
aria-selected={profile.id === selected?.id}
key={profile.id}
onClick={() => selectProfile(profile.id)}
>
<span className="dk-row-lead idn-avatar" aria-hidden="true">{profile.name.slice(0, 2).toLocaleUpperCase(i18n.language)}</span>
<span className="dk-row-main">
<span className="dk-row-title">{profile.name}</span>
<span className="dk-row-meta">{profile.description || t('voiceLibrary.metadata.languageUnknown')}</span>
</span>
<span className="dk-row-end voice-row__end">
<CompactWaveform />
<span className="dk-mono voice-row__meta">
{profile.language || t('voiceLibrary.metadata.languageUnknown')}
<span aria-hidden="true"> · </span>
{formatDuration(profile.audio?.duration_ms)}
</span>
<Icon name="chevron-right" className="voice-row__chevron" />
</span>
</button>
))}
</div>
)}
</div>
{!loading && !error && profiles.length > 0 && (
<p className="idn-privacy"><Icon name="lock" /> {t('voiceCreate.privacy.body')}</p>
)}
{!loading && !error && profiles.length === 0 && !modelsLoading && cloningModels.length === 0 && (
<VoiceModelSetup
t={t}
galleryLoading={galleryLoading}
installableModels={installableModels}
galleryError={galleryError}
installing={installing}
operations={operations}
onInstall={installModel}
/>
)}
{selected && (
<SideSheet
title={selected.name}
description={selected.description ? null : t('voiceLibrary.detail.eyebrow')}
onClose={closeProfile}
closeLabel={t('voiceLibrary.detail.close')}
wide
testId="speech-voice-sheet"
labelId="speech-voice-title"
footer={(
<>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--danger voice-delete" onClick={() => setConfirmDelete(true)}>
<Icon name="trash" /> {t('voiceLibrary.actions.delete')}
</button>
<button
type="button"
className="dk-btn dk-btn--primary"
disabled={cloningModels.length === 0}
onClick={() => navigate(`/app/tts?voice=${encodeURIComponent(selected.id)}`)}
>
<Icon name="headphones" /> {t('voiceLibrary.actions.useInTTS')}
</button>
</>
)}
>
<div className="voice-library-detail">
{selected.description && (
<div className="markdown-body voice-detail__description" dangerouslySetInnerHTML={{ __html: renderMarkdown(selected.description) }} />
)}
<div className="voice-detail__player">
<WaveformPlayer
src={voiceProfilesApi.audioUrl(selected.id)}
height={88}
label={t('voiceLibrary.detail.referenceAudio')}
audioTestId="voice-profile-audio"
/>
</div>
<div className="voice-detail__section">
<h3 className="dk-eyebrow">{t('voiceLibrary.detail.transcript')}</h3>
<blockquote>{selected.transcript}</blockquote>
</div>
<dl className="dk-kv voice-detail__metadata">
<dt>{t('voiceLibrary.metadata.language')}</dt><dd>{selected.language || t('voiceLibrary.metadata.languageUnknown')}</dd>
<dt>{t('voiceLibrary.metadata.duration')}</dt><dd>{formatDuration(selected.audio?.duration_ms)}</dd>
<dt>{t('voiceLibrary.metadata.sampleRate')}</dt><dd>{Math.round((selected.audio?.sample_rate || 0) / 1000)} kHz</dd>
<dt>{t('voiceLibrary.metadata.created')}</dt><dd>{dateFmt(selected.created_at, 'long')}</dd>
<dt>{t('voiceLibrary.consent.label')}</dt>
<dd><span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('voiceLibrary.consent.confirmed')}</span> <span className="dk-hint">{t('voiceLibrary.consent.confirmedAt', { date: dateFmt(selected.consent_confirmed_at, 'medium') })}</span></dd>
</dl>
{!modelsLoading && cloningModels.length === 0 && (
<VoiceModelSetup
t={t}
galleryLoading={galleryLoading}
installableModels={installableModels}
galleryError={galleryError}
installing={installing}
operations={operations}
onInstall={installModel}
/>
)}
<details className="voice-detail__api idn-disclosure">
<summary>
<Icon name="chevron-right" />
<span><strong>{t('voiceLibrary.api.title')}</strong> <small className="dk-hint">{t('voiceLibrary.api.summary')}</small></span>
</summary>
<div className="voice-detail__api-body">
<p>{t('voiceLibrary.api.body')}</p>
<div className="voice-detail__compatible-models">
<strong>{t('voiceLibrary.api.compatibleModels')}</strong>
{cloningModels.length > 0 ? (
<ul>
{cloningModels.map(model => (
<li key={model.id}><span className="dk-mono">{model.id}</span><small>{model.backend}</small></li>
))}
</ul>
) : (
<span>{t('voiceLibrary.api.noInstalledModels')}</span>
)}
</div>
<div className="voice-detail__code-heading">
<span className="dk-eyebrow">{t('voiceLibrary.api.curlExample')}</span>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={copyAPIExample}>
<Icon name="copy" /> {t('voiceLibrary.api.copy')}
</button>
</div>
<pre><code>{apiExample}</code></pre>
<p className="dk-hint">{t('voiceLibrary.api.endpointNote')}</p>
</div>
</details>
</div>
</SideSheet>
)}
<ConfirmDialog
open={confirmDelete}
title={t('voiceLibrary.deleteDialog.title')}
message={selected ? t('voiceLibrary.deleteDialog.message', { name: selected.name }) : ''}
confirmLabel={deleting ? t('voiceLibrary.deleteDialog.deleting') : t('voiceLibrary.actions.delete')}
danger
onConfirm={deleteSelected}
onCancel={() => !deleting && setConfirmDelete(false)}
/>
</section>
)
}
@@ -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 (
<li className={`voice-readiness__item voice-readiness__item--${tone}`}>
<Icon name={ready ? 'check' : warning ? 'warning' : 'minus'} />
<span>{children}</span>
</li>
)
}
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 (
<main className="page voice-create-page">
<UnsavedChangesGuard
when={!submitting && !!(audio || additionalReferences.length || name || description || language || transcript || consent)}
/>
<PageHeader
title={<><Icon name="mic" /> {t('voiceCreate.title')}</>}
supporting={t('voiceCreate.subtitle')}
actions={<Link className="dk-btn dk-btn--secondary" to="/app/voice-library"><Icon name="arrow-left" /> {t('voiceCreate.actions.back')}</Link>}
/>
<form className="voice-create-grid" onSubmit={submit}>
<div className="voice-create-form">
<section className="voice-create-section" aria-labelledby="voice-reference-heading">
<div className="voice-create-section__heading">
<span>01</span>
<div><h2 id="voice-reference-heading">{t('voiceCreate.sections.reference.title')}</h2><p>{t('voiceCreate.sections.reference.body')}</p></div>
</div>
<ClipInput
kind="audio"
label={t('voiceCreate.audio.label')}
value={audio}
onChange={handleAudio}
idPrefix="voice-profile"
/>
{audioProcessing && <div className="voice-create-processing"><LoadingSpinner size="sm" /> {t('voiceCreate.audio.normalizing')}</div>}
{audioError && <p className="dk-field-error" role="alert">{audioError}</p>}
{audio && (
<div className="voice-create-preview">
<WaveformPlayer src={audio.dataUrl} height={72} label={t('voiceCreate.audio.preview')} />
<div className="voice-create-preview__meta">
<span><Icon name="clock" /> {audio.duration.toFixed(1)}s</span>
<span><Icon name="waveform" /> {Math.round(audio.sampleRate / 1000)} kHz · PCM WAV</span>
<span className={durationRecommended ? 'tone-success' : 'tone-warning'}>
{durationRecommended ? t('voiceCreate.audio.qualityReady') : t('voiceCreate.audio.qualityHint')}
</span>
</div>
</div>
)}
{additionalReferences.map((reference, index) => (
<div className="voice-create-preview" key={index}>
<div className="voice-create-label-row">
<strong>{t('voiceCreate.references.additional', { number: index + 2 })}</strong>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => {
if (reference.audio?.objectUrl) URL.revokeObjectURL(reference.audio.dataUrl)
setAdditionalReferences(current => current.filter((_, itemIndex) => itemIndex !== index))
}}>{t('voiceCreate.references.remove')}</button>
</div>
<ClipInput kind="audio" label={t('voiceCreate.audio.label')} value={reference.audio} onChange={(sample) => handleAdditionalAudio(index, sample)} idPrefix={`voice-profile-${index + 2}`} />
<label className="dk-label" htmlFor={`voice-profile-transcript-${index + 2}`}>{t('voiceCreate.fields.transcript')}</label>
<textarea id={`voice-profile-transcript-${index + 2}`} className="dk-textarea" rows={3} maxLength={4000} value={reference.transcript} onChange={(event) => setAdditionalReferences(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, transcript: event.target.value } : item))} required />
</div>
))}
{additionalReferences.length < 9 && <button type="button" className="dk-btn dk-btn--secondary" onClick={() => setAdditionalReferences(current => [...current, { audio: null, transcript: '' }])}><Icon name="plus" /> {t('voiceCreate.references.add')}</button>}
</section>
<section className="voice-create-section" aria-labelledby="voice-details-heading">
<div className="voice-create-section__heading">
<span>02</span>
<div><h2 id="voice-details-heading">{t('voiceCreate.sections.details.title')}</h2><p>{t('voiceCreate.sections.details.body')}</p></div>
</div>
<div className="form-grid-2col">
<div className="dk-field">
<label className="dk-label" htmlFor="voice-profile-name">{t('voiceCreate.fields.name')}</label>
<input id="voice-profile-name" className="dk-input" value={name} maxLength={80} onChange={(event) => setName(event.target.value)} placeholder={t('voiceCreate.fields.namePlaceholder')} required />
</div>
<div className="dk-field">
<label className="dk-label" htmlFor="voice-profile-language">{t('voiceCreate.fields.language')}</label>
<input id="voice-profile-language" className="dk-input" value={language} maxLength={35} onChange={(event) => setLanguage(event.target.value)} placeholder={t('voiceCreate.fields.languagePlaceholder')} />
</div>
</div>
<div className="dk-field">
<label className="dk-label" htmlFor="voice-profile-description">{t('voiceCreate.fields.description')}</label>
<input id="voice-profile-description" className="dk-input" value={description} maxLength={500} onChange={(event) => setDescription(event.target.value)} placeholder={t('voiceCreate.fields.descriptionPlaceholder')} />
</div>
<div className="dk-field">
<div className="voice-create-label-row">
<label className="dk-label" htmlFor="voice-profile-transcript">{t('voiceCreate.fields.transcript')}</label>
<span>{transcript.length}/4000</span>
</div>
<textarea id="voice-profile-transcript" className="dk-textarea" rows={5} maxLength={4000} value={transcript} onChange={(event) => setTranscript(event.target.value)} placeholder={t('voiceCreate.fields.transcriptPlaceholder')} required />
<p className="dk-hint">{t('voiceCreate.fields.transcriptHint')}</p>
</div>
</section>
<section className="voice-create-section" aria-labelledby="voice-consent-heading">
<div className="voice-create-section__heading">
<span>03</span>
<div><h2 id="voice-consent-heading">{t('voiceCreate.sections.consent.title')}</h2><p>{t('voiceCreate.sections.consent.body')}</p></div>
</div>
<label className="dk-choice voice-consent-check">
<input className="dk-check" type="checkbox" checked={consent} onChange={(event) => setConsent(event.target.checked)} />
<span><strong>{t('voiceCreate.consent.title')}</strong><span className="dk-hint">{t('voiceCreate.consent.body')}</span></span>
</label>
</section>
<div className="voice-create-actions">
<Link className="dk-btn dk-btn--secondary" to="/app/voice-library">{t('voiceCreate.actions.cancel')}</Link>
<button type="submit" className="dk-btn dk-btn--primary" disabled={!formReady || submitting}>
{submitting ? <><LoadingSpinner size="sm" /> {t('voiceCreate.actions.saving')}</> : <><Icon name="save" /> {t('voiceCreate.actions.save')}</>}
</button>
</div>
</div>
<aside className="voice-readiness" aria-label={t('voiceCreate.readiness.title')}>
<div className="voice-readiness__header">
<span className="voice-readiness__icon"><Icon name="shield" /></span>
<div><h2>{t('voiceCreate.readiness.title')}</h2><p>{t('voiceCreate.readiness.body')}</p></div>
</div>
<ul>
<ReadinessItem ready={readiness.audio}>{t('voiceCreate.readiness.audio')}</ReadinessItem>
<ReadinessItem ready={readiness.quality} warning={!!audio && !readiness.quality}>{t('voiceCreate.readiness.quality')}</ReadinessItem>
<ReadinessItem ready={readiness.name}>{t('voiceCreate.readiness.name')}</ReadinessItem>
<ReadinessItem ready={readiness.transcript}>{t('voiceCreate.readiness.transcript')}</ReadinessItem>
<ReadinessItem ready={readiness.consent}>{t('voiceCreate.readiness.consent')}</ReadinessItem>
</ul>
<div className="voice-readiness__privacy">
<Icon name="lock" />
<p><strong>{t('voiceCreate.privacy.title')}</strong>{t('voiceCreate.privacy.body')}</p>
</div>
</aside>
</form>
</main>
)
}
@@ -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 (
<main className="page idn-page" data-testid="voices-page">
<PageHeader
title={t('voices.title')}
supporting={t('voices.lede')}
actions={tab === 'speakers' && !needModel ? (
<div className="idn-modelpick">
<ModelSelector value={model} onChange={setModel} capability={CAP_SPEAKER_RECOGNITION} options={names} loading={modelsLoading} triggerClassName="idn-model" />
</div>
) : null}
/>
<nav className="dk-tabs idn-tabs" role="tablist" aria-label={t('voices.tabs')}>
<Link className="dk-tab" role="tab" to="/app/voice" aria-selected={tab === 'speakers'} aria-current={tab === 'speakers' ? 'page' : undefined}>{t('voices.speakers')} <span className="dk-hubtab-count">{savedCount}</span></Link>
{isAdmin && (
<Link className="dk-tab" role="tab" to="/app/voice-library" aria-selected={tab === 'speech'} aria-current={tab === 'speech' ? 'page' : undefined}>
{t('voices.speech')} <span className="dk-hubtab-count">{library.loading ? '' : library.profiles.length}</span>
</Link>
)}
<Link className="dk-tab" role="tab" to="/app/voice?tab=recording" aria-selected={tab === 'recording'} aria-current={tab === 'recording' ? 'page' : undefined}>{t('voices.recording')}</Link>
</nav>
{tab === 'speakers' && (
<Workbench
kind="voice" model={model} addToast={addToast} canSpeech={isAdmin} onCount={setSavedCount}
needed={needModel ? <ModelNeeded kind="voice" addToast={addToast} isAdmin={isAdmin} onInstalled={refetch} /> : null}
more={needModel ? null : (
<details className="idn-disclosure idn-disclosure--page idn-more">
<summary><Icon name="chevron-right" /> {t('voice.moreTitle')} <span className="dk-hint">{t('voice.moreHint')}</span></summary>
<MoreVoiceTools model={model} addToast={addToast} />
</details>
)}
/>
)}
{tab === 'speech' && <SpeechVoices library={library} />}
{tab === 'recording' && (
<section className="idn-recording dk-card" data-testid="recording-tab">
<h2 className="idn-h2">{t('recording.title')}</h2>
<p className="idn-recording__text">{t('recording.text')}</p>
<ol className="idn-recording__steps">
<li>{t('recording.step1')}</li>
<li>{t('recording.step2')}</li>
<li>{t('recording.step3')}</li>
</ol>
{hasFeature('audio_diarization')
? <Link className="dk-btn dk-btn--primary" to="/app/studio/diarization">{t('recording.open')} <Icon name="arrow-right" /></Link>
: <p className="dk-hint">{t('recording.off')}</p>}
<p className="dk-hint">{t('recording.warning')}</p>
</section>
)}
</main>
)
}
-296
View File
@@ -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); } }
+18 -36
View File
@@ -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=<id>: 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=<id>: 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 <Navigate to={`/app/addons/${addon}/${page}${query ? `?${query}` : ''}${hash}`} replace />
}
return children
return <Navigate to={addonPageAddress({ addon, page, model: withModel ? model : undefined, search, hash })} replace />
}
function Admin({ children }) {
@@ -302,13 +284,13 @@ const appChildren = [
{ path: 'collections/:name', element: <Feature feature="collections"><Collections /></Feature> },
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
{ path: 'agent-jobs/tasks/:id', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ path: 'fine-tune', element: <MovedPage addon="fine-tuning" page="jobs"><Feature feature="fine_tuning" disabled={<ToolOff tool="fineTune" />}><FineTune /></Feature></MovedPage> },
{ path: 'quantize', element: <MovedPage addon="quantization" page="jobs"><Feature feature="quantization" disabled={<ToolOff tool="quantize" />}><Quantize /></Feature></MovedPage> },
{ path: 'face', element: <MovedPage addon="face" page="recognition"><Feature feature="face_recognition" disabled={<FeatureOff feature="face" />}><FaceRecognition /></Feature></MovedPage> },
{ path: 'face/:model', element: <MovedPage addon="face" page="recognition" withModel><Feature feature="face_recognition" disabled={<FeatureOff feature="face" />}><FaceRecognition /></Feature></MovedPage> },
{ path: 'voice', element: <MovedPage addon="voice" page="voice/recognition"><Feature feature="voice_recognition" disabled={<FeatureOff feature="voice" />}><VoiceRecognition /></Feature></MovedPage> },
{ path: 'voice/:model', element: <MovedPage addon="voice" page="voice/recognition" withModel><Feature feature="voice_recognition" disabled={<FeatureOff feature="voice" />}><VoiceRecognition /></Feature></MovedPage> },
{ path: 'voice-library', element: <Admin><MovedPage addon="voice" page="voice/library"><VoiceRecognition tab="speech" /></MovedPage></Admin> },
{ path: 'fine-tune', element: <AddonRedirect addon="fine-tuning" page="jobs" /> },
{ path: 'quantize', element: <AddonRedirect addon="quantization" page="jobs" /> },
{ path: 'face', element: <AddonRedirect addon="face" page="recognition" /> },
{ path: 'face/:model', element: <AddonRedirect addon="face" page="recognition" withModel /> },
{ path: 'voice', element: <AddonRedirect addon="voice" page="voice/recognition" /> },
{ path: 'voice/:model', element: <AddonRedirect addon="voice" page="voice/recognition" withModel /> },
{ path: 'voice-library', element: <AddonRedirect addon="voice" page="voice/library" /> },
{ path: 'import-model', element: <Admin><ImportModel /></Admin> },
],
},
@@ -363,7 +345,7 @@ const appChildren = [
children: [{ path: ':id', element: <ModelPage /> }],
},
{ path: 'manage', element: <Admin><ManageRedirect /></Admin> },
{ path: 'voice-library/new', element: <Admin><MovedPage addon="voice" page="voice/library/new"><VoiceProfileCreate /></MovedPage></Admin> },
{ path: 'voice-library/new', element: <AddonRedirect addon="voice" page="voice/library/new" /> },
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
{ path: '*', element: <NotFound /> },
+6
View File
@@ -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=<id>.
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) {
-39
View File
@@ -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(() => {})
}
}
@@ -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(`<MovedPage addon="${addon}" page="${page}">`) || router.includes(`<MovedPage addon="${addon}" page="${page}" withModel>`), `router.jsx does not wrap ${r.from}`)
assert.ok(router.includes(`<AddonRedirect addon="${addon}" page="${page}" />`) || router.includes(`<AddonRedirect addon="${addon}" page="${page}" withModel />`), `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('<MovedPage addon="face" page="recognition">'), 'face is not wrapped')
assert.ok(withModel?.includes('<MovedPage addon="face" page="recognition" withModel>'), 'face/:model is not wrapped with withModel')
assert.ok(plain?.includes('<AddonRedirect addon="face" page="recognition" />'), 'face does not redirect')
assert.ok(withModel?.includes('<AddonRedirect addon="face" page="recognition" withModel />'), '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(`<MovedPage addon="${r.addon}" page="jobs">`), `${r.path} is not wrapped`)
assert.ok(entry.includes(`<Feature feature="${r.feature}" disabled={<ToolOff tool="${r.tool}" />}>`), `${r.path} lost its Feature and ToolOff`)
assert.ok(entry?.includes(`<AddonRedirect addon="${r.addon}" page="jobs" />`), `${r.path} does not redirect`)
}
})
+5 -6
View File
@@ -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