From 60254e582de0971419c7a1d24464e178384bb9c1 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Fri, 23 Oct 2026 11:54:53 +0200 Subject: [PATCH] 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 Co-authored-by: Ettore Di Giacinto --- addons/fine-tuning/ui/test/page.test.mjs | 8 - addons/quantization/ui/test/page.test.mjs | 8 - .../react-ui/e2e/addon-navigation.spec.js | 36 +- core/http/react-ui/e2e/build-tools.spec.js | 513 ------- core/http/react-ui/e2e/chrome-audit.spec.js | 3 +- core/http/react-ui/e2e/identity.spec.js | 530 ------- core/http/react-ui/e2e/no-edge-rails.spec.js | 2 +- .../react-ui/e2e/page-render-smoke.spec.js | 6 - .../react-ui/e2e/voice-library-empty.spec.js | 37 - core/http/react-ui/e2e/voice-library.spec.js | 226 --- .../public/locales/en/biometrics.json | 292 ---- .../react-ui/src/components/EmptyState.jsx | 14 - .../biometrics/BoundingBoxCanvas.jsx | 63 - .../components/biometrics/WaveformStrip.jsx | 59 - .../src/components/identity/ClipInput.jsx | 213 --- .../src/components/identity/DistanceScale.jsx | 42 - .../src/components/identity/EnrolSheet.jsx | 164 --- .../src/components/identity/ErrorNote.jsx | 26 - .../src/components/identity/FeatureOff.jsx | 21 - .../src/components/identity/KnownList.jsx | 109 -- .../src/components/identity/ModelNeeded.jsx | 81 - .../src/components/identity/MoreFaceTools.jsx | 260 ---- .../components/identity/MoreVoiceTools.jsx | 222 --- .../src/components/identity/Verdict.jsx | 22 - .../src/components/identity/Workbench.jsx | 363 ----- .../src/components/identity/useRegistry.js | 76 - .../react-ui/src/components/tools/JobLog.jsx | 75 - .../src/components/tools/LossChart.jsx | 164 --- .../src/components/tools/StageLine.jsx | 21 - .../react-ui/src/components/tools/ToolOff.jsx | 28 - core/http/react-ui/src/i18n/index.js | 1 - .../react-ui/src/pages/FaceRecognition.jsx | 56 - core/http/react-ui/src/pages/FineTune.jsx | 1297 ----------------- core/http/react-ui/src/pages/Quantize.jsx | 558 ------- core/http/react-ui/src/pages/VoiceLibrary.jsx | 421 ------ .../react-ui/src/pages/VoiceProfileCreate.jsx | 251 ---- .../react-ui/src/pages/VoiceRecognition.jsx | 98 -- core/http/react-ui/src/pages/identity.css | 296 ---- core/http/react-ui/src/router.jsx | 54 +- core/http/react-ui/src/utils/legacyRoutes.js | 6 + .../http/react-ui/src/utils/referenceAudio.js | 39 - .../react-ui/tests/unit/legacyroutes.test.mjs | 24 +- docs/content/features/addons.md | 11 +- 43 files changed, 61 insertions(+), 6735 deletions(-) delete mode 100644 core/http/react-ui/e2e/build-tools.spec.js delete mode 100644 core/http/react-ui/e2e/identity.spec.js delete mode 100644 core/http/react-ui/e2e/voice-library-empty.spec.js delete mode 100644 core/http/react-ui/e2e/voice-library.spec.js delete mode 100644 core/http/react-ui/public/locales/en/biometrics.json delete mode 100644 core/http/react-ui/src/components/EmptyState.jsx delete mode 100644 core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx delete mode 100644 core/http/react-ui/src/components/biometrics/WaveformStrip.jsx delete mode 100644 core/http/react-ui/src/components/identity/ClipInput.jsx delete mode 100644 core/http/react-ui/src/components/identity/DistanceScale.jsx delete mode 100644 core/http/react-ui/src/components/identity/EnrolSheet.jsx delete mode 100644 core/http/react-ui/src/components/identity/ErrorNote.jsx delete mode 100644 core/http/react-ui/src/components/identity/FeatureOff.jsx delete mode 100644 core/http/react-ui/src/components/identity/KnownList.jsx delete mode 100644 core/http/react-ui/src/components/identity/ModelNeeded.jsx delete mode 100644 core/http/react-ui/src/components/identity/MoreFaceTools.jsx delete mode 100644 core/http/react-ui/src/components/identity/MoreVoiceTools.jsx delete mode 100644 core/http/react-ui/src/components/identity/Verdict.jsx delete mode 100644 core/http/react-ui/src/components/identity/Workbench.jsx delete mode 100644 core/http/react-ui/src/components/identity/useRegistry.js delete mode 100644 core/http/react-ui/src/components/tools/JobLog.jsx delete mode 100644 core/http/react-ui/src/components/tools/LossChart.jsx delete mode 100644 core/http/react-ui/src/components/tools/StageLine.jsx delete mode 100644 core/http/react-ui/src/components/tools/ToolOff.jsx delete mode 100644 core/http/react-ui/src/pages/FaceRecognition.jsx delete mode 100644 core/http/react-ui/src/pages/FineTune.jsx delete mode 100644 core/http/react-ui/src/pages/Quantize.jsx delete mode 100644 core/http/react-ui/src/pages/VoiceLibrary.jsx delete mode 100644 core/http/react-ui/src/pages/VoiceProfileCreate.jsx delete mode 100644 core/http/react-ui/src/pages/VoiceRecognition.jsx delete mode 100644 core/http/react-ui/src/pages/identity.css delete mode 100644 core/http/react-ui/src/utils/referenceAudio.js diff --git a/addons/fine-tuning/ui/test/page.test.mjs b/addons/fine-tuning/ui/test/page.test.mjs index c14464dcf..29554797f 100644 --- a/addons/fine-tuning/ui/test/page.test.mjs +++ b/addons/fine-tuning/ui/test/page.test.mjs @@ -207,14 +207,6 @@ test('plural: the job count picks the singular and the plural key', async () => assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server') }) -test('parity: every data-testid of the core page and chart is in the add-on or the shared views', () => { - const core = ['pages/FineTune.jsx', 'components/tools/LossChart.jsx'].map(f => read(join(hostUi, 'src', f))).join('\n') - const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1])) - assert.ok(ids.size >= 20, `found only ${ids.size} test ids in the core sources`) - const mine = ownText + sharedText - for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`) -}) - test('i18n: every literal key the add-on and the shared views read is in en.js', () => { const keys = new Set() for (const text of [ownText, sharedText]) for (const m of text.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'/g)) keys.add(m[1]) diff --git a/addons/quantization/ui/test/page.test.mjs b/addons/quantization/ui/test/page.test.mjs index 16e629641..ec0a8c885 100644 --- a/addons/quantization/ui/test/page.test.mjs +++ b/addons/quantization/ui/test/page.test.mjs @@ -170,14 +170,6 @@ test('plural: the job count picks the singular and the plural key', async () => assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server') }) -test('parity: every data-testid of the core page is in the add-on or the shared views', () => { - const core = read(join(hostUi, 'src/pages/Quantize.jsx')) - const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1])) - assert.ok(ids.size >= 10, `found only ${ids.size} test ids in the core source`) - const mine = ownText + sharedText - for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`) -}) - test('icons: every icon name the page draws is one the SDK knows', async () => { const { ICON_NAMES } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href) const known = new Set(ICON_NAMES) diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index d2e615ea9..011cfd5dc 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -387,14 +387,6 @@ async function asAdmin(page, features = {}) { await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, voice_recognition: true, ...features }) })) } -async function expectVoicesPage(page) { - await expect(page.getByTestId('voices-page')).toBeVisible() - await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible() - for (const name of [/Speakers/, /Speech voices/, /From a recording/]) { - await expect(page.getByRole('tab', { name })).toBeVisible() - } -} - // The tabs of the one Voice page. An administrator sees all four. async function expectVoiceTabs(page, active) { const tabs = page.getByTestId('voice-tabs') @@ -426,13 +418,12 @@ test.describe('Add-on navigation: the Voice recognition page', () => { await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet') }) - test('the core page, with the add-on absent, shows the same test ids and tabs', async ({ page }) => { + test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) - await mockIdentity(page) await page.goto('/app/voice') - await expect(page).toHaveURL(/\/app\/voice$/) - await expectVoicesPage(page) + await expect(page).toHaveURL(/\/app\/addons\/voice\//) + await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) test('an old model link ends in the add-on with the model in the query', async ({ page }) => { @@ -667,13 +658,12 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet') }) - test('the core page, with the add-on absent, shows the same test id and title', async ({ page }) => { + test('an old Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) - await asFaceAdmin(page) - await mockIdentity(page) + await asAdmin(page) await page.goto('/app/face') - await expect(page).toHaveURL(/\/app\/face$/) - await expectFacesPage(page) + await expect(page).toHaveURL(/\/app\/addons\/face\//) + await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) test('an old model link ends in the add-on with the model in the query', async ({ page }) => { @@ -705,10 +695,12 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page.getByTestId('tool-off')).toBeVisible() }) - test('an old fine-tuning link opens the core page when the add-on is not installed', async ({ page }) => { + test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) + await asAdmin(page) await page.goto('/app/fine-tune') - await expect(page).toHaveURL(/\/app\/fine-tune$/) + await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//) + await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) test('an old fine-tuning link opens the add-on when it is installed and running', async ({ page }) => { @@ -718,10 +710,12 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/) }) - test('an old quantization link opens the core page when the add-on is not installed', async ({ page }) => { + test('an old quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) + await asAdmin(page) await page.goto('/app/quantize') - await expect(page).toHaveURL(/\/app\/quantize$/) + await expect(page).toHaveURL(/\/app\/addons\/quantization\//) + await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) test('an old quantization link opens the add-on when it is installed and running', async ({ page }) => { diff --git a/core/http/react-ui/e2e/build-tools.spec.js b/core/http/react-ui/e2e/build-tools.spec.js deleted file mode 100644 index e63047f24..000000000 --- a/core/http/react-ui/e2e/build-tools.spec.js +++ /dev/null @@ -1,513 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { CPU_HOST, GPU_HOST, ftEvents, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js' - -// Fine-tune and Quantize share one pattern: set up, check before you start (the -// list redraws as the form changes), run with progress and a log, then a result -// with real next steps. Every number on these pages comes from the stubs below. - -const MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0' -const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state) -const check = (page, id) => page.locator(`[data-testid="tool-checks"] [data-check="${id}"]`) - -async function boot(page, { host = GPU_HOST, ft = {}, qz = {} } = {}) { - await mockMachine(page, { host }) - const ftState = { jobs: [], ...ft } - const qzState = { jobs: [], ...qz } - await mockFineTune(page, ftState) - await mockQuantize(page, qzState) - return { ftState, qzState } -} - -async function fillFineTune(page) { - await page.fill('#ft-model', MODEL) - await page.fill('#ft-dataset', 'tatsu-lab/alpaca') -} - -test.describe('Fine-tune: set up and check', () => { - test('starts empty: Set up is current, Start is off, and the check says what is missing', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - await expectStep(page, 'setup', 'current') - await expect(page.getByTestId('ft-start')).toBeDisabled() - await expect(check(page, 'model')).toHaveAttribute('data-tone', 'fail') - await expect(check(page, 'data')).toContainText('Add a dataset') - await expect(page.getByTestId('ft-bar')).toContainText('Enter the model to train.') - await expect(page.getByTestId('ft-recipe')).toContainText('Choose a model and a dataset') - }) - - test('a model and a dataset move to Check and switch Start on', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await expectStep(page, 'check', 'current') - await expect(page.getByTestId('ft-start')).toBeEnabled() - await expect(check(page, 'model')).toHaveAttribute('data-tone', 'ok') - await expect(check(page, 'data')).toContainText('Data comes from tatsu-lab/alpaca') - await expect(check(page, 'backend')).toContainText('trl is installed') - await expect(check(page, 'memory')).toContainText('RTX 4070: 9.8 GB of 12 GB free') - await expect(check(page, 'memory')).toContainText('LocalAI does not estimate it before the job starts') - await expect(check(page, 'memory').getByRole('img')).toHaveAttribute('aria-label', /18 percent in use/) - await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') - await expect(page.getByTestId('ft-recipe')).toContainText('Train a LoRA adapter of TinyLlama-1.1B-Chat-v1.0 with SFT, 3 epochs over tatsu-lab/alpaca.') - await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') - }) - - test('the check list redraws as the form changes', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await expect(check(page, 'memory')).toContainText('Batch size 2') - await page.fill('#ft-batch', '4') - await expect(check(page, 'memory')).toContainText('Batch size 4') - await page.fill('#ft-epochs', '5') - await expect(page.getByTestId('ft-recipe')).toContainText('5 epochs') - await page.getByRole('radio', { name: /Full model/ }).check() - await expect(page.getByTestId('ft-recipe')).toContainText('full model') - - // GRPO needs a reward function: a warning that goes when one is chosen. - await page.getByTestId('ft-more-toggle').click() - await page.selectOption('#ft-method', 'grpo') - await expect(check(page, 'reward')).toHaveAttribute('data-tone', 'warn') - await expect(page.getByTestId('ft-bar')).toContainText('1 warning. You can still start.') - await expect(page.getByTestId('ft-start')).toBeEnabled() - await page.getByLabel('format_reward').check() - await expect(check(page, 'reward')).toHaveCount(0) - await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') - }) - - test('with no GPU the memory check warns and names the RAM', async ({ page }) => { - await boot(page, { host: CPU_HOST }) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await expect(check(page, 'memory')).toHaveAttribute('data-tone', 'warn') - await expect(check(page, 'memory')).toContainText('No GPU found. Training uses the CPU and 20 GB of 32 GB RAM is free.') - }) - - test('with no backend installed it warns, links to Backends, and still lets you start', async ({ page }) => { - await boot(page, { ft: { backends: [] } }) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') - await expect(check(page, 'backend')).toContainText('No fine-tuning backend is installed') - await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toHaveAttribute('href', '/app/backends') - await expect(page.getByTestId('ft-start')).toBeEnabled() - }) - - test('a user who cannot read the machine still gets a check list', async ({ page }) => { - await boot(page, { host: null }) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await expect(check(page, 'memory')).toContainText('This account cannot read the machine') - await expect(check(page, 'disk')).toHaveCount(0) - }) - - test('Start sends the request the form describes and opens the job', async ({ page }) => { - const { ftState } = await boot(page) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await page.fill('#ft-split', 'train') - await page.fill('#ft-lr', '5e-5') - await page.getByTestId('ft-start').click() - await expect(page.getByTestId('job-view')).toBeVisible() - expect(ftState.started).toHaveLength(1) - expect(ftState.started[0]).toMatchObject({ - model: MODEL, backend: 'trl', training_method: 'sft', training_type: 'lora', dataset_source: 'tatsu-lab/alpaca', - dataset_split: 'train', num_epochs: 3, batch_size: 2, learning_rate: 0.00005, adapter_rank: 16, - }) - expect(ftState.started[0].extra_options).toMatchObject({ max_seq_length: '2048', eval_strategy: 'no' }) - await expectStep(page, 'run', 'current') - }) - - test('an uploaded file is the dataset, and is sent to the upload endpoint first', async ({ page }) => { - const { ftState } = await boot(page) - await page.goto('/app/fine-tune') - await page.fill('#ft-model', MODEL) - await page.setInputFiles('#ft-dataset-file', { name: 'pairs.jsonl', mimeType: 'application/json', buffer: Buffer.from('{"text":"a"}\n') }) - await expect(check(page, 'data')).toContainText('pairs.jsonl') - await page.getByTestId('ft-start').click() - await expect(page.getByTestId('job-view')).toBeVisible() - expect(ftState.started[0].dataset_source).toBe('/data/datasets/upload.jsonl') - }) - - test('a refused start shows the server message and keeps the form', async ({ page }) => { - await boot(page, { ft: { startError: 'failed to load backend trl: not found' } }) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await page.getByTestId('ft-start').click() - await expect(page.getByTestId('tool-error')).toContainText('failed to load backend trl: not found') - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - await expect(page.locator('#ft-model')).toHaveValue(MODEL) - }) - - test('More options carries the method, adapter and evaluation settings', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await expect(page.locator('#ft-method')).toHaveCount(0) - await page.getByTestId('ft-more-toggle').click() - for (const id of ['#ft-backend', '#ft-method', '#ft-type', '#ft-rank', '#ft-optimizer', '#ft-hf-token', '#ft-seq-len']) await expect(page.locator(id)).toBeVisible() - await page.getByRole('switch', { name: 'Evaluate while training' }).click() - await expect(page.locator('#ft-eval-strategy')).toBeVisible() - }) -}) - -test.describe('Fine-tune: run, fail, finish', () => { - const open = async (page) => { - await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() - await expect(page.getByTestId('job-view')).toBeVisible() - } - - test('a running job shows progress, stages, chart and log from its events', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) - await page.goto('/app/fine-tune') - await open(page) - await expectStep(page, 'run', 'current') - const view = page.getByTestId('job-view') - await expect(view.locator('.bt-job__percent')).toContainText('60') - await expect(view).toContainText('step 540 of 900') - await expect(view).toContainText('epoch 1.8 of 3') - await expect(view).toContainText('about 12 min left') - await expect(view).toContainText('312 tokens/s') - await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '60') - await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Training') - // The chart: a line, the evaluation loss as hollow dots, the last value. - await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() - await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(5) - await expect(page.getByTestId('job-chart')).toContainText('Hollow dots are the evaluation loss') - await page.getByRole('tab', { name: 'Learning rate' }).click() - await expect(page.getByTestId('job-chart').getByRole('heading', { name: 'Learning rate' })).toBeVisible() - await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(0) - // The log is what the events said, and the filter shows warnings only. - await expect(page.getByTestId('job-log')).toContainText('step 530/900 loss') - await page.getByTestId('job-log-filter').check() - await expect(page.getByTestId('job-log')).toContainText('No warnings or errors.') - }) - - test('Stop asks whether to keep a checkpoint and sends the answer', async ({ page }) => { - const { ftState } = await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) - await page.goto('/app/fine-tune') - await open(page) - await page.getByTestId('job-stop').click() - await expect(page.getByTestId('stop-dialog')).toContainText('Stop training?') - await page.getByTestId('stop-keep').click() - await expect(page.getByTestId('stop-dialog')).toHaveCount(0) - expect(ftState.stopped).toEqual([{ id: 'job-run', save: 'true' }]) - await page.getByTestId('job-stop').click() - await page.getByTestId('stop-discard').click() - expect(ftState.stopped[1]).toEqual({ id: 'job-run', save: 'false' }) - await page.getByTestId('job-stop').click() - await page.getByRole('button', { name: 'Keep training' }).click() - expect(ftState.stopped).toHaveLength(2) - }) - - test('a failed job shows the server message, offers the fixes for a memory failure, and applies them to a copy', async ({ page }) => { - const message = 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.34 GiB.' - await boot(page, { - ft: { - jobs: [ftJob('job-f', 'failed', { message })], - checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: '2026-10-08 10:10' }] }, - }, - }) - await page.goto('/app/fine-tune') - await open(page) - await expectStep(page, 'run', 'failed') - await expect(page.getByTestId('job-failed')).toHaveAttribute('role', 'alert') - await expect(page.getByTestId('job-failed-message')).toHaveText(message) - await expect(page.getByTestId('job-try')).toContainText('Lower the batch size to 1') - await expect(page.getByRole('button', { name: 'Resume from step 200' })).toBeVisible() - await page.getByTestId('job-retry').click() - // Back on the form, with the setup restored and the two fixes applied. - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - await expect(page.locator('#ft-model')).toHaveValue(MODEL) - await expect(page.locator('#ft-batch')).toHaveValue('1') - await page.getByTestId('ft-more-toggle').click() - await expect(page.getByLabel(/Gradient checkpointing/)).toBeChecked() - }) - - test('a failure that is not about memory gets no invented advice', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-f', 'failed', { message: 'dataset not found: tatsu-lab/alpaca' })] } }) - await page.goto('/app/fine-tune') - await open(page) - await expect(page.getByTestId('job-failed-message')).toHaveText('dataset not found: tatsu-lab/alpaca') - await expect(page.getByTestId('job-try')).not.toContainText('batch size') - await expect(page.getByTestId('job-try')).toContainText('change what the message points at') - }) - - test('Resume from a checkpoint puts it in the form', async ({ page }) => { - await boot(page, { - ft: { - jobs: [ftJob('job-f', 'failed', { message: 'stopped' })], - checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: 'x' }] }, - }, - }) - await page.goto('/app/fine-tune') - await open(page) - await page.getByRole('button', { name: 'Resume from step 200' }).click() - await expect(page.getByText('Resuming from checkpoint:')).toBeVisible() - await expect(page.getByTestId('fine-tune-form')).toContainText('/data/finetune/job-f/checkpoint-200') - await expect(page.getByTestId('ft-start')).toContainText('Resume training') - }) - - test('a finished job offers the export, and an exported model leads to Chat and Models', async ({ page }) => { - const { ftState } = await boot(page, { - ft: { - jobs: [ftJob('job-d', 'completed')], - checkpoints: { 'job-d': [{ path: '/data/finetune/job-d/checkpoint-500', step: 500, epoch: 1.66, loss: 0.8911, created_at: 'x' }] }, - }, - }) - await page.goto('/app/fine-tune') - await open(page) - await expectStep(page, 'result', 'current') - await expect(page.getByTestId('job-finished')).toContainText('Training finished.') - await expect(page.getByTestId('job-checkpoints')).toContainText('1.4012'.replace('1.4012', '0.8911')) - await page.fill('#ft-export-name', 'tinyllama-ft') - await page.selectOption('#ft-export-format', 'gguf') - await page.fill('#ft-export-quant', 'q5_k_m') - await page.getByRole('region', { name: 'Export as a model' }).getByRole('button', { name: 'Export', exact: true }).click() - expect(ftState.exported[0]).toMatchObject({ name: 'tinyllama-ft', export_format: 'gguf', quantization_method: 'q5_k_m', checkpoint_path: '/data/finetune/job-d', model: MODEL }) - }) - - test('an exported job links to Chat, Models and the archive', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-d', 'completed', { export_status: 'completed', export_model_name: 'tinyllama-ft' })] } }) - await page.goto('/app/fine-tune') - await open(page) - const next = page.getByTestId('export-next') - await expect(next).toContainText('tinyllama-ft is a model now') - await expect(next.getByRole('link', { name: 'Chat with tinyllama-ft' })).toHaveAttribute('href', '/app/chat/tinyllama-ft') - await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') - await expect(next.getByRole('link', { name: 'Download archive' })).toHaveAttribute('href', /jobs\/job-d\/download/) - }) - - test('New job returns to the form', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-d', 'completed')] } }) - await page.goto('/app/fine-tune') - await open(page) - await page.getByTestId('job-back').click() - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - }) -}) - -test.describe('Fine-tune: earlier jobs', () => { - test('lists jobs with kind and status, reuses a setup and deletes after a confirmation', async ({ page }) => { - const { ftState } = await boot(page, { - ft: { jobs: [ftJob('job-1', 'completed', { model: 'Qwen2.5-0.5B', training_method: 'dpo', config: { model: 'Qwen2.5-0.5B', dataset_source: 'x/y', training_method: 'dpo', batch_size: 3 } }), ftJob('job-2', 'failed', { model: 'gemma-2-2b', message: 'unsupported' })] }, - }) - await page.goto('/app/fine-tune') - const jobs = page.getByTestId('ft-jobs') - await expect(jobs).toContainText('2 kept on this server') - await expect(jobs.locator('tbody tr')).toHaveCount(2) - await expect(jobs.locator('tbody tr').first()).toContainText('lora, dpo') - await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported') - await jobs.locator('tbody tr').first().getByRole('button', { name: 'Reuse' }).click() - await expect(page.locator('#ft-model')).toHaveValue('Qwen2.5-0.5B') - await expect(page.locator('#ft-dataset')).toHaveValue('x/y') - await expect(page.locator('#ft-batch')).toHaveValue('3') - await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() - await expect(page.getByRole('alertdialog')).toContainText('Delete this job?') - await page.getByRole('button', { name: 'Cancel' }).click() - expect(ftState.deleted).toEqual([]) - await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() - await page.getByTestId('delete-confirm').click() - await expect.poll(() => ftState.deleted).toEqual(['job-2']) - }) - - test('with no jobs it names the next step', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await expect(page.getByTestId('ft-jobs').locator('.dk-empty-title')).toHaveText('No fine-tuning jobs yet') - }) -}) - -test.describe('Quantize', () => { - test('setup: a model is needed, the type can be custom, and the check lists the facts', async ({ page }) => { - await boot(page) - await page.goto('/app/quantize') - await expectStep(page, 'setup', 'current') - await expect(page.getByTestId('qz-start')).toBeDisabled() - await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') - await expectStep(page, 'check', 'current') - await expect(page.getByTestId('qz-start')).toContainText('Quantize (q4_k_m)') - await expect(check(page, 'backend')).toContainText('llama-cpp-quantization is installed') - await expect(check(page, 'memory')).toContainText('20 GB of 32 GB RAM is free. Conversion runs on the CPU.') - await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') - await expect(page.getByTestId('qz-recipe')).toContainText('Convert Llama-3.2-1B to GGUF at q4_k_m.') - await page.selectOption('#qz-type', 'q8_0') - await expect(page.getByTestId('qz-recipe')).toContainText('at q8_0') - await page.selectOption('#qz-type', '__custom__') - await expect(check(page, 'type')).toHaveAttribute('data-tone', 'fail') - await expect(page.getByTestId('qz-start')).toBeDisabled() - await page.fill('#qz-custom', 'iq4_nl') - await expect(page.getByTestId('qz-start')).toContainText('Quantize (iq4_nl)') - await expect(page.getByTestId('qz-start')).toBeEnabled() - }) - - test('no backend: a warning and a link, and the start stays possible', async ({ page }) => { - await boot(page, { qz: { backends: [] } }) - await page.goto('/app/quantize') - await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') - await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') - await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toBeVisible() - await expect(page.getByTestId('qz-start')).toBeEnabled() - }) - - test('Start sends the request, with the token under extra options, and opens the job', async ({ page }) => { - const { qzState } = await boot(page) - await page.goto('/app/quantize') - await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') - await page.getByRole('button', { name: /More options/ }).click() - await page.fill('#qz-token', 'hf_secret') - await page.getByTestId('qz-start').click() - await expect(page.getByTestId('job-view')).toBeVisible() - expect(qzState.started[0]).toEqual({ model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', quantization_type: 'q4_k_m', extra_options: { hf_token: 'hf_secret' } }) - await expectStep(page, 'run', 'current') - }) - - test('a running job shows the stage, progress and log, and Stop sends the stop', async ({ page }) => { - const events = [{ job_id: 'qz1', status: 'downloading', progress_percent: 10, message: 'Downloading model files' }, { job_id: 'qz1', status: 'converting', progress_percent: 42, message: 'converting tensor 84 of 201' }] - const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz1', 'converting')], events: { qz1: events } } }) - await page.goto('/app/quantize') - await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() - const view = page.getByTestId('job-view') - await expect(view.locator('.bt-job__percent')).toContainText('42') - await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '42') - await expect(view).toContainText('converting tensor 84 of 201') - await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Converting') - await expect(page.locator('[data-testid="job-stages"] [data-state="done"]')).toHaveCount(2) - await expect(page.getByTestId('job-log')).toContainText('Downloading model files') - await page.getByTestId('job-stop').click() - expect(qzState.stopped).toEqual(['qz1']) - }) - - test('a failed job shows the server message and reuses its setup', async ({ page }) => { - await boot(page, { qz: { jobs: [qzJob('qz9', 'failed', { model: 'acme/odd-model', quantization_type: 'q5_k_m', message: 'unsupported architecture: OddForCausalLM' })] } }) - await page.goto('/app/quantize') - await page.getByTestId('qz-jobs').getByRole('button', { name: 'acme/odd-model' }).click() - await expectStep(page, 'run', 'failed') - await expect(page.getByTestId('job-failed-message')).toHaveText('unsupported architecture: OddForCausalLM') - await page.getByTestId('job-retry').click() - await expect(page.locator('#qz-model')).toHaveValue('acme/odd-model') - await expect(page.locator('#qz-type')).toHaveValue('q5_k_m') - }) - - test('a finished job imports under a name and then leads to Chat and Models', async ({ page }) => { - const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/model-q4_k_m.gguf' })] } }) - await page.goto('/app/quantize') - await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() - await expectStep(page, 'result', 'current') - await expect(page.getByTestId('job-finished')).toContainText('Quantization finished: q4_k_m.') - await expect(page.getByTestId('quantize-output')).toContainText('/data/quant/qz2/model-q4_k_m.gguf') - await expect(page.getByRole('link', { name: 'Download GGUF' })).toHaveAttribute('href', /jobs\/qz2\/download/) - await page.fill('#qz-import-name', 'llama-1b-q4') - await page.getByTestId('quantize-import').click() - const next = page.getByTestId('quantize-next') - await expect(next).toContainText('llama-1b-q4 is a model now') - expect(qzState.imported).toEqual([{ name: 'llama-1b-q4' }]) - await expect(next.getByRole('link', { name: 'Chat with llama-1b-q4' })).toHaveAttribute('href', '/app/chat/llama-1b-q4') - await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') - }) - - test('earlier jobs: imported mark, failure line, delete after a confirmation', async ({ page }) => { - const { qzState } = await boot(page, { - qz: { jobs: [qzJob('qz0', 'completed', { import_status: 'completed', import_model_name: 'llama-3.2-1b-q4_k_m' }), qzJob('qz9', 'failed', { model: 'acme/odd-model', message: 'unsupported architecture' })] }, - }) - await page.goto('/app/quantize') - const jobs = page.getByTestId('qz-jobs') - await expect(jobs.locator('tbody tr').first()).toContainText('imported as llama-3.2-1b-q4_k_m') - await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported architecture') - await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() - await page.getByTestId('delete-confirm').click() - await expect.poll(() => qzState.deleted).toEqual(['qz9']) - }) - - test('with no jobs it names the next step', async ({ page }) => { - await boot(page) - await page.goto('/app/quantize') - await expect(page.getByTestId('qz-jobs').locator('.dk-empty-title')).toHaveText('No quantization jobs yet') - }) -}) - -test.describe('Disabled: the account lacks the permission', () => { - const member = (permissions) => (page) => page.route('**/api/auth/status', route => route.fulfill({ - json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } }, - })) - - test('Fine-tune says the account cannot use it, who can change that, and where to go', async ({ page }) => { - await boot(page) - await member({ chat: true })(page) - await page.goto('/app/fine-tune') - const off = page.getByTestId('tool-off') - await expect(off).toHaveAttribute('data-cause', 'account') - await expect(off).toContainText('Your account cannot fine-tune') - await expect(off).toContainText('Ask an administrator') - await expect(off.getByRole('link', { name: 'Go to Home' })).toHaveAttribute('href', '/app') - await expect(page.getByTestId('fine-tune-form')).toHaveCount(0) - }) - - test('Quantize has its own words', async ({ page }) => { - await boot(page) - await member({ chat: true, fine_tuning: true })(page) - await page.goto('/app/quantize') - await expect(page.getByTestId('tool-off')).toContainText('Your account cannot quantize') - await page.goto('/app/fine-tune') - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - }) - - test('an administrator is never shown the page', async ({ page }) => { - await boot(page) - await page.goto('/app/fine-tune') - await expect(page.getByTestId('fine-tune-form')).toBeVisible() - await expect(page.getByTestId('tool-off')).toHaveCount(0) - }) -}) - -test.describe('Build tools: phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) - const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) - - test('Fine-tune setup and check fit the screen', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-1', 'completed'), ftJob('job-2', 'failed', { message: 'x' })] } }) - await page.goto('/app/fine-tune') - await fillFineTune(page) - await page.getByTestId('ft-more-toggle').click() - await expect(check(page, 'memory')).toBeVisible() - expect(await noOverflow(page)).toBe(false) - await expect(page.getByTestId('ft-start')).toBeVisible() - }) - - test('a running Fine-tune job fits the screen', async ({ page }) => { - await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) - await page.goto('/app/fine-tune') - await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() - await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() - expect(await noOverflow(page)).toBe(false) - }) - - test('Quantize finished fits the screen', async ({ page }) => { - await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/a-very-long-file-name-q4_k_m.gguf' })] } }) - await page.goto('/app/quantize') - await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() - await expect(page.getByTestId('quantize-output')).toBeVisible() - expect(await noOverflow(page)).toBe(false) - }) - - test('the disabled page fits the screen', async ({ page }) => { - await boot(page) - await page.route('**/api/auth/status', route => route.fulfill({ - json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } }, - })) - await page.goto('/app/quantize') - await expect(page.getByTestId('tool-off')).toBeVisible() - expect(await noOverflow(page)).toBe(false) - }) -}) - -test.describe('Build tools: reduced motion', () => { - test('the job card does not animate', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) - await page.goto('/app/fine-tune') - await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() - const duration = await page.getByTestId('job-view').locator('.bt-job').evaluate(el => getComputedStyle(el).animationDuration) - expect(parseFloat(duration)).toBeLessThan(0.01) - }) -}) diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index 1a85ec249..891e0285f 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -8,10 +8,9 @@ import { installStudioAddons } from './studio-addon.js' const ROUTES = [ '/app', '/app/chat', '/app/models', '/app/studio', '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs', - '/app/fine-tune', '/app/quantize', '/app/face', '/app/voice', '/app/models?view=installed', '/app/backends', '/app/activity', '/app/operate', '/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', - '/app/voice-library', '/app/voice-library/new', '/app/account', + '/app/account', ] test('no page renders a dead icon or a default-chrome control', async ({ page }) => { diff --git a/core/http/react-ui/e2e/identity.spec.js b/core/http/react-ui/e2e/identity.spec.js deleted file mode 100644 index 792cdbe07..000000000 --- a/core/http/react-ui/e2e/identity.spec.js +++ /dev/null @@ -1,530 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { - FACE_KEY, VOICE_KEY, clipFile, faces, match, mockIdentity, people, photoFile, -} from './identity-fixtures.js' - -const marta = match('v-marta', 'Marta Rossi', 0.18) -const jonas = match('v-jonas', 'Jonas Weber', 0.41) -const ilse = match('v-ilse', 'Ilse Janssen', 0.52) -const closeSet = { matches: [marta, jonas, ilse] } -const farSet = { matches: [match('v-jonas', 'Jonas Weber', 0.31), match('v-ilse', 'Ilse Janssen', 0.47), match('v-marta', 'Marta Rossi', 0.55)] } - -async function addProbe(page, name = 'board-sync-clip.wav', seconds = 8) { - await page.locator('#voice-probe-audio-file').setInputFiles(clipFile(name, seconds)) - await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled') -} - -test.describe('Voices: Speakers', () => { - test('empty: says nobody is enrolled and that the list lives in the browser', async ({ page }) => { - await mockIdentity(page) - await page.goto('/app/voice') - await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible() - await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet') - await expect(page.getByTestId('registry-note')).toContainText('kept in this browser') - await expect(page.getByTestId('registry-note')).toContainText('no list call') - await expect(page.getByTestId('identify-go')).toBeDisabled() - // The Build bar has no Voices tab and no second row. - await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveCount(0) - await expect(page.locator('.hub-subnav')).toHaveCount(0) - }) - - test('who is this: a match is explained in words, on a scale, with the real numbers', async ({ page }) => { - const calls = await mockIdentity(page, { stored: people, identify: closeSet }) - await page.goto('/app/voice') - await addProbe(page) - await expect(page.getByText('good length')).toBeVisible() - await page.getByTestId('identify-go').click() - const verdict = page.getByTestId('verdict') - await expect(verdict).toContainText('Probably Marta Rossi') - await expect(verdict).toContainText('Likely match') - await expect(verdict).toContainText('0.18 away') - await expect(verdict).toContainText('under 0.25') - await expect(page.getByTestId('distance-scale')).toBeVisible() - await expect(page.getByTestId('rank-row')).toHaveCount(3) - await expect(page.getByTestId('rank-row').first()).toContainText('within cut-off') - await expect(page.getByTestId('cutoff-value')).toHaveText('0.25') - // The request carries the model, the clip, a top_k that covers everyone, and the cut-off. - expect(calls.identify).toHaveLength(1) - expect(calls.identify[0]).toMatchObject({ model: 'wespeaker-resnet34', threshold: 0.25 }) - expect(calls.identify[0].audio).toMatch(/^data:audio\//) - expect(calls.identify[0].top_k).toBeGreaterThanOrEqual(25) - await expect(page.getByTestId('known-row-v-marta')).toContainText('best match') - }) - - test('the cut-off slider re-reads the answer without another call', async ({ page }) => { - const calls = await mockIdentity(page, { stored: people, identify: closeSet }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi') - const slider = page.locator('#voice-find-cutoff') - await slider.fill('0.1') - await expect(page.getByTestId('cutoff-value')).toHaveText('0.10') - await expect(page.getByTestId('verdict')).toContainText('Nobody enrolled sounds like this') - await expect(page.getByRole('button', { name: 'Reset to 0.25' })).toBeVisible() - await page.getByRole('button', { name: 'Reset to 0.25' }).click() - await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi') - expect(calls.identify).toHaveLength(1) - }) - - test('no match: names the closest person and says why it may be wrong', async ({ page }) => { - await mockIdentity(page, { stored: people, identify: farSet }) - await page.goto('/app/voice') - await addProbe(page, 'hallway-recording.wav', 2.4) - await expect(page.getByText('short', { exact: true })).toBeVisible() - await page.getByTestId('identify-go').click() - const verdict = page.getByTestId('verdict') - await expect(verdict).toContainText('Nobody enrolled sounds like this') - await expect(verdict).toContainText('closest was Jonas Weber at 0.31') - await expect(page.getByRole('button', { name: 'Enrol this voice' })).toBeVisible() - await page.getByText('Why this might be wrong').click() - await expect(page.getByTestId('why-list')).toContainText('not a security check') - await expect(page.getByTestId('why-list')).toContainText('short: 2.4 s') - }) - - test('working: a progress bar and a disabled button while the call is out', async ({ page }) => { - await mockIdentity(page, { stored: people, identify: closeSet, delay: 1500 }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('identify-working')).toBeVisible() - await expect(page.getByTestId('identify-working')).toHaveAttribute('aria-busy', 'true') - await expect(page.getByTestId('identify-go')).toBeDisabled() - await expect(page.getByTestId('verdict')).toBeVisible() - }) - - test('failed: plain words, the server message, a retry and a trace link', async ({ page }) => { - const calls = await mockIdentity(page, { stored: people, identify: { status: 500, body: { error: { message: 'backend crashed' } } } }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - const err = page.getByTestId('identity-error') - await expect(err).toContainText('The call failed') - await expect(err).toContainText('backend crashed') - await expect(err.getByRole('link', { name: 'Open traces' })).toHaveAttribute('href', /\/app\/traces/) - await err.getByRole('button', { name: 'Try again' }).click() - await expect.poll(() => calls.identify.length).toBe(2) - }) - - test('failed: a missing model is named as such', async ({ page }) => { - await mockIdentity(page, { stored: people, identify: { status: 404, body: { error: { message: 'model not found' } } } }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('identity-error')).toContainText('cannot find this model') - }) - - test('same person: uses the model cut-off from the answer and shows the pair on the scale', async ({ page }) => { - const calls = await mockIdentity(page, { stored: people, verify: { verified: false, distance: 0.31, threshold: 0.28, confidence: 0, model: 'x' } }) - await page.goto('/app/voice') - await page.getByRole('tab', { name: 'Same person?' }).click() - await page.locator('#voice-a-audio-file').setInputFiles(clipFile('a.wav')) - await page.locator('#voice-b-audio-file').setInputFiles(clipFile('b.wav', 6)) - await page.getByTestId('compare-go').click() - const verdict = page.getByTestId('verdict') - await expect(verdict).toContainText('Probably different people') - await expect(verdict).toContainText('0.31 apart') - await expect(verdict).toContainText('under 0.28') - expect(calls.verify[0]).toMatchObject({ model: 'wespeaker-resnet34' }) - expect(calls.verify[0]).not.toHaveProperty('threshold') - await expect(page.getByTestId('cutoff-value')).toHaveText('0.28') - await page.locator('#voice-pair-cutoff').fill('0.4') - await expect(verdict).toContainText('Probably the same person') - expect(calls.verify).toHaveLength(1) - }) - - test('mic blocked: says so and offers a file', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(navigator, 'mediaDevices', { - value: { getUserMedia: () => Promise.reject(Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' })) }, - configurable: true, - }) - }) - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' }).click() - await expect(page.getByTestId('clip-notice')).toContainText('microphone is blocked') - await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Choose a file' })).toBeVisible() - await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' })).toHaveCount(0) - }) - - test('mic allowed: shows the state when the browser reports it', async ({ page }) => { - await page.addInitScript(() => { - const status = { state: 'granted', onchange: null } - Object.defineProperty(navigator, 'permissions', { value: { query: () => Promise.resolve(status) }, configurable: true }) - Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: () => Promise.reject(new Error('unused')) }, configurable: true }) - }) - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await expect(page.getByTestId('mic-allowed')).toContainText('Microphone allowed') - }) - - test('mic on an insecure origin is explained', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }) - Object.defineProperty(navigator, 'mediaDevices', { value: undefined, configurable: true }) - }) - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await expect(page.getByTestId('clip-notice')).toContainText('https or on localhost') - }) -}) - -test.describe('Voices: the registry tells the truth', () => { - const priya = { id: 'v-priya', name: 'Priya Nair', labels: { team: 'platform' }, registeredAt: new Date(Date.now() - 86400000).toISOString() } - - test('a person the search did not return is marked, and a stranger is listed', async ({ page }) => { - await mockIdentity(page, { - stored: [...people, priya], - identify: { matches: [marta, jonas, ilse, match('v-other', 'Omar Haddad', 0.6)] }, - }) - await page.goto('/app/voice') - // Before a search nothing is claimed. - await expect(page.getByTestId('not-on-server')).toHaveCount(0) - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toBeVisible() - await expect(page.getByTestId('known-row-v-priya').getByTestId('not-on-server')).toContainText('not on the server') - await expect(page.getByTestId('known-row-v-priya')).toContainText('may have restarted') - await expect(page.getByTestId('known-row-v-marta').getByTestId('not-on-server')).toHaveCount(0) - await expect(page.getByTestId('known-row-v-other').getByTestId('not-in-browser')).toBeVisible() - await expect(page.getByTestId('registry-note')).toContainText('The last search checked it') - }) - - test('the server returning nobody is never read as proof when the search was cut short', async ({ page }) => { - // 25 asked, 25 returned: the search may have missed people, so nothing is marked. - const many = Array.from({ length: 25 }, (_, i) => match(`x-${i}`, `Person ${i}`, 0.3 + i / 100)) - await mockIdentity(page, { stored: [...people, priya], identify: { matches: many } }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toBeVisible() - await expect(page.getByTestId('not-on-server')).toHaveCount(0) - }) - - test('an empty answer after a restart says the voices are gone', async ({ page }) => { - await mockIdentity(page, { stored: people, identify: { matches: [] } }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toContainText('The server returned no speakers') - await expect(page.getByTestId('verdict')).toContainText('enrol them again') - await expect(page.getByTestId('not-on-server')).toHaveCount(3) - }) - - test('what is stored, and where, is stated', async ({ page }) => { - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await page.getByTestId('stored-note').getByText('What is stored, and where').click() - const note = page.getByTestId('stored-note') - await expect(note).toContainText('lost when it restarts') - await expect(note).toContainText('The recording is not kept') - await expect(note).toContainText('only if you tick Keep a copy') - }) -}) - -test.describe('Voices: enrol sheet', () => { - test('lists what is still needed, then registers and saves only the metadata', async ({ page }) => { - const calls = await mockIdentity(page, { stored: [] }) - await page.goto('/app/voice') - await page.getByTestId('enrol-open').click() - const sheet = page.getByTestId('enrol-sheet') - await expect(sheet).toBeVisible() - const submit = sheet.getByRole('button', { name: /^Enrol/ }) - await expect(submit).toBeDisabled() - await expect(page.getByTestId('enrol-needs')).toContainText('a recording, a name, permission') - await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 5)) - await page.locator('#voice-enrol-name').fill('Marta Rossi') - await page.locator('#voice-enrol-labels').fill('team: platform') - await expect(page.getByTestId('enrol-needs')).toContainText('permission') - await sheet.getByText('This person agreed').click() - await expect(page.getByTestId('enrol-needs')).toHaveText('Ready to enrol.') - await submit.click() - await expect(sheet).toHaveCount(0) - expect(calls.register[0]).toMatchObject({ model: 'wespeaker-resnet34', name: 'Marta Rossi', labels: { team: 'platform' } }) - expect(calls.register[0].audio).toMatch(/^data:audio\//) - await expect(page.getByTestId('known-list')).toContainText('Marta Rossi') - const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY) - expect(stored).toHaveLength(1) - expect(stored[0]).toMatchObject({ name: 'Marta Rossi', labels: { team: 'platform' } }) - // The copy of the recording is opt-in; it was not ticked. - expect(stored[0]).not.toHaveProperty('sampleUrl') - }) - - test('keeping a copy is opt-in and stays in the browser', async ({ page }) => { - const calls = await mockIdentity(page, { stored: [] }) - await page.goto('/app/voice') - await page.getByTestId('enrol-open').click() - await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2)) - await page.locator('#voice-enrol-name').fill('Ada') - const sheet = page.getByTestId('enrol-sheet') - await sheet.getByText('This person agreed').click() - await sheet.getByText('Keep a copy in this browser').click() - await sheet.getByRole('button', { name: /^Enrol/ }).click() - await expect(sheet).toHaveCount(0) - const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY) - expect(stored[0].sampleUrl).toMatch(/^data:audio\//) - expect(JSON.stringify(calls.register[0])).not.toContain('sampleUrl') - }) - - test('can also keep the recording as a speech voice (admin)', async ({ page }) => { - const calls = await mockIdentity(page, { stored: [] }) - await page.goto('/app/voice') - await page.getByTestId('enrol-open').click() - const sheet = page.getByTestId('enrol-sheet') - await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 3)) - await page.locator('#voice-enrol-name').fill('Marta Rossi') - await sheet.getByText('Also keep it as a speech voice').click() - await expect(page.getByTestId('enrol-needs')).toContainText('a transcript') - await page.locator('#voice-enrol-transcript').fill('The words spoken.') - await sheet.getByText('This person agreed').click() - await sheet.getByRole('button', { name: /^Enrol/ }).click() - await expect(sheet).toHaveCount(0) - expect(calls.register).toHaveLength(1) - expect(calls.profile).toHaveLength(1) - }) - - test('a failed enrol keeps the sheet and says why', async ({ page }) => { - await mockIdentity(page, { stored: [] }) - await page.route('**/v1/voice/register', route => route.fulfill({ status: 500, json: { error: { message: 'store unavailable' } } })) - await page.goto('/app/voice') - await page.getByTestId('enrol-open').click() - await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2)) - await page.locator('#voice-enrol-name').fill('Ada') - const sheet = page.getByTestId('enrol-sheet') - await sheet.getByText('This person agreed').click() - await sheet.getByRole('button', { name: /^Enrol/ }).click() - await expect(sheet.getByTestId('identity-error')).toContainText('store unavailable') - await expect(page.locator('#voice-enrol-name')).toHaveValue('Ada') - }) - - test('Escape closes the sheet', async ({ page }) => { - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await page.getByTestId('enrol-open').click() - await expect(page.getByTestId('enrol-sheet')).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.getByTestId('enrol-sheet')).toHaveCount(0) - }) -}) - -test.describe('Voices: delete with undo', () => { - test('nothing is sent before the window ends, and Undo sends nothing', async ({ page }) => { - await page.clock.install() - const calls = await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await page.getByRole('button', { name: 'Remove Jonas Weber' }).click() - await expect(page.getByTestId('forget-undo-toast')).toContainText('Jonas Weber will be removed') - await expect(page.getByTestId('known-row-v-jonas')).toContainText('removing') - await page.clock.runFor(9000) - expect(calls.forget).toHaveLength(0) - await page.getByTestId('forget-undo-toast').getByRole('button', { name: 'Undo' }).click() - await page.clock.runFor(20000) - expect(calls.forget).toHaveLength(0) - await expect(page.getByTestId('known-row-v-jonas')).toBeVisible() - await expect(page.getByTestId('known-row-v-jonas')).not.toContainText('removing') - }) - - test('after ten seconds the forget call goes out and the row is gone', async ({ page }) => { - await page.clock.install() - const calls = await mockIdentity(page, { stored: people }) - await page.goto('/app/voice') - await page.getByRole('button', { name: 'Remove Jonas Weber' }).click() - await page.clock.runFor(9500) - expect(calls.forget).toHaveLength(0) - await page.clock.runFor(1500) - await expect.poll(() => calls.forget.length).toBe(1) - expect(calls.forget[0]).toEqual({ id: 'v-jonas' }) - await expect(page.getByTestId('known-row-v-jonas')).toHaveCount(0) - const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)).map(x => x.id), VOICE_KEY) - expect(stored).toEqual(['v-marta', 'v-ilse']) - }) - - test('a 404 from the server still removes the row', async ({ page }) => { - await page.clock.install() - const calls = await mockIdentity(page, { stored: people, forgetStatus: 404 }) - await page.goto('/app/voice') - await page.getByRole('button', { name: 'Remove Ilse Janssen' }).click() - await page.clock.runFor(10500) - await expect.poll(() => calls.forget.length).toBe(1) - await expect(page.getByTestId('known-row-v-ilse')).toHaveCount(0) - }) -}) - -test.describe('Voices: states of the page', () => { - test('disabled: no speaker model says what turns it on and offers installs', async ({ page }) => { - await mockIdentity(page, { - voiceModels: [], - stored: people, - gallery: [{ id: 'localai@wespeaker-resnet34', name: 'wespeaker-resnet34', backend: 'wespeaker', installed: false }], - }) - let installed = null - await page.route('**/api/models/install/**', route => { installed = decodeURIComponent(route.request().url().split('/').pop()); return route.fulfill({ json: { jobID: 'j' } }) }) - await page.goto('/app/voice') - const needed = page.getByTestId('model-needed') - await expect(needed).toContainText('Voices need a speaker model') - await expect(needed).toContainText('Not enabled on this server') - await expect(needed.getByRole('button', { name: 'Install' })).toBeVisible() - await needed.getByText('What turns this on').click() - await expect(needed).toContainText('Voice recognition permission') - await needed.getByRole('button', { name: 'Install' }).click() - await expect.poll(() => installed).toBe('localai@wespeaker-resnet34') - // The remembered people stay manageable without a model. - await expect(page.getByTestId('known-list')).toContainText('Marta Rossi') - await expect(page.getByTestId('enrol-open')).toBeDisabled() - }) - - test('a user without the permission sees what is off, not a redirect', async ({ page }) => { - await mockIdentity(page) - await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { id: 'u', role: 'user', permissions: {} } } })) - await page.goto('/app/voice') - await expect(page.getByTestId('feature-off')).toContainText('Voice recognition is off for your account') - await expect(page.getByTestId('feature-off')).toContainText('Voice recognition permission') - await page.goto('/app/face') - await expect(page.getByTestId('feature-off')).toContainText('Face recognition is off for your account') - }) - - test('From a recording links to the diarization workspace', async ({ page }) => { - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice?tab=recording') - await expect(page.getByTestId('recording-tab')).toContainText('Name the speakers in a recording') - await page.getByRole('link', { name: /Open the diarization workspace/ }).click() - await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/diarization/) - }) - - test('the route /app/voice/:model keeps working', async ({ page }) => { - await mockIdentity(page, { stored: people }) - await page.goto('/app/voice/wespeaker-resnet34') - await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible() - await expect(page.getByTestId('known-list')).toBeVisible() - }) - - test('phone: no sideways scroll with a result open, and the table stays inside the screen', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await mockIdentity(page, { stored: [...people, { id: 'v-priya', name: 'Priya Nair', labels: {}, registeredAt: new Date().toISOString() }], identify: closeSet }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toBeVisible() - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) - expect(overflow).toBeLessThanOrEqual(0) - const box = await page.getByTestId('distance-scale').boundingBox() - expect(box.x + box.width).toBeLessThanOrEqual(390) - await page.getByTestId('enrol-open').click() - const sheet = await page.getByTestId('enrol-sheet').boundingBox() - expect(sheet.width).toBeLessThanOrEqual(390) - expect(sheet.x).toBeGreaterThanOrEqual(0) - }) - - test('reduced motion: the working bar and recording pulse do not animate', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await mockIdentity(page, { stored: people, identify: closeSet, delay: 2500 }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - const bar = page.getByTestId('identify-working').locator('.dk-progress-bar') - await expect(bar).toBeVisible() - const name = await bar.evaluate(el => getComputedStyle(el).animationName) - expect(name).toBe('none') - }) - - test('no left rail marks on rows, cards or the verdict', async ({ page }) => { - await mockIdentity(page, { stored: people, identify: closeSet }) - await page.goto('/app/voice') - await addProbe(page) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('verdict')).toBeVisible() - const widths = await page.evaluate(() => [...document.querySelectorAll('.idn-page *')] - .map(el => getComputedStyle(el)) - .filter(cs => parseFloat(cs.borderLeftWidth) >= 3 && cs.borderLeftStyle !== 'none' && parseFloat(cs.borderTopWidth) === 0) - .length) - expect(widths).toBe(0) - }) -}) - -test.describe('Faces', () => { - const anna = match('f-anna', 'Anna Kowalski', 0.21) - const tomas = match('f-tomas', 'Tomas Ribeiro', 0.52) - - test('who is this: a match, with the faceprint worded as such', async ({ page }) => { - const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [anna, tomas] } }) - await page.goto('/app/face') - await expect(page.getByRole('heading', { name: 'Faces', level: 1 })).toBeVisible() - await page.locator('#face-probe-image-file').setInputFiles(photoFile()) - await page.getByTestId('identify-go').click() - const verdict = page.getByTestId('verdict') - await expect(verdict).toContainText('Probably Anna Kowalski') - await expect(verdict).toContainText('faceprint') - await expect(verdict).toContainText('under 0.35') - expect(calls.faceIdentify[0]).toMatchObject({ model: 'insightface-buffalo-l', threshold: 0.35 }) - expect(calls.faceIdentify[0].img).toMatch(/^data:image\//) - await expect(page.getByTestId('known-row-f-anna')).toContainText('best match') - }) - - test('no face found: says what to try', async ({ page }) => { - await mockIdentity(page, { storedFaces: faces, faceIdentify: { status: 400, body: { error: { message: 'no face detected in the image' } } } }) - await page.goto('/app/face') - await page.locator('#face-probe-image-file').setInputFiles(photoFile()) - await page.getByTestId('identify-go').click() - await expect(page.getByTestId('identity-error')).toContainText('No face found in this photo') - await expect(page.getByTestId('identity-error')).toContainText('front-facing') - }) - - test('same person: sends both photos, optional anti-spoofing, draws the faces it found', async ({ page }) => { - const calls = await mockIdentity(page, { storedFaces: faces }) - await page.goto('/app/face') - await page.getByRole('tab', { name: 'Same person?' }).click() - await page.locator('#face-a-image-file').setInputFiles(photoFile('a.png')) - await page.locator('#face-b-image-file').setInputFiles(photoFile('b.png')) - await page.getByText('Check for photos of photos').click() - await page.getByTestId('compare-go').click() - await expect(page.getByTestId('verdict')).toContainText('Probably the same person') - expect(calls.faceVerify[0]).toMatchObject({ anti_spoofing: true }) - expect(calls.faceVerify[0].img1).toMatch(/^data:image\//) - await expect(page.locator('.biometrics-bbox')).toHaveCount(2) - await expect(page.getByTestId('cutoff-value')).toHaveText('0.35') - }) - - test('enrol a person: a photo copy is opt-in', async ({ page }) => { - const calls = await mockIdentity(page, { storedFaces: [] }) - await page.goto('/app/face') - await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet') - await page.getByTestId('enrol-open').click() - const sheet = page.getByTestId('enrol-sheet') - await page.locator('#face-enrol-image-file').setInputFiles(photoFile()) - await page.locator('#face-enrol-name').fill('Anna Kowalski') - await sheet.getByText('This person agreed').click() - await sheet.getByRole('button', { name: /^Enrol/ }).click() - await expect(sheet).toHaveCount(0) - expect(calls.faceRegister[0]).toMatchObject({ model: 'insightface-buffalo-l', name: 'Anna Kowalski' }) - const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), FACE_KEY) - expect(stored[0]).not.toHaveProperty('thumbnail') - await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski') - }) - - test('delete uses the face forget call after the window', async ({ page }) => { - await page.clock.install() - const calls = await mockIdentity(page, { storedFaces: faces }) - await page.goto('/app/face') - await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click() - await page.clock.runFor(9000) - expect(calls.forget).toHaveLength(0) - await page.clock.runFor(2000) - await expect.poll(() => calls.forget.length).toBe(1) - expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' }) - }) - - test('disabled: no face model', async ({ page }) => { - await mockIdentity(page, { faceModels: [] }) - await page.goto('/app/face') - await expect(page.getByTestId('model-needed')).toContainText('Faces need a face model') - }) - - test('more tools stay reachable: detect and the raw faceprint', async ({ page }) => { - await mockIdentity(page, { storedFaces: faces }) - await page.goto('/app/face') - await page.getByText('More tools').click() - await expect(page.getByRole('tab', { name: /Detect and analyze/ })).toBeVisible() - await expect(page.getByRole('tab', { name: 'Embedding' })).toBeVisible() - }) -}) diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js index c20baa50e..9a8e51154 100644 --- a/core/http/react-ui/e2e/no-edge-rails.spec.js +++ b/core/http/react-ui/e2e/no-edge-rails.spec.js @@ -12,7 +12,7 @@ const ROUTES = [ '/app', '/app/chat', '/app/models', '/app/models?view=installed', '/app/studio', '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs', '/app/backends', '/app/activity', '/app/operate', '/app/build', '/app/settings', - '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/voice-library', + '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/account', ] diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js index 2d2ed8314..efb2864bc 100644 --- a/core/http/react-ui/e2e/page-render-smoke.spec.js +++ b/core/http/react-ui/e2e/page-render-smoke.spec.js @@ -32,12 +32,6 @@ const PAGES = [ ['/app/failover', 'Failover'], ['/app/nodes/add', 'Add a node'], ['/app/p2p', 'P2P'], - ['/app/face', 'Face recognition'], - ['/app/voice', 'Voice recognition'], - ['/app/fine-tune', 'Fine-tuning'], - ['/app/quantize', 'Quantize'], - ['/app/voice-library', 'Voice Library'], - ['/app/voice-library/new', 'Create voice profile'], ] test.describe('Page render smoke', () => { diff --git a/core/http/react-ui/e2e/voice-library-empty.spec.js b/core/http/react-ui/e2e/voice-library-empty.spec.js deleted file mode 100644 index eefa8b6d7..000000000 --- a/core/http/react-ui/e2e/voice-library-empty.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -// The empty voice library must offer its action, visibly, inside the panel. - -test.describe('Voice library empty state', () => { - test.beforeEach(async ({ page }) => { - await page.route('**/api/voice-profiles', route => route.fulfill({ json: [] })) - await page.goto('/app/voice-library') - }) - - test('the create action is visible and a normal size', async ({ page }) => { - const action = page.locator('.empty-state__actions a.btn').first() - await expect(action).toBeVisible() - const box = await action.boundingBox() - // It had been carrying the panel's own min-height:430px, which made it an - // invisible box that pushed itself out of view. - expect(box.height).toBeLessThan(80) - }) - - test('the action sits inside the panel, not past its edge', async ({ page }) => { - const panel = page.locator('.empty-state').first() - const action = page.locator('.empty-state__actions a.btn').first() - // Measure after the layout settles; the two boxes are read one after the - // other, and a reflow in between made the comparison race. - await expect(action).toBeVisible() - await page.waitForTimeout(500) - const [p, a] = [await panel.boundingBox(), await action.boundingBox()] - expect(a.y + a.height).toBeLessThanOrEqual(p.y + p.height + 1) - }) - - test('the panel renders its icon', async ({ page }) => { - const icon = page.locator('.empty-state-icon').first() - await expect(icon).toBeVisible() - const box = await icon.boundingBox() - expect(box.width).toBeGreaterThan(0) - }) -}) diff --git a/core/http/react-ui/e2e/voice-library.spec.js b/core/http/react-ui/e2e/voice-library.spec.js deleted file mode 100644 index bb7608209..000000000 --- a/core/http/react-ui/e2e/voice-library.spec.js +++ /dev/null @@ -1,226 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockStudio, stubMedia } from './studio-fixtures.js' - -const VOICE_ID = '00000000-0000-0000-0000-000000000001' - -function pcmWav(seconds = 2) { - const sampleRate = 16000 - const dataSize = sampleRate * seconds * 2 - const buffer = Buffer.alloc(44 + dataSize) - buffer.write('RIFF', 0) - buffer.writeUInt32LE(36 + dataSize, 4) - buffer.write('WAVEfmt ', 8) - buffer.writeUInt32LE(16, 16) - buffer.writeUInt16LE(1, 20) - buffer.writeUInt16LE(1, 22) - buffer.writeUInt32LE(sampleRate, 24) - buffer.writeUInt32LE(sampleRate * 2, 28) - buffer.writeUInt16LE(2, 32) - buffer.writeUInt16LE(16, 34) - buffer.write('data', 36) - buffer.writeUInt32LE(dataSize, 40) - return buffer -} - -const TTS_MODELS = [ - { id: 'qwen-base', capabilities: ['FLAG_TTS'], backend: 'qwen3-tts-cpp', voice_cloning: { reference_transcript_required: true, accepted_audio_formats: ['audio/wav'] } }, - { id: 'piper-default', capabilities: ['FLAG_TTS'], backend: 'piper' }, -] - -// The speech page is a page of the Studio add-on: install it, and serve the -// same models through the capabilities call it reads. -async function openStudioSpeech(page, query = '') { - await mockStudio(page, { capabilityModels: TTS_MODELS }) - await stubMedia(page) - await page.goto(`/app/studio/tts${query}`) -} - -const profile = { - id: VOICE_ID, - name: 'Documentary narrator', - description: 'Measured and clear', - language: 'en-US', - transcript: 'The exact words spoken in this reference.', - voice: `localai://voice-profiles/${VOICE_ID}`, - consent_confirmed_at: '2026-07-01T12:00:00Z', - created_at: '2026-07-01T12:00:00Z', - updated_at: '2026-07-01T12:00:00Z', - audio: { duration_ms: 2000, sample_rate: 16000, channels: 1, bit_depth: 16, size_bytes: 64044, mime_type: 'audio/wav' }, -} - -async function mockVoiceAPIs(page) { - const state = { createdMultipart: null } - await page.route('**/api/models/capabilities', route => route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: TTS_MODELS }), - })) - await page.route('**/api/voice-profiles', async route => { - if (route.request().method() === 'POST') { - state.createdMultipart = route.request().postDataBuffer() - await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(profile) }) - return - } - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [profile] }) }) - }) - await page.route(`**/api/voice-profiles/${VOICE_ID}/audio`, route => route.fulfill({ - status: 200, - contentType: 'audio/wav', - body: pcmWav(), - })) - return state -} - -test.describe('Speech voices', () => { - let apiState - - test.beforeEach(async ({ page }) => { - apiState = await mockVoiceAPIs(page) - }) - - test('lists the voices and opens one in a sheet', async ({ page }) => { - await page.goto('/app/voice-library') - await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible() - await expect(page.getByRole('heading', { name: /Speech voices/i, level: 2 })).toBeVisible() - await expect(page.locator('.idn-tabs').getByRole('tab', { name: /Speech voices/ })).toHaveAttribute('aria-current', 'page') - const row = page.locator('.voice-row', { hasText: 'Documentary narrator' }) - await expect(row).toBeVisible() - // Nothing opens by itself: the detail is a sheet the person asks for. - await expect(page.getByTestId('speech-voice-sheet')).toHaveCount(0) - await row.click() - await expect(page.getByTestId('speech-voice-sheet')).toBeVisible() - await expect(page.locator('.voice-library-detail')).toContainText('The exact words spoken in this reference.') - await expect(page.locator('.voice-library-detail')).toContainText('Consent confirmed') - await expect(page.getByRole('button', { name: /Use in Text to Speech/i })).toBeEnabled() - }) - - test('shows inline API usage and installed model compatibility', async ({ page }) => { - await page.goto(`/app/voice-library?selected=${VOICE_ID}`) - await page.getByText('API usage and compatible models').click() - const apiHelp = page.locator('.voice-detail__api') - await expect(apiHelp).toContainText('qwen-base') - await expect(apiHelp).toContainText('qwen3-tts-cpp') - await expect(apiHelp.locator('code')).toContainText('/v1/audio/speech') - await expect(apiHelp.locator('code')).toContainText(`localai://voice-profiles/${VOICE_ID}`) - }) - - test('offers server-declared gallery models when none are installed', async ({ page }) => { - let galleryCapability = null - let installedModel = null - await page.route('**/api/models/capabilities', route => route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: [] }), - })) - await page.route('**/api/models?**', route => { - galleryCapability = new URL(route.request().url()).searchParams.get('capability') - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ models: [{ - id: 'localai@omnivoice-cpp', - name: 'omnivoice-cpp', - backend: 'omnivoice-cpp', - installed: false, - voice_cloning: { reference_transcript_required: true, accepted_audio_formats: ['audio/wav'] }, - }] }), - }) - }) - await page.route('**/api/models/install/**', route => { - installedModel = decodeURIComponent(route.request().url().split('/').pop()) - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ jobID: 'voice-install' }) }) - }) - - await page.goto(`/app/voice-library?selected=${VOICE_ID}`) - await expect(page.getByRole('heading', { name: 'Install a voice-cloning model' })).toBeVisible() - await expect(page.locator('.voice-detail__model-list')).toContainText('omnivoice-cpp') - expect(galleryCapability).toBe('voice_cloning') - await page.locator('.voice-detail__model-list').getByRole('button', { name: 'Install' }).click() - await expect.poll(() => installedModel).toBe('localai@omnivoice-cpp') - await expect(page.getByText(/Installing omnivoice-cpp/)).toBeVisible() - }) - - test('keeps the Build tab bar compact and scrollable on small screens', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/app/voice-library') - const bar = page.locator('.dk-hubtabs') - await expect(bar).toBeVisible() - expect((await bar.boundingBox()).width).toBeLessThanOrEqual(390) - await expect(bar.locator('[data-hub-tab="voices"]')).toHaveCount(0) - await expect(page.locator('.idn-tabs').getByRole('tab', { name: /Speech voices/ })).toHaveAttribute('aria-current', 'page') - await expect(page.locator('.hub-subnav')).toHaveCount(0) - }) - - test('passes the stable voice URI from the library into TTS', async ({ page }) => { - let ttsBody = null - await page.route('**/tts', async route => { - ttsBody = route.request().postDataJSON() - await route.fulfill({ - status: 200, - contentType: 'audio/wav', - headers: { 'Content-Disposition': 'attachment; filename="speech.wav"' }, - body: pcmWav(1), - }) - }) - - await openStudioSpeech(page, `?voice=${VOICE_ID}`) - await expect(page.locator('#tts-voice')).toHaveValue(VOICE_ID) - await page.getByPlaceholder('Enter text to synthesize...').fill('Hello from the saved voice.') - await page.getByRole('button', { name: /Generate$/ }).click() - await expect.poll(() => ttsBody?.voice).toBe(`localai://voice-profiles/${VOICE_ID}`) - expect(ttsBody.model).toBe('qwen-base') - }) - - test('sends trimmed speech instructions and omits blank instructions', async ({ page }) => { - const ttsBodies = [] - await page.route('**/tts', async route => { - if (route.request().method() !== 'POST') { - await route.fallback() - return - } - ttsBodies.push(route.request().postDataJSON()) - await route.fulfill({ - status: 200, - contentType: 'audio/wav', - headers: { 'Content-Disposition': 'attachment; filename="speech.wav"' }, - body: pcmWav(1), - }) - }) - - await openStudioSpeech(page) - await page.getByPlaceholder('Enter text to synthesize...').fill('Read this sentence.') - // Delivery instructions sit in the Advanced fold of the workspace. - await page.getByRole('button', { name: /^Instructions/ }).click() - await page.getByLabel('Instructions').fill(' Speak slowly and warmly. ') - await page.getByRole('button', { name: /Generate$/ }).click() - await expect.poll(() => ttsBodies.length).toBe(1) - expect(ttsBodies[0].instructions).toBe('Speak slowly and warmly.') - - await expect(page.getByRole('button', { name: /Generate$/ })).toBeEnabled() - await page.getByLabel('Instructions').fill(' \n ') - await page.getByRole('button', { name: /Generate$/ }).click() - await expect.poll(() => ttsBodies.length).toBe(2) - expect(ttsBodies[1]).not.toHaveProperty('instructions') - }) - - test('normalizes an upload and creates a consented profile', async ({ page }) => { - await page.goto('/app/voice-library/new') - await page.locator('#voice-profile-audio-file').setInputFiles({ - name: 'reference.wav', - mimeType: 'audio/wav', - buffer: pcmWav(2), - }) - await expect(page.getByText('Normalized reference')).toBeVisible() - await page.locator('#voice-profile-name').fill('Documentary narrator') - await page.locator('#voice-profile-transcript').fill('The exact words spoken in this reference.') - await page.getByText('I confirm that this voice may be cloned').click() - await page.getByRole('button', { name: /Save voice/i }).click() - await expect(page).toHaveURL(new RegExp(`/app/voice-library\\?selected=${VOICE_ID}$`)) - - const wavOffset = apiState.createdMultipart.indexOf(Buffer.from('RIFF')) - expect(wavOffset).toBeGreaterThanOrEqual(0) - expect(apiState.createdMultipart.readUInt16LE(wavOffset + 22)).toBe(1) - expect(apiState.createdMultipart.readUInt32LE(wavOffset + 24)).toBe(24000) - expect(apiState.createdMultipart.readUInt16LE(wavOffset + 34)).toBe(16) - }) -}) diff --git a/core/http/react-ui/public/locales/en/biometrics.json b/core/http/react-ui/public/locales/en/biometrics.json deleted file mode 100644 index 8fdcfe068..000000000 --- a/core/http/react-ui/public/locales/en/biometrics.json +++ /dev/null @@ -1,292 +0,0 @@ -{ - "voices": { - "title": "Voices", - "lede": "Recognise who is speaking, and keep voices that text-to-speech can use.", - "tabs": "Voices sections", - "speakers": "Speakers", - "speech": "Speech voices", - "recording": "From a recording" - }, - "faces": { - "title": "Faces", - "lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server." - }, - "tool": { - "aria": "Recognition tool", - "sameTab": "Same person?", - "clip": "Clip", - "clipA": "Clip A", - "clipB": "Clip B", - "photo": "Photo", - "photoA": "Photo A", - "photoB": "Photo B", - "compare": "Compare", - "comparing": "Comparing", - "workingAria": "Working on it" - }, - "voice": { - "speaker": "speaker", - "print": "voiceprint", - "whoTab": "Who is this", - "whoLede": "Matches a clip against the people you enrolled.", - "sameLede": "Two clips, one question. Nobody has to be enrolled.", - "whoGo": "Identify speaker", - "working": "Identifying", - "whoNote": "Searches the speakers enrolled on this server. The clip you test with is not stored.", - "sameNote": "The answer comes from one voiceprint per clip. Nothing is stored.", - "workingText": "Comparing the clip with the voiceprints the server holds.", - "probably": "Probably {{name}}", - "matchText": "{{name}}'s voiceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.", - "nobody": "Nobody enrolled sounds like this", - "missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new speaker, or a clip that is too short or noisy.", - "noneEnrolled": "Nobody is enrolled to compare with", - "noneEnrolledText": "Enrol a speaker first, then identify a clip against them.", - "noneReturned": "The server returned no speakers", - "noneReturnedText": "It holds no voiceprints made with this model. If the server restarted, enrolled voices are gone: enrol them again.", - "enrolThis": "Enrol this voice", - "same": "Probably the same person", - "different": "Probably different people", - "knownTitle": "Known speakers", - "enrol": "Enrol a speaker", - "enrolTitle": "Enrol a speaker", - "enrolIntro": "The server keeps a voiceprint and a name. It does not keep the recording.", - "emptyTitle": "Nobody enrolled in this browser yet", - "emptyText": "Enrol a speaker with a short recording, then test a clip against them.", - "agreedHint": "I have their permission, or another lawful basis, to store a voiceprint of them.", - "keepHint": "Lets you replay it and enrol again after a server restart. Off by default. It is stored in this browser only.", - "neededTitle": "Voices need a speaker model", - "neededText": "Voice recognition turns a recording into a voiceprint and compares voiceprints. It runs on a speaker-recognition model, and none is installed on this server.", - "neededWhat": "Install a model that supports speaker recognition. Once one is installed, this page turns on by itself. Users also need the Voice recognition permission.", - "moreTitle": "More tools", - "moreHint": "guess a speaker's traits, get the raw voiceprint", - "why": [ - "One speaker is assumed. A clip with two people mixes both voices.", - "A different microphone or room changes how a voice sounds.", - "This is a similarity score, not a security check. Do not use it to unlock anything." - ], - "stored": [ - { "where": "On the server", "what": "A {{print}} (numbers that describe the voice, not the audio), a name and your labels. They are kept in memory, are shared by everyone who uses this server, and are lost when it restarts. The recording is not kept." }, - { "where": "In this browser", "what": "The name, labels and time of each person you enrolled. A copy of the recording only if you tick Keep a copy when enrolling." }, - { "where": "Clips you test with", "what": "They go to the model on this server to be compared and are not kept." } - ] - }, - "face": { - "person": "person", - "print": "faceprint", - "whoTab": "Who is this", - "whoLede": "Matches a photo against the people you enrolled.", - "sameLede": "Two photos, one question. Nobody has to be enrolled.", - "whoGo": "Find who this is", - "working": "Searching", - "whoNote": "Searches the people enrolled on this server. The photo you test with is not stored.", - "sameNote": "The answer comes from one faceprint per photo. Nothing is stored.", - "workingText": "Comparing the photo with the faceprints the server holds.", - "probably": "Probably {{name}}", - "matchText": "{{name}}'s faceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.", - "nobody": "Nobody enrolled looks like this", - "missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new person, or a photo that is blurred, turned away or in poor light.", - "noneEnrolled": "Nobody is enrolled to compare with", - "noneEnrolledText": "Enrol a person first, then look up a photo against them.", - "noneReturned": "The server returned no people", - "noneReturnedText": "It holds no faceprints made with this model. If the server restarted, enrolled faces are gone: enrol them again.", - "enrolThis": "Enrol this face", - "same": "Probably the same person", - "different": "Probably different people", - "knownTitle": "Known people", - "enrol": "Enrol a person", - "enrolTitle": "Enrol a person", - "enrolIntro": "The server keeps a faceprint and a name. It does not keep the photo.", - "emptyTitle": "Nobody enrolled in this browser yet", - "emptyText": "Enrol a person with one clear photo, then test a photo against them.", - "agreedHint": "I have their permission, or another lawful basis, to store a faceprint of them.", - "keepHint": "Lets you see the photo and enrol again after a server restart. Off by default. It is stored in this browser only.", - "antiSpoof": "Check for photos of photos", - "antiSpoofHint": "Needs a model that supports it.", - "real": "Real", - "spoof": "Spoof", - "found": "Face in photo {{n}}", - "neededTitle": "Faces need a face model", - "neededText": "Face recognition turns a photo into a faceprint and compares faceprints. It runs on a face-recognition model, and none is installed on this server.", - "neededWhat": "Install a model that supports face recognition. Once one is installed, this page turns on by itself. Users also need the Face recognition permission.", - "moreTitle": "More tools", - "moreHint": "detect faces, guess traits, get the raw faceprint", - "why": [ - "The server matches one face per photo. A group photo is matched on a single face.", - "Light, angle, glasses and blur change a face a lot.", - "This is a similarity score, not a security check. Do not use it to unlock anything." - ], - "stored": [ - { "where": "On the server", "what": "A {{print}} (numbers that describe the face, not the picture), a name and your labels. They are kept in memory and are lost when the server restarts. The photo is not kept." }, - { "where": "In this browser", "what": "The name, labels and time of each person you enrolled. A copy of the photo only if you tick Keep a copy when enrolling." }, - { "where": "Photos you test with", "what": "They go to the model on this server to be compared and are not kept." } - ] - }, - "same": { - "text": "The two {{print}}s are {{distance}} apart. Anything under {{cutoff}} counts as the same person.", - "distance": "distance" - }, - "word": { - "strong": "Strong match", - "likely": "Likely match", - "close": "Close call", - "near": "Just past the cut-off" - }, - "scale": { - "aria": "Distance scale with the cut-off at {{cutoff}}. Lower means more alike. {{dots}}", - "cutoff": "cut-off {{value}}", - "axis": "distance between {{print}}s, lower means more alike", - "oneDot": "one dot per enrolled {{person}}", - "onePair": "one dot for the pair" - }, - "ranks": { - "aria": "Closest people, nearest first", - "within": "within cut-off", - "far": "too far" - }, - "cutoff": { - "label": "Cut-off", - "default": "model default", - "reset": "Reset to {{value}}", - "stricter": "Stricter", - "looser": "Looser", - "explain": "Stricter means fewer wrong matches and more misses. The answer above updates as you move it. The server is not asked again." - }, - "how": { - "title": "How to read this.", - "body": "Distance is how far apart two {{print}}s are, 0 meaning identical. The cut-off is the line between same and different. The word Strong or Likely says how far inside the line a result sits. It is not a probability." - }, - "why": { - "title": "Why this might be wrong", - "short": "The clip is short: {{s}} s. Three seconds or more of speech works better." - }, - "clip": { - "mic": "microphone", - "camera": "camera", - "record": "Record", - "takePhoto": "Take a photo", - "chooseAudio": "Choose a file", - "choosePhoto": "Choose a photo", - "readAloud": "Read this aloud, normal pace", - "remove": "Remove", - "blocked": "The {{thing}} is blocked. Allow it in the browser's site settings and try again, or choose a file instead.", - "insecure": "A browser only opens the {{thing}} on https or on localhost. You are on {{origin}}. Open this page through localhost or add TLS, or choose a file.", - "unsupported": "This browser cannot open the {{thing}}. Choose a file instead.", - "missing": "No {{thing}} was found on this device. Choose a file instead.", - "failed": "The {{thing}} could not be opened. Choose a file instead.", - "allowed": "Microphone allowed.", - "capture": "Capture", - "cancel": "Cancel", - "recording": "Recording, {{s}} s", - "micReady": "Microphone ready", - "stop": "Stop", - "start": "Start recording", - "recorded": "Recording", - "photographed": "Photo", - "uploaded": "Uploaded file", - "goodLength": "good length", - "short": "short", - "checkLevel": "check level", - "preview": "Preview", - "photoAlt": "The photo you chose" - }, - "known": { - "caption": "People saved in this browser, with their {{noun}} state", - "name": "Name", - "added": "Added", - "actions": "Actions", - "best": "best match", - "notOnServer": "not on the server", - "notInBrowser": "not in this browser's list", - "removing": "removing", - "goneText": "The last search did not return this person. The server may have restarted, so the {{print}} is gone, or it was enrolled with another model.", - "extraText": "The server returned this person but this browser has no record of them.", - "noLabels": "no labels", - "undo": "Undo", - "reenrolSaved": "Re-enrol from saved copy", - "enrolAgain": "Enrol again", - "play": "Play the saved sample of {{name}}", - "forget": "Remove {{name}}", - "note": "This list is kept in this browser. The server has no list call, so it cannot be checked until you run a search.", - "noteSearched": "This list is kept in this browser. The last search checked it against the server; a person it did not return is marked." - }, - "stored": { - "title": "What is stored, and where" - }, - "enrol": { - "close": "Close", - "cancel": "Cancel", - "submit": "Enrol", - "working": "Enrolling", - "ready": "Ready to enrol.", - "stillNeeded": "Still needed: {{list}}.", - "needModel": "a model", - "needRecording": "a recording", - "needPhoto": "a photo", - "needName": "a name", - "needTranscript": "a transcript", - "needPermission": "permission", - "recordingStep": "Recording", - "photoStep": "Photo", - "recordingLabel": "Recording", - "photoLabel": "Photo", - "whoStep": "Who is it", - "name": "Name", - "namePlaceholder": "e.g. Marta Rossi", - "labels": "Labels", - "labelsHint": "optional, one per line as key: value", - "asSpeech": "Also keep it as a speech voice", - "asSpeechHint": "Text-to-speech can then speak in this voice. Needs the exact transcript.", - "transcript": "Exact transcript", - "permissionStep": "Permission", - "agreed": "This person agreed", - "keep": "Keep a copy in this browser" - }, - "toast": { - "enrolled": "Enrolled {{name}}", - "enrolledBoth": "Enrolled {{name}} and saved a speech voice", - "tooBig": "The copy was too large for browser storage, so it was not kept.", - "speechFailed": "Enrolled, but the speech voice could not be saved: {{message}}", - "removing": "{{name}} will be removed.", - "removed": "Removed {{name}}", - "alreadyGone": "{{name}} was already gone from the server." - }, - "error": { - "retry": "Try again", - "traces": "Open traces" - }, - "needed": { - "badge": "Not enabled on this server", - "backend": "backend {{backend}}", - "install": "Install", - "installing": "Installing", - "started": "Installing {{name}}. Progress is in Operations.", - "loadFailed": "Models could not be listed.", - "noneListed": "No matching model is listed in the galleries.", - "browse": "Browse all models", - "askAdmin": "Ask an administrator to install one.", - "whatTitle": "What turns this on" - }, - "off": { - "badge": "Not enabled for your account", - "voice": { - "title": "Voice recognition is off for your account", - "text": "An administrator has not allowed you to use voice recognition.", - "what": "An administrator can turn on the Voice recognition permission for your user in Users and keys." - }, - "face": { - "title": "Face recognition is off for your account", - "text": "An administrator has not allowed you to use face recognition.", - "what": "An administrator can turn on the Face recognition permission for your user in Users and keys." - } - }, - "recording": { - "title": "Name the speakers in a recording", - "text": "The diarization workspace finds who spoke when in a recording. From there you can remember a speaker, and that voice joins the Speakers list here.", - "step1": "Open the workspace, add a recording and tick Prepare speakers to remember.", - "step2": "Run diarization. Each speaker with enough clean speech gets a Name and remember button.", - "step3": "Name a speaker and remember it. That voice then appears under Speakers, ready for Who is this.", - "open": "Open the diarization workspace", - "off": "Diarization is not turned on for your account.", - "warning": "Remembered voices are shared on this server and are lost when it restarts. Nothing is remembered automatically." - } -} diff --git a/core/http/react-ui/src/components/EmptyState.jsx b/core/http/react-ui/src/components/EmptyState.jsx deleted file mode 100644 index 1a45f3b04..000000000 --- a/core/http/react-ui/src/components/EmptyState.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import Icon from './Icon' -// Editorial empty state: optional eyebrow + icon, serif title, lede, actions. -// Wraps the existing .empty-state CSS so legacy callers keep working. -export default function EmptyState({ icon, eyebrow, title, body, actions, className = '' }) { - return ( -
- {eyebrow && {eyebrow}} - {icon && } - {title &&

{title}

} - {body &&

{body}

} - {actions &&
{actions}
} -
- ) -} diff --git a/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx b/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx deleted file mode 100644 index df72f7e8e..000000000 --- a/core/http/react-ui/src/components/biometrics/BoundingBoxCanvas.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useRef, useState } from 'react' - -// BoundingBoxCanvas — overlay face-detection rectangles on the user-supplied image. -// boxes: [{ x, y, w, h, label?, sublabel?, tone? }] -// tone: 'default' | 'success' | 'warning' | 'error' | 'accent' -export default function BoundingBoxCanvas({ src, boxes = [], alt = '' }) { - const wrapRef = useRef(null) - const imgRef = useRef(null) - const [dims, setDims] = useState({ w: 0, h: 0, natW: 0, natH: 0 }) - - useEffect(() => { - const update = () => { - if (!wrapRef.current || !imgRef.current) return - const rect = imgRef.current.getBoundingClientRect() - setDims({ - w: rect.width, - h: rect.height, - natW: imgRef.current.naturalWidth || 1, - natH: imgRef.current.naturalHeight || 1, - }) - } - update() - const ro = new ResizeObserver(update) - if (imgRef.current) ro.observe(imgRef.current) - window.addEventListener('resize', update) - return () => { - ro.disconnect() - window.removeEventListener('resize', update) - } - }, [src]) - - const sx = dims.natW ? dims.w / dims.natW : 1 - const sy = dims.natH ? dims.h / dims.natH : 1 - - return ( -
- {src && {alt} { - setDims({ - w: e.target.getBoundingClientRect().width, - h: e.target.getBoundingClientRect().height, - natW: e.target.naturalWidth, - natH: e.target.naturalHeight, - }) - }} />} - {boxes.map((b, i) => ( -
- {(b.label || b.sublabel) && ( -
- {b.label && {b.label}} - {b.sublabel && {b.sublabel}} -
- )} -
- ))} -
- ) -} diff --git a/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx b/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx deleted file mode 100644 index 4375daf05..000000000 --- a/core/http/react-ui/src/components/biometrics/WaveformStrip.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useEffect, useRef } from 'react' -import useAudioPeaks from '../../hooks/useAudioPeaks' - -// WaveformStrip — display-only waveform with optional colored segment -// overlays. For a player with click-to-seek + audio controls, use -// `components/audio/WaveformPlayer` instead. Both share the -// `useAudioPeaks` hook for peak extraction. -// segments: [{ start: seconds, end: seconds, label?, tone? }] -export default function WaveformStrip({ src, segments = [], height = 120 }) { - const canvasRef = useRef(null) - const { peaks, duration, error } = useAudioPeaks(src) - - useEffect(() => { - if (!canvasRef.current || !peaks) return - const canvas = canvasRef.current - const dpr = window.devicePixelRatio || 1 - const cssW = canvas.clientWidth - const cssH = height - canvas.width = Math.floor(cssW * dpr) - canvas.height = Math.floor(cssH * dpr) - const ctx = canvas.getContext('2d') - ctx.scale(dpr, dpr) - ctx.clearRect(0, 0, cssW, cssH) - - const accent = getComputedStyle(canvas).getPropertyValue('--biometrics-wave').trim() || '#e8a87c' - ctx.fillStyle = accent - const mid = cssH / 2 - const barW = Math.max(1, cssW / peaks.length) - for (let i = 0; i < peaks.length; i++) { - const h = Math.max(1, peaks[i] * (cssH * 0.9)) - ctx.fillRect(i * barW, mid - h / 2, Math.max(0.5, barW - 0.5), h) - } - }, [peaks, height]) - - if (error) return
{error}
- if (!src) return null - - return ( -
- - {duration > 0 && segments.map((s, i) => { - const left = (Math.max(0, s.start) / duration) * 100 - const right = (Math.min(duration, s.end) / duration) * 100 - return ( -
- {s.label && {s.label}} -
- ) - })} - {duration > 0 && ( - - )} - {!peaks && ( -
Decoding…
- )} -
- ) -} diff --git a/core/http/react-ui/src/components/identity/ClipInput.jsx b/core/http/react-ui/src/components/identity/ClipInput.jsx deleted file mode 100644 index c86ccb487..000000000 --- a/core/http/react-ui/src/components/identity/ClipInput.jsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useMediaCapture } from '../../hooks/useMediaCapture' -import useObjectUrl from '../../hooks/useObjectUrl' -import { fileToBase64 } from '../../utils/api' -import { judgeClip } from '../../utils/identity' -// eslint-disable-next-line no-unused-vars -import WaveformPlayer from '../audio/WaveformPlayer' -import Icon from '../Icon' - -// Read a clip in the browser: how long it is and how loud. Nothing is sent. -async function readClip(blob) { - const Ctx = window.AudioContext || window.webkitAudioContext - if (!Ctx || !blob) return null - const ctx = new Ctx() - try { - const buf = await ctx.decodeAudioData(await blob.arrayBuffer()) - const data = buf.getChannelData(0) - let peak = 0 - let hot = 0 - for (let i = 0; i < data.length; i += 1) { - const v = Math.abs(data[i]) - if (v > peak) peak = v - if (v >= 0.99) hot += 1 - } - return { seconds: buf.duration, peak, clipped: hot / data.length > 0.001 } - } catch { - return null - } finally { - ctx.close().catch(() => {}) - } -} - -// A permission state read without asking: 'denied', 'prompt', 'granted' or ''. -function useMicState(enabled) { - const [state, setState] = useState('') - useEffect(() => { - if (!enabled || !navigator.permissions?.query) return undefined - let status - let live = true - navigator.permissions.query({ name: 'microphone' }).then((s) => { - if (!live) return - status = s - setState(s.state) - s.onchange = () => setState(s.state) - }).catch(() => {}) - return () => { live = false; if (status) status.onchange = null } - }, [enabled]) - return state -} - -// One sample, in the kit's own look: a drop of choices before there is a -// sample, a card with the facts after. It is the input for both the Voices and -// the Faces pages. -// -// kind 'audio' or 'image' -// value null or { base64, blob, dataUrl, mime, source, name } -// onChange gets the same shape, or null on Remove -// idPrefix the file input is `${idPrefix}-${kind}-file` -export default function ClipInput({ kind, label, value, onChange, idPrefix, hint, prompt, onFacts }) { - const { t } = useTranslation('biometrics') - const fileRef = useRef(null) - const cap = useMediaCapture(kind) - const [facts, setFacts] = useState(null) - const [busy, setBusy] = useState(false) - const micState = useMicState(kind === 'audio') - const secure = typeof window === 'undefined' || (window.isSecureContext ?? true) - const live = kind === 'audio' ? t('clip.mic') : t('clip.camera') - - useEffect(() => { - let current = true - setFacts(null) - onFacts?.(null) - if (kind === 'audio' && value?.blob) readClip(value.blob).then((f) => { if (current) { setFacts(f); onFacts?.(f) } }) - return () => { current = false } - }, [kind, value]) // eslint-disable-line react-hooks/exhaustive-deps - - const take = async (file, source = 'file') => { - setBusy(true) - try { - const base64 = await fileToBase64(file) - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onerror = () => reject(reader.error) - reader.onload = () => resolve(reader.result) - reader.readAsDataURL(file) - }) - const name = source === 'paste' ? `pasted-image.${(file.type.split('/')[1] || 'png').replace('+xml', '')}` : file.name - onChange({ base64, blob: file, dataUrl, mime: file.type, source, name }) - } finally { - setBusy(false) - } - } - - const onFile = (e) => { - const f = e.target.files?.[0] - if (f) take(f) - else onChange(null) - } - - const onPaste = (e) => { - if (kind !== 'image') return - const item = Array.from(e.clipboardData?.items || []).find(entry => entry.type.startsWith('image/')) - const f = item?.getAsFile() - if (!f) return - e.preventDefault() - take(f, 'paste') - } - - const record = async () => { - if (cap.recording) { cap.stopRecording(); return } - const pending = cap.startRecording() - if (!pending) return - const result = await pending - cap.stop() - onChange({ ...result, source: 'live', name: 'recording.wav' }) - } - - const snap = () => { - const shot = cap.snap() - if (shot) { cap.stop(); onChange({ ...shot, source: 'live', name: 'photo.png' }) } - } - - const clear = () => { - onChange(null) - if (fileRef.current) fileRef.current.value = '' - } - - const playUrl = useObjectUrl(kind === 'audio' ? value?.blob : null) - const verdict = facts ? judgeClip(facts) : null - const blocked = cap.errorKind === 'denied' || micState === 'denied' - const inputId = `${idPrefix}-${kind}-file` - - return ( -
-
- {label} - {value && } -
- - - - {!value && !cap.active && ( -
- {prompt &&

{t('clip.readAloud')}{prompt}

} -
- {cap.supported && !blocked && ( - - )} - -
- {(!cap.supported || blocked || cap.error) && ( -

- - - {blocked && t('clip.blocked', { thing: live })} - {!blocked && !cap.supported && !secure && t('clip.insecure', { thing: live, origin: window.location.origin })} - {!blocked && !cap.supported && secure && t('clip.unsupported', { thing: live })} - {!blocked && cap.supported && cap.error && t(cap.errorKind === 'missing' ? 'clip.missing' : 'clip.failed', { thing: live })} - -

- )} - {cap.supported && !blocked && !cap.error && micState === 'granted' && kind === 'audio' && ( -

{t('clip.allowed')}

- )} - {hint &&

{hint}

} -
- )} - - {!value && cap.active && kind === 'image' && ( -
-
- )} - - {!value && cap.active && kind === 'audio' && ( -
-

{cap.recording ? t('clip.recording', { s: cap.elapsed.toFixed(1) }) : t('clip.micReady')}

-
- - -
-
- )} - - {value && ( -
-
- - {value.source === 'live' ? t(kind === 'audio' ? 'clip.recorded' : 'clip.photographed') : value.name || t('clip.uploaded')} - {facts && {facts.seconds.toFixed(1)} s} - {verdict?.level === 'good' && {t('clip.goodLength')}} - {verdict?.level === 'warn' && {facts.seconds < 3 ? t('clip.short') : t('clip.checkLevel')}} -
- {kind === 'audio' - ? - : {t('clip.photoAlt')}} - {verdict?.level === 'warn' &&
    {verdict.notes.map(n =>
  • {n}
  • )}
} -
- )} -
- ) -} diff --git a/core/http/react-ui/src/components/identity/DistanceScale.jsx b/core/http/react-ui/src/components/identity/DistanceScale.jsx deleted file mode 100644 index 95c44f6e1..000000000 --- a/core/http/react-ui/src/components/identity/DistanceScale.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { labelRows, pct, scaleMax, scaleTicks } from '../../utils/identity' - -const at = (value, max) => ({ '--at': pct(value, max) }) - -// One scale for the whole answer: distance runs left to right, the cut-off is -// a line across it, and each enrolled person (or the single pair) is a dot. -// Lower means more alike. The text under it repeats the numbers, so the dots -// are never the only way to read the result. -// -// dots [{ id, label, distance, within, best }] one per person, any order -// cutoff where "same" ends -export default function DistanceScale({ dots, cutoff, noun, single = false }) { - const { t } = useTranslation('biometrics') - const max = scaleMax(cutoff, dots.map(d => d.distance)) - const ordered = [...dots].sort((a, b) => a.distance - b.distance) - const rows = labelRows(ordered.map(d => (d.distance / max) * 100)) - const plot = { '--rows': Math.max(...rows, 0) } - const summary = ordered.map(d => `${d.label} ${d.distance.toFixed(2)}`).join(', ') - - return ( -
-
- {t('scale.cutoff', { value: cutoff.toFixed(2) })} - - {ordered.map((d, i) => ( - - - {d.label} {d.distance.toFixed(2)} - - ))} - -
-
- {t('scale.axis', { print: noun })} - {single ? t('scale.onePair') : t('scale.oneDot', { person: noun === 'faceprint' ? t('face.person') : t('voice.speaker') })} -
-
- ) -} diff --git a/core/http/react-ui/src/components/identity/EnrolSheet.jsx b/core/http/react-ui/src/components/identity/EnrolSheet.jsx deleted file mode 100644 index 6ca654848..000000000 --- a/core/http/react-ui/src/components/identity/EnrolSheet.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { faceApi, voiceApi, voiceProfilesApi } from '../../utils/api' -import { labelsText, parseLabels } from '../../utils/identity' -import { normalizeAudioSample } from '../../utils/referenceAudio' -// eslint-disable-next-line no-unused-vars -import ErrorNote from './ErrorNote' -// eslint-disable-next-line no-unused-vars -import LoadingSpinner from '../LoadingSpinner' -// eslint-disable-next-line no-unused-vars -import SideSheet from '../SideSheet' -import Icon from '../Icon' -// eslint-disable-next-line no-unused-vars -import ClipInput from './ClipInput' - -// A saved copy of a sample lives in localStorage, which is small. A larger one -// is not kept, and the sheet says so instead of failing quietly. -const KEEP_LIMIT = 1_500_000 - -const READ_ALOUD = 'The harbour was quiet before the ferry arrived. Two gulls argued over a crust, and somebody carried a ladder down to the water.' - -// Enrol one person: a sample, who it is, and permission. For a voice, an admin -// can also keep the same recording as a speech voice, which is a different -// store with its own transcript and consent record. -export default function EnrolSheet({ kind, model, canSpeech, prefill, onClose, onDone }) { - const { t } = useTranslation('biometrics') - const isFace = kind === 'face' - const [sample, setSample] = useState(prefill?.sample || null) - const [name, setName] = useState(prefill?.name || '') - const [labels, setLabels] = useState(labelsText(prefill?.labels).replaceAll(', ', '\n')) - const [asSpeech, setAsSpeech] = useState(false) - const [transcript, setTranscript] = useState('') - const [agreed, setAgreed] = useState(false) - const [keep, setKeep] = useState(false) - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - - const needs = useMemo(() => { - const out = [] - if (!model) out.push(t('enrol.needModel')) - if (!sample) out.push(t(isFace ? 'enrol.needPhoto' : 'enrol.needRecording')) - if (!name.trim()) out.push(t('enrol.needName')) - if (asSpeech && !transcript.trim()) out.push(t('enrol.needTranscript')) - if (!agreed) out.push(t('enrol.needPermission')) - return out - }, [model, sample, name, asSpeech, transcript, agreed, isFace, t]) - - const submit = async (e) => { - e.preventDefault() - if (needs.length || busy) return - setBusy(true) - setError(null) - let data - try { - const parsed = parseLabels(labels) - data = isFace - ? await faceApi.register({ model, name: name.trim(), img: sample.dataUrl, labels: parsed }) - : await voiceApi.register({ model, name: name.trim(), audio: sample.dataUrl, labels: parsed }) - const small = sample.dataUrl.length <= KEEP_LIMIT - const kept = keep && small - const entry = { - id: data.id, - name: data.name, - labels: parsed, - model, - registeredAt: data.registered_at || new Date().toISOString(), - ...(kept ? (isFace ? { thumbnail: sample.dataUrl } : { sampleUrl: sample.dataUrl }) : {}), - } - let speechError = null - if (asSpeech && canSpeech && !isFace) { - try { - const wav = await normalizeAudioSample(sample) - const form = new FormData() - form.append('name', name.trim()) - form.append('description', '') - form.append('language', '') - form.append('transcript', transcript.trim()) - form.append('consent_confirmed', 'true') - form.append('audio', wav.blob, 'reference.wav') - await voiceProfilesApi.create(form) - } catch (err) { - speechError = err - } finally { - // The normalised copy only exists for this call. - } - } - onDone(entry, { keptTooBig: keep && !small, speechError, asSpeech }) - } catch (err) { - setError(err) - } finally { - setBusy(false) - } - } - - const footer = ( - <> - {needs.length ? t('enrol.stillNeeded', { list: needs.join(', ') }) : t('enrol.ready')} - - - - ) - - return ( - -
-
-

1 {t(isFace ? 'enrol.photoStep' : 'enrol.recordingStep')}

- -
- -
-

2 {t('enrol.whoStep')}

-
- - setName(e.target.value)} placeholder={t('enrol.namePlaceholder')} autoComplete="off" /> -
-
- -