From 4eec7c767018d98cb72ec1dfb4d3e2ba403e0cff Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 19 Nov 2025 11:36:45 -0800 Subject: [PATCH] improve handling of incomplete model downloads on app quit or eject, more helpful hf searching for image gen models --- package.json | 2 +- .../screens/Launch/ImageGenerationTab.tsx | 6 ++ src/main/modules/koboldcpp/launcher/index.ts | 8 ++- src/main/modules/koboldcpp/model-download.ts | 70 ++++++++++++++++--- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1bf762e..15873dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "2.0.0", + "version": "1.10.0", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", diff --git a/src/components/screens/Launch/ImageGenerationTab.tsx b/src/components/screens/Launch/ImageGenerationTab.tsx index b8f4107..d0d8ed3 100644 --- a/src/components/screens/Launch/ImageGenerationTab.tsx +++ b/src/components/screens/Launch/ImageGenerationTab.tsx @@ -87,6 +87,7 @@ export const ImageGenerationTab = () => { tooltip="T5-XXL text encoder model for advanced text understanding." onChange={handleSdt5xxlChange} onSelectFile={handleSelectSdt5xxlFile} + searchUrl="https://huggingface.co/models?search=t5-xxl&library=gguf&sort=trending" paramType="sdt5xxl" /> @@ -97,6 +98,7 @@ export const ImageGenerationTab = () => { tooltip="CLIP-L text encoder model for text-image understanding." onChange={handleSdcliplChange} onSelectFile={handleSelectSdcliplFile} + searchUrl="https://huggingface.co/models?search=clip-l&library=gguf&sort=trending" paramType="sdclipl" /> @@ -107,6 +109,7 @@ export const ImageGenerationTab = () => { tooltip="CLIP-G text encoder model for enhanced text-image understanding." onChange={handleSdclipgChange} onSelectFile={handleSelectSdclipgFile} + searchUrl="https://huggingface.co/models?search=clip-g&library=gguf&sort=trending" paramType="sdclipg" /> @@ -117,6 +120,7 @@ export const ImageGenerationTab = () => { tooltip="PhotoMaker is a model that allows face cloning. Select a .safetensors PhotoMaker file to be loaded (SDXL only)." onChange={handleSdphotomakerChange} onSelectFile={handleSelectSdphotomakerFile} + searchUrl="https://huggingface.co/models?search=photomaker&library=safetensors&sort=trending" paramType="sdphotomaker" /> @@ -127,6 +131,7 @@ export const ImageGenerationTab = () => { tooltip="Variational Autoencoder model for improved image quality." onChange={handleSdvaeChange} onSelectFile={handleSelectSdvaeFile} + searchUrl="https://huggingface.co/models?search=vae&library=safetensors&sort=trending" paramType="sdvae" /> @@ -137,6 +142,7 @@ export const ImageGenerationTab = () => { tooltip="LoRa (Low-Rank Adaptation) file for customizing image generation. Select a .safetensors or .gguf LoRa file to be loaded. Should be unquantized." onChange={handleSdloraChange} onSelectFile={handleSelectSdloraFile} + searchUrl="https://huggingface.co/models?search=lora&library=safetensors&sort=trending" paramType="sdlora" /> diff --git a/src/main/modules/koboldcpp/launcher/index.ts b/src/main/modules/koboldcpp/launcher/index.ts index eab917a..0304910 100644 --- a/src/main/modules/koboldcpp/launcher/index.ts +++ b/src/main/modules/koboldcpp/launcher/index.ts @@ -15,7 +15,7 @@ import { startFrontend as startSillyTavernFrontend } from '@/main/modules/sillyt import { startFrontend as startOpenWebUIFrontend } from '@/main/modules/openwebui'; import { patchKliteEmbd, patchKcppSduiEmbd, filterSpam } from './patches'; import { startProxy, stopProxy } from '../proxy'; -import { resolveModelPath } from '../model-download'; +import { resolveModelPath, abortActiveDownloads } from '../model-download'; import type { FrontendPreference, ImageGenerationFrontendPreference, @@ -55,6 +55,11 @@ async function resolveModelPaths(args: string[]) { resolvedArgs.push(resolvedPath); i++; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes('aborted')) { + throw error; + } logError(`Failed to resolve model path for ${arg}:`, error as Error); resolvedArgs.push(urlOrPath); i++; @@ -207,6 +212,7 @@ export async function launchKoboldCpp( } export async function stopKoboldCpp() { + abortActiveDownloads(); await stopProxy(); return terminateProcess(koboldProcess); } diff --git a/src/main/modules/koboldcpp/model-download.ts b/src/main/modules/koboldcpp/model-download.ts index 21ff17c..362017b 100644 --- a/src/main/modules/koboldcpp/model-download.ts +++ b/src/main/modules/koboldcpp/model-download.ts @@ -1,5 +1,5 @@ import { join, basename, dirname } from 'path'; -import { mkdir, readdir, stat } from 'fs/promises'; +import { mkdir, readdir, stat, rename, unlink } from 'fs/promises'; import { createWriteStream } from 'fs'; import { get as httpGet } from 'http'; import { get as httpsGet } from 'https'; @@ -8,6 +8,12 @@ import { pathExists } from '@/utils/node/fs'; import { logError } from '@/utils/node/logging'; import { sendToRenderer } from '@/main/modules/window'; import type { ModelParamType, CachedModel } from '@/types'; +import type { IncomingMessage } from 'http'; + +const activeDownloads = new Set<{ + abort: () => void; + tempPath: string; +}>(); interface DownloadProgress { type: 'progress' | 'complete' | 'error'; @@ -61,19 +67,45 @@ async function downloadFile( ) { const normalizedUrl = normalizeUrl(url); const outputDir = dirname(outputPath); + const tempPath = `${outputPath}.tmp`; await mkdir(outputDir, { recursive: true }); return new Promise((resolve, reject) => { const httpModule = normalizedUrl.startsWith('https') ? httpsGet : httpGet; + let currentRequest: IncomingMessage | null = null; + let fileStream: ReturnType; + let isAborted = false; + + const cleanup = async () => { + fileStream?.close(); + await unlink(tempPath).catch(() => void 0); + }; + + const abortController = { + abort: () => { + isAborted = true; + currentRequest?.destroy(); + cleanup(); + reject(new Error('Download aborted by user')); + }, + tempPath, + }; + + activeDownloads.add(abortController); const handleRedirect = (requestUrl: string, redirectCount = 0): void => { + if (isAborted) return; + if (redirectCount > 10) { + activeDownloads.delete(abortController); reject(new Error('Too many redirects')); return; } httpModule(requestUrl, (response) => { + if (isAborted) return; + currentRequest = response; if ( response.statusCode === 301 || response.statusCode === 302 || @@ -90,6 +122,7 @@ async function downloadFile( } if (response.statusCode !== 200) { + activeDownloads.delete(abortController); reject( new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`) ); @@ -101,7 +134,7 @@ async function downloadFile( let lastReportTime = Date.now(); let lastReportedBytes = 0; - const fileStream = createWriteStream(outputPath); + fileStream = createWriteStream(tempPath); response.on('data', (chunk: Buffer) => { downloadedBytes += chunk.length; @@ -152,22 +185,37 @@ async function downloadFile( }); response.on('end', () => { + if (isAborted) return; fileStream.end(); - fileStream.on('finish', () => { - sendToRenderer('kobold-output', '\n'); - resolve(true); + fileStream.on('finish', async () => { + try { + await rename(tempPath, outputPath); + sendToRenderer('kobold-output', '\n'); + activeDownloads.delete(abortController); + resolve(true); + } catch (err) { + activeDownloads.delete(abortController); + reject(err instanceof Error ? err : new Error(String(err))); + } }); }); - response.on('error', (err) => { - fileStream.close(); + response.on('error', async (err) => { + if (isAborted) return; + activeDownloads.delete(abortController); + await cleanup(); reject(err); }); - fileStream.on('error', (err) => { + fileStream.on('error', async (err) => { + if (isAborted) return; + activeDownloads.delete(abortController); + await cleanup(); reject(err); }); }).on('error', (err) => { + if (isAborted) return; + activeDownloads.delete(abortController); reject(err); }); }; @@ -289,3 +337,9 @@ export async function getLocalModelsForType(paramType: ModelParamType) { return models; } + +export function abortActiveDownloads() { + const downloads = Array.from(activeDownloads); + downloads.forEach((controller) => controller.abort()); + activeDownloads.clear(); +}