@@ -255,20 +289,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
Loaded
)}
- {model.downloaded && !model.loaded && (
+ {/* Only show Downloaded if actually downloaded AND not downloading */}
+ {model.downloaded && !model.loaded && !showDownloading && (
Downloaded
)}
- {model.downloaded && model.size_mb && (
+ {model.downloaded && model.size_mb && !showDownloading && (
Size: {formatSize(model.size_mb)}
)}
- {model.downloaded ? (
+ {model.downloaded && !showDownloading ? (
Ready
@@ -283,19 +318,15 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
+ ) : showDownloading ? (
+
) : (
-
diff --git a/app/src/components/ServerSettings/ModelProgress.tsx b/app/src/components/ServerSettings/ModelProgress.tsx
index f222ed10..76aa99f1 100644
--- a/app/src/components/ServerSettings/ModelProgress.tsx
+++ b/app/src/components/ServerSettings/ModelProgress.tsx
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
interface ModelProgressProps {
modelName: string;
displayName: string;
+ /** Only connect to SSE when actively downloading - prevents connection exhaustion */
+ isDownloading?: boolean;
}
-export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
+export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
const [progress, setProgress] = useState
(null);
const serverUrl = useServerStore((state) => state.serverUrl);
useEffect(() => {
- if (!serverUrl) return;
+ // IMPORTANT: Only connect to SSE when this specific model is downloading
+ // Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
+ // which causes other fetches (like the download trigger) to be queued/blocked
+ if (!serverUrl || !isDownloading) {
+ return;
+ }
+
+ console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
// Subscribe to progress updates via Server-Sent Events
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
// Close connection if complete or error
if (data.status === 'complete' || data.status === 'error') {
+ console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
eventSource.close();
}
} catch (error) {
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
};
eventSource.onerror = (error) => {
- console.error('SSE error:', error);
+ console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
eventSource.close();
};
return () => {
+ console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
eventSource.close();
};
- }, [serverUrl, modelName]);
+ }, [serverUrl, modelName, isDownloading]);
// Don't render if no progress or if complete/error and some time has passed
if (
diff --git a/app/src/hooks/useAutoUpdater.ts b/app/src/hooks/useAutoUpdater.ts
index 44940349..7a9f169a 100644
--- a/app/src/hooks/useAutoUpdater.ts
+++ b/app/src/hooks/useAutoUpdater.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlatform } from '@/platform/PlatformContext';
import type { UpdateStatus } from '@/platform/types';
@@ -7,9 +7,8 @@ export type { UpdateStatus };
export function useAutoUpdater(checkOnMount = false) {
const platform = usePlatform();
- const [status, setStatus] = useState(
- platform.updater.getStatus(),
- );
+ const [status, setStatus] = useState(platform.updater.getStatus());
+ const hasCheckedRef = useRef(false);
// Subscribe to updater status changes
useEffect(() => {
@@ -17,25 +16,32 @@ export function useAutoUpdater(checkOnMount = false) {
setStatus(newStatus);
});
return unsubscribe;
- }, [platform]);
+ // Empty dependency array - platform is stable from context
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.subscribe]);
const checkForUpdates = useCallback(async () => {
await platform.updater.checkForUpdates();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.checkForUpdates]);
const downloadAndInstall = useCallback(async () => {
await platform.updater.downloadAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.downloadAndInstall]);
const restartAndInstall = useCallback(async () => {
await platform.updater.restartAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.restartAndInstall]);
useEffect(() => {
- if (checkOnMount && platform.metadata.isTauri) {
+ if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
+ hasCheckedRef.current = true;
checkForUpdates();
}
- }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
return {
status,
diff --git a/app/src/hooks/useAutoUpdater.tsx b/app/src/hooks/useAutoUpdater.tsx
index a7562115..8a6351f6 100644
--- a/app/src/hooks/useAutoUpdater.tsx
+++ b/app/src/hooks/useAutoUpdater.tsx
@@ -44,19 +44,24 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
setStatus(newStatus);
});
return unsubscribe;
- }, [platform]);
+ // Empty dependency array - platform is stable from context
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.subscribe]);
const checkForUpdates = useCallback(async () => {
await platform.updater.checkForUpdates();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.checkForUpdates]);
const downloadAndInstall = useCallback(async () => {
await platform.updater.downloadAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.downloadAndInstall]);
const restartAndInstall = useCallback(async () => {
await platform.updater.restartAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.restartAndInstall]);
// Check for updates on mount
useEffect(() => {
@@ -66,7 +71,9 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
console.error('Auto update check failed:', error);
});
}
- }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
+ // Empty dependency array - only run once on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
// Show toast when update is available
useEffect(() => {
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index cd87ab89..c5b079b2 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -310,10 +310,13 @@ class ApiClient {
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
- return this.request<{ message: string }>('/models/download', {
+ console.log('[API] triggerModelDownload called for:', modelName, 'at', new Date().toISOString());
+ const result = await this.request<{ message: string }>('/models/download', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
});
+ console.log('[API] triggerModelDownload response:', result);
+ return result;
}
async deleteModel(modelName: string): Promise<{ message: string }> {
diff --git a/app/src/lib/api/models/ModelStatus.ts b/app/src/lib/api/models/ModelStatus.ts
index e8b806b3..0d744893 100644
--- a/app/src/lib/api/models/ModelStatus.ts
+++ b/app/src/lib/api/models/ModelStatus.ts
@@ -9,6 +9,7 @@ export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
+ downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 6da3161a..131c1be5 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -96,6 +96,7 @@ export interface ModelStatus {
model_name: string;
display_name: string;
downloaded: boolean;
+ downloading: boolean; // True if download is in progress
size_mb?: number;
loaded: boolean;
}
diff --git a/app/src/lib/hooks/useModelDownloadToast.tsx b/app/src/lib/hooks/useModelDownloadToast.tsx
index d1865bb4..b78fdc9c 100644
--- a/app/src/lib/hooks/useModelDownloadToast.tsx
+++ b/app/src/lib/hooks/useModelDownloadToast.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { useServerStore } from '@/stores/serverStore';
import { Progress } from '@/components/ui/progress';
@@ -9,6 +9,8 @@ interface UseModelDownloadToastOptions {
modelName: string;
displayName: string;
enabled?: boolean;
+ onComplete?: () => void;
+ onError?: () => void;
}
/**
@@ -19,47 +21,59 @@ export function useModelDownloadToast({
modelName,
displayName,
enabled = false,
+ onComplete,
+ onError,
}: UseModelDownloadToastOptions) {
const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl);
const toastIdRef = useRef(null);
- const toastUpdateRef = useRef<
- ((props: {
- title?: React.ReactNode;
- description?: React.ReactNode;
- duration?: number;
- variant?: 'default' | 'destructive';
- open?: boolean;
- }) => void) | null
- >(null);
+ // biome-ignore lint: Using any for toast update ref to handle complex toast types
+ const toastUpdateRef = useRef(null);
const eventSourceRef = useRef(null);
- const formatBytes = (bytes: number): string => {
+ const formatBytes = useCallback((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 / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
- };
+ }, []);
useEffect(() => {
+ console.log('[useModelDownloadToast] useEffect triggered', { enabled, serverUrl, modelName, displayName });
+
if (!enabled || !serverUrl || !modelName) {
+ console.log('[useModelDownloadToast] Not enabled, skipping');
return;
}
+ console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
+
// Create initial toast
const toastResult = toast({
title: displayName,
- description: 'Starting download...',
+ description: (
+
+
+ Connecting to download...
+
+ ),
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
});
toastIdRef.current = toastResult.id;
toastUpdateRef.current = toastResult.update;
// Subscribe to progress updates via Server-Sent Events
- const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
+ const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
+ console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
+ const eventSource = new EventSource(eventSourceUrl);
+
+ eventSource.onopen = () => {
+ console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
+ };
eventSource.onmessage = (event) => {
+ console.log('[useModelDownloadToast] Received SSE message:', event.data);
try {
const progress = JSON.parse(event.data) as ModelProgress;
@@ -86,7 +100,7 @@ export function useModelDownloadToast({
break;
case 'downloading':
statusIcon = ;
- statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
+ statusText = progress.filename || 'Downloading...';
break;
case 'extracting':
statusIcon = ;
@@ -121,6 +135,15 @@ export function useModelDownloadToast({
eventSource.close();
eventSourceRef.current = null;
+ // Call callbacks
+ if (progress.status === 'complete' && onComplete) {
+ console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
+ onComplete();
+ } else if (progress.status === 'error' && onError) {
+ console.log('[useModelDownloadToast] Download error, calling onError callback');
+ onError();
+ }
+
// Auto-dismiss on completion after delay
if (progress.status === 'complete') {
setTimeout(() => {
@@ -141,7 +164,8 @@ export function useModelDownloadToast({
};
eventSource.onerror = (error) => {
- console.error('SSE error:', error);
+ console.error('[useModelDownloadToast] SSE error for:', modelName, error);
+ console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
eventSource.close();
eventSourceRef.current = null;
@@ -162,13 +186,14 @@ export function useModelDownloadToast({
// Cleanup on unmount or when disabled
return () => {
+ console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Note: We don't dismiss the toast here as it might still be showing completion state
};
- }, [enabled, serverUrl, modelName, displayName, toast]);
+ }, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
return {
isTracking: enabled && eventSourceRef.current !== null,
diff --git a/backend/main.py b/backend/main.py
index 7ac2ed93..40c39a33 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1161,6 +1161,10 @@ async def get_model_status():
import os
backend_type = get_backend_type()
+ task_manager = get_task_manager()
+
+ # Get set of currently downloading models
+ active_downloads = {task.model_name for task in task_manager.get_active_downloads()}
# Try to import scan_cache_dir (might not be available in older versions)
try:
@@ -1328,10 +1332,18 @@ async def get_model_status():
except Exception:
loaded = False
+ # Check if this model is currently being downloaded
+ is_downloading = config["model_name"] in active_downloads
+
+ # If downloading, don't report as downloaded (partial files exist)
+ if is_downloading:
+ downloaded = False
+
statuses.append(models.ModelStatus(
model_name=config["model_name"],
display_name=config["display_name"],
downloaded=downloaded,
+ downloading=is_downloading,
size_mb=size_mb,
loaded=loaded,
))
@@ -1342,10 +1354,14 @@ async def get_model_status():
except Exception:
loaded = False
+ # Check if this model is currently being downloaded
+ is_downloading = config["model_name"] in active_downloads
+
statuses.append(models.ModelStatus(
model_name=config["model_name"],
display_name=config["display_name"],
downloaded=False, # Assume not downloaded if check failed
+ downloading=is_downloading,
size_mb=None,
loaded=loaded,
))
diff --git a/backend/models.py b/backend/models.py
index 7e72cd13..59e45405 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -134,6 +134,7 @@ class ModelStatus(BaseModel):
model_name: str
display_name: str
downloaded: bool
+ downloading: bool = False # True if download is in progress
size_mb: Optional[float] = None
loaded: bool = False
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index 0ebcba94..add24cf4 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ