From e2ff70a2dcc95ce3001c8777c76bfb5c0559437f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 29 Aug 2025 23:28:11 -0700 Subject: [PATCH] add windows rocm build, better support for binaries that unpack without a launcher --- README.md | 6 - src/constants/index.ts | 8 +- src/main/managers/KoboldCppManager.ts | 218 +++++-------------------- src/utils/download.ts | 2 +- src/utils/rocm.ts | 58 ++++++- src/utils/server/download.ts | 223 ++++++++++++++++++++++++++ 6 files changed, 321 insertions(+), 194 deletions(-) create mode 100644 src/utils/server/download.ts diff --git a/README.md b/README.md index 33b4c8c..99eda43 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,6 @@ The AUR package automatically handles installation, desktop integration, and sys -### Windows ROCm Support - -There is ROCm Windows support maintained by YellowRoseCx in a separate fork. -Unfortunately it does not properly support unpacking, which would greatly diminish its performance and provide a poor UX when used alongside this app. -For Friendly Kobold to work with this fork, [this issue must be fixed first](https://github.com/YellowRoseCx/koboldcpp-rocm/issues/129). - ### Future features Not all koboldcpp features have currently been ported over to the UI. As a workaround one may use the "Additional arguments" on the "Advanced" tab of the launcher to provide additional command line arguments if you know them. diff --git a/src/constants/index.ts b/src/constants/index.ts index 249a7a8..3317864 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -39,9 +39,11 @@ export const ASSET_SUFFIXES = { } as const; export const ROCM = { - BINARY_NAME: 'koboldcpp-linux-x64-rocm', - DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm', - SIZE_BYTES_APPROX: 1024 * 1024 * 1024, + LINUX: { + BINARY_NAME: 'koboldcpp-linux-x64-rocm', + DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm', + SIZE_BYTES_APPROX: 1024 * 1024 * 1024, + }, } as const; export const FRONTENDS = { diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index e7a8dda..831c4b9 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -4,11 +4,8 @@ import { existsSync, readdirSync, statSync, - createWriteStream, - chmodSync, readFileSync, writeFileSync, - unlinkSync, } from 'fs'; import { rm } from 'fs/promises'; import { dialog } from 'electron'; @@ -16,13 +13,11 @@ import { dialog } from 'electron'; import { execa } from 'execa'; import { terminateProcess } from '@/utils/process'; import { getROCmDownload } from '@/utils/rocm'; -import { got } from 'got'; -import { pipeline } from 'stream/promises'; import { ConfigManager } from '@/main/managers/ConfigManager'; import { LogManager } from '@/main/managers/LogManager'; import { WindowManager } from '@/main/managers/WindowManager'; import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants'; -import { stripAssetExtensions } from '@/utils/version'; +import { downloadAndUnpackBinary } from '@/utils/server/download'; import type { GitHubAsset, InstalledVersion, @@ -82,95 +77,34 @@ export class KoboldCppManager { asset: GitHubAsset, onProgress?: (progress: number) => void ): Promise { - const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`); - const baseFilename = stripAssetExtensions(asset.name); - const unpackedDirPath = join(this.installDir, baseFilename); - - if (asset.isUpdate && existsSync(unpackedDirPath)) { - try { - if (this.koboldProcess && !this.koboldProcess.killed) { - this.windowManager.sendKoboldOutput( - 'Stopping KoboldCpp process before update...' - ); - - await this.cleanup(); - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - await this.removeDirectoryWithRetry(unpackedDirPath); - } catch (error) { - this.logManager.logError( - 'Failed to remove existing directory for update:', - error as Error - ); - throw new Error( - `Cannot update: Failed to remove existing installation. ` + - `Please ensure KoboldCpp is stopped and try again. ` + - `Error: ${(error as Error).message}` - ); + const result = await downloadAndUnpackBinary( + { + name: asset.name, + url: asset.browser_download_url, + size: asset.size, + type: 'asset' as const, + }, + { + installDir: this.installDir, + onProgress, + isUpdate: asset.isUpdate, + wasCurrentBinary: asset.wasCurrentBinary, + logManager: this.logManager, + configManager: this.configManager, + windowManager: this.windowManager, + unpackFunction: this.unpackKoboldCpp.bind(this), + getLauncherPath: this.getLauncherPath.bind(this), + removeDirectoryWithRetry: this.removeDirectoryWithRetry.bind(this), + cleanup: this.cleanup.bind(this), + koboldProcess: this.koboldProcess || undefined, } + ); + + if (!result.success) { + throw new Error(result.error || 'Download failed'); } - const writer = createWriteStream(tempPackedFilePath); - let downloadedBytes = 0; - const totalBytes = asset.size; - - try { - await pipeline( - got - .stream(asset.browser_download_url) - .on('downloadProgress', (progress) => { - downloadedBytes = progress.transferred; - if (onProgress && totalBytes > 0) { - onProgress((downloadedBytes / totalBytes) * 100); - } - }), - writer - ); - } catch (error) { - throw new Error(`Download failed: ${(error as Error).message}`); - } - - if (process.platform !== 'win32') { - try { - chmodSync(tempPackedFilePath, 0o755); - } catch (error) { - this.logManager.logError( - 'Failed to make binary executable:', - error as Error - ); - } - } - - try { - await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath); - - try { - unlinkSync(tempPackedFilePath); - } catch (error) { - this.logManager.logError( - 'Failed to cleanup packed file:', - error as Error - ); - } - - const launcherPath = this.getLauncherPath(unpackedDirPath); - if (launcherPath && existsSync(launcherPath)) { - const currentBinary = this.configManager.getCurrentKoboldBinary(); - if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) { - this.configManager.setCurrentKoboldBinary(launcherPath); - } - - this.windowManager.sendToRenderer('versions-updated'); - - return launcherPath; - } else { - throw new Error('Failed to find koboldcpp-launcher after unpacking'); - } - } catch (error) { - this.logManager.logError('Failed to unpack KoboldCpp:', error as Error); - throw error; - } + return result.path!; } private async unpackKoboldCpp( @@ -562,91 +496,23 @@ export class KoboldCppManager { }; } - const tempPackedFilePath = join( - this.installDir, - `${rocmInfo.name}.packed` + return await downloadAndUnpackBinary( + { + name: rocmInfo.name, + url: rocmInfo.url, + size: rocmInfo.size, + type: 'rocm' as const, + }, + { + installDir: this.installDir, + onProgress, + logManager: this.logManager, + configManager: this.configManager, + windowManager: this.windowManager, + unpackFunction: this.unpackKoboldCpp.bind(this), + getLauncherPath: this.getLauncherPath.bind(this), + } ); - const baseFilename = stripAssetExtensions(rocmInfo.name); - const unpackedDirPath = join(this.installDir, baseFilename); - - const response = await fetch(rocmInfo.url); - if (!response.ok) { - return { - success: false, - error: `Failed to download: ${response.statusText}`, - }; - } - - const writer = createWriteStream(tempPackedFilePath); - let downloadedBytes = 0; - const totalBytes = rocmInfo.size; - - try { - await pipeline( - got.stream(rocmInfo.url).on('downloadProgress', (progress) => { - downloadedBytes = progress.transferred; - if (onProgress && totalBytes > 0) { - onProgress((downloadedBytes / totalBytes) * 100); - } - }), - writer - ); - } catch (error) { - return { - success: false, - error: `Download failed: ${(error as Error).message}`, - }; - } - - if (process.platform !== 'win32') { - try { - chmodSync(tempPackedFilePath, 0o755); - } catch (error) { - this.logManager.logError( - 'Failed to make ROCm binary executable:', - error as Error - ); - } - } - - try { - await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath); - - try { - unlinkSync(tempPackedFilePath); - } catch (error) { - this.logManager.logError( - 'Failed to cleanup packed ROCm file:', - error as Error - ); - } - - const launcherPath = this.getLauncherPath(unpackedDirPath); - if (launcherPath && existsSync(launcherPath)) { - const currentBinary = this.configManager.getCurrentKoboldBinary(); - if (!currentBinary) { - this.configManager.setCurrentKoboldBinary(launcherPath); - } - - this.windowManager.sendToRenderer('versions-updated'); - - return { - success: true, - path: launcherPath, - }; - } else { - return { - success: false, - error: 'Failed to find koboldcpp-launcher after unpacking ROCm', - }; - } - } catch (error) { - this.logManager.logError('Failed to unpack ROCm:', error as Error); - return { - success: false, - error: `Failed to unpack ROCm: ${(error as Error).message}`, - }; - } } catch (error) { return { success: false, diff --git a/src/utils/download.ts b/src/utils/download.ts index 8f7c613..22182a4 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -3,7 +3,7 @@ import { ROCM } from '@/constants'; export const formatDownloadSize = (size: number, url?: string): string => { if (!size) return ''; - const isApproximateSize = url?.includes(ROCM.DOWNLOAD_URL); + const isApproximateSize = url?.includes(ROCM.LINUX.DOWNLOAD_URL); return isApproximateSize ? `~${formatFileSizeInMB(size)}` diff --git a/src/utils/rocm.ts b/src/utils/rocm.ts index 5d68789..c360054 100644 --- a/src/utils/rocm.ts +++ b/src/utils/rocm.ts @@ -1,13 +1,20 @@ import { GITHUB_API, ROCM } from '@/constants'; import type { DownloadItem } from '@/types/electron'; +import type { GitHubAsset } from '@/types'; export async function getROCmDownload( platform: string ): Promise { - if (platform !== 'linux') { - return null; + if (platform === 'linux') { + return getLinuxROCmDownload(); + } else if (platform === 'win32') { + return getWindowsROCmDownload(); } + return null; +} + +async function getLinuxROCmDownload(): Promise { try { const response = await fetch(GITHUB_API.LATEST_RELEASE_URL); if (!response.ok) { @@ -18,19 +25,54 @@ export async function getROCmDownload( const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown'; return { - name: ROCM.BINARY_NAME, - url: ROCM.DOWNLOAD_URL, - size: ROCM.SIZE_BYTES_APPROX, + name: ROCM.LINUX.BINARY_NAME, + url: ROCM.LINUX.DOWNLOAD_URL, + size: ROCM.LINUX.SIZE_BYTES_APPROX, version, type: 'rocm', }; } catch { return { - name: ROCM.BINARY_NAME, - url: ROCM.DOWNLOAD_URL, - size: ROCM.SIZE_BYTES_APPROX, + name: ROCM.LINUX.BINARY_NAME, + url: ROCM.LINUX.DOWNLOAD_URL, + size: ROCM.LINUX.SIZE_BYTES_APPROX, version: 'unknown', type: 'rocm', }; } } + +async function getWindowsROCmDownload(): Promise { + try { + const response = await fetch(GITHUB_API.ROCM_LATEST_RELEASE_URL); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const latestRelease = await response.json(); + const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown'; + + const windowsAsset = + latestRelease.assets?.find( + (asset: GitHubAsset) => asset.name === 'koboldcpp_rocm.exe' + ) || + latestRelease.assets?.find( + (asset: GitHubAsset) => + asset.name.includes('koboldcpp_rocm') && asset.name.endsWith('.exe') + ); + + if (!windowsAsset) { + throw new Error('Windows ROCm executable not found in release'); + } + + return { + name: windowsAsset.name.replace('.exe', ''), + url: windowsAsset.browser_download_url, + size: windowsAsset.size, + version, + type: 'rocm', + }; + } catch { + return null; + } +} diff --git a/src/utils/server/download.ts b/src/utils/server/download.ts new file mode 100644 index 0000000..9c62fd1 --- /dev/null +++ b/src/utils/server/download.ts @@ -0,0 +1,223 @@ +import { createWriteStream, chmodSync, existsSync } from 'fs'; +import { unlink, rename, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { pipeline } from 'stream/promises'; +import got from 'got'; +import type { LogManager } from '@/main/managers/LogManager'; +import type { ConfigManager } from '@/main/managers/ConfigManager'; +import type { WindowManager } from '@/main/managers/WindowManager'; +import type { DownloadItem } from '@/types/electron'; +import { stripAssetExtensions } from '@/utils/version'; +import type { ChildProcess } from 'child_process'; + +export interface DownloadOptions { + installDir: string; + onProgress?: (progress: number) => void; + isUpdate?: boolean; + wasCurrentBinary?: boolean; + logManager: LogManager; + configManager: ConfigManager; + windowManager: WindowManager; + unpackFunction: (packedPath: string, unpackDir: string) => Promise; + getLauncherPath: (unpackedDir: string) => string | null; + removeDirectoryWithRetry?: (dirPath: string) => Promise; + cleanup?: () => Promise; + koboldProcess?: ChildProcess; +} + +export interface DownloadResult { + success: boolean; + path?: string; + error?: string; +} + +async function handleExistingDirectory( + unpackedDirPath: string, + isUpdate: boolean, + koboldProcess: ChildProcess | undefined, + windowManager: WindowManager, + cleanup: (() => Promise) | undefined, + removeDirectoryWithRetry: ((dirPath: string) => Promise) | undefined, + logManager: LogManager +): Promise<{ success: boolean; error?: string }> { + if (!isUpdate || !existsSync(unpackedDirPath)) { + return { success: true }; + } + + try { + if (koboldProcess && !koboldProcess.killed) { + windowManager.sendKoboldOutput( + 'Stopping KoboldCpp process before update...' + ); + + if (cleanup) { + await cleanup(); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (removeDirectoryWithRetry) { + await removeDirectoryWithRetry(unpackedDirPath); + } + return { success: true }; + } catch (error) { + logManager.logError( + 'Failed to remove existing directory for update:', + error as Error + ); + return { + success: false, + error: + `Cannot update: Failed to remove existing installation. ` + + `Please ensure KoboldCpp is stopped and try again. ` + + `Error: ${(error as Error).message}`, + }; + } +} + +async function downloadFile( + item: DownloadItem, + tempPackedFilePath: string, + onProgress: ((progress: number) => void) | undefined, + logManager: LogManager +): Promise<{ success: boolean; error?: string }> { + const writer = createWriteStream(tempPackedFilePath); + let downloadedBytes = 0; + const totalBytes = item.size; + + try { + await pipeline( + got.stream(item.url).on('downloadProgress', (progress) => { + downloadedBytes = progress.transferred; + if (onProgress && totalBytes > 0) { + onProgress((downloadedBytes / totalBytes) * 100); + } + }), + writer + ); + + if (process.platform !== 'win32') { + try { + chmodSync(tempPackedFilePath, 0o755); + } catch (error) { + logManager.logError( + 'Failed to make binary executable:', + error as Error + ); + } + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Download failed: ${(error as Error).message}`, + }; + } +} + +export async function downloadAndUnpackBinary( + item: DownloadItem, + options: DownloadOptions +): Promise { + const { + installDir, + onProgress, + isUpdate, + wasCurrentBinary, + logManager, + configManager, + windowManager, + unpackFunction, + getLauncherPath, + removeDirectoryWithRetry, + cleanup, + koboldProcess, + } = options; + + const tempPackedFilePath = join(installDir, `${item.name}.packed`); + const baseFilename = stripAssetExtensions(item.name); + const unpackedDirPath = join(installDir, baseFilename); + + const dirResult = await handleExistingDirectory( + unpackedDirPath, + Boolean(isUpdate), + koboldProcess, + windowManager, + cleanup, + removeDirectoryWithRetry, + logManager + ); + + if (!dirResult.success) { + return { success: false, error: dirResult.error }; + } + + const downloadResult = await downloadFile( + item, + tempPackedFilePath, + onProgress, + logManager + ); + if (!downloadResult.success) { + return { success: false, error: downloadResult.error }; + } + + try { + await mkdir(unpackedDirPath, { recursive: true }); + await unpackFunction(tempPackedFilePath, unpackedDirPath); + + let launcherPath = getLauncherPath(unpackedDirPath); + + if (!launcherPath || !existsSync(launcherPath)) { + const expectedLauncherName = + process.platform === 'win32' + ? 'koboldcpp-launcher.exe' + : 'koboldcpp-launcher'; + const newLauncherPath = join(unpackedDirPath, expectedLauncherName); + + if (existsSync(tempPackedFilePath)) { + try { + await rename(tempPackedFilePath, newLauncherPath); + launcherPath = newLauncherPath; + } catch (error) { + logManager.logError( + 'Failed to rename binary as launcher:', + error as Error + ); + } + } + } else { + try { + await unlink(tempPackedFilePath); + } catch (error) { + logManager.logError('Failed to cleanup packed file:', error as Error); + } + } + + if (launcherPath && existsSync(launcherPath)) { + const currentBinary = configManager.getCurrentKoboldBinary(); + if (!currentBinary || (isUpdate && wasCurrentBinary)) { + configManager.setCurrentKoboldBinary(launcherPath); + } + + windowManager.sendToRenderer('versions-updated'); + + return { + success: true, + path: launcherPath, + }; + } else { + return { + success: false, + error: 'Failed to find or create koboldcpp launcher', + }; + } + } catch (error) { + logManager.logError('Failed to unpack binary:', error as Error); + return { + success: false, + error: `Failed to unpack binary: ${(error as Error).message}`, + }; + } +}