mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
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:
1 parent
756b019ce4
commit
60254e582d
43 files changed
+61
-6735
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
-1
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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); } }
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user