import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, ChevronUp, CircleCheck, CircleX, Download, ExternalLink, HardDrive, Heart, Loader2, RotateCcw, Scale, Trash2, Unplug, X, } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Progress } from '@/components/ui/progress'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; async function fetchHuggingFaceModelInfo(repoId: string): Promise { const response = await fetch(`https://huggingface.co/api/models/${repoId}`); if (!response.ok) throw new Error(`Failed to fetch model info: ${response.status}`); return response.json(); } function formatDownloads(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return n.toString(); } function formatLicense(license: string): string { const map: Record = { 'apache-2.0': 'Apache 2.0', mit: 'MIT', 'cc-by-4.0': 'CC BY 4.0', 'cc-by-sa-4.0': 'CC BY-SA 4.0', 'cc-by-nc-4.0': 'CC BY-NC 4.0', 'openrail++': 'OpenRAIL++', openrail: 'OpenRAIL', }; return map[license] || license; } function formatPipelineTag(tag: string): string { return tag .split('-') .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); } function 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]}`; } export function ModelManagement() { const { toast } = useToast(); const queryClient = useQueryClient(); const [downloadingModel, setDownloadingModel] = useState(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); const [consoleOpen, setConsoleOpen] = useState(false); const [dismissedErrors, setDismissedErrors] = useState>(new Set()); const [localErrors, setLocalErrors] = useState>(new Map()); // Modal state const [selectedModel, setSelectedModel] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const { data: modelStatus, isLoading } = useQuery({ queryKey: ['modelStatus'], queryFn: async () => { const result = await apiClient.getModelStatus(); return result; }, refetchInterval: 5000, }); const { data: activeTasks } = useQuery({ queryKey: ['activeTasks'], queryFn: () => apiClient.getActiveTasks(), refetchInterval: (query) => { const data = query.state.data; const hasActive = data?.downloads.some((d) => d.status === 'downloading'); return hasActive ? 1000 : 5000; }, }); // HuggingFace model card query - only fetches when modal is open and model has a repo ID const { data: hfModelInfo, isLoading: hfLoading } = useQuery({ queryKey: ['hfModelInfo', selectedModel?.hf_repo_id], queryFn: () => fetchHuggingFaceModelInfo(selectedModel!.hf_repo_id!), enabled: detailOpen && !!selectedModel?.hf_repo_id, staleTime: 1000 * 60 * 30, // Cache for 30 minutes retry: 1, }); // Build a map of errored downloads for quick lookup, excluding dismissed ones const erroredDownloads = new Map(); if (activeTasks?.downloads) { for (const dl of activeTasks.downloads) { if (dl.status === 'error' && !dismissedErrors.has(dl.model_name)) { const localErr = localErrors.get(dl.model_name); erroredDownloads.set(dl.model_name, localErr ? { ...dl, error: localErr } : dl); } } } for (const [modelName, error] of localErrors) { if (!erroredDownloads.has(modelName) && !dismissedErrors.has(modelName)) { erroredDownloads.set(modelName, { model_name: modelName, status: 'error', started_at: new Date().toISOString(), error, }); } } const errorCount = erroredDownloads.size; // Build progress map from active tasks for inline display const downloadProgressMap = useMemo(() => { const map = new Map(); if (activeTasks?.downloads) { for (const dl of activeTasks.downloads) { if (dl.status === 'downloading') { map.set(dl.model_name, dl); } } } return map; }, [activeTasks]); const handleDownloadComplete = useCallback(() => { setDownloadingModel(null); setDownloadingDisplayName(null); queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); }, [queryClient]); const handleDownloadError = useCallback( (error: string) => { if (downloadingModel) { setLocalErrors((prev) => new Map(prev).set(downloadingModel, error)); setConsoleOpen(true); } setDownloadingModel(null); setDownloadingDisplayName(null); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); }, [queryClient, downloadingModel], ); useModelDownloadToast({ modelName: downloadingModel || '', displayName: downloadingDisplayName || '', enabled: !!downloadingModel && !!downloadingDisplayName, onComplete: handleDownloadComplete, onError: handleDownloadError, }); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [modelToDelete, setModelToDelete] = useState<{ name: string; displayName: string; sizeMb?: number; } | null>(null); const handleDownload = async (modelName: string) => { setDismissedErrors((prev) => { const next = new Set(prev); next.delete(modelName); return next; }); const model = modelStatus?.models.find((m) => m.model_name === modelName); const displayName = model?.display_name || modelName; try { await apiClient.triggerModelDownload(modelName); setDownloadingModel(modelName); setDownloadingDisplayName(displayName); queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); } catch (error) { setDownloadingModel(null); setDownloadingDisplayName(null); toast({ title: 'Download failed', description: error instanceof Error ? error.message : 'Unknown error', variant: 'destructive', }); } }; const cancelMutation = useMutation({ mutationFn: (modelName: string) => apiClient.cancelDownload(modelName), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['activeTasks'], refetchType: 'all' }); }, }); const handleCancel = (modelName: string) => { const prevDismissed = dismissedErrors; const prevLocalErrors = localErrors; const prevDownloadingModel = downloadingModel; const prevDownloadingDisplayName = downloadingDisplayName; setDismissedErrors((prev) => new Set(prev).add(modelName)); setLocalErrors((prev) => { const next = new Map(prev); next.delete(modelName); return next; }); if (downloadingModel === modelName) { setDownloadingModel(null); setDownloadingDisplayName(null); } cancelMutation.mutate(modelName, { onError: () => { setDismissedErrors(prevDismissed); setLocalErrors(prevLocalErrors); setDownloadingModel(prevDownloadingModel); setDownloadingDisplayName(prevDownloadingDisplayName); toast({ title: 'Cancel failed', description: 'Could not cancel the download task.', variant: 'destructive', }); }, }); }; const clearAllMutation = useMutation({ mutationFn: () => apiClient.clearAllTasks(), onSuccess: async () => { setDismissedErrors(new Set()); setLocalErrors(new Map()); setDownloadingModel(null); setDownloadingDisplayName(null); await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['activeTasks'], refetchType: 'all' }); }, }); const deleteMutation = useMutation({ mutationFn: async (modelName: string) => { const result = await apiClient.deleteModel(modelName); return result; }, onSuccess: async () => { toast({ title: 'Model deleted', description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`, }); setDeleteDialogOpen(false); setModelToDelete(null); setDetailOpen(false); setSelectedModel(null); await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); await queryClient.refetchQueries({ queryKey: ['modelStatus'] }); }, onError: (error: Error) => { toast({ title: 'Delete failed', description: error.message, variant: 'destructive', }); }, }); const unloadMutation = useMutation({ mutationFn: async (modelName: string) => { return await apiClient.unloadModel(modelName); }, onSuccess: async (_data, modelName) => { toast({ title: 'Model unloaded', description: `${modelName} has been unloaded from memory.`, }); await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' }); await queryClient.refetchQueries({ queryKey: ['modelStatus'] }); }, onError: (error: Error) => { toast({ title: 'Unload failed', description: error.message, variant: 'destructive', }); }, }); const formatSize = (sizeMb?: number): string => { if (!sizeMb) return 'Unknown size'; if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`; return `${(sizeMb / 1024).toFixed(2)} GB`; }; const getModelState = (model: ModelStatus) => { const isDownloading = (model.downloading || downloadingModel === model.model_name) && !erroredDownloads.has(model.model_name) && !dismissedErrors.has(model.model_name); const hasError = erroredDownloads.has(model.model_name); return { isDownloading, hasError }; }; const openModelDetail = (model: ModelStatus) => { setSelectedModel(model); setDetailOpen(true); }; const ttsModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) ?? []; const otherTtsModels = modelStatus?.models.filter( (m) => 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: 'Transcription', models: whisperModels }, ]; // Get detail modal state for selected model const selectedState = selectedModel ? getModelState(selectedModel) : null; const selectedError = selectedModel ? erroredDownloads.get(selectedModel.model_name) : undefined; // Keep selectedModel data fresh from query results const freshSelectedModel = selectedModel && modelStatus ? modelStatus.models.find((m) => m.model_name === selectedModel.model_name) || selectedModel : selectedModel; // Derive license from HF data const license = hfModelInfo?.cardData?.license || hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', ''); return (
{/* Header */}

Models

Download and manage AI models for voice generation and transcription

{/* Model list */} {isLoading ? (
) : modelStatus ? (
{sections.map((section) => (

{section.label}

{section.models.map((model) => { const { isDownloading, hasError } = getModelState(model); return ( ); })}
))} {/* Error console */} {errorCount > 0 && (
{consoleOpen && (
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
[error]{' '} {modelName} {dl.error ? ( <> {': '} {dl.error} ) : ( <> {': '} No error details available. Try downloading again. )}
started at {new Date(dl.started_at).toLocaleString()}
))}
)}
)}
) : null} {/* Model Detail Modal */} {freshSelectedModel && ( <> {freshSelectedModel.display_name} {freshSelectedModel.hf_repo_id ? ( {freshSelectedModel.hf_repo_id} ) : ( freshSelectedModel.model_name )}
{/* Status badges */}
{freshSelectedModel.loaded && ( Loaded )} {freshSelectedModel.downloaded && !freshSelectedModel.loaded && ( Downloaded )} {selectedState?.hasError && ( Error )} {!freshSelectedModel.downloaded && !selectedState?.isDownloading && !selectedState?.hasError && ( Not downloaded )}
{/* HuggingFace model card info */} {hfLoading && freshSelectedModel.hf_repo_id && (
Loading model info...
)} {hfModelInfo && (
{/* Stats row */}
{formatDownloads(hfModelInfo.downloads)} {formatDownloads(hfModelInfo.likes)} {license && ( {formatLicense(license)} )}
{/* Pipeline tag + author */}
{hfModelInfo.pipeline_tag && ( {formatPipelineTag(hfModelInfo.pipeline_tag)} )} {hfModelInfo.library_name && ( {hfModelInfo.library_name} )} {hfModelInfo.author && ( by {hfModelInfo.author} )}
{/* Languages */} {hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
{hfModelInfo.cardData.language.length > 10 ? `${hfModelInfo.cardData.language.length} languages supported` : `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
)}
)} {/* Disk size */} {freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
{formatSize(freshSelectedModel.size_mb)} on disk
)} {/* Error detail */} {selectedError?.error && (
{selectedError.error}
)} {/* Actions */}
{selectedState?.hasError ? ( <> ) : selectedState?.isDownloading ? ( <>
{(() => { const dl = freshSelectedModel ? downloadProgressMap.get(freshSelectedModel.model_name) : undefined; const pct = dl?.progress ?? 0; const hasProgress = dl && dl.total && dl.total > 0; return ( <>
{hasProgress ? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)` : dl?.filename || 'Connecting to HuggingFace...'}
); })()}
) : freshSelectedModel.downloaded ? (
{freshSelectedModel.loaded && ( )}
) : ( )}
)}
{/* Delete Confirmation Dialog */} Delete Model Are you sure you want to delete {modelToDelete?.displayName}? {modelToDelete?.sizeMb && ( <> {' '} This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will need to be re-downloaded if you want to use it again. )} Cancel { if (modelToDelete) { deleteMutation.mutate(modelToDelete.name); } }} disabled={deleteMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {deleteMutation.isPending ? ( <> Deleting... ) : ( 'Delete' )}
); } interface ModelItemProps { model: { model_name: string; display_name: string; downloaded: boolean; downloading?: boolean; // From server - true if download in progress size_mb?: number; loaded: boolean; }; onDownload: () => void; onDelete: () => void; isDownloading: boolean; // Local state - true if user just clicked download formatSize: (sizeMb?: number) => string; } function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) { // Use server's downloading state OR local state (for immediate feedback before server updates) const showDownloading = model.downloading || isDownloading; const statusText = model.loaded ? 'Loaded' : showDownloading ? 'Downloading' : model.downloaded ? 'Downloaded' : 'Not downloaded'; const sizeText = model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : ''; const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`; return (
{model.display_name} {model.loaded && ( Loaded )} {/* Only show Downloaded if actually downloaded AND not downloading */} {model.downloaded && !model.loaded && !showDownloading && ( Downloaded )}
{model.downloaded && model.size_mb && !showDownloading && (
Size: {formatSize(model.size_mb)}
)}
{model.downloaded && !showDownloading ? (
Ready
) : showDownloading ? ( ) : ( )}
); }