mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-05 20:42:10 -04:00
* feat(schema): validate portable speaker profiles Add the versioned profile schema for explicit speaker enrollment. Validate compatibility against separately supplied loaded-encoder metadata. Reject unusable speakers, invalid vectors, and inconsistent clean spans. This slice does not change HTTP routes, backend integration, or the UI. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(parakeet): export profiles with transcripts Export opt-in speaker profiles and trusted encoder metadata. Replay registrations by ID so duplicate display names keep independent vectors. Use one profile-capable diarization for slots, names, and clean spans. Assign timestamped ASR words to those slots without a second diarization. Preserve legacy opt-out and no-ASR behavior, and propagate failures. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(audio): enroll portable speaker profiles Gate profile exports with voice-recognition permission and validate registration against metadata from the loaded encoder. Preserve audio enrollment and independent registrations with duplicate display names. Exclude diarization and registration exchanges before API trace capture so persisted traces cannot retain profile vectors or JSON audio. Defer candidate dimensions to trusted loaded metadata. Sort candidates by registration ID so incompatible profiles cannot suppress legacy voices through registry iteration order. Keep portable identity checks closed when trusted metadata is unavailable. Test persisted traces, explicit slot zero, and selection through offline and live transport. Document privacy and the ephemeral registry lifecycle. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): remember speakers from diarization Add a Studio page for diarization and opt-in speaker profiles. Preview clean intervals from the original recording before explicit registration. Join profiles by raw speaker labels, preserve duplicate names, and relabel turns only after a successful save. Discard stale results when the model or recording changes. Share registration metadata with voice management without storing vectors or recordings from this flow. Document permissions and the global, ephemeral registry. Cover enrollment, permissions, previews, and asynchronous races with mocked Playwright tests. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: clarify HTTP speaker enrollment support Replace the stale enrollment limitation with the current HTTP workflow. Distinguish native transport from explicit registration and link its docs. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(parakeet): pin merged speaker profile support Use the merged commit from mudler/parakeet.cpp#80. Its tree matches the previously accepted native pin. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: add diarization enrollment setup example Connect the existing gallery modes to the speaker enrollment workflow. Show installation, private profile export, explicit raw-slot registration, and later recognition without another export. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): explain diarization speaker profiles Put the diarization walkthrough on the LocalAI website in the feature PR. Cover the three gallery modes, explicit enrollment, and privacy limits. Link setup instructions and keep availability conditional on feature support. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): focus diarization on everyday use Explain what users can do with recordings before the setup steps. Replace the technical walkthrough with a short Studio guide and link readers to the existing reference for model names and developer use. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(blog): lead with speaker capabilities Present speaker recognition through everyday uses and a short UI flow. Keep technical reference details in the existing documentation. Assisted-by: OpenAI:unknown Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(diarization): satisfy Go lint checks Avoid copying protobuf message state when extending backend status, check the multipart reader close result, and document the focused testing.T lint exemptions. Assisted-by: nib:gpt-5.6-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
272 lines
14 KiB
React
272 lines
14 KiB
React
import { lazy } from 'react'
|
|
import { createBrowserRouter, Navigate, useParams } from 'react-router-dom'
|
|
import { routerBasename } from './utils/basePath'
|
|
import App from './App'
|
|
import RequireAdmin from './components/RequireAdmin'
|
|
import RequireAuth from './components/RequireAuth'
|
|
import RequireAuthEnabled from './components/RequireAuthEnabled'
|
|
import RequireFeature from './components/RequireFeature'
|
|
|
|
// Pages are code-split: each becomes its own chunk loaded on demand, so a route
|
|
// no longer drags every other page (and its heavy deps — CodeMirror, the MCP
|
|
// SDK, yaml, marked) into the initial bundle. The <Suspense> boundary in
|
|
// App.jsx (around <Outlet/>) shows nothing while a chunk loads, keeping the
|
|
// sidebar/header mounted.
|
|
//
|
|
// `page(key, loader)` registers the dynamic import under a route-segment key
|
|
// (the first segment after /app/) so a NavLink can warm the chunk on hover via
|
|
// `preloadRoute('/app/chat')`. Dynamic import() is memoised by the module
|
|
// loader, so a preloaded chunk is reused — not re-fetched — when the user
|
|
// actually navigates. Pages with `key: null` aren't sidebar-reachable; they
|
|
// still code-split, they just won't be preloaded from the nav.
|
|
const preloaders = {}
|
|
|
|
// A deploy swaps the whole content-hashed asset set at once, so a tab holding
|
|
// an older index.html (or one whose request lands on a replica that hasn't
|
|
// been swapped yet, the normal state during a rolling update) asks for a page
|
|
// chunk the server no longer has. The import rejects and React Router's default
|
|
// error boundary replaces the app with "Unexpected Application Error!" until
|
|
// someone thinks to reload. Reloading is what fixes it, so do it automatically:
|
|
// index.html is served no-cache, so the reload lands on a self-consistent set.
|
|
//
|
|
// The timestamp guard bounds that to one reload per RELOAD_WINDOW_MS. Without
|
|
// it, a chunk that is genuinely gone rather than merely stale would reload the
|
|
// app forever, which is worse than the error screen: it never settles and never
|
|
// says why. The failure isn't narrowed to fetch errors: the message differs per
|
|
// browser and a missed match costs the recovery, while a module that throws
|
|
// while evaluating costs one wasted reload before the error surfaces anyway.
|
|
const RELOAD_KEY = 'localai.chunkReloadedAt'
|
|
const RELOAD_WINDOW_MS = 10_000
|
|
let reloading = false
|
|
|
|
function claimReload() {
|
|
try {
|
|
const last = Number(window.sessionStorage.getItem(RELOAD_KEY)) || 0
|
|
if (Date.now() - last < RELOAD_WINDOW_MS) return false
|
|
window.sessionStorage.setItem(RELOAD_KEY, String(Date.now()))
|
|
return true
|
|
} catch {
|
|
// No usable sessionStorage means no way to bound the reloads.
|
|
return false
|
|
}
|
|
}
|
|
|
|
function page(key, loader) {
|
|
if (key !== null) preloaders[key] = loader
|
|
// preloadRoute keeps the raw loader: a hover that fails must stay silent
|
|
// rather than reload the page out from under the pointer. The click that
|
|
// follows goes through this wrapper and recovers there.
|
|
return lazy(() => loader().catch(err => {
|
|
// A reload is already committing: a second chunk failing in the same
|
|
// document must wait for it, not throw and flash the error boundary.
|
|
if (reloading) return new Promise(() => {})
|
|
if (!claimReload()) throw err
|
|
reloading = true
|
|
window.location.reload()
|
|
// Stay pending. Resolving or rejecting here would flash the error boundary
|
|
// in the frames before the reload commits.
|
|
return new Promise(() => {})
|
|
}))
|
|
}
|
|
|
|
export function preloadRoute(path) {
|
|
if (!path) return
|
|
const m = path.match(/^\/app(?:\/([^/?#]*))?/)
|
|
if (!m) return
|
|
preloaders[m[1] ?? '']?.().catch(() => { /* network blip — real click will retry */ })
|
|
}
|
|
|
|
const Home = page('', () => import('./pages/Home'))
|
|
const Chat = page('chat', () => import('./pages/Chat'))
|
|
const Models = page('models', () => import('./pages/Models'))
|
|
const ManageRedirect = page('manage', () => import('./pages/ManageRedirect'))
|
|
const ImageGen = page('image', () => import('./pages/ImageGen'))
|
|
const VideoGen = page('video', () => import('./pages/VideoGen'))
|
|
const ThreeDGen = page('3d', () => import('./pages/ThreeDGen'))
|
|
const TTS = page('tts', () => import('./pages/TTS'))
|
|
const Sound = page('sound', () => import('./pages/Sound'))
|
|
const Diarization = page('diarization', () => import('./pages/Diarization'))
|
|
const AudioTransform = page('transform', () => import('./pages/AudioTransform'))
|
|
const Talk = page('talk', () => import('./pages/Talk'))
|
|
// Referenced only from JSX below — same blind spot as Activity further down.
|
|
// eslint-disable-next-line no-unused-vars
|
|
const OperateOverview = page('operate', () => import('./pages/OperateOverview'))
|
|
const Backends = page('backends', () => import('./pages/Backends'))
|
|
// Only referenced from JSX below, which eslint cannot see without
|
|
// eslint-plugin-react. Suppressed here rather than left to widen the file's
|
|
// warning count; the surrounding page consts predate the lint baseline.
|
|
// eslint-disable-next-line no-unused-vars
|
|
const Activity = page('activity', () => import('./pages/Activity'))
|
|
const Settings = page('settings', () => import('./pages/Settings'))
|
|
const Traces = page('traces', () => import('./pages/Traces'))
|
|
const P2P = page('p2p', () => import('./pages/P2P'))
|
|
const Agents = page('agents', () => import('./pages/Agents'))
|
|
const AgentCreate = page(null, () => import('./pages/AgentCreate'))
|
|
const AgentChat = page(null, () => import('./pages/AgentChat'))
|
|
const AgentStatus = page(null, () => import('./pages/AgentStatus'))
|
|
const Collections = page('collections', () => import('./pages/Collections'))
|
|
const CollectionDetails = page(null, () => import('./pages/CollectionDetails'))
|
|
const Skills = page('skills', () => import('./pages/Skills'))
|
|
const SkillEdit = page(null, () => import('./pages/SkillEdit'))
|
|
const AgentJobs = page('agent-jobs', () => import('./pages/AgentJobs'))
|
|
const AgentTaskDetails = page(null, () => import('./pages/AgentTaskDetails'))
|
|
const AgentJobDetails = page(null, () => import('./pages/AgentJobDetails'))
|
|
const ModelEditor = page(null, () => import('./pages/ModelEditor'))
|
|
// PipelineEditor removed — the Model Editor with templates handles all model types
|
|
const ImportModel = page(null, () => import('./pages/ImportModel'))
|
|
const BackendLogs = page(null, () => import('./pages/BackendLogs'))
|
|
const Explorer = page(null, () => import('./pages/Explorer'))
|
|
const Login = page(null, () => import('./pages/Login'))
|
|
const FineTune = page('fine-tune', () => import('./pages/FineTune'))
|
|
const Quantize = page('quantize', () => import('./pages/Quantize'))
|
|
const Studio = page('studio', () => import('./pages/Studio'))
|
|
const FaceRecognition = page('face', () => import('./pages/FaceRecognition'))
|
|
const VoiceRecognition = page('voice', () => import('./pages/VoiceRecognition'))
|
|
const VoiceLibrary = page('voice-library', () => import('./pages/VoiceLibrary'))
|
|
const VoiceProfileCreate = page(null, () => import('./pages/VoiceProfileCreate'))
|
|
const Nodes = page('nodes', () => import('./pages/Nodes'))
|
|
const Scheduling = page('scheduling', () => import('./pages/Scheduling'))
|
|
const NodeBackendLogs = page(null, () => import('./pages/NodeBackendLogs'))
|
|
const NodeDetail = page(null, () => import('./pages/NodeDetail'))
|
|
const NotFound = page(null, () => import('./pages/NotFound'))
|
|
const Usage = page('usage', () => import('./pages/Usage'))
|
|
const Users = page('users', () => import('./pages/Users'))
|
|
const Middleware = page('middleware', () => import('./pages/Middleware'))
|
|
const Failover = page('failover', () => import('./pages/Failover'))
|
|
const Account = page('account', () => import('./pages/Account'))
|
|
|
|
import ConsoleLayout from './components/console/ConsoleLayout'
|
|
import { buildConsole, operateConsole } from './components/console/consoleConfig'
|
|
|
|
function BrowseRedirect() {
|
|
const { '*': splat } = useParams()
|
|
return <Navigate to={`/app/${splat || ''}`} replace />
|
|
}
|
|
|
|
|
|
function Admin({ children }) {
|
|
return <RequireAdmin>{children}</RequireAdmin>
|
|
}
|
|
|
|
function Feature({ feature, children }) {
|
|
return <RequireFeature feature={feature}>{children}</RequireFeature>
|
|
}
|
|
|
|
const appChildren = [
|
|
{ index: true, element: <Home /> },
|
|
{ path: 'chat', element: <Chat /> },
|
|
{ path: 'chat/:model', element: <Chat /> },
|
|
{ path: 'image', element: <ImageGen /> },
|
|
{ path: 'image/:model', element: <ImageGen /> },
|
|
{ path: 'video', element: <VideoGen /> },
|
|
{ path: 'video/:model', element: <VideoGen /> },
|
|
{ path: '3d', element: <Feature feature="3d"><ThreeDGen /></Feature> },
|
|
{ path: '3d/:model', element: <Feature feature="3d"><ThreeDGen /></Feature> },
|
|
{ path: 'tts', element: <TTS /> },
|
|
{ path: 'tts/:model', element: <TTS /> },
|
|
{ path: 'sound', element: <Sound /> },
|
|
{ path: 'sound/:model', element: <Sound /> },
|
|
{ path: 'diarization', element: <Feature feature="audio_diarization"><Diarization /></Feature> },
|
|
{ path: 'diarization/:model', element: <Feature feature="audio_diarization"><Diarization /></Feature> },
|
|
{ path: 'transform', element: <Feature feature="audio_transform"><AudioTransform /></Feature> },
|
|
{ path: 'transform/:model', element: <Feature feature="audio_transform"><AudioTransform /></Feature> },
|
|
{ path: 'studio', element: <Studio /> },
|
|
// Tabs are path segments, not a query parameter: switching generator is
|
|
// navigation, and ?tab= reads like a filter. Legacy ?tab= links redirect.
|
|
{ path: 'studio/:tab', element: <Studio /> },
|
|
{ path: 'talk', element: <Talk /> },
|
|
{ path: 'account', element: <Account /> },
|
|
|
|
// Build console — Automation, Training, and Recognition groups share one rail.
|
|
// Only the section landing pages live under the rail; deep create/edit/chat
|
|
// flows below render full-width.
|
|
{
|
|
element: <ConsoleLayout config={buildConsole} />,
|
|
children: [
|
|
{ path: 'agents', element: <Feature feature="agents"><Agents /></Feature> },
|
|
{ path: 'skills', element: <Feature feature="skills"><Skills /></Feature> },
|
|
{ path: 'collections', element: <Feature feature="collections"><Collections /></Feature> },
|
|
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
|
|
{ path: 'fine-tune', element: <Feature feature="fine_tuning"><FineTune /></Feature> },
|
|
{ path: 'quantize', element: <Feature feature="quantization"><Quantize /></Feature> },
|
|
{ path: 'face', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
|
|
{ path: 'face/:model', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
|
|
{ path: 'voice', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
|
|
{ path: 'voice/:model', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
|
|
],
|
|
},
|
|
// Build deep flows — full-width, no rail.
|
|
{ path: 'agents/new', element: <Feature feature="agents"><AgentCreate /></Feature> },
|
|
{ path: 'agents/:name/edit', element: <Feature feature="agents"><AgentCreate /></Feature> },
|
|
{ path: 'agents/:name/chat', element: <Feature feature="agents"><AgentChat /></Feature> },
|
|
{ path: 'agents/:name/status', element: <Feature feature="agents"><AgentStatus /></Feature> },
|
|
{ path: 'collections/:name', element: <Feature feature="collections"><CollectionDetails /></Feature> },
|
|
{ path: 'skills/new', element: <Feature feature="skills"><SkillEdit /></Feature> },
|
|
{ path: 'skills/edit/:name', element: <Feature feature="skills"><SkillEdit /></Feature> },
|
|
{ path: 'agent-jobs/tasks/new', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
|
{ path: 'agent-jobs/tasks/:id', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
|
{ path: 'agent-jobs/tasks/:id/edit', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
|
{ path: 'agent-jobs/jobs/:id', element: <Feature feature="mcp_jobs"><AgentJobDetails /></Feature> },
|
|
|
|
// Operate console (admin).
|
|
{
|
|
element: <ConsoleLayout config={operateConsole} />,
|
|
children: [
|
|
{ path: 'operate', element: <Admin><OperateOverview /></Admin> },
|
|
{ path: 'backends', element: <Admin><Backends /></Admin> },
|
|
{ path: 'activity', element: <Admin><Activity /></Admin> },
|
|
{ path: 'voice-library', element: <Admin><VoiceLibrary /></Admin> },
|
|
{ path: 'settings', element: <Admin><Settings /></Admin> },
|
|
{ path: 'traces', element: <Admin><Traces /></Admin> },
|
|
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
|
|
{ path: 'p2p', element: <Admin><P2P /></Admin> },
|
|
{ path: 'nodes', element: <Admin><Nodes /></Admin> },
|
|
{ path: 'nodes/:id', element: <Admin><NodeDetail /></Admin> },
|
|
{ path: 'scheduling', element: <Admin><Scheduling /></Admin> },
|
|
{ path: 'node-backend-logs/:nodeId/:modelId', element: <Admin><NodeBackendLogs /></Admin> },
|
|
{ path: 'usage', element: <Usage /> },
|
|
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
|
|
{ path: 'middleware', element: <Admin><Middleware /></Admin> },
|
|
{ path: 'failover', element: <Admin><Failover /></Admin> },
|
|
],
|
|
},
|
|
|
|
// Canonical resource pages and legacy management compatibility.
|
|
{ path: 'models', element: <Admin><Models /></Admin> },
|
|
{ path: 'manage', element: <Admin><ManageRedirect /></Admin> },
|
|
{ path: 'voice-library/new', element: <Admin><VoiceProfileCreate /></Admin> },
|
|
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
|
|
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
|
|
{ path: 'import-model', element: <Admin><ImportModel /></Admin> },
|
|
{ path: '*', element: <NotFound /> },
|
|
]
|
|
|
|
export const router = createBrowserRouter([
|
|
{
|
|
path: '/login',
|
|
element: <Login />,
|
|
},
|
|
{
|
|
path: '/invite/:code',
|
|
element: <Login />,
|
|
},
|
|
{
|
|
path: '/explorer',
|
|
element: <Explorer />,
|
|
},
|
|
{
|
|
path: '/app',
|
|
element: <RequireAuth><App /></RequireAuth>,
|
|
children: appChildren,
|
|
},
|
|
// Backward compatibility: redirect /browse/* to /app/*
|
|
{
|
|
path: '/browse/*',
|
|
element: <BrowseRedirect />,
|
|
},
|
|
{
|
|
path: '/',
|
|
element: <Navigate to="/app" replace />,
|
|
},
|
|
], { basename: routerBasename })
|