import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronUp, Download, Loader2, RotateCcw, Trash2, X } from 'lucide-react'; import { useCallback, 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { ActiveDownloadTask } from '@/lib/api/types'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; 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()); const { data: modelStatus, isLoading } = useQuery({ queryKey: ['modelStatus'], queryFn: async () => { console.log('[Query] Fetching model status'); const result = await apiClient.getModelStatus(); console.log('[Query] Model status fetched:', result); return result; }, refetchInterval: 5000, // Refresh every 5 seconds }); const { data: activeTasks } = useQuery({ queryKey: ['activeTasks'], queryFn: () => apiClient.getActiveTasks(), refetchInterval: 5000, }); // Build a map of errored downloads for quick lookup, excluding dismissed ones // Merge server errors with locally captured SSE errors const erroredDownloads = new Map(); if (activeTasks?.downloads) { for (const dl of activeTasks.downloads) { if (dl.status === 'error' && !dismissedErrors.has(dl.model_name)) { // Prefer locally captured error (from SSE) over server error const localErr = localErrors.get(dl.model_name); erroredDownloads.set(dl.model_name, localErr ? { ...dl, error: localErr } : dl); } } } // Also add locally captured errors that aren't in server response yet 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; // Callbacks for download completion const handleDownloadComplete = useCallback(() => { console.log('[ModelManagement] Download complete, clearing state'); setDownloadingModel(null); setDownloadingDisplayName(null); queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); }, [queryClient]); const handleDownloadError = useCallback((error: string) => { console.log('[ModelManagement] Download error, clearing state'); if (downloadingModel) { setLocalErrors((prev) => new Map(prev).set(downloadingModel, error)); setConsoleOpen(true); } setDownloadingModel(null); setDownloadingDisplayName(null); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); }, [queryClient, downloadingModel]); // Use progress toast hook for the downloading model 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) => { console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString()); // Clear any previous dismissal so fresh errors can appear setDismissedErrors((prev) => { const next = new Set(prev); next.delete(modelName); return next; }); // Find display name const model = modelStatus?.models.find((m) => m.model_name === modelName); const displayName = model?.display_name || modelName; try { // IMPORTANT: Call the API FIRST before setting state // Setting state enables the SSE EventSource in useModelDownloadToast, // which can block/delay the download fetch due to HTTP/1.1 connection limits console.log('[Download] Calling download API for:', modelName); const result = await apiClient.triggerModelDownload(modelName); console.log('[Download] Download API responded:', result); // NOW set state to enable SSE tracking (after download has started on backend) setDownloadingModel(modelName); setDownloadingDisplayName(displayName); // Download initiated successfully - state will be cleared when SSE reports completion // or by the polling interval detecting the model is downloaded queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); queryClient.invalidateQueries({ queryKey: ['activeTasks'] }); } catch (error) { console.error('[Download] Download failed:', 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) => { // Immediately hide the error and suppress downloading state in UI setDismissedErrors((prev) => new Set(prev).add(modelName)); setLocalErrors((prev) => { const next = new Map(prev); next.delete(modelName); return next; }); // Also clear local downloading state if this was our current download if (downloadingModel === modelName) { setDownloadingModel(null); setDownloadingDisplayName(null); } // Fire-and-forget the backend cancel, then refetch to sync cancelMutation.mutate(modelName); }; 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) => { console.log('[Delete] Deleting model:', modelName); const result = await apiClient.deleteModel(modelName); console.log('[Delete] Model deleted successfully:', modelName); return result; }, onSuccess: async (_data, _modelName) => { console.log('[Delete] onSuccess - showing toast and invalidating queries'); toast({ title: 'Model deleted', description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`, }); setDeleteDialogOpen(false); setModelToDelete(null); console.log('[Delete] Invalidating modelStatus query'); await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all', }); console.log('[Delete] Explicitly refetching modelStatus query'); await queryClient.refetchQueries({ queryKey: ['modelStatus'] }); console.log('[Delete] Query refetched'); }, onError: (error: Error) => { console.log('[Delete] onError:', error); toast({ title: 'Delete failed', description: error.message, variant: 'destructive', }); }, }); const formatSize = (sizeMb?: number): string => { if (!sizeMb) return 'Unknown'; if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`; return `${(sizeMb / 1024).toFixed(2)} GB`; }; return ( Model Management Download and manage AI models for voice generation and transcription {isLoading ? (
) : modelStatus ? (
{/* TTS Models */}

Voice Generation Models

{modelStatus.models .filter((m) => m.model_name.startsWith('qwen-tts')) .map((model) => ( handleDownload(model.model_name)} onDelete={() => { setModelToDelete({ name: model.model_name, displayName: model.display_name, sizeMb: model.size_mb, }); setDeleteDialogOpen(true); }} onCancel={() => handleCancel(model.model_name)} isDownloading={downloadingModel === model.model_name} isCancelling={cancelMutation.isPending} isDismissed={dismissedErrors.has(model.model_name)} erroredDownload={erroredDownloads.get(model.model_name)} formatSize={formatSize} /> ))}
{/* Whisper Models */}

Transcription Models

{modelStatus.models .filter((m) => m.model_name.startsWith('whisper')) .map((model) => ( handleDownload(model.model_name)} onDelete={() => { setModelToDelete({ name: model.model_name, displayName: model.display_name, sizeMb: model.size_mb, }); setDeleteDialogOpen(true); }} onCancel={() => handleCancel(model.model_name)} isDownloading={downloadingModel === model.model_name} isCancelling={cancelMutation.isPending} isDismissed={dismissedErrors.has(model.model_name)} erroredDownload={erroredDownloads.get(model.model_name)} formatSize={formatSize} /> ))}
{/* Console Panel */} {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}
{/* 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; onCancel: () => void; isDownloading: boolean; // Local state - true if user just clicked download isCancelling: boolean; isDismissed: boolean; erroredDownload?: ActiveDownloadTask; formatSize: (sizeMb?: number) => string; } function ModelItem({ model, onDownload, onDelete, onCancel, isDownloading, isCancelling, isDismissed, erroredDownload, formatSize }: ModelItemProps) { // Use server's downloading state OR local state (for immediate feedback before server updates) // Suppress downloading if user just dismissed/cancelled this model const showDownloading = (model.downloading || isDownloading) && !erroredDownload && !isDismissed; return (
{model.display_name} {model.loaded && ( Loaded )} {model.downloaded && !model.loaded && !showDownloading && !erroredDownload && ( Downloaded )} {erroredDownload && ( Error )}
{model.downloaded && model.size_mb && !showDownloading && !erroredDownload && (
Size: {formatSize(model.size_mb)}
)}
{erroredDownload ? (
) : model.downloaded && !showDownloading ? (
Ready
) : showDownloading ? (
) : ( )}
); }