improve handling of incomplete model downloads on app quit or eject, more helpful hf searching for image gen models

This commit is contained in:
Egor 2025-11-19 11:36:45 -08:00
parent 763c4ab6c8
commit 4eec7c7670
4 changed files with 76 additions and 10 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "2.0.0", "version": "1.10.0",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",

View file

@ -87,6 +87,7 @@ export const ImageGenerationTab = () => {
tooltip="T5-XXL text encoder model for advanced text understanding." tooltip="T5-XXL text encoder model for advanced text understanding."
onChange={handleSdt5xxlChange} onChange={handleSdt5xxlChange}
onSelectFile={handleSelectSdt5xxlFile} onSelectFile={handleSelectSdt5xxlFile}
searchUrl="https://huggingface.co/models?search=t5-xxl&library=gguf&sort=trending"
paramType="sdt5xxl" paramType="sdt5xxl"
/> />
@ -97,6 +98,7 @@ export const ImageGenerationTab = () => {
tooltip="CLIP-L text encoder model for text-image understanding." tooltip="CLIP-L text encoder model for text-image understanding."
onChange={handleSdcliplChange} onChange={handleSdcliplChange}
onSelectFile={handleSelectSdcliplFile} onSelectFile={handleSelectSdcliplFile}
searchUrl="https://huggingface.co/models?search=clip-l&library=gguf&sort=trending"
paramType="sdclipl" paramType="sdclipl"
/> />
@ -107,6 +109,7 @@ export const ImageGenerationTab = () => {
tooltip="CLIP-G text encoder model for enhanced text-image understanding." tooltip="CLIP-G text encoder model for enhanced text-image understanding."
onChange={handleSdclipgChange} onChange={handleSdclipgChange}
onSelectFile={handleSelectSdclipgFile} onSelectFile={handleSelectSdclipgFile}
searchUrl="https://huggingface.co/models?search=clip-g&library=gguf&sort=trending"
paramType="sdclipg" 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)." tooltip="PhotoMaker is a model that allows face cloning. Select a .safetensors PhotoMaker file to be loaded (SDXL only)."
onChange={handleSdphotomakerChange} onChange={handleSdphotomakerChange}
onSelectFile={handleSelectSdphotomakerFile} onSelectFile={handleSelectSdphotomakerFile}
searchUrl="https://huggingface.co/models?search=photomaker&library=safetensors&sort=trending"
paramType="sdphotomaker" paramType="sdphotomaker"
/> />
@ -127,6 +131,7 @@ export const ImageGenerationTab = () => {
tooltip="Variational Autoencoder model for improved image quality." tooltip="Variational Autoencoder model for improved image quality."
onChange={handleSdvaeChange} onChange={handleSdvaeChange}
onSelectFile={handleSelectSdvaeFile} onSelectFile={handleSelectSdvaeFile}
searchUrl="https://huggingface.co/models?search=vae&library=safetensors&sort=trending"
paramType="sdvae" 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." 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} onChange={handleSdloraChange}
onSelectFile={handleSelectSdloraFile} onSelectFile={handleSelectSdloraFile}
searchUrl="https://huggingface.co/models?search=lora&library=safetensors&sort=trending"
paramType="sdlora" paramType="sdlora"
/> />

View file

@ -15,7 +15,7 @@ import { startFrontend as startSillyTavernFrontend } from '@/main/modules/sillyt
import { startFrontend as startOpenWebUIFrontend } from '@/main/modules/openwebui'; import { startFrontend as startOpenWebUIFrontend } from '@/main/modules/openwebui';
import { patchKliteEmbd, patchKcppSduiEmbd, filterSpam } from './patches'; import { patchKliteEmbd, patchKcppSduiEmbd, filterSpam } from './patches';
import { startProxy, stopProxy } from '../proxy'; import { startProxy, stopProxy } from '../proxy';
import { resolveModelPath } from '../model-download'; import { resolveModelPath, abortActiveDownloads } from '../model-download';
import type { import type {
FrontendPreference, FrontendPreference,
ImageGenerationFrontendPreference, ImageGenerationFrontendPreference,
@ -55,6 +55,11 @@ async function resolveModelPaths(args: string[]) {
resolvedArgs.push(resolvedPath); resolvedArgs.push(resolvedPath);
i++; i++;
} catch (error) { } 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); logError(`Failed to resolve model path for ${arg}:`, error as Error);
resolvedArgs.push(urlOrPath); resolvedArgs.push(urlOrPath);
i++; i++;
@ -207,6 +212,7 @@ export async function launchKoboldCpp(
} }
export async function stopKoboldCpp() { export async function stopKoboldCpp() {
abortActiveDownloads();
await stopProxy(); await stopProxy();
return terminateProcess(koboldProcess); return terminateProcess(koboldProcess);
} }

View file

@ -1,5 +1,5 @@
import { join, basename, dirname } from 'path'; 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 { createWriteStream } from 'fs';
import { get as httpGet } from 'http'; import { get as httpGet } from 'http';
import { get as httpsGet } from 'https'; import { get as httpsGet } from 'https';
@ -8,6 +8,12 @@ import { pathExists } from '@/utils/node/fs';
import { logError } from '@/utils/node/logging'; import { logError } from '@/utils/node/logging';
import { sendToRenderer } from '@/main/modules/window'; import { sendToRenderer } from '@/main/modules/window';
import type { ModelParamType, CachedModel } from '@/types'; import type { ModelParamType, CachedModel } from '@/types';
import type { IncomingMessage } from 'http';
const activeDownloads = new Set<{
abort: () => void;
tempPath: string;
}>();
interface DownloadProgress { interface DownloadProgress {
type: 'progress' | 'complete' | 'error'; type: 'progress' | 'complete' | 'error';
@ -61,19 +67,45 @@ async function downloadFile(
) { ) {
const normalizedUrl = normalizeUrl(url); const normalizedUrl = normalizeUrl(url);
const outputDir = dirname(outputPath); const outputDir = dirname(outputPath);
const tempPath = `${outputPath}.tmp`;
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
return new Promise<boolean>((resolve, reject) => { return new Promise<boolean>((resolve, reject) => {
const httpModule = normalizedUrl.startsWith('https') ? httpsGet : httpGet; const httpModule = normalizedUrl.startsWith('https') ? httpsGet : httpGet;
let currentRequest: IncomingMessage | null = null;
let fileStream: ReturnType<typeof createWriteStream>;
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 => { const handleRedirect = (requestUrl: string, redirectCount = 0): void => {
if (isAborted) return;
if (redirectCount > 10) { if (redirectCount > 10) {
activeDownloads.delete(abortController);
reject(new Error('Too many redirects')); reject(new Error('Too many redirects'));
return; return;
} }
httpModule(requestUrl, (response) => { httpModule(requestUrl, (response) => {
if (isAborted) return;
currentRequest = response;
if ( if (
response.statusCode === 301 || response.statusCode === 301 ||
response.statusCode === 302 || response.statusCode === 302 ||
@ -90,6 +122,7 @@ async function downloadFile(
} }
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
activeDownloads.delete(abortController);
reject( reject(
new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`) new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)
); );
@ -101,7 +134,7 @@ async function downloadFile(
let lastReportTime = Date.now(); let lastReportTime = Date.now();
let lastReportedBytes = 0; let lastReportedBytes = 0;
const fileStream = createWriteStream(outputPath); fileStream = createWriteStream(tempPath);
response.on('data', (chunk: Buffer) => { response.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length; downloadedBytes += chunk.length;
@ -152,22 +185,37 @@ async function downloadFile(
}); });
response.on('end', () => { response.on('end', () => {
if (isAborted) return;
fileStream.end(); fileStream.end();
fileStream.on('finish', () => { fileStream.on('finish', async () => {
sendToRenderer('kobold-output', '\n'); try {
resolve(true); 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) => { response.on('error', async (err) => {
fileStream.close(); if (isAborted) return;
activeDownloads.delete(abortController);
await cleanup();
reject(err); reject(err);
}); });
fileStream.on('error', (err) => { fileStream.on('error', async (err) => {
if (isAborted) return;
activeDownloads.delete(abortController);
await cleanup();
reject(err); reject(err);
}); });
}).on('error', (err) => { }).on('error', (err) => {
if (isAborted) return;
activeDownloads.delete(abortController);
reject(err); reject(err);
}); });
}; };
@ -289,3 +337,9 @@ export async function getLocalModelsForType(paramType: ModelParamType) {
return models; return models;
} }
export function abortActiveDownloads() {
const downloads = Array.from(activeDownloads);
downloads.forEach((controller) => controller.abort());
activeDownloads.clear();
}