mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
feat: add Chatterbox TTS engine for multilingual voice cloning
- New ChatterboxTTSBackend wrapping ChatterboxMultilingualTTS (ResembleAI/chatterbox) - Supports 23 languages including Hebrew, forces CPU on macOS (MPS issue) - Monkey-patches torch.load for CPU loading, forces eager attention for compatibility - trim_tts_output utility cuts trailing silence/hallucination from Chatterbox output - Full engine integration: /generate, /generate/stream, model status/download/delete - Hebrew (he) added to supported languages in frontend and backend validation - Single flat model dropdown extended with Chatterbox option in both generation UIs - ModelManagement UI groups LuxTTS and Chatterbox under 'Other Voice Models' section
This commit is contained in:
@@ -316,7 +316,7 @@ export function FloatingGenerateBox({
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && form.watch('engine') !== 'luxtts' && (
|
||||
{isExpanded && form.watch('engine') === 'qwen' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -407,11 +407,15 @@ export function FloatingGenerateBox({
|
||||
value={
|
||||
form.watch('engine') === 'luxtts'
|
||||
? 'luxtts'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} else {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
@@ -434,6 +438,9 @@ export function FloatingGenerateBox({
|
||||
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
|
||||
LuxTTS
|
||||
</SelectItem>
|
||||
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
|
||||
Chatterbox
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function GenerationForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('engine') !== 'luxtts' && (
|
||||
{form.watch('engine') === 'qwen' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
@@ -107,11 +107,15 @@ export function GenerationForm() {
|
||||
value={
|
||||
form.watch('engine') === 'luxtts'
|
||||
? 'luxtts'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} else {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
@@ -128,12 +132,15 @@ export function GenerationForm() {
|
||||
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
|
||||
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
|
||||
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
||||
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{form.watch('engine') === 'luxtts'
|
||||
? 'Fast, English-focused'
|
||||
: 'Multi-language, two sizes'}
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'Multilingual, incl. Hebrew'
|
||||
: 'Multi-language, two sizes'}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
|
||||
export function ModelsTab() {
|
||||
return (
|
||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<ModelManagement />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronUp, Download, Loader2, RotateCcw, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Download,
|
||||
ExternalLink,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Scale,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -13,12 +29,50 @@ import {
|
||||
} 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
|
||||
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
|
||||
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<string, string> = {
|
||||
'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(' ');
|
||||
}
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -28,15 +82,17 @@ export function ModelManagement() {
|
||||
const [dismissedErrors, setDismissedErrors] = useState<Set<string>>(new Set());
|
||||
const [localErrors, setLocalErrors] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Modal state
|
||||
const [selectedModel, setSelectedModel] = useState<ModelStatus | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
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
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: activeTasks } = useQuery({
|
||||
@@ -45,19 +101,25 @@ export function ModelManagement() {
|
||||
refetchInterval: 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
|
||||
// Merge server errors with locally captured SSE errors
|
||||
const erroredDownloads = new Map<string, ActiveDownloadTask>();
|
||||
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, {
|
||||
@@ -71,9 +133,7 @@ export function ModelManagement() {
|
||||
|
||||
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'] });
|
||||
@@ -82,7 +142,6 @@ export function ModelManagement() {
|
||||
|
||||
const handleDownloadError = useCallback(
|
||||
(error: string) => {
|
||||
console.log('[ModelManagement] Download error, clearing state');
|
||||
if (downloadingModel) {
|
||||
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
|
||||
setConsoleOpen(true);
|
||||
@@ -94,7 +153,6 @@ export function ModelManagement() {
|
||||
[queryClient, downloadingModel],
|
||||
);
|
||||
|
||||
// Use progress toast hook for the downloading model
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModel || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
@@ -111,36 +169,24 @@ export function ModelManagement() {
|
||||
} | 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);
|
||||
await apiClient.triggerModelDownload(modelName);
|
||||
|
||||
// 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({
|
||||
@@ -160,13 +206,11 @@ export function ModelManagement() {
|
||||
});
|
||||
|
||||
const handleCancel = (modelName: string) => {
|
||||
// Snapshot previous state for rollback
|
||||
const prevDismissed = dismissedErrors;
|
||||
const prevLocalErrors = localErrors;
|
||||
const prevDownloadingModel = downloadingModel;
|
||||
const prevDownloadingDisplayName = downloadingDisplayName;
|
||||
|
||||
// Optimistically hide the error and suppress downloading state in UI
|
||||
setDismissedErrors((prev) => new Set(prev).add(modelName));
|
||||
setLocalErrors((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -180,7 +224,6 @@ export function ModelManagement() {
|
||||
|
||||
cancelMutation.mutate(modelName, {
|
||||
onError: () => {
|
||||
// Rollback optimistic updates on failure
|
||||
setDismissedErrors(prevDismissed);
|
||||
setLocalErrors(prevLocalErrors);
|
||||
setDownloadingModel(prevDownloadingModel);
|
||||
@@ -208,30 +251,22 @@ export function ModelManagement() {
|
||||
|
||||
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');
|
||||
onSuccess: async () => {
|
||||
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');
|
||||
setDetailOpen(false);
|
||||
setSelectedModel(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
|
||||
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,
|
||||
@@ -241,185 +276,416 @@ export function ModelManagement() {
|
||||
});
|
||||
|
||||
const formatSize = (sizeMb?: number): string => {
|
||||
if (!sizeMb) return 'Unknown';
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Model Management</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 pb-4">
|
||||
<h1 className="text-lg font-semibold">Models</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download and manage AI models for voice generation and transcription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="space-y-4">
|
||||
{/* TTS Models */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Voice Generation Models
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models
|
||||
.filter((m) => m.model_name.startsWith('qwen-tts'))
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||
{section.label}
|
||||
</h2>
|
||||
<div className="border rounded-lg divide-y overflow-hidden">
|
||||
{section.models.map((model) => {
|
||||
const { isDownloading, hasError } = getModelState(model);
|
||||
return (
|
||||
<button
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => 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 && cancelMutation.variables === model.model_name
|
||||
}
|
||||
isDismissed={dismissedErrors.has(model.model_name)}
|
||||
erroredDownload={erroredDownloads.get(model.model_name)}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LuxTTS Models */}
|
||||
{modelStatus.models.some((m) => m.model_name.startsWith('luxtts')) && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">LuxTTS Models</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models
|
||||
.filter((m) => m.model_name.startsWith('luxtts'))
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => handleDownload(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
displayName: model.display_name,
|
||||
sizeMb: model.size_mb,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
isDownloading={downloadingModel === model.model_name}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whisper Models */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Transcription Models
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models
|
||||
.filter((m) => m.model_name.startsWith('whisper'))
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => 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 && cancelMutation.variables === model.model_name
|
||||
}
|
||||
isDismissed={dismissedErrors.has(model.model_name)}
|
||||
erroredDownload={erroredDownloads.get(model.model_name)}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console Panel */}
|
||||
{errorCount > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 text-xs font-medium text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsoleOpen((v) => !v)}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{consoleOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Problems</span>
|
||||
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => clearAllMutation.mutate()}
|
||||
disabled={clearAllMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
{consoleOpen && (
|
||||
<div className="bg-[#1e1e1e] text-[#d4d4d4] p-3 max-h-48 overflow-auto font-mono text-xs leading-relaxed">
|
||||
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
|
||||
<div key={modelName} className="mb-2 last:mb-0">
|
||||
<span className="text-[#f44747]">[error]</span>{' '}
|
||||
<span className="text-[#569cd6]">{modelName}</span>
|
||||
{dl.error ? (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#ce9178] whitespace-pre-wrap break-all">
|
||||
{dl.error}
|
||||
</span>
|
||||
</>
|
||||
type="button"
|
||||
onClick={() => openModelDetail(model)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="shrink-0">
|
||||
{hasError ? (
|
||||
<CircleX className="h-4 w-4 text-destructive" />
|
||||
) : isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : model.loaded ? (
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
) : model.downloaded ? (
|
||||
<CircleCheck className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#808080]">
|
||||
No error details available. Try downloading again.
|
||||
</span>
|
||||
</>
|
||||
<Download className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="text-[#6a9955] mt-0.5">
|
||||
started at {new Date(dl.started_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{model.display_name}</span>
|
||||
</div>
|
||||
|
||||
{/* Right side info */}
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{isDownloading && (
|
||||
<span className="text-xs text-muted-foreground">Downloading...</span>
|
||||
)}
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{model.loaded && (
|
||||
<Badge variant="default" className="text-[10px] h-5">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !model.loaded && !isDownloading && !hasError && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatSize(model.size_mb)}
|
||||
</span>
|
||||
)}
|
||||
{!model.downloaded && !isDownloading && !hasError && (
|
||||
<span className="text-xs text-muted-foreground/60">Not downloaded</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error console */}
|
||||
{errorCount > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 text-xs font-medium text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsoleOpen((v) => !v)}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{consoleOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Problems</span>
|
||||
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => clearAllMutation.mutate()}
|
||||
disabled={clearAllMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
{consoleOpen && (
|
||||
<div className="bg-[#1e1e1e] text-[#d4d4d4] p-3 max-h-48 overflow-auto font-mono text-xs leading-relaxed">
|
||||
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
|
||||
<div key={modelName} className="mb-2 last:mb-0">
|
||||
<span className="text-[#f44747]">[error]</span>{' '}
|
||||
<span className="text-[#569cd6]">{modelName}</span>
|
||||
{dl.error ? (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#ce9178] whitespace-pre-wrap break-all">
|
||||
{dl.error}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#808080]">
|
||||
No error details available. Try downloading again.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="text-[#6a9955] mt-0.5">
|
||||
started at {new Date(dl.started_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Model Detail Modal */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{freshSelectedModel && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{freshSelectedModel.display_name}</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
{freshSelectedModel.hf_repo_id ? (
|
||||
<a
|
||||
href={`https://huggingface.co/${freshSelectedModel.hf_repo_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{freshSelectedModel.hf_repo_id}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
freshSelectedModel.model_name
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Status badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{freshSelectedModel.loaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
Loaded in memory
|
||||
</Badge>
|
||||
)}
|
||||
{freshSelectedModel.downloaded && !freshSelectedModel.loaded && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CircleCheck className="h-3 w-3 mr-1" />
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
{selectedState?.isDownloading && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
Downloading
|
||||
</Badge>
|
||||
)}
|
||||
{selectedState?.hasError && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<CircleX className="h-3 w-3 mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{!freshSelectedModel.downloaded &&
|
||||
!selectedState?.isDownloading &&
|
||||
!selectedState?.hasError && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Not downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HuggingFace model card info */}
|
||||
{hfLoading && freshSelectedModel.hf_repo_id && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading model info...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{formatPipelineTag(hfModelInfo.pipeline_tag)}
|
||||
</Badge>
|
||||
)}
|
||||
{hfModelInfo.library_name && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{hfModelInfo.library_name}
|
||||
</Badge>
|
||||
)}
|
||||
{hfModelInfo.author && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
by {hfModelInfo.author}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
{hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hfModelInfo.cardData.language.length > 10
|
||||
? `${hfModelInfo.cardData.language.length} languages supported`
|
||||
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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" />
|
||||
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{selectedError?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-xs text-destructive">
|
||||
{selectedError.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
{selectedState?.hasError ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(freshSelectedModel.model_name)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Retry Download
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCancel(freshSelectedModel.model_name)}
|
||||
variant="ghost"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === freshSelectedModel.model_name
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : selectedState?.isDownloading ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled className="flex-1">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCancel(freshSelectedModel.model_name)}
|
||||
variant="ghost"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === freshSelectedModel.model_name
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : freshSelectedModel.downloaded ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setModelToDelete({
|
||||
name: freshSelectedModel.model_name,
|
||||
displayName: freshSelectedModel.display_name,
|
||||
sizeMb: freshSelectedModel.size_mb,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
disabled={freshSelectedModel.loaded}
|
||||
title={
|
||||
freshSelectedModel.loaded ? 'Unload model before deleting' : 'Delete model'
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{freshSelectedModel.loaded ? 'Unload to Delete' : 'Delete Model'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(freshSelectedModel.model_name)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
@@ -460,126 +726,6 @@ export function ModelManagement() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{model.display_name}</span>
|
||||
{model.loaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !model.loaded && !showDownloading && !erroredDownload && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
{erroredDownload && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && !showDownloading && !erroredDownload && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Size: {formatSize(model.size_mb)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
{erroredDownload ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={onDownload} variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
disabled={isCancelling}
|
||||
title="Dismiss error"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : model.downloaded && !showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
disabled={isCancelling}
|
||||
title="Cancel download"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={onDownload} variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user