From 2c63dfff25ecd305b8d94fa8a6db37ad1ac4197f Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 16 Mar 2026 09:31:14 -0700 Subject: [PATCH 1/4] overhaul settings: split into routed sub-tabs, add server logs, changelog, reusable setting components - Rename Server tab to Settings with horizontal sub-tab navigation (General, Generation, GPU, Logs, Changelog) - All sub-tabs are proper routes under /settings/* with /server redirect for backwards compat - General: connection settings, link cards (docs + discord), API reference card, app updates - Generation: auto-chunking, crossfade, normalize, autoplay as SettingRow components - GPU: info card with platform-aware icons (Apple logo for MPS), CUDA management, explainer text - Logs: real-time server log viewer piped from Tauri sidecar via event system (Tauri-only) - Changelog: parsed from CHANGELOG.md at build time via Vite virtual module plugin - New reusable SettingRow/SettingSection components for consistent settings layout - New Toggle (switch) UI component replacing checkboxes in settings - Toast viewport now offsets when audio player is open - Sidebar stays active on settings sub-routes (fuzzy matching) --- CHANGELOG.md | 44 ++ app/plugins/changelog.ts | 23 + app/src/App.tsx | 9 + app/src/components/History/HistoryTable.tsx | 42 +- .../components/ServerTab/ChangelogPage.tsx | 220 ++++++++++ app/src/components/ServerTab/GeneralPage.tsx | 372 ++++++++++++++++ .../components/ServerTab/GenerationPage.tsx | 90 ++++ app/src/components/ServerTab/GpuPage.tsx | 414 ++++++++++++++++++ app/src/components/ServerTab/LogsPage.tsx | 104 +++++ app/src/components/ServerTab/ServerTab.tsx | 81 +++- app/src/components/ServerTab/SettingRow.tsx | 62 +++ app/src/components/Sidebar.tsx | 11 +- app/src/components/ui/slider.tsx | 4 +- app/src/components/ui/toaster.tsx | 4 +- app/src/components/ui/toggle.tsx | 47 ++ app/src/global.d.ts | 5 + app/src/lib/utils/parseChangelog.ts | 37 ++ app/src/platform/types.ts | 6 + app/src/router.tsx | 70 ++- app/src/stores/logStore.ts | 29 ++ app/tsconfig.node.json | 2 +- app/vite.config.ts | 3 +- backend/build_binary.py | 8 +- backend/voicebox-server.spec | 6 +- docs/notes/BACKEND_CODE_REVIEW.md | 188 ++++++++ tauri/src-tauri/src/main.rs | 25 +- tauri/src/platform/lifecycle.ts | 16 +- tauri/vite.config.ts | 5 +- web/src/platform/lifecycle.ts | 7 +- web/vite.config.ts | 5 +- 30 files changed, 1879 insertions(+), 60 deletions(-) create mode 100644 app/plugins/changelog.ts create mode 100644 app/src/components/ServerTab/ChangelogPage.tsx create mode 100644 app/src/components/ServerTab/GeneralPage.tsx create mode 100644 app/src/components/ServerTab/GenerationPage.tsx create mode 100644 app/src/components/ServerTab/GpuPage.tsx create mode 100644 app/src/components/ServerTab/LogsPage.tsx create mode 100644 app/src/components/ServerTab/SettingRow.tsx create mode 100644 app/src/components/ui/toggle.tsx create mode 100644 app/src/lib/utils/parseChangelog.ts create mode 100644 app/src/stores/logStore.ts create mode 100644 docs/notes/BACKEND_CODE_REVIEW.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9056c239..00bcf160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ ## [Unreleased] +This release rewrites the backend into a modular architecture, migrates the documentation site to Fumadocs, and ships a batch of bug fixes and UI polish across the stack. + +The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, model loading status is now visible in the UI, effects presets get a dropdown, and several race conditions and accessibility gaps are closed. + +### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285)) +- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10 +- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/` +- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules +- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config +- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile` +- Deduplicated shared logic across TTS backends into `backends/base.py` +- Improved startup logging with version, platform, data directory, and database stats +- Fixed startup database session leak — sessions now rollback and close in `finally` block +- Isolated shutdown unload calls so one backend failure doesn't block the others +- Handled null duration in `story_items` migration +- Reject model migration when target is a subdirectory of source cache + +### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288)) +- Migrated docs site from Mintlify to Fumadocs (Next.js-based) +- Rewrote introduction and root page with content from README +- Added "Edit on GitHub" links and last-updated timestamps on all pages +- Generated OpenAPI spec and auto-generated API reference pages +- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.) +- Sidebar groups now expand by default; root redirects to `/docs` +- Added OG image metadata and `/og` preview page + +### UI & Frontend +- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344)) +- Fixed take-label race condition during regeneration +- Added accessible focus styling to select component +- Softened select focus indicator opacity +- Addressed 4 critical and 12 major issues from CodeRabbit review + +### Platform Fixes +- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277)) +- Fixed Docker frontend build and cleaned up Docker docs +- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz` +- Added dynamic download redirect routes to landing site + +### Release Tooling +- Added `draft-release-notes` and `release-bump` agent skills +- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases +- Backfilled changelog with all historical releases + ## [0.2.3] - 2026-03-15 The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds. diff --git a/app/plugins/changelog.ts b/app/plugins/changelog.ts new file mode 100644 index 00000000..d131fe53 --- /dev/null +++ b/app/plugins/changelog.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Plugin } from 'vite'; + +/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */ +export function changelogPlugin(repoRoot: string): Plugin { + const virtualId = 'virtual:changelog'; + const resolvedId = '\0' + virtualId; + const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md'); + + return { + name: 'changelog', + resolveId(id) { + if (id === virtualId) return resolvedId; + }, + load(id) { + if (id === resolvedId) { + const raw = readFileSync(changelogPath, 'utf-8'); + return `export default ${JSON.stringify(raw)};`; + } + }, + }; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 458686c8..b6964db1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlatform } from '@/platform/PlatformContext'; import { router } from '@/router'; +import { useLogStore } from '@/stores/logStore'; import { useServerStore } from '@/stores/serverStore'; const LOADING_MESSAGES = [ @@ -63,6 +64,14 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [platform.lifecycle]); + // Subscribe to server logs + useEffect(() => { + const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => { + useLogStore.getState().addEntry(entry); + }); + return unsubscribe; + }, [platform.lifecycle]); + // Setup window close handler and auto-start server when running in Tauri (production only) useEffect(() => { if (!platform.metadata.isTauri) { diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index b37817df..e7b87e0c 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -15,7 +15,7 @@ import { Wand2, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import Loader from 'react-loaders'; + import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; import { Button } from '@/components/ui/button'; import { @@ -56,8 +56,35 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; -// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) -// This is the new alternate history view with fixed height rows +// ─── Audio Bars ───────────────────────────────────────────────────────────── + +function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) { + const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40'; + return ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ); +} // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { @@ -446,12 +473,9 @@ export function HistoryTable() { > {/* Status icon */}
-
- -
+
{/* Left side - Meta information */} diff --git a/app/src/components/ServerTab/ChangelogPage.tsx b/app/src/components/ServerTab/ChangelogPage.tsx new file mode 100644 index 00000000..9582d0c2 --- /dev/null +++ b/app/src/components/ServerTab/ChangelogPage.tsx @@ -0,0 +1,220 @@ +import changelogRaw from 'virtual:changelog'; +import { useMemo, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog'; + +function renderMarkdown(md: string): React.ReactNode[] { + const lines = md.split('\n'); + const elements: React.ReactNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Skip empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Tables — collect all lines starting with | + if (line.trim().startsWith('|')) { + const tableLines: string[] = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i]); + i++; + } + elements.push(renderTable(tableLines, elements.length)); + continue; + } + + // Headings + if (line.startsWith('#### ')) { + elements.push( +
+ {inlineMarkdown(line.slice(5))} +
, + ); + i++; + continue; + } + if (line.startsWith('### ')) { + elements.push( +

+ {inlineMarkdown(line.slice(4))} +

, + ); + i++; + continue; + } + + // List items — collect consecutive + if (line.startsWith('- ')) { + const items: string[] = []; + while (i < lines.length && lines[i].startsWith('- ')) { + items.push(lines[i].slice(2)); + i++; + } + elements.push( + , + ); + continue; + } + + // Paragraph + elements.push( +

+ {inlineMarkdown(line)} +

, + ); + i++; + } + + return elements; +} + +function renderTable(tableLines: string[], keyBase: number): React.ReactNode { + const parseRow = (line: string) => + line + .split('|') + .slice(1, -1) + .map((c) => c.trim()); + + const headers = parseRow(tableLines[0]); + // Skip separator line (index 1) + const rows = tableLines.slice(2).map(parseRow); + + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {inlineMarkdown(h)} +
+ {inlineMarkdown(cell)} +
+
+ ); +} + +function inlineMarkdown(text: string): React.ReactNode { + // Process inline markdown: bold, code, links + const parts: React.ReactNode[] = []; + // Regex matches: **bold**, `code`, [text](url) + const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = inlineRe.exec(text); + + while (match !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + if (match[1] !== undefined) { + // Bold + parts.push( + + {match[1]} + , + ); + } else if (match[2] !== undefined) { + // Code + parts.push( + + {match[2]} + , + ); + } else if (match[3] !== undefined && match[4] !== undefined) { + // Link + parts.push( + + {match[3]} + , + ); + } + + lastIndex = match.index + match[0].length; + match = inlineRe.exec(text); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length === 1 ? parts[0] : parts; +} + +function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) { + const [expanded, setExpanded] = useState(false); + const content = useMemo(() => renderMarkdown(entry.body), [entry.body]); + const isLong = entry.body.split('\n').length > 12; + + return ( +
+
+

{entry.version}

+ {entry.date && {entry.date}} + {entry.version === 'Unreleased' && dev} +
+ +
+ {content} + {isLong && !expanded && ( +
+ )} +
+ + {isLong && ( + + )} +
+ ); +} + +export function ChangelogPage() { + const entries = useMemo(() => parseChangelog(changelogRaw), []); + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx new file mode 100644 index 00000000..51b5ed74 --- /dev/null +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -0,0 +1,372 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { Toggle } from '@/components/ui/toggle'; +import { useToast } from '@/components/ui/use-toast'; +import { useAutoUpdater } from '@/hooks/useAutoUpdater'; +import { useServerHealth } from '@/lib/hooks/useServer'; +import { usePlatform } from '@/platform/PlatformContext'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +const connectionSchema = z.object({ + serverUrl: z.string().url('Please enter a valid URL'), +}); + +type ConnectionFormValues = z.infer; + +export function GeneralPage() { + const platform = usePlatform(); + const serverUrl = useServerStore((state) => state.serverUrl); + const setServerUrl = useServerStore((state) => state.setServerUrl); + const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose); + const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose); + const mode = useServerStore((state) => state.mode); + const setMode = useServerStore((state) => state.setMode); + const { toast } = useToast(); + const { data: health, isLoading, error: healthError } = useServerHealth(); + + const form = useForm({ + resolver: zodResolver(connectionSchema), + defaultValues: { serverUrl }, + }); + + useEffect(() => { + form.reset({ serverUrl }); + }, [serverUrl, form]); + + const { isDirty } = form.formState; + + function onSubmit(data: ConnectionFormValues) { + setServerUrl(data.serverUrl); + form.reset(data); + toast({ + title: 'Server URL updated', + description: `Connected to ${data.serverUrl}`, + }); + } + + return ( +
+ + + + + } + > +
+ + ( + + + + + + + )} + /> + {isDirty && ( + + )} + + +
+ + { + setKeepServerRunningOnClose(checked); + platform.lifecycle.setKeepServerRunning(checked).catch((error) => { + console.error('Failed to sync setting to Rust:', error); + }); + toast({ + title: 'Setting updated', + description: checked + ? 'Server will continue running when app closes' + : 'Server will stop when app closes', + }); + }} + /> + } + /> + + {platform.metadata.isTauri && ( + { + setMode(checked ? 'remote' : 'local'); + toast({ + title: 'Setting updated', + description: checked + ? 'Network access enabled. Restart the app to apply.' + : 'Network access disabled. Restart the app to apply.', + }); + }} + /> + } + /> + )} +
+ + + + {platform.metadata.isTauri && } +
+ ); +} + +function ConnectionStatus({ + health, + isLoading, + healthError, +}: { + health: ReturnType['data']; + isLoading: boolean; + healthError: ReturnType['error']; +}) { + if (isLoading) { + return ( +
+ + Connecting +
+ ); + } + if (healthError) { + return ( +
+ + + + + Offline +
+ ); + } + if (health) { + return ( +
+ + + + + Online +
+ ); + } + return null; +} + +function UpdatesSection() { + const platform = usePlatform(); + const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false); + const [currentVersion, setCurrentVersion] = useState(''); + const isDev = !import.meta.env?.PROD; + + useEffect(() => { + platform.metadata + .getVersion() + .then(setCurrentVersion) + .catch(() => setCurrentVersion('Unknown')); + }, [platform]); + + return ( + + {isDev ? ( + + ) : ( + <> + + + Check + + } + /> + + {status.error && ( + +
+ + {status.error} +
+
+ )} + + {status.available && !status.downloading && !status.readyToInstall && ( + + + Download + + } + /> + )} + + {status.downloading && ( + +
+ +
+ {status.downloadedBytes !== undefined && + status.totalBytes !== undefined && + status.totalBytes > 0 ? ( + + {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '} + {(status.totalBytes / 1024 / 1024).toFixed(1)} MB + + ) : ( + + )} + {status.downloadProgress !== undefined && {status.downloadProgress}%} +
+
+
+ )} + + {status.readyToInstall && ( + + + Restart Now + + } + /> + )} + + )} +
+ ); +} + +const API_ENDPOINTS = [ + { method: 'POST', path: '/generate', label: 'Generate speech' }, + { method: 'GET', path: '/health', label: 'Server status' }, + { method: 'GET', path: '/profiles', label: 'List voices' }, + { method: 'GET', path: '/history', label: 'Past generations' }, +]; + +function ApiReferenceCard({ serverUrl }: { serverUrl: string }) { + return ( +
+
+

API Access

+

+ Integrate Voicebox into your workflow via the REST API at{' '} + {serverUrl} +

+
+
+ {API_ENDPOINTS.map((ep) => ( +
+ + {ep.method} + + {ep.path} + {ep.label} +
+ ))} +
+

+ + View the full API reference + +

+
+ ); +} diff --git a/app/src/components/ServerTab/GenerationPage.tsx b/app/src/components/ServerTab/GenerationPage.tsx new file mode 100644 index 00000000..12ddb01f --- /dev/null +++ b/app/src/components/ServerTab/GenerationPage.tsx @@ -0,0 +1,90 @@ +import { Slider } from '@/components/ui/slider'; +import { Toggle } from '@/components/ui/toggle'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +export function GenerationPage() { + const maxChunkChars = useServerStore((state) => state.maxChunkChars); + const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars); + const crossfadeMs = useServerStore((state) => state.crossfadeMs); + const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs); + const normalizeAudio = useServerStore((state) => state.normalizeAudio); + const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio); + const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate); + const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate); + + return ( +
+ + + {maxChunkChars} chars + + } + > + setMaxChunkChars(value)} + min={100} + max={5000} + step={50} + aria-label="Auto-chunking character limit" + /> + + + + {crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`} + + } + > + setCrossfadeMs(value)} + min={0} + max={200} + step={10} + aria-label="Chunk crossfade duration" + /> + + + + } + /> + + + } + /> + +
+ ); +} diff --git a/app/src/components/ServerTab/GpuPage.tsx b/app/src/components/ServerTab/GpuPage.tsx new file mode 100644 index 00000000..f0f38681 --- /dev/null +++ b/app/src/components/ServerTab/GpuPage.tsx @@ -0,0 +1,414 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { apiClient } from '@/lib/api/client'; +import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types'; +import { useServerHealth } from '@/lib/hooks/useServer'; +import { usePlatform } from '@/platform/PlatformContext'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready'; + +function AppleLogo({ className }: { className?: string }) { + return ( + + ); +} + +function GpuIcon({ className }: { className?: string }) { + return ( + + ); +} + +function GpuInfoCard({ health }: { health: HealthResponse }) { + const hasGpu = health.gpu_available && health.gpu_type; + + // Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)" + const gpuName = hasGpu + ? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') || + health.gpu_type! + : null; + const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null; + const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal'; + const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu'; + + return ( +
+
+ {hasGpu ? ( + isApple ? ( + + ) : ( + + ) + ) : ( + + )} +
+
{hasGpu ? gpuName : 'CPU Only'}
+
+ {hasGpu ? ( + <> + {gpuBackend} + {showBackendVariant && ( + <> + | + {health.backend_variant} + + )} + {health.vram_used_mb != null && health.vram_used_mb > 0 && ( + <> + | + {health.vram_used_mb.toFixed(0)} MB VRAM + + )} + + ) : ( + No GPU acceleration detected + )} +
+
+ {hasGpu && ( +
+ + + + + Active +
+ )} +
+
+ ); +} + +export function GpuPage() { + const platform = usePlatform(); + const queryClient = useQueryClient(); + const serverUrl = useServerStore((state) => state.serverUrl); + const { data: health } = useServerHealth(); + + const [restartPhase, setRestartPhase] = useState('idle'); + const [error, setError] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(null); + const healthPollRef = useRef | null>(null); + + const { + data: cudaStatus, + isLoading: _cudaStatusLoading, + refetch: refetchCudaStatus, + } = useQuery({ + queryKey: ['cuda-status', serverUrl], + queryFn: () => apiClient.getCudaStatus(), + refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000), + retry: 1, + enabled: !!health, + }); + + const isCurrentlyCuda = health?.backend_variant === 'cuda'; + const cudaAvailable = cudaStatus?.available ?? false; + const cudaDownloading = cudaStatus?.downloading ?? false; + + useEffect(() => { + return () => { + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!cudaDownloading || !serverUrl) return; + + const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as CudaDownloadProgress; + setDownloadProgress(data); + + if (data.status === 'complete') { + eventSource.close(); + setDownloadProgress(null); + refetchCudaStatus(); + } else if (data.status === 'error') { + eventSource.close(); + setError(data.error || 'Download failed'); + setDownloadProgress(null); + refetchCudaStatus(); + } + } catch (e) { + console.error('Error parsing CUDA progress event:', e); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [cudaDownloading, serverUrl, refetchCudaStatus]); + + const startHealthPolling = useCallback(() => { + if (healthPollRef.current) return; + + healthPollRef.current = setInterval(async () => { + try { + const result = await apiClient.getHealth(); + if (result.status === 'healthy') { + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + setRestartPhase('ready'); + queryClient.invalidateQueries(); + setTimeout(() => setRestartPhase('idle'), 2000); + } + } catch { + // Server still down, keep polling + } + }, 1000); + }, [queryClient]); + + const handleDownload = async () => { + setError(null); + try { + await apiClient.downloadCudaBackend(); + refetchCudaStatus(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to start download'; + if (msg.includes('already downloaded')) { + refetchCudaStatus(); + } else { + setError(msg); + } + } + }; + + const handleRestart = async () => { + setError(null); + setRestartPhase('stopping'); + try { + setRestartPhase('waiting'); + startHealthPolling(); + await platform.lifecycle.restartServer(); + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + setRestartPhase('ready'); + queryClient.invalidateQueries(); + setTimeout(() => setRestartPhase('idle'), 2000); + } catch (e: unknown) { + setRestartPhase('idle'); + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + setError(e instanceof Error ? e.message : 'Restart failed'); + } + }; + + const handleSwitchToCpu = async () => { + setError(null); + setRestartPhase('stopping'); + try { + await apiClient.deleteCudaBackend(); + setRestartPhase('waiting'); + startHealthPolling(); + await platform.lifecycle.restartServer(); + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + setRestartPhase('ready'); + queryClient.invalidateQueries(); + setTimeout(() => setRestartPhase('idle'), 2000); + } catch (e: unknown) { + setRestartPhase('idle'); + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + setError(e instanceof Error ? e.message : 'Failed to switch to CPU'); + refetchCudaStatus(); + } + }; + + const handleDelete = async () => { + setError(null); + try { + await apiClient.deleteCudaBackend(); + refetchCudaStatus(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend'); + } + }; + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`; + }; + + if (!health) return null; + + const hasNativeGpu = + health.gpu_available && + !isCurrentlyCuda && + health.gpu_type && + !health.gpu_type.includes('CUDA'); + + return ( +
+ + + {/* CUDA section — only when no native GPU and not already on CUDA */} + {!hasNativeGpu && !isCurrentlyCuda && ( + + {/* Download progress */} + {cudaDownloading && downloadProgress && ( + +
+ +
+ + {downloadProgress.filename || + (cudaAvailable ? 'Updating...' : 'Downloading...')} + + + {downloadProgress.total > 0 + ? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}` + : `${downloadProgress.progress.toFixed(1)}%`} + +
+
+
+ )} + + {/* Restart in progress */} + {restartPhase !== 'idle' && ( + } + /> + )} + + {/* Error */} + {error && ( + +
+ + {error} +
+
+ )} + + {/* Actions */} + {restartPhase === 'idle' && !cudaDownloading && ( + <> + {!cudaAvailable && !isCurrentlyCuda && ( + + + Download + + } + /> + )} + + {cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && ( + + + Restart + + } + /> + )} + + {isCurrentlyCuda && platform.metadata.isTauri && ( + + + Switch + + } + /> + )} + + {cudaAvailable && !isCurrentlyCuda && ( + + + Remove + + } + /> + )} + + )} +
+ )} + +

+ Voicebox automatically detects and uses the best available GPU on your system. On Apple + Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal + Performance Shaders (MPS), with no additional setup required. On Windows and Linux with + NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference. + AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When + no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower. +

+
+ ); +} diff --git a/app/src/components/ServerTab/LogsPage.tsx b/app/src/components/ServerTab/LogsPage.tsx new file mode 100644 index 00000000..b7cf85ae --- /dev/null +++ b/app/src/components/ServerTab/LogsPage.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils/cn'; +import { type LogEntry, useLogStore } from '@/stores/logStore'; + +function formatTime(timestamp: number): string { + const d = new Date(timestamp); + return d.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function LogLine({ entry }: { entry: LogEntry }) { + return ( +
+ + {formatTime(entry.timestamp)} + + + {entry.line} + +
+ ); +} + +export function LogsPage() { + const entries = useLogStore((s) => s.entries); + const clear = useLogStore((s) => s.clear); + const containerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Auto-scroll to bottom when new entries arrive + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries.length, autoScroll]); + + // Detect manual scroll to disable auto-scroll + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + setAutoScroll(atBottom); + }; + + return ( +
+
+
+

Server Logs

+

+ {entries.length} {entries.length === 1 ? 'line' : 'lines'} +

+
+
+ {!autoScroll && ( + + )} + +
+
+ +
+ {entries.length === 0 ? ( +
+

No log output yet.

+ {!import.meta.env?.PROD && ( +

+ Server logs are only captured when the app manages the server process (production + builds). +

+ )} +
+ ) : ( + entries.map((entry, i) => ) + )} +
+
+ ); +} diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx index d9954c90..3aa0933d 100644 --- a/app/src/components/ServerTab/ServerTab.tsx +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -1,35 +1,68 @@ -import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; -import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings'; -import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration'; -import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; +import { Link, Outlet, useMatchRoute } from '@tanstack/react-router'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlatform } from '@/platform/PlatformContext'; import { usePlayerStore } from '@/stores/playerStore'; -export function ServerTab() { +interface SettingsTab { + label: string; + path: + | '/settings' + | '/settings/generation' + | '/settings/gpu' + | '/settings/logs' + | '/settings/changelog'; + tauriOnly?: boolean; +} + +const tabs: SettingsTab[] = [ + { label: 'General', path: '/settings' }, + { label: 'Generation', path: '/settings/generation' }, + { label: 'GPU', path: '/settings/gpu', tauriOnly: true }, + { label: 'Logs', path: '/settings/logs', tauriOnly: true }, + { label: 'Changelog', path: '/settings/changelog' }, +]; + +export function SettingsLayout() { const platform = usePlatform(); const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl); + const matchRoute = useMatchRoute(); + return ( -
-
- - - {platform.metadata.isTauri && } - {platform.metadata.isTauri && } -
-
- Created by{' '} - - Jamie Pine - +
+ + +
+
); diff --git a/app/src/components/ServerTab/SettingRow.tsx b/app/src/components/ServerTab/SettingRow.tsx new file mode 100644 index 00000000..f2a54f58 --- /dev/null +++ b/app/src/components/ServerTab/SettingRow.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react'; + +/** + * A section header with title and optional description, separated by a border. + */ +export function SettingSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: ReactNode; +}) { + return ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ {children} +
+
+ ); +} + +/** + * A single settings row: label+description on the left, action on the right. + * Use for toggles, inputs, buttons, badges — any control type. + */ +export function SettingRow({ + title, + description, + htmlFor, + action, + children, +}: { + title: string; + description?: string; + htmlFor?: string; + /** Right-aligned control (checkbox, button, badge, etc.) */ + action?: ReactNode; + /** Full-width content rendered below the label row (for sliders, inputs, etc.) */ + children?: ReactNode; +}) { + return ( +
+
+
+ + {description &&

{description}

} +
+ {action &&
{action}
} +
+ {children &&
{children}
} +
+ ); +} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 5a37bd64..e4985a44 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { Link, useMatchRoute } from '@tanstack/react-router'; -import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react'; +import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { cn } from '@/lib/utils/cn'; @@ -19,7 +19,7 @@ const tabs = [ { id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' }, { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'models', path: '/models', icon: Box, label: 'Models' }, - { id: 'server', path: '/server', icon: Server, label: 'Server' }, + { id: 'settings', path: '/settings', icon: Settings, label: 'Settings' }, ]; export function Sidebar({ isMacOS }: SidebarProps) { @@ -54,9 +54,10 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{tabs.map((tab, index) => { const Icon = tab.icon; - // For index route, use exact match; for others, use default matching const isActive = - tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path }); + tab.path === '/' + ? matchRoute({ to: '/', fuzzy: false }) + : matchRoute({ to: tab.path, fuzzy: true }); // Accent fades as buttons get further from the logo const accentOpacity = Math.max(0.08, 0.5 - index * 0.07); @@ -98,7 +99,7 @@ export function Sidebar({ isMacOS }: SidebarProps) { v{version} {updateStatus.available && ( Update diff --git a/app/src/components/ui/slider.tsx b/app/src/components/ui/slider.tsx index 8c944ec7..c567ec10 100644 --- a/app/src/components/ui/slider.tsx +++ b/app/src/components/ui/slider.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import * as SliderPrimitive from '@radix-ui/react-slider'; +import * as React from 'react'; import { cn } from '@/lib/utils/cn'; const Slider = React.forwardRef< @@ -14,7 +14,7 @@ const Slider = React.forwardRef< - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/app/src/components/ui/toaster.tsx b/app/src/components/ui/toaster.tsx index ae772ab6..7332623b 100644 --- a/app/src/components/ui/toaster.tsx +++ b/app/src/components/ui/toaster.tsx @@ -1,3 +1,4 @@ +import { usePlayerStore } from '@/stores/playerStore'; import { Toast, ToastClose, @@ -10,6 +11,7 @@ import { useToast } from './use-toast'; export function Toaster() { const { toasts } = useToast(); + const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl); return ( @@ -23,7 +25,7 @@ export function Toaster() { ))} - + ); } diff --git a/app/src/components/ui/toggle.tsx b/app/src/components/ui/toggle.tsx new file mode 100644 index 00000000..8259695e --- /dev/null +++ b/app/src/components/ui/toggle.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface ToggleProps { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; + className?: string; + id?: string; +} + +const Toggle = React.forwardRef( + ({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => { + return ( + + ); + }, +); +Toggle.displayName = 'Toggle'; + +export { Toggle }; diff --git a/app/src/global.d.ts b/app/src/global.d.ts index d405eef5..9f97c210 100644 --- a/app/src/global.d.ts +++ b/app/src/global.d.ts @@ -1,3 +1,8 @@ interface Window { __voiceboxServerStartedByApp?: boolean; } + +declare module 'virtual:changelog' { + const raw: string; + export default raw; +} diff --git a/app/src/lib/utils/parseChangelog.ts b/app/src/lib/utils/parseChangelog.ts new file mode 100644 index 00000000..96e561a4 --- /dev/null +++ b/app/src/lib/utils/parseChangelog.ts @@ -0,0 +1,37 @@ +export interface ChangelogEntry { + version: string; + date: string | null; + body: string; +} + +/** + * Parses a Keep-a-Changelog style markdown string into structured entries. + * + * Splits on `## [version]` headings and extracts the version + date from each. + * The body is the raw markdown between headings (trimmed), with the leading + * `# Changelog` title and trailing link references stripped. + */ +export function parseChangelog(raw: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + + // Strip trailing link reference definitions (e.g. [0.1.0]: https://...) + const cleaned = raw.replace(/^\[[\w.]+\]:.*$/gm, '').trimEnd(); + + // Match `## [version]` or `## [version] - date` + const headingRe = /^## \[(.+?)\](?:\s*-\s*(.+))?$/gm; + const matches = [...cleaned.matchAll(headingRe)]; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const version = match[1]; + const date = match[2]?.trim() || null; + + const start = match.index! + match[0].length; + const end = i + 1 < matches.length ? matches[i + 1].index! : cleaned.length; + const body = cleaned.slice(start, end).trim(); + + entries.push({ version, date, body }); + } + + return entries; +} diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts index ef936575..28c3c071 100644 --- a/app/src/platform/types.ts +++ b/app/src/platform/types.ts @@ -50,12 +50,18 @@ export interface PlatformAudio { stopPlayback(): void; } +export interface ServerLogEntry { + stream: 'stdout' | 'stderr'; + line: string; +} + export interface PlatformLifecycle { startServer(remote?: boolean, modelsDir?: string | null): Promise; stopServer(): Promise; restartServer(modelsDir?: string | null): Promise; setKeepServerRunning(keep: boolean): Promise; setupWindowCloseHandler(): Promise; + subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void; onServerReady?: () => void; } diff --git a/app/src/router.tsx b/app/src/router.tsx index 7e6e14ef..fd5b7cf2 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -1,10 +1,21 @@ -import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'; +import { + createRootRoute, + createRoute, + createRouter, + Outlet, + redirect, +} from '@tanstack/react-router'; import { AppFrame } from '@/components/AppFrame/AppFrame'; import { AudioTab } from '@/components/AudioTab/AudioTab'; import { EffectsTab } from '@/components/EffectsTab/EffectsTab'; import { MainEditor } from '@/components/MainEditor/MainEditor'; import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; -import { ServerTab } from '@/components/ServerTab/ServerTab'; +import { ChangelogPage } from '@/components/ServerTab/ChangelogPage'; +import { GeneralPage } from '@/components/ServerTab/GeneralPage'; +import { GenerationPage } from '@/components/ServerTab/GenerationPage'; +import { GpuPage } from '@/components/ServerTab/GpuPage'; +import { LogsPage } from '@/components/ServerTab/LogsPage'; +import { SettingsLayout } from '@/components/ServerTab/ServerTab'; import { Sidebar } from '@/components/Sidebar'; import { StoriesTab } from '@/components/StoriesTab/StoriesTab'; import { Toaster } from '@/components/ui/toaster'; @@ -120,11 +131,51 @@ const modelsRoute = createRoute({ component: ModelsTab, }); -// Server route -const serverRoute = createRoute({ +// Settings layout route (parent for sub-tabs) +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsLayout, +}); + +// Settings sub-routes +const settingsGeneralRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: GeneralPage, +}); + +const settingsGenerationRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/generation', + component: GenerationPage, +}); + +const settingsGpuRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/gpu', + component: GpuPage, +}); + +const settingsChangelogRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/changelog', + component: ChangelogPage, +}); + +const settingsLogsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/logs', + component: LogsPage, +}); + +// Redirect old /server path to /settings +const serverRedirectRoute = createRoute({ getParentRoute: () => rootRoute, path: '/server', - component: ServerTab, + beforeLoad: () => { + throw redirect({ to: '/settings' }); + }, }); // Route tree @@ -135,7 +186,14 @@ const routeTree = rootRoute.addChildren([ audioRoute, effectsRoute, modelsRoute, - serverRoute, + settingsRoute.addChildren([ + settingsGeneralRoute, + settingsGenerationRoute, + settingsGpuRoute, + settingsLogsRoute, + settingsChangelogRoute, + ]), + serverRedirectRoute, ]); // Create router diff --git a/app/src/stores/logStore.ts b/app/src/stores/logStore.ts new file mode 100644 index 00000000..2f369d86 --- /dev/null +++ b/app/src/stores/logStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; +import type { ServerLogEntry } from '@/platform/types'; + +const MAX_LOG_ENTRIES = 2000; + +export interface LogEntry extends ServerLogEntry { + timestamp: number; +} + +interface LogStore { + entries: LogEntry[]; + addEntry: (entry: ServerLogEntry) => void; + clear: () => void; +} + +export const useLogStore = create((set) => ({ + entries: [], + addEntry: (entry) => + set((state) => { + const newEntry: LogEntry = { ...entry, timestamp: Date.now() }; + const entries = [...state.entries, newEntry]; + // Cap buffer size + if (entries.length > MAX_LOG_ENTRIES) { + return { entries: entries.slice(entries.length - MAX_LOG_ENTRIES) }; + } + return { entries }; + }), + clear: () => set({ entries: [] }), +})); diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 42872c59..ff9e35d5 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "plugins/**/*.ts"] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 69105abc..36bc168b 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -2,9 +2,10 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from './plugins/changelog'; export default defineConfig({ - plugins: [tailwindcss(), react()], + plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/backend/build_binary.py b/backend/build_binary.py index ce789895..f9cdb1b7 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -127,7 +127,13 @@ def build_server(cuda=False): "uvicorn", "--hidden-import", "sqlalchemy", - "--hidden-import", + # librosa uses lazy_loader which generates .pyi stub files at + # install time and reads them at runtime to discover submodules. + # --hidden-import alone doesn't bundle the stubs, causing + # "Cannot load imports from non-existent stub" at runtime. + "--collect-all", + "lazy_loader", + "--collect-all", "librosa", "--hidden-import", "soundfile", diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index e38d7233..b5756c66 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -6,7 +6,7 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] binaries = [] -hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] +hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += collect_data_files('qwen_tts') datas += copy_metadata('qwen-tts') datas += copy_metadata('requests') @@ -23,6 +23,10 @@ tmp_ret = collect_all('zipvoice') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('linacodec') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('lazy_loader') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('librosa') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('inflect') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('perth') diff --git a/docs/notes/BACKEND_CODE_REVIEW.md b/docs/notes/BACKEND_CODE_REVIEW.md new file mode 100644 index 00000000..ccb8bfd4 --- /dev/null +++ b/docs/notes/BACKEND_CODE_REVIEW.md @@ -0,0 +1,188 @@ +# Code Review: `backend/` Post-Refactor + +**Date:** 2026-03-16 +**Scope:** Full review of `backend/` after major refactor + +## Overall Assessment + +The refactor is well-executed. The codebase follows a clean layered architecture (routes -> services -> backends) with good separation of concerns. The code is readable, the module boundaries are sensible, and the migration strategy is pragmatic for a desktop app. Below are findings organized by severity. + +--- + +## Critical Issues + +### 1. Double `init_db()` in bundled entry point + +**File:** `server.py:261` + +`server.py:261` calls `database.init_db()` explicitly, but `app.py:140` also calls `database.init_db()` inside the `startup` event handler. When running via `server.py`, the database gets initialized twice -- once before uvicorn starts and once during the startup event. This is likely benign (idempotent migrations), but the second call recreates the engine and `SessionLocal`, which could cause subtle issues if any sessions were opened between the two calls. + +**Recommendation:** Remove the explicit `init_db()` call in `server.py:260-262` and rely solely on the startup event in `app.py`. The same issue exists in `main.py:38`. + +### 2. SSE endpoint holds DB session open indefinitely + +**File:** `routes/generations.py:179-212` + +The `get_generation_status` SSE endpoint receives a `db` session via `Depends(get_db)` but keeps it open for the lifetime of the SSE stream (polling every 1 second). This ties up a SQLite connection for potentially minutes. With SQLite's single-writer model, this is a contention risk. + +**Recommendation:** Open and close a short-lived session on each poll iteration instead of holding one via dependency injection: + +```python +async def event_stream(): + while True: + db = next(get_db()) + try: + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + ... + finally: + db.close() + await asyncio.sleep(1) +``` + +--- + +## High Severity + +### 3. `_save_retry` creates no version record + +**File:** `services/generation.py:201-214` + +`_save_retry` writes the audio file but creates no `GenerationVersion` entry. If the generation previously had versions (from an initial generate that failed mid-effects, for example), the retry result won't appear in the versions list. This creates an inconsistency: some generations have versions, retried ones don't. + +**Recommendation:** Create a "clean" version in `_save_retry` the same way `_save_generate` does. + +### 4. `datetime.utcnow()` is deprecated + +**File:** `services/stories.py` and others + +`datetime.utcnow()` is deprecated as of Python 3.12 and returns a naive datetime. Used throughout `services/stories.py` (lines 95, 96, 193, 307, 360, 404, 457, 529, 537, 598, 610, 652, 716, 775) and possibly other service files. + +**Recommendation:** Replace with `datetime.now(datetime.UTC)` or `datetime.now(timezone.utc)`. + +### 5. `list_stories` N+1 query + +**File:** `services/stories.py:122-132` + +`list_stories` issues one `COUNT(*)` query per story inside a loop. For N stories, that's N+1 queries. + +**Recommendation:** Use a subquery or a single aggregated query: + +```python +from sqlalchemy import func +counts = dict( + db.query(DBStoryItem.story_id, func.count(DBStoryItem.id)) + .group_by(DBStoryItem.story_id) + .all() +) +``` + +--- + +## Medium Severity + +### 6. `create_story` queries item count immediately after creation + +**File:** `services/stories.py:103` + +Line 103 queries the item count for a story that was just created -- it will always be 0. This is wasted I/O. + +### 7. Bare `except Exception` with silent `pass` + +**File:** `routes/generations.py:69-70` + +When parsing a profile's stored `effects_chain` JSON, exceptions are silently swallowed. A corrupt JSON blob would result in no effects being applied with no logging. + +**Recommendation:** Log the exception at warning level. + +### 8. `update_story_item_times` uses `generation_id` as key + +**File:** `services/stories.py:643-649` + +`item_map` is keyed by `generation_id`, but the same generation can appear in a story multiple times (via split/duplicate). This would cause key collisions, and only the last item per generation_id would be updatable. + +**Recommendation:** Key by `item_id` instead, and change the `StoryItemUpdateTime` model to use `item_id`. + +### 9. Thread safety gap in `get_stt_backend` + +**File:** `backends/__init__.py:499-520` + +`get_stt_backend()` uses no locking (unlike `get_tts_backend_for_engine` which uses `_tts_backends_lock`). A race condition could create duplicate STT backend instances. + +**Recommendation:** Add a lock or use the same double-checked locking pattern. + +### 10. Unused `_tts_backend` global + +**File:** `backends/__init__.py:156` + +`_tts_backend` is declared but never read or written outside of `reset_backends()`. All TTS access goes through `_tts_backends` dict. Dead code. + +### 11. `trim_story_item` returns `None` for validation errors + +**File:** `services/stories.py:448` + +Returning `None` for "item not found" and "invalid trim values" is ambiguous. The route handler can't distinguish between a 404 and a 400 response. + +**Recommendation:** Raise specific exceptions (e.g., `ValueError` for invalid trim) so the route can return the appropriate HTTP status. + +### 12. `load_engine_model` calls different method names + +**File:** `backends/__init__.py:340-346` + +For Qwen, it calls `load_model_async(model_size)`. For others, it calls `load_model()` with no arguments. But the `TTSBackend` protocol defines `load_model(self, model_size: str)`. This means the protocol signature doesn't match actual usage for either path. + +**Recommendation:** Align the protocol definition with actual backend implementations, or add `load_model_async` to the protocol. + +--- + +## Low Severity / Style + +### 13. Inconsistent `async` usage in services + +Functions like `create_story`, `list_stories`, etc. in `services/stories.py` are `async def` but contain no `await` expressions. They do synchronous SQLAlchemy I/O. While this works (the functions are awaitable), it's misleading -- these will block the event loop during DB access. + +This is a known tradeoff with synchronous SQLAlchemy + FastAPI, and acceptable for a single-user desktop app with SQLite, but worth noting for documentation. + +### 14. `getattr(item, "version_id", None)` pattern + +**File:** `services/stories.py:57, 504, 524, etc.` + +Multiple places use `getattr(item, "version_id", None)` on a DB model that has `version_id` as a declared column (from migrations). After the migration runs, this is always a real attribute. The defensive `getattr` is cargo-culted. + +**Recommendation:** Access `item.version_id` directly. If the column is missing, the ORM will raise a clear error. + +### 15. `reorder_story_items` ignores trim values + +**File:** `services/stories.py:707` + +When recalculating timecodes, it uses the full `generation.duration` rather than the effective (trimmed) duration. Trimmed items will have larger gaps than intended. + +### 16. Module-level `import torch` in `app.py:44` + +`import torch` at module level in `app.py` means torch loads on every import of the app module. This is intentional (AMD env vars must be set first), but the comment on line 38 should mention that this is why the import is here and not at the top. + +### 17. f-strings in logging in `server.py` + +`server.py` uses f-strings in logging calls (e.g., lines 63-66, 252, 256, 264). This evaluates the string even when the log level is filtered out. The rest of the codebase correctly uses `%s` style (e.g., `app.py:131`). + +--- + +## Architecture Observations (Not Issues) + +- **Clean layered design**: routes -> services -> backends with Pydantic models as the API contract. +- **Backend abstraction** with `Protocol` classes and a config registry is a solid pattern. +- **Serial generation queue** (`task_queue.py`) is simple and effective for single-GPU serialization. +- **Migration approach** is pragmatic for the use case. The idempotent, check-then-act pattern is reliable. +- **The `generation.py` refactor** (collapsing three closures into `run_generation` with a mode parameter) is a clear improvement. + +--- + +## Summary + +| Severity | Count | +|----------|-------| +| Critical | 2 | +| High | 3 | +| Medium | 7 | +| Low/Style | 5 | + +The refactor achieved its goals: clear module boundaries, reduced duplication (especially in `generation.py`), and a well-organized backend abstraction. The critical items (double init_db and SSE session leak) should be addressed first, followed by the version consistency issue in retry and the N+1 query. diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 21c44190..a881f46e 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -413,6 +413,10 @@ async fn start_server( tauri_plugin_shell::process::CommandEvent::Stdout(line) => { let line_str = String::from_utf8_lossy(&line); println!("Server output: {}", line_str); + let _ = app.emit("server-log", serde_json::json!({ + "stream": "stdout", + "line": line_str.trim_end(), + })); if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") { println!("Server is ready!"); @@ -422,6 +426,10 @@ async fn start_server( tauri_plugin_shell::process::CommandEvent::Stderr(line) => { let line_str = String::from_utf8_lossy(&line).to_string(); eprintln!("Server: {}", line_str); + let _ = app.emit("server-log", serde_json::json!({ + "stream": "stderr", + "line": line_str.trim_end(), + })); // Collect error lines for debugging if line_str.contains("ERROR") || line_str.contains("Error") || line_str.contains("Failed") { @@ -482,15 +490,26 @@ async fn start_server( } } - // Spawn task to continue reading output + // Spawn task to continue reading output and emit to frontend + let app_handle = app.clone(); tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) => { - println!("Server: {}", String::from_utf8_lossy(&line)); + let line_str = String::from_utf8_lossy(&line); + println!("Server: {}", line_str); + let _ = app_handle.emit("server-log", serde_json::json!({ + "stream": "stdout", + "line": line_str.trim_end(), + })); } tauri_plugin_shell::process::CommandEvent::Stderr(line) => { - eprintln!("Server error: {}", String::from_utf8_lossy(&line)); + let line_str = String::from_utf8_lossy(&line); + eprintln!("Server error: {}", line_str); + let _ = app_handle.emit("server-log", serde_json::json!({ + "stream": "stderr", + "line": line_str.trim_end(), + })); } _ => {} } diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts index 357f48d3..ad20fa0a 100644 --- a/tauri/src/platform/lifecycle.ts +++ b/tauri/src/platform/lifecycle.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { emit, listen } from '@tauri-apps/api/event'; -import type { PlatformLifecycle } from '@/platform/types'; +import type { PlatformLifecycle, ServerLogEntry } from '@/platform/types'; class TauriLifecycle implements PlatformLifecycle { onServerReady?: () => void; @@ -86,6 +86,20 @@ class TauriLifecycle implements PlatformLifecycle { console.error('Failed to setup window close handler:', error); } } + + subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void { + let unlisten: (() => void) | null = null; + + listen('server-log', (event) => { + callback(event.payload); + }).then((fn) => { + unlisten = fn; + }); + + return () => { + unlisten?.(); + }; + } } export const tauriLifecycle = new TauriLifecycle(); diff --git a/tauri/vite.config.ts b/tauri/vite.config.ts index 71caf5f7..8e0362e4 100644 --- a/tauri/vite.config.ts +++ b/tauri/vite.config.ts @@ -1,10 +1,11 @@ import path from 'node:path'; -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from '../app/plugins/changelog'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, '../app/src'), diff --git a/web/src/platform/lifecycle.ts b/web/src/platform/lifecycle.ts index 9a6d825a..14156180 100644 --- a/web/src/platform/lifecycle.ts +++ b/web/src/platform/lifecycle.ts @@ -1,4 +1,4 @@ -import type { PlatformLifecycle } from '@/platform/types'; +import type { PlatformLifecycle, ServerLogEntry } from '@/platform/types'; class WebLifecycle implements PlatformLifecycle { onServerReady?: () => void; @@ -27,6 +27,11 @@ class WebLifecycle implements PlatformLifecycle { async setupWindowCloseHandler(): Promise { // No-op for web - no window close handling needed } + + subscribeToServerLogs(_callback: (_entry: ServerLogEntry) => void): () => void { + // No-op for web - server logs are not available + return () => {}; + } } export const webLifecycle = new WebLifecycle(); diff --git a/web/vite.config.ts b/web/vite.config.ts index e3aa4013..b9abffe5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,11 @@ import path from 'node:path'; -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from '../app/plugins/changelog'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, '../app/src'), From 1526f2de26cb9de16e142b4988e18d81a1d86879 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 16 Mar 2026 10:14:47 -0700 Subject: [PATCH 2/4] about page + generation folder open and display --- app/src/components/ServerTab/AboutPage.tsx | 135 ++++++++++++++++++ .../components/ServerTab/GenerationPage.tsx | 48 +++++++ app/src/components/ServerTab/ServerTab.tsx | 4 +- app/src/router.tsx | 8 ++ backend/routes/health.py | 2 +- 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 app/src/components/ServerTab/AboutPage.tsx diff --git a/app/src/components/ServerTab/AboutPage.tsx b/app/src/components/ServerTab/AboutPage.tsx new file mode 100644 index 00000000..6fff5cb7 --- /dev/null +++ b/app/src/components/ServerTab/AboutPage.tsx @@ -0,0 +1,135 @@ +import { ArrowUpRight } from 'lucide-react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import voiceboxLogo from '@/assets/voicebox-logo.png'; +import { usePlatform } from '@/platform/PlatformContext'; + +function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AboutPage() { + const platform = usePlatform(); + const [version, setVersion] = useState(''); + + useEffect(() => { + platform.metadata + .getVersion() + .then(setVersion) + .catch(() => setVersion('')); + }, [platform]); + + return ( + <> + +
+
+ + Voicebox + + + +
+

Voicebox

+

+ {version ? `v${version}` : '\u00A0'} +

+
+
+ + +

+ The open-source voice synthesis studio. Clone voices, generate speech, apply effects, + and build voice-powered apps — all running locally on your machine. +

+
+ + +
+ Created by + + Jamie Pine + +
+
+ + + + + + +

+ Licensed under{' '} + + BSL 1.1 + +

+
+
+
+ + ); +} diff --git a/app/src/components/ServerTab/GenerationPage.tsx b/app/src/components/ServerTab/GenerationPage.tsx index 12ddb01f..4bb162a2 100644 --- a/app/src/components/ServerTab/GenerationPage.tsx +++ b/app/src/components/ServerTab/GenerationPage.tsx @@ -1,9 +1,15 @@ +import { FolderOpen } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Toggle } from '@/components/ui/toggle'; +import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; import { SettingRow, SettingSection } from './SettingRow'; export function GenerationPage() { + const platform = usePlatform(); + const serverUrl = useServerStore((state) => state.serverUrl); const maxChunkChars = useServerStore((state) => state.maxChunkChars); const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars); const crossfadeMs = useServerStore((state) => state.crossfadeMs); @@ -12,6 +18,32 @@ export function GenerationPage() { const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio); const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate); const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate); + const [opening, setOpening] = useState(false); + const [generationsPath, setGenerationsPath] = useState(null); + + useEffect(() => { + fetch(`${serverUrl}/health/filesystem`) + .then((res) => res.json()) + .then((data) => { + const genDir = data.directories?.find((d: { path: string }) => + d.path.includes('generations'), + ); + if (genDir?.path) setGenerationsPath(genDir.path); + }) + .catch(() => {}); + }, [serverUrl]); + + const openGenerationsFolder = useCallback(async () => { + if (!generationsPath) return; + setOpening(true); + try { + await platform.filesystem.openPath(generationsPath); + } catch (e) { + console.error('Failed to open generations folder:', e); + } finally { + setOpening(false); + } + }, [platform, generationsPath]); return (
@@ -84,6 +116,22 @@ export function GenerationPage() { /> } /> + + + + Open + + } + />
); diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx index 3aa0933d..b2f78dc3 100644 --- a/app/src/components/ServerTab/ServerTab.tsx +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -11,7 +11,8 @@ interface SettingsTab { | '/settings/generation' | '/settings/gpu' | '/settings/logs' - | '/settings/changelog'; + | '/settings/changelog' + | '/settings/about'; tauriOnly?: boolean; } @@ -21,6 +22,7 @@ const tabs: SettingsTab[] = [ { label: 'GPU', path: '/settings/gpu', tauriOnly: true }, { label: 'Logs', path: '/settings/logs', tauriOnly: true }, { label: 'Changelog', path: '/settings/changelog' }, + { label: 'About', path: '/settings/about' }, ]; export function SettingsLayout() { diff --git a/app/src/router.tsx b/app/src/router.tsx index fd5b7cf2..45876cd3 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -10,6 +10,7 @@ import { AudioTab } from '@/components/AudioTab/AudioTab'; import { EffectsTab } from '@/components/EffectsTab/EffectsTab'; import { MainEditor } from '@/components/MainEditor/MainEditor'; import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; +import { AboutPage } from '@/components/ServerTab/AboutPage'; import { ChangelogPage } from '@/components/ServerTab/ChangelogPage'; import { GeneralPage } from '@/components/ServerTab/GeneralPage'; import { GenerationPage } from '@/components/ServerTab/GenerationPage'; @@ -169,6 +170,12 @@ const settingsLogsRoute = createRoute({ component: LogsPage, }); +const settingsAboutRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/about', + component: AboutPage, +}); + // Redirect old /server path to /settings const serverRedirectRoute = createRoute({ getParentRoute: () => rootRoute, @@ -192,6 +199,7 @@ const routeTree = rootRoute.addChildren([ settingsGpuRoute, settingsLogsRoute, settingsChangelogRoute, + settingsAboutRoute, ]), serverRedirectRoute, ]); diff --git a/backend/routes/health.py b/backend/routes/health.py index 0053f423..f138e336 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -207,7 +207,7 @@ async def filesystem_health(): checks.append( models.DirectoryCheck( - path=str(dir_path), + path=str(dir_path.resolve()), exists=exists, writable=writable, error=error, From a8469b39f10db3f23a0969dd280a8d57af0d7c4a Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 16 Mar 2026 12:31:33 -0700 Subject: [PATCH 3/4] fix about license --- app/src/components/ServerTab/AboutPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/ServerTab/AboutPage.tsx b/app/src/components/ServerTab/AboutPage.tsx index 6fff5cb7..29c27814 100644 --- a/app/src/components/ServerTab/AboutPage.tsx +++ b/app/src/components/ServerTab/AboutPage.tsx @@ -124,7 +124,7 @@ export function AboutPage() { rel="noopener noreferrer" className="hover:text-muted-foreground/60 transition-colors" > - BSL 1.1 + MIT

From 2ad4776a76a071cb1201a7095686860923a97a64 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 16 Mar 2026 13:06:57 -0700 Subject: [PATCH 4/4] fix review feedback: restart race, listener cleanup, stable keys, accessibility --- .../components/ServerTab/ChangelogPage.tsx | 16 ++--- app/src/components/ServerTab/GeneralPage.tsx | 7 ++ app/src/components/ServerTab/GpuPage.tsx | 65 ++++++++----------- app/src/components/ServerTab/LogsPage.tsx | 2 +- app/src/components/ServerTab/SettingRow.tsx | 2 +- app/src/components/ui/toaster.tsx | 2 +- app/src/components/ui/toggle.tsx | 1 + app/src/stores/logStore.ts | 6 +- tauri/src/platform/lifecycle.ts | 19 ++++-- 9 files changed, 66 insertions(+), 54 deletions(-) diff --git a/app/src/components/ServerTab/ChangelogPage.tsx b/app/src/components/ServerTab/ChangelogPage.tsx index 9582d0c2..afb32b47 100644 --- a/app/src/components/ServerTab/ChangelogPage.tsx +++ b/app/src/components/ServerTab/ChangelogPage.tsx @@ -57,8 +57,8 @@ function renderMarkdown(md: string): React.ReactNode[] { } elements.push(
    - {items.map((item) => ( -
  • + {items.map((item, idx) => ( +
  • {inlineMarkdown(item)}
  • @@ -96,9 +96,9 @@ function renderTable(tableLines: string[], keyBase: number): React.ReactNode { - {headers.map((h) => ( + {headers.map((h, hIdx) => ( - {rows.map((row) => ( - - {row.map((cell) => ( - + {row.map((cell, cellIdx) => ( + ))} diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx index 51b5ed74..ccf117b1 100644 --- a/app/src/components/ServerTab/GeneralPage.tsx +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -133,6 +133,13 @@ export function GeneralPage() { setKeepServerRunningOnClose(checked); platform.lifecycle.setKeepServerRunning(checked).catch((error) => { console.error('Failed to sync setting to Rust:', error); + setKeepServerRunningOnClose(!checked); + toast({ + title: 'Failed to update setting', + description: 'Could not sync setting to backend.', + variant: 'destructive', + }); + return; }); toast({ title: 'Setting updated', diff --git a/app/src/components/ServerTab/GpuPage.tsx b/app/src/components/ServerTab/GpuPage.tsx index f0f38681..0ebe1d37 100644 --- a/app/src/components/ServerTab/GpuPage.tsx +++ b/app/src/components/ServerTab/GpuPage.tsx @@ -171,17 +171,21 @@ export function GpuPage() { }; }, [cudaDownloading, serverUrl, refetchCudaStatus]); + const clearHealthPolling = useCallback(() => { + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + }, []); + const startHealthPolling = useCallback(() => { - if (healthPollRef.current) return; + clearHealthPolling(); healthPollRef.current = setInterval(async () => { try { const result = await apiClient.getHealth(); if (result.status === 'healthy') { - if (healthPollRef.current) { - clearInterval(healthPollRef.current); - healthPollRef.current = null; - } + clearHealthPolling(); setRestartPhase('ready'); queryClient.invalidateQueries(); setTimeout(() => setRestartPhase('idle'), 2000); @@ -190,7 +194,23 @@ export function GpuPage() { // Server still down, keep polling } }, 1000); - }, [queryClient]); + }, [queryClient, clearHealthPolling]); + + const restartServerWithPolling = useCallback( + async (errorMessage: string) => { + setRestartPhase('stopping'); + try { + await platform.lifecycle.restartServer(); + setRestartPhase('waiting'); + startHealthPolling(); + } catch (e: unknown) { + clearHealthPolling(); + setRestartPhase('idle'); + throw new Error(e instanceof Error ? e.message : errorMessage); + } + }, + [platform, startHealthPolling, clearHealthPolling], + ); const handleDownload = async () => { setError(null); @@ -209,24 +229,9 @@ export function GpuPage() { const handleRestart = async () => { setError(null); - setRestartPhase('stopping'); try { - setRestartPhase('waiting'); - startHealthPolling(); - await platform.lifecycle.restartServer(); - if (healthPollRef.current) { - clearInterval(healthPollRef.current); - healthPollRef.current = null; - } - setRestartPhase('ready'); - queryClient.invalidateQueries(); - setTimeout(() => setRestartPhase('idle'), 2000); + await restartServerWithPolling('Restart failed'); } catch (e: unknown) { - setRestartPhase('idle'); - if (healthPollRef.current) { - clearInterval(healthPollRef.current); - healthPollRef.current = null; - } setError(e instanceof Error ? e.message : 'Restart failed'); } }; @@ -236,22 +241,8 @@ export function GpuPage() { setRestartPhase('stopping'); try { await apiClient.deleteCudaBackend(); - setRestartPhase('waiting'); - startHealthPolling(); - await platform.lifecycle.restartServer(); - if (healthPollRef.current) { - clearInterval(healthPollRef.current); - healthPollRef.current = null; - } - setRestartPhase('ready'); - queryClient.invalidateQueries(); - setTimeout(() => setRestartPhase('idle'), 2000); + await restartServerWithPolling('Failed to switch to CPU'); } catch (e: unknown) { - setRestartPhase('idle'); - if (healthPollRef.current) { - clearInterval(healthPollRef.current); - healthPollRef.current = null; - } setError(e instanceof Error ? e.message : 'Failed to switch to CPU'); refetchCudaStatus(); } diff --git a/app/src/components/ServerTab/LogsPage.tsx b/app/src/components/ServerTab/LogsPage.tsx index b7cf85ae..68b8f317 100644 --- a/app/src/components/ServerTab/LogsPage.tsx +++ b/app/src/components/ServerTab/LogsPage.tsx @@ -96,7 +96,7 @@ export function LogsPage() { )} ) : ( - entries.map((entry, i) => ) + entries.map((entry) => ) )} diff --git a/app/src/components/ServerTab/SettingRow.tsx b/app/src/components/ServerTab/SettingRow.tsx index f2a54f58..f4b42faa 100644 --- a/app/src/components/ServerTab/SettingRow.tsx +++ b/app/src/components/ServerTab/SettingRow.tsx @@ -48,7 +48,7 @@ export function SettingRow({
    diff --git a/app/src/components/ui/toaster.tsx b/app/src/components/ui/toaster.tsx index 7332623b..3c1d2978 100644 --- a/app/src/components/ui/toaster.tsx +++ b/app/src/components/ui/toaster.tsx @@ -25,7 +25,7 @@ export function Toaster() { ))} - + ); } diff --git a/app/src/components/ui/toggle.tsx b/app/src/components/ui/toggle.tsx index 8259695e..3db31a81 100644 --- a/app/src/components/ui/toggle.tsx +++ b/app/src/components/ui/toggle.tsx @@ -26,6 +26,7 @@ const Toggle = React.forwardRef( }} className={cn( 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', checked ? 'bg-accent' : 'bg-muted-foreground/25', disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', className, diff --git a/app/src/stores/logStore.ts b/app/src/stores/logStore.ts index 2f369d86..833f3088 100644 --- a/app/src/stores/logStore.ts +++ b/app/src/stores/logStore.ts @@ -3,7 +3,10 @@ import type { ServerLogEntry } from '@/platform/types'; const MAX_LOG_ENTRIES = 2000; +let nextLogEntryId = 0; + export interface LogEntry extends ServerLogEntry { + id: number; timestamp: number; } @@ -17,9 +20,8 @@ export const useLogStore = create((set) => ({ entries: [], addEntry: (entry) => set((state) => { - const newEntry: LogEntry = { ...entry, timestamp: Date.now() }; + const newEntry: LogEntry = { ...entry, id: nextLogEntryId++, timestamp: Date.now() }; const entries = [...state.entries, newEntry]; - // Cap buffer size if (entries.length > MAX_LOG_ENTRIES) { return { entries: entries.slice(entries.length - MAX_LOG_ENTRIES) }; } diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts index ad20fa0a..b20da778 100644 --- a/tauri/src/platform/lifecycle.ts +++ b/tauri/src/platform/lifecycle.ts @@ -88,16 +88,27 @@ class TauriLifecycle implements PlatformLifecycle { } subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void { + let disposed = false; let unlisten: (() => void) | null = null; - listen('server-log', (event) => { + void listen('server-log', (event) => { callback(event.payload); - }).then((fn) => { - unlisten = fn; - }); + }) + .then((fn) => { + if (disposed) { + fn(); + return; + } + unlisten = fn; + }) + .catch((error) => { + console.error('Failed to subscribe to server logs:', error); + }); return () => { + disposed = true; unlisten?.(); + unlisten = null; }; } }
    {inlineMarkdown(h)} @@ -107,10 +107,10 @@ function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
    + {rows.map((row, rowIdx) => ( +
    {inlineMarkdown(cell)}