feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 23:18:08 +00:00
1 parent 78fa0b93a8
commit 7b7cde274f
9 files changed
+125 -48

No files matched your search

+3 -3
View File
@@ -14,9 +14,9 @@
"entry": "ui/voice.js",
"css": ["ui/voice.css"],
"sdk": { "min": 2, "max": 2 },
"pages": ["recognition", "library", "create", "speech"],
"navigation": ["speech", "library"],
"mainPage": "recognition",
"pages": ["voice", "recognition", "library", "create", "speech"],
"navigation": ["voice"],
"mainPage": "voice",
"icon": "ui/icon.png",
"title": "Voice",
"description": "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them."
+2 -1
View File
@@ -6,7 +6,7 @@ export function createSpeech(ctx) {
const { h, t, ui, hooks, viewer } = ctx
const { useState, useEffect, useMemo } = ctx.React
function Speech({ path }) {
function Speech({ path, tabs }) {
const [model, setModel] = useState(ctx.modelFrom(path))
const [models, setModels] = useState({ status: 'loading', list: [] })
const [profiles, setProfiles] = useState([])
@@ -57,6 +57,7 @@ export function createSpeech(ctx) {
return (
<main className="page vc-speech" data-testid="voice-speech">
<ui.PageHeader title={t('speech.title')} supporting={t('speech.lede')} />
{tabs}
<label className="vc-field">
<span>{t('speech.model')}</span>
<ui.ModelSelector value={model} onChange={setModel} options={models.list.map(m => m.id)} loading={models.status === 'loading'} />
+72
View File
@@ -0,0 +1,72 @@
// The one page of the Voice add-on. Its sections are tabs: Recognition,
// Library, Text to speech and From a recording. The tab is the first segment of
// the page path (voice/library), the same convention the Studio add-on uses;
// the profile form is a view of the Library tab (voice/library/new).
const DEFAULT_TAB = 'recognition'
const TABS = ['recognition', 'library', 'speech', 'recording']
export function createVoiceHub(ctx, api, identity, sections) {
const { React, h, Fragment, t } = ctx
const { useEffect, useMemo, useState } = React
// Where an older page address goes now. `search` is the query string the
// old address carried (a model, a selected profile, a voice); it is kept.
function redirectFor(legacy, path, search = '') {
const params = new URLSearchParams(search)
let tab = legacy
let rest = path
if (legacy === 'recognition' && params.get('tab') === 'recording') { tab = 'recording'; rest = '' }
if (legacy === 'recognition') params.delete('tab')
if (legacy === 'create') { tab = 'library'; rest = 'new' }
const query = params.toString()
return `voice/${tab}${rest ? `/${rest}` : ''}${query ? `?${query}` : ''}`
}
function Hub({ path }) {
const segments = String(path || '').split('/').filter(Boolean)
const tab = TABS.includes(segments[0]) ? segments[0] : DEFAULT_TAB
const rest = segments.slice(tab === segments[0] ? 1 : 0).join('/')
const isAdmin = ctx.viewer.isAdmin()
// The counts on the tabs. The people this browser knows come from the
// recognition tab; the saved voices are read once for an administrator and
// then follow the library tab.
const [speakers, setSpeakers] = useState(() => identity.loadList('localai_voice_enrollments').length)
const [library, setLibrary] = useState({ loading: true, count: 0 })
useEffect(() => {
if (!isAdmin) return undefined
let live = true
api.voiceProfiles.list()
.then((body) => { if (live) setLibrary({ loading: false, count: (body?.data || []).length }) })
.catch(() => { if (live) setLibrary({ loading: false, count: 0 }) })
return () => { live = false }
}, [isAdmin])
const onLibraryCount = useMemo(() => (count) => setLibrary({ loading: false, count }), [])
const label = {
recognition: <>{t('voices.tab.recognition')} <span className="dk-hubtab-count">{speakers}</span></>,
library: <>{t('voices.tab.library')} <span className="dk-hubtab-count">{library.loading ? '' : library.count}</span></>,
speech: t('voices.tab.speech'),
recording: t('voices.tab.recording'),
}
const shown = TABS.filter((key) => key !== 'library' || isAdmin)
const tabs = (
<nav className="dk-tabs idn-tabs vc-tabs" role="tablist" aria-label={t('voices.tabs')} data-testid="voice-tabs">
{shown.map((key) => (
<ctx.Link key={key} className="dk-tab" role="tab" page={`voice/${key}`} data-tab={key} aria-selected={tab === key} aria-current={tab === key ? 'page' : undefined}>
{label[key]}
</ctx.Link>
))}
</nav>
)
if (tab === 'library') {
const Page = rest === 'new' ? sections.create : sections.library
return <Page tabs={tabs} onLibraryCount={onLibraryCount} />
}
if (tab === 'speech') return <sections.speech path={rest} tabs={tabs} />
return <sections.recognition path={rest} tab={tab === 'recording' ? 'recording' : 'speakers'} tabs={tabs} onSpeakerCount={setSpeakers} />
}
return { Hub, redirectFor }
}
+6 -3
View File
@@ -295,7 +295,7 @@ JSON`
type="button"
className="dk-btn dk-btn--primary"
disabled={cloningModels.length === 0}
onClick={() => { close(); ctx.sdk.navigation.navigate(`speech?voice=${encodeURIComponent(profile.id)}`) }}
onClick={() => { close(); ctx.sdk.navigation.navigate(`voice/speech?voice=${encodeURIComponent(profile.id)}`) }}
>
<Icon name="headphones" /> {t('voiceLibrary.actions.useInTTS')}
</button>
@@ -314,8 +314,10 @@ JSON`
)
}
function VoiceLibrary() {
function VoiceLibrary({ tabs, onLibraryCount }) {
const { profiles, loading, error, refetch } = useProfiles()
// The count on the Library tab follows the list this tab shows.
useEffect(() => { if (!loading && !error) onLibraryCount?.(profiles.length) }, [loading, error, profiles.length, onLibraryCount])
const setup = useModelSetup()
const { cloningModels, modelsLoading } = setup
const [search, setSearch] = useState('')
@@ -356,7 +358,7 @@ JSON`
}, [selectedParam, profiles, openProfile])
const newButton = (className) => admin && (
<ctx.Link page="create" className={className} data-testid="voice-library-new">
<ctx.Link page="voice/library/new" className={className} data-testid="voice-library-new">
<Icon name="plus" /> {t('voiceLibrary.actions.create')}
</ctx.Link>
)
@@ -375,6 +377,7 @@ JSON`
</div>
)}
/>
{tabs}
{error && (
<div className="idn-error" role="alert">
+5 -4
View File
@@ -19,7 +19,7 @@ export function createVoiceProfileCreate(ctx, api) {
)
}
function VoiceProfileCreate() {
function VoiceProfileCreate({ tabs }) {
const [audio, setAudio] = useState(null)
const [audioProcessing, setAudioProcessing] = useState(false)
const [audioError, setAudioError] = useState('')
@@ -116,7 +116,7 @@ export function createVoiceProfileCreate(ctx, api) {
})
const profile = await api.voiceProfiles.create(formData)
ctx.toast(t('voiceCreate.toasts.created', { name: profile.name }), 'success')
ctx.sdk.navigation.navigate(`library?selected=${encodeURIComponent(profile.id)}`)
ctx.sdk.navigation.navigate(`voice/library?selected=${encodeURIComponent(profile.id)}`)
} catch (err) {
ctx.toast(err.message, 'error')
} finally {
@@ -129,8 +129,9 @@ export function createVoiceProfileCreate(ctx, api) {
<ui.PageHeader
title={<><Icon name="mic" /> {t('voiceCreate.title')}</>}
supporting={t('voiceCreate.subtitle')}
actions={<ctx.Link page="library" className="dk-btn dk-btn--secondary"><Icon name="arrow-left" /> {t('voiceCreate.actions.back')}</ctx.Link>}
actions={<ctx.Link page="voice/library" className="dk-btn dk-btn--secondary"><Icon name="arrow-left" /> {t('voiceCreate.actions.back')}</ctx.Link>}
/>
{tabs}
<form className="voice-create-grid" onSubmit={submit}>
<div className="voice-create-form">
@@ -218,7 +219,7 @@ export function createVoiceProfileCreate(ctx, api) {
</section>
<div className="voice-create-actions">
<ctx.Link page="library" className="dk-btn dk-btn--secondary">{t('voiceCreate.actions.cancel')}</ctx.Link>
<ctx.Link page="voice/library" className="dk-btn dk-btn--secondary">{t('voiceCreate.actions.cancel')}</ctx.Link>
<button type="submit" className="dk-btn dk-btn--primary" disabled={!formReady || submitting}>
{submitting ? <><ui.Spinner size="sm" /> {t('voiceCreate.actions.saving')}</> : <><Icon name="save" /> {t('voiceCreate.actions.save')}</>}
</button>
+10 -33
View File
@@ -1,9 +1,7 @@
// The Voices page: who is speaking. Ported from pages/VoiceRecognition.jsx.
// Two unrelated things carry the word voice and are kept apart: Speakers
// (voiceprints used to recognise who is speaking) and Speech voices (reference
// recordings text-to-speech speaks in). Speech voices is the library page of
// this add-on, so its tab is a link to that page; From a recording is the
// diarization hand-off, a tab of this page.
// The Recognition and From a recording tabs of the Voice page. Ported from
// pages/VoiceRecognition.jsx. Speakers are voiceprints used to recognise who is
// speaking; the speech voices (reference recordings text-to-speech speaks in)
// are the Library tab. The tab strip comes from VoiceHub as `tabs`.
import { createClipInput } from '../../../internal/identity-ui/src/ClipInput.jsx'
import { createWorkbench } from '../../../internal/identity-ui/src/Workbench.jsx'
import { createKnownList } from '../../../internal/identity-ui/src/KnownList.jsx'
@@ -37,9 +35,10 @@ export function createVoiceRecognition(ctx, api, identity) {
// Shown in place of a page whose feature the signed-in user may not use. It
// replaces a silent redirect: the person learns what is off and what turns it on.
function FeatureOff({ feature }) {
function FeatureOff({ feature, tabs }) {
return (
<main className="page idn-page idn-off" data-testid="voice-off">
{tabs}
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t(`off.${feature}.title`)}</h1>
@@ -71,27 +70,13 @@ export function createVoiceRecognition(ctx, api, identity) {
return { ...state, refetch: useCallback(() => load(true), [load]) }
}
function VoicesPage({ path }) {
const tab = ctx.useSearch().get('tab') === 'recording' ? 'recording' : 'speakers'
function VoicesPage({ path, tab = 'speakers', tabs, onSpeakerCount }) {
const isAdmin = viewer.isAdmin()
const [model, setModel] = useState(() => ctx.modelFrom(path) || '')
const { models, loading: modelsLoading, refetch } = useSpeakerModels()
const names = useMemo(() => models.map(m => m.id), [models])
const [library, setLibrary] = useState({ loading: true, count: 0 })
const [savedCount, setSavedCount] = useState(() => identity.loadList('localai_voice_enrollments').length)
const needModel = !modelsLoading && names.length === 0
// The speech voices count on the tab. Only an administrator sees the tab,
// so only an administrator's page asks for the list.
useEffect(() => {
if (!isAdmin) return undefined
let live = true
api.voiceProfiles.list()
.then((body) => { if (live) setLibrary({ loading: false, count: (body?.data || []).length }) })
.catch(() => { if (live) setLibrary({ loading: false, count: 0 }) })
return () => { live = false }
}, [isAdmin])
return (
<main className="page idn-page" data-testid="voices-page">
<ui.PageHeader
@@ -104,19 +89,11 @@ export function createVoiceRecognition(ctx, api, identity) {
) : null}
/>
<nav className="dk-tabs idn-tabs" role="tablist" aria-label={t('voices.tabs')}>
<ctx.Link className="dk-tab" role="tab" page="recognition" aria-selected={tab === 'speakers'} aria-current={tab === 'speakers' ? 'page' : undefined}>{t('voices.speakers')} <span className="dk-hubtab-count">{savedCount}</span></ctx.Link>
{isAdmin && (
<ctx.Link className="dk-tab" role="tab" page="library" aria-selected="false">
{t('voices.speech')} <span className="dk-hubtab-count">{library.loading ? '' : library.count}</span>
</ctx.Link>
)}
<ctx.Link className="dk-tab" role="tab" page="recognition" query="tab=recording" aria-selected={tab === 'recording'} aria-current={tab === 'recording' ? 'page' : undefined}>{t('voices.recording')}</ctx.Link>
</nav>
{tabs}
{tab === 'speakers' && (
<Workbench
kind="voice" model={model} canSpeech={isAdmin} onCount={setSavedCount}
kind="voice" model={model} canSpeech={isAdmin} onCount={onSpeakerCount}
needed={needModel ? <ModelNeeded kind="voice" isAdmin={isAdmin} onInstalled={refetch} /> : null}
more={needModel ? null : (
<details className="idn-disclosure idn-disclosure--page idn-more">
@@ -150,7 +127,7 @@ export function createVoiceRecognition(ctx, api, identity) {
// the gate of the page is the gate of the core route). The server checks every
// /v1/voice/* call again. A viewer without it gets the notice and no call.
function VoiceRecognition(props) {
if (!viewer.hasFeature('voice_recognition')) return <FeatureOff feature="voice" />
if (!viewer.hasFeature('voice_recognition')) return <FeatureOff feature="voice" tabs={props.tabs} />
return <VoicesPage {...props} />
}
+4 -3
View File
@@ -15,9 +15,10 @@ export default {
"voices.title": "Voices",
"voices.lede": "Recognise who is speaking, and keep voices that text-to-speech can use.",
"voices.tabs": "Voices sections",
"voices.speakers": "Speakers",
"voices.speech": "Speech voices",
"voices.recording": "From a recording",
"voices.tab.recognition": "Recognition",
"voices.tab.library": "Library",
"voices.tab.speech": "Text to speech",
"voices.tab.recording": "From a recording",
"tool.aria": "Recognition tool",
"tool.sameTab": "Same person?",
"tool.clip": "Clip",
+19 -1
View File
@@ -3,6 +3,7 @@ import { createSpeech } from './Speech.jsx'
import { createApi } from './api.js'
import { createVoiceLibrary } from './VoiceLibrary.jsx'
import { createVoiceProfileCreate } from './VoiceProfileCreate.jsx'
import { createVoiceHub } from './VoiceHub.jsx'
import { createVoiceRecognition } from './VoiceRecognition.jsx'
import * as identity from '../../../internal/identity-ui/src/identity.js'
@@ -14,23 +15,40 @@ export function createAddon(sdk, options = {}) {
// The library and the profile form are for administrators (the server
// refuses everyone else). A viewer who is not one gets this notice and the
// page below is never rendered, so it makes no call and shows no profile data.
// The library and the profile form are for administrators (the server
// refuses everyone else). A viewer who is not one gets this notice and the
// page below is never rendered, so it makes no call and shows no profile data.
const adminOnly = (Page) => function AdminPage(props) {
if (!ctx.viewer.isAdmin()) {
return (
<main className="page vc-off" data-testid="voice-admin-only">
<ctx.ui.PageHeader title={ctx.t('voice.adminOnly.title')} supporting={ctx.t('voice.adminOnly.text')} />
{props.tabs}
</main>
)
}
return <Page {...props} />
}
const pages = {
const sections = {
recognition: createVoiceRecognition(ctx, api, identity).VoiceRecognition,
library: adminOnly(createVoiceLibrary(ctx, api).VoiceLibrary),
create: adminOnly(createVoiceProfileCreate(ctx, api).VoiceProfileCreate),
speech: createSpeech(ctx).Speech,
...options.pages,
}
const { Hub, redirectFor } = createVoiceHub(ctx, api, identity, sections)
// One page, "voice", holds every section as a tab. The older page names stay
// declared in the manifest so a saved link still opens, and each one is
// sent to its tab (see redirectFor).
const pages = { voice: Hub }
for (const legacy of ['recognition', 'library', 'create', 'speech']) {
pages[legacy] = function LegacyPage({ path }) {
const { useEffect } = ctx.React
const target = redirectFor(legacy, path || '', sdk.navigation.current().search)
useEffect(() => { sdk.navigation.navigate(target, { replace: true }) }, [target])
return null
}
}
function Component({ page, path }) {
const Page = pages[page]
+4
View File
@@ -262,3 +262,7 @@
.idn-sheet__need { flex: 1 1 160px; min-width: 0; margin-right: 0; }
.idn-again { align-self: flex-start; margin-top: var(--dk-space-2); }
.idn-sheet__foot { display: flex; align-items: center; justify-content: flex-end; gap: var(--dk-space-3); flex-wrap: wrap; padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); }
/* The tab strip of the Voice page. The recognition page is a flex column with
its own gap; the other pages are block layouts and need the space here. */
.vc-speech > .vc-tabs, .voice-library-page > .vc-tabs, .voice-create-page > .vc-tabs, .vc-off > .vc-tabs { margin: var(--dk-space-4) 0 var(--dk-space-5); }