refactor: restructure server settings and models UI

- Split chunking/crossfade sliders into dedicated GenerationSettings card
- Merge connection status badges into ConnectionForm (remove ServerStatus card)
- 2-column grid layout for the entire settings page
- GPU Acceleration: remove icon, badge, and MLX info card
- Models: merge 'Other Voice Models' into single 'Voice Generation' list
- Model detail: remove 'Downloaded' badge, border above actions, swap
  badges above stats row, match disk size font to stats
This commit is contained in:
James Pine
2026-03-13 07:26:46 -07:00
parent 97292ecef7
commit 9aa7080c51
6 changed files with 150 additions and 139 deletions
@@ -1,7 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, XCircle } from 'lucide-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
@@ -15,8 +17,8 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -34,11 +36,8 @@ export function ConnectionForm() {
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
const mode = useServerStore((state) => state.mode);
const setMode = useServerStore((state) => state.setMode);
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 { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(connectionSchema),
@@ -56,7 +55,7 @@ export function ConnectionForm() {
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
form.reset(data); // Reset form state after successful submission
form.reset(data);
toast({
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
@@ -90,6 +89,37 @@ export function ConnectionForm() {
</form>
</Form>
{/* Connection status */}
<div className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Checking connection...</span>
</div>
) : healthError ? (
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
Connection failed: {healthError.message}
</span>
</div>
) : health ? (
<div className="flex flex-wrap gap-2">
<Badge
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
>
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
</Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</Badge>
{health.vram_used_mb && (
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
)}
</div>
) : null}
</div>
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
@@ -154,55 +184,6 @@ export function ConnectionForm() {
</div>
</div>
)}
<div className="mt-6 pt-6 border-t">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={2000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs. Default is 800.
</p>
</div>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
</div>
</CardContent>
</Card>
);
@@ -0,0 +1,71 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore';
export function GenerationSettings() {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
<CardHeader>
<CardTitle>Generation Settings</CardTitle>
<CardDescription>
Controls for long text generation. These settings apply to all engines.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={2000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2, Zap } from 'lucide-react';
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -216,31 +215,19 @@ export function GpuAcceleration() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-4 w-4" />
GPU Acceleration
</CardTitle>
<CardTitle>GPU Acceleration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current status */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Backend</div>
<div className="text-sm text-muted-foreground">
{isCurrentlyCuda ? 'CUDA (GPU accelerated)' : 'CPU'}
</div>
<div className="space-y-1">
<div className="text-sm font-medium">Backend</div>
<div className="text-sm text-muted-foreground">
{isCurrentlyCuda
? 'CUDA (GPU accelerated)'
: hasNativeGpu
? `${health.backend_type === 'mlx' ? 'MLX' : 'PyTorch'} (GPU accelerated)`
: 'CPU'}
</div>
<Badge variant={isCurrentlyCuda ? 'default' : 'secondary'}>
{isCurrentlyCuda ? (
<>
<Zap className="h-3 w-3 mr-1" /> CUDA
</>
) : (
<>
<Cpu className="h-3 w-3 mr-1" /> CPU
</>
)}
</Badge>
</div>
{/* GPU info from health */}
@@ -257,14 +244,6 @@ export function GpuAcceleration() {
)}
{/* Native GPU detected - no CUDA download needed */}
{hasNativeGpu && (
<div className="p-3 rounded-lg bg-accent/10 border border-accent/20">
<div className="text-sm">
Your system uses <strong>{health.gpu_type}</strong> for acceleration. No additional
downloads needed.
</div>
</div>
)}
{/* CUDA download section - only show when native GPU is NOT detected (i.e., Windows/Linux NVIDIA users) */}
{!hasNativeGpu && (
@@ -342,17 +342,18 @@ export function ModelManagement() {
setDetailOpen(true);
};
const ttsModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) ?? [];
const otherTtsModels =
const voiceModels =
modelStatus?.models.filter(
(m) => m.model_name.startsWith('luxtts') || m.model_name.startsWith('chatterbox'),
(m) =>
m.model_name.startsWith('qwen-tts') ||
m.model_name.startsWith('luxtts') ||
m.model_name.startsWith('chatterbox'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: 'Voice Generation', models: ttsModels },
...(otherTtsModels.length > 0 ? [{ label: 'Other Voice Models', models: otherTtsModels }] : []),
{ label: 'Voice Generation', models: voiceModels },
{ label: 'Transcription', models: whisperModels },
];
@@ -564,12 +565,6 @@ export function ModelManagement() {
Loaded
</Badge>
)}
{freshSelectedModel.downloaded && !freshSelectedModel.loaded && (
<Badge variant="secondary" className="text-xs">
<CircleCheck className="h-3 w-3 mr-1" />
Downloaded
</Badge>
)}
{selectedState?.hasError && (
<Badge variant="destructive" className="text-xs">
<CircleX className="h-3 w-3 mr-1" />
@@ -595,24 +590,6 @@ export function ModelManagement() {
{hfModelInfo && (
<div className="space-y-3">
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Downloads">
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title="Likes">
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span className="flex items-center gap-1" title="License">
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
)}
</div>
{/* Pipeline tag + author */}
<div className="flex flex-wrap gap-1.5">
{hfModelInfo.pipeline_tag && (
@@ -632,6 +609,24 @@ export function ModelManagement() {
)}
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Downloads">
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title="Likes">
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span className="flex items-center gap-1" title="License">
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
)}
</div>
{/* Languages */}
{hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
<div>
@@ -647,8 +642,8 @@ export function ModelManagement() {
{/* Disk size */}
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="h-4 w-4" />
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
</div>
)}
@@ -661,7 +656,7 @@ export function ModelManagement() {
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t">
<div className="flex items-center gap-2 pt-2">
{selectedState?.hasError ? (
<>
<Button
@@ -3,18 +3,13 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerHealth } from '@/lib/hooks/useServer';
import { useServerStore } from '@/stores/serverStore';
import { ModelProgress } from './ModelProgress';
export function ServerStatus() {
const { data: health, isLoading, error } = useServerHealth();
const serverUrl = useServerStore((state) => state.serverUrl);
return (
<Card
role="region"
aria-label="Server Status"
tabIndex={0}
>
<Card role="region" aria-label="Server Status" tabIndex={0}>
<CardHeader>
<CardTitle>Server Status</CardTitle>
</CardHeader>
@@ -24,16 +19,6 @@ export function ServerStatus() {
<div className="font-mono text-sm">{serverUrl}</div>
</div>
{/* Model download progress */}
<div className="space-y-2">
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
</div>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
+5 -5
View File
@@ -1,19 +1,19 @@
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { usePlatform } from '@/platform/PlatformContext';
export function ServerTab() {
const platform = usePlatform();
return (
<div className="space-y-4 overflow-y-auto flex flex-col">
<div className="overflow-y-auto flex flex-col">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
<GenerationSettings />
{platform.metadata.isTauri && <GpuAcceleration />}
{platform.metadata.isTauri && <UpdateStatus />}
</div>
{platform.metadata.isTauri && <GpuAcceleration />}
{platform.metadata.isTauri && <UpdateStatus />}
<div className="py-8 text-center text-sm text-muted-foreground">
Created by{' '}
<a