mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
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:
9 files changed
+125
-48
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user