mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 23:00:45 -07:00
Merge pull request #238 from luminest-llc/feat/download-cancel-and-error-ui
Added download cancel/clear UI, fixed model downloading
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Download, Loader2, RotateCcw, Trash2, X } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
|
||||||
export function ModelManagement() {
|
export function ModelManagement() {
|
||||||
@@ -23,6 +24,9 @@ export function ModelManagement() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
|
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||||
|
const [dismissedErrors, setDismissedErrors] = useState<Set<string>>(new Set());
|
||||||
|
const [localErrors, setLocalErrors] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
const { data: modelStatus, isLoading } = useQuery({
|
const { data: modelStatus, isLoading } = useQuery({
|
||||||
queryKey: ['modelStatus'],
|
queryKey: ['modelStatus'],
|
||||||
@@ -35,19 +39,57 @@ export function ModelManagement() {
|
|||||||
refetchInterval: 5000, // Refresh every 5 seconds
|
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<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, {
|
||||||
|
model_name: modelName,
|
||||||
|
status: 'error',
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCount = erroredDownloads.size;
|
||||||
|
|
||||||
// Callbacks for download completion
|
// Callbacks for download completion
|
||||||
const handleDownloadComplete = useCallback(() => {
|
const handleDownloadComplete = useCallback(() => {
|
||||||
console.log('[ModelManagement] Download complete, clearing state');
|
console.log('[ModelManagement] Download complete, clearing state');
|
||||||
setDownloadingModel(null);
|
setDownloadingModel(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const handleDownloadError = useCallback(() => {
|
const handleDownloadError = useCallback((error: string) => {
|
||||||
console.log('[ModelManagement] Download error, clearing state');
|
console.log('[ModelManagement] Download error, clearing state');
|
||||||
|
if (downloadingModel) {
|
||||||
|
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
|
||||||
|
setConsoleOpen(true);
|
||||||
|
}
|
||||||
setDownloadingModel(null);
|
setDownloadingModel(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
}, []);
|
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||||
|
}, [queryClient, downloadingModel]);
|
||||||
|
|
||||||
// Use progress toast hook for the downloading model
|
// Use progress toast hook for the downloading model
|
||||||
useModelDownloadToast({
|
useModelDownloadToast({
|
||||||
@@ -67,6 +109,12 @@ export function ModelManagement() {
|
|||||||
|
|
||||||
const handleDownload = async (modelName: string) => {
|
const handleDownload = async (modelName: string) => {
|
||||||
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
|
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
|
// Find display name
|
||||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||||
@@ -87,6 +135,7 @@ export function ModelManagement() {
|
|||||||
// Download initiated successfully - state will be cleared when SSE reports completion
|
// Download initiated successfully - state will be cleared when SSE reports completion
|
||||||
// or by the polling interval detecting the model is downloaded
|
// or by the polling interval detecting the model is downloaded
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Download] Download failed:', error);
|
console.error('[Download] Download failed:', error);
|
||||||
setDownloadingModel(null);
|
setDownloadingModel(null);
|
||||||
@@ -99,6 +148,53 @@ export function ModelManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// 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); next.delete(modelName); return next; });
|
||||||
|
if (downloadingModel === modelName) {
|
||||||
|
setDownloadingModel(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMutation.mutate(modelName, {
|
||||||
|
onError: () => {
|
||||||
|
// Rollback optimistic updates on failure
|
||||||
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (modelName: string) => {
|
mutationFn: async (modelName: string) => {
|
||||||
console.log('[Delete] Deleting model:', modelName);
|
console.log('[Delete] Deleting model:', modelName);
|
||||||
@@ -114,14 +210,11 @@ export function ModelManagement() {
|
|||||||
});
|
});
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setModelToDelete(null);
|
setModelToDelete(null);
|
||||||
// Invalidate AND explicitly refetch to ensure UI updates
|
|
||||||
// Using refetchType: 'all' ensures we refetch even if the query is stale
|
|
||||||
console.log('[Delete] Invalidating modelStatus query');
|
console.log('[Delete] Invalidating modelStatus query');
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['modelStatus'],
|
queryKey: ['modelStatus'],
|
||||||
refetchType: 'all',
|
refetchType: 'all',
|
||||||
});
|
});
|
||||||
// Also explicitly refetch to guarantee fresh data
|
|
||||||
console.log('[Delete] Explicitly refetching modelStatus query');
|
console.log('[Delete] Explicitly refetching modelStatus query');
|
||||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||||
console.log('[Delete] Query refetched');
|
console.log('[Delete] Query refetched');
|
||||||
@@ -178,7 +271,11 @@ export function ModelManagement() {
|
|||||||
});
|
});
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => handleCancel(model.model_name)}
|
||||||
isDownloading={downloadingModel === 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}
|
formatSize={formatSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -206,13 +303,73 @@ export function ModelManagement() {
|
|||||||
});
|
});
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => handleCancel(model.model_name)}
|
||||||
isDownloading={downloadingModel === 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}
|
formatSize={formatSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{': '}
|
||||||
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -271,17 +428,22 @@ interface ModelItemProps {
|
|||||||
};
|
};
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
isDownloading: boolean; // Local state - true if user just clicked download
|
isDownloading: boolean; // Local state - true if user just clicked download
|
||||||
|
isCancelling: boolean;
|
||||||
|
isDismissed: boolean;
|
||||||
|
erroredDownload?: ActiveDownloadTask;
|
||||||
formatSize: (sizeMb?: number) => string;
|
formatSize: (sizeMb?: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
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)
|
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||||
const showDownloading = model.downloading || isDownloading;
|
// Suppress downloading if user just dismissed/cancelled this model
|
||||||
|
const showDownloading = (model.downloading || isDownloading) && !erroredDownload && !isDismissed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm">{model.display_name}</span>
|
<span className="font-medium text-sm">{model.display_name}</span>
|
||||||
{model.loaded && (
|
{model.loaded && (
|
||||||
@@ -289,21 +451,41 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
Loaded
|
Loaded
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
{model.downloaded && !model.loaded && !showDownloading && !erroredDownload && (
|
||||||
{model.downloaded && !model.loaded && !showDownloading && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Downloaded
|
Downloaded
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{erroredDownload && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Error
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{model.downloaded && model.size_mb && !showDownloading && (
|
{model.downloaded && model.size_mb && !showDownloading && !erroredDownload && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Size: {formatSize(model.size_mb)}
|
Size: {formatSize(model.size_mb)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
{model.downloaded && !showDownloading ? (
|
{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-2">
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span>Ready</span>
|
<span>Ready</span>
|
||||||
@@ -319,10 +501,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : showDownloading ? (
|
) : showDownloading ? (
|
||||||
<Button size="sm" variant="outline" disabled>
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Button size="sm" variant="outline" disabled>
|
||||||
Downloading...
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
</Button>
|
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">
|
<Button size="sm" onClick={onDownload} variant="outline">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
|||||||
@@ -325,11 +325,22 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/models/download/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Task Management
|
// Task Management
|
||||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearAllTasks(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// Audio Channels
|
// Audio Channels
|
||||||
async listChannels(): Promise<
|
async listChannels(): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface ActiveDownloadTask {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveGenerationTask {
|
export interface ActiveGenerationTask {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface UseModelDownloadToastOptions {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onError?: () => void;
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,7 +101,7 @@ export function useModelDownloadToast({
|
|||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
statusText = 'Download failed. See Problems panel for details.';
|
||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
@@ -131,8 +131,7 @@ export function useModelDownloadToast({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
duration: progress.status === 'complete' ? 5000 : Infinity,
|
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||||
variant: progress.status === 'error' ? 'destructive' : 'default',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// Close connection and dismiss toast on completion or error
|
||||||
@@ -169,7 +168,7 @@ export function useModelDownloadToast({
|
|||||||
onComplete();
|
onComplete();
|
||||||
} else if (isError && onError) {
|
} else if (isError && onError) {
|
||||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
onError();
|
onError(progress.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,6 +379,14 @@ class MLXTTSBackend:
|
|||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
WHISPER_HF_REPOS = {
|
||||||
|
"base": "openai/whisper-base",
|
||||||
|
"small": "openai/whisper-small",
|
||||||
|
"medium": "openai/whisper-medium",
|
||||||
|
"large": "openai/whisper-large-v3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MLXSTTBackend:
|
class MLXSTTBackend:
|
||||||
"""MLX-based STT backend using mlx-audio Whisper."""
|
"""MLX-based STT backend using mlx-audio Whisper."""
|
||||||
|
|
||||||
@@ -402,8 +410,8 @@ class MLXSTTBackend:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from huggingface_hub import constants as hf_constants
|
from huggingface_hub import constants as hf_constants
|
||||||
model_name = f"openai/whisper-{model_size}"
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
|
||||||
|
|
||||||
if not repo_cache.exists():
|
if not repo_cache.exists():
|
||||||
return False
|
return False
|
||||||
@@ -474,7 +482,7 @@ class MLXSTTBackend:
|
|||||||
from mlx_audio.stt import load
|
from mlx_audio.stt import load
|
||||||
|
|
||||||
# MLX Whisper uses the standard OpenAI models
|
# MLX Whisper uses the standard OpenAI models
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
|
||||||
print(f"Loading MLX Whisper model {model_size}...")
|
print(f"Loading MLX Whisper model {model_size}...")
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,14 @@ class PyTorchTTSBackend:
|
|||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
WHISPER_HF_REPOS = {
|
||||||
|
"base": "openai/whisper-base",
|
||||||
|
"small": "openai/whisper-small",
|
||||||
|
"medium": "openai/whisper-medium",
|
||||||
|
"large": "openai/whisper-large-v3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PyTorchSTTBackend:
|
class PyTorchSTTBackend:
|
||||||
"""PyTorch-based STT backend using Whisper."""
|
"""PyTorch-based STT backend using Whisper."""
|
||||||
|
|
||||||
@@ -416,8 +424,8 @@ class PyTorchSTTBackend:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from huggingface_hub import constants as hf_constants
|
from huggingface_hub import constants as hf_constants
|
||||||
model_name = f"openai/whisper-{model_size}"
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
|
||||||
|
|
||||||
if not repo_cache.exists():
|
if not repo_cache.exists():
|
||||||
return False
|
return False
|
||||||
@@ -494,7 +502,7 @@ class PyTorchSTTBackend:
|
|||||||
# Import transformers
|
# Import transformers
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
print(f"[DEBUG] Model name: {model_name}")
|
print(f"[DEBUG] Model name: {model_name}")
|
||||||
|
|
||||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||||
|
|||||||
+53
-4
@@ -932,7 +932,11 @@ async def transcribe_audio(
|
|||||||
|
|
||||||
# Check if Whisper model is downloaded (uses default size "base")
|
# Check if Whisper model is downloaded (uses default size "base")
|
||||||
model_size = whisper_model.model_size
|
model_size = whisper_model.model_size
|
||||||
model_name = f"openai/whisper-{model_size}"
|
# Map model sizes to HF repo IDs (whisper-large needs -v3 suffix)
|
||||||
|
whisper_hf_repos = {
|
||||||
|
"large": "openai/whisper-large-v3",
|
||||||
|
}
|
||||||
|
model_name = whisper_hf_repos.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
|
||||||
# Check if model is cached
|
# Check if model is cached
|
||||||
from huggingface_hub import constants as hf_constants
|
from huggingface_hub import constants as hf_constants
|
||||||
@@ -1310,14 +1314,14 @@ async def get_model_status():
|
|||||||
whisper_base_id = "openai/whisper-base"
|
whisper_base_id = "openai/whisper-base"
|
||||||
whisper_small_id = "openai/whisper-small"
|
whisper_small_id = "openai/whisper-small"
|
||||||
whisper_medium_id = "openai/whisper-medium"
|
whisper_medium_id = "openai/whisper-medium"
|
||||||
whisper_large_id = "openai/whisper-large"
|
whisper_large_id = "openai/whisper-large-v3"
|
||||||
else:
|
else:
|
||||||
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||||
whisper_base_id = "openai/whisper-base"
|
whisper_base_id = "openai/whisper-base"
|
||||||
whisper_small_id = "openai/whisper-small"
|
whisper_small_id = "openai/whisper-small"
|
||||||
whisper_medium_id = "openai/whisper-medium"
|
whisper_medium_id = "openai/whisper-medium"
|
||||||
whisper_large_id = "openai/whisper-large"
|
whisper_large_id = "openai/whisper-large-v3"
|
||||||
|
|
||||||
model_configs = [
|
model_configs = [
|
||||||
{
|
{
|
||||||
@@ -1586,6 +1590,42 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
return {"message": f"Model {request.model_name} download started"}
|
return {"message": f"Model {request.model_name} download started"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/models/download/cancel")
|
||||||
|
async def cancel_model_download(request: models.ModelDownloadRequest):
|
||||||
|
"""Cancel or dismiss an errored/stale download task."""
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
|
||||||
|
removed = task_manager.cancel_download(request.model_name)
|
||||||
|
|
||||||
|
# Also clear progress state so the model doesn't show as downloading
|
||||||
|
progress_removed = False
|
||||||
|
with progress_manager._lock:
|
||||||
|
if request.model_name in progress_manager._progress:
|
||||||
|
del progress_manager._progress[request.model_name]
|
||||||
|
progress_removed = True
|
||||||
|
|
||||||
|
if removed or progress_removed:
|
||||||
|
return {"message": f"Download task for {request.model_name} cancelled"}
|
||||||
|
return {"message": f"No active task found for {request.model_name}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/tasks/clear")
|
||||||
|
async def clear_all_tasks():
|
||||||
|
"""Clear all download tasks and progress state. Does not delete downloaded files."""
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
|
||||||
|
task_manager.clear_all()
|
||||||
|
|
||||||
|
with progress_manager._lock:
|
||||||
|
progress_manager._progress.clear()
|
||||||
|
progress_manager._last_notify_time.clear()
|
||||||
|
progress_manager._last_notify_progress.clear()
|
||||||
|
|
||||||
|
return {"message": "All task state cleared"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/models/{model_name}")
|
@app.delete("/models/{model_name}")
|
||||||
async def delete_model(model_name: str):
|
async def delete_model(model_name: str):
|
||||||
"""Delete a downloaded model from the HuggingFace cache."""
|
"""Delete a downloaded model from the HuggingFace cache."""
|
||||||
@@ -1621,7 +1661,7 @@ async def delete_model(model_name: str):
|
|||||||
"model_type": "whisper",
|
"model_type": "whisper",
|
||||||
},
|
},
|
||||||
"whisper-large": {
|
"whisper-large": {
|
||||||
"hf_repo_id": "openai/whisper-large",
|
"hf_repo_id": "openai/whisper-large-v3",
|
||||||
"model_size": "large",
|
"model_size": "large",
|
||||||
"model_type": "whisper",
|
"model_type": "whisper",
|
||||||
},
|
},
|
||||||
@@ -1710,10 +1750,18 @@ async def get_active_tasks():
|
|||||||
progress = progress_map.get(model_name)
|
progress = progress_map.get(model_name)
|
||||||
|
|
||||||
if task:
|
if task:
|
||||||
|
# Prefer task error, fall back to progress manager error
|
||||||
|
error = task.error
|
||||||
|
if not error:
|
||||||
|
with progress_manager._lock:
|
||||||
|
pm_data = progress_manager._progress.get(model_name)
|
||||||
|
if pm_data:
|
||||||
|
error = pm_data.get("error")
|
||||||
active_downloads.append(models.ActiveDownloadTask(
|
active_downloads.append(models.ActiveDownloadTask(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
status=task.status,
|
status=task.status,
|
||||||
started_at=task.started_at,
|
started_at=task.started_at,
|
||||||
|
error=error,
|
||||||
))
|
))
|
||||||
elif progress:
|
elif progress:
|
||||||
# Progress exists but no task - create from progress data
|
# Progress exists but no task - create from progress data
|
||||||
@@ -1730,6 +1778,7 @@ async def get_active_tasks():
|
|||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
status=progress.get("status", "downloading"),
|
status=progress.get("status", "downloading"),
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
|
error=progress.get("error"),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Get active generations
|
# Get active generations
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class ActiveDownloadTask(BaseModel):
|
|||||||
model_name: str
|
model_name: str
|
||||||
status: str
|
status: str
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ActiveGenerationTask(BaseModel):
|
class ActiveGenerationTask(BaseModel):
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ class TaskManager:
|
|||||||
"""Get all active generations."""
|
"""Get all active generations."""
|
||||||
return list(self._active_generations.values())
|
return list(self._active_generations.values())
|
||||||
|
|
||||||
|
def cancel_download(self, model_name: str) -> bool:
|
||||||
|
"""Cancel/dismiss a download task (removes it from active list)."""
|
||||||
|
return self._active_downloads.pop(model_name, None) is not None
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Clear all download and generation tasks."""
|
||||||
|
self._active_downloads.clear()
|
||||||
|
self._active_generations.clear()
|
||||||
|
|
||||||
def is_download_active(self, model_name: str) -> bool:
|
def is_download_active(self, model_name: str) -> bool:
|
||||||
"""Check if a download is active."""
|
"""Check if a download is active."""
|
||||||
return model_name in self._active_downloads
|
return model_name in self._active_downloads
|
||||||
|
|||||||
Reference in New Issue
Block a user