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",
"productName": "Gerbil",
"version": "2.0.0",
"version": "1.10.0",
"description": "Run Large Language Models locally",
"main": "out/main/index.js",
"homepage": "./",

View file

@ -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"
/>

View file

@ -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);
}

View file

@ -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<boolean>((resolve, reject) => {
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 => {
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', () => {
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();
}