add windows rocm build, better support for binaries that unpack without a launcher

This commit is contained in:
Egor 2025-08-29 23:28:11 -07:00
parent c8098a3e68
commit e2ff70a2dc
6 changed files with 321 additions and 194 deletions

View file

@ -74,12 +74,6 @@ The AUR package automatically handles installation, desktop integration, and sys
</div> </div>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
### 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 ### 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. 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.

View file

@ -39,9 +39,11 @@ export const ASSET_SUFFIXES = {
} as const; } as const;
export const ROCM = { export const ROCM = {
LINUX: {
BINARY_NAME: 'koboldcpp-linux-x64-rocm', BINARY_NAME: 'koboldcpp-linux-x64-rocm',
DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm', DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm',
SIZE_BYTES_APPROX: 1024 * 1024 * 1024, SIZE_BYTES_APPROX: 1024 * 1024 * 1024,
},
} as const; } as const;
export const FRONTENDS = { export const FRONTENDS = {

View file

@ -4,11 +4,8 @@ import {
existsSync, existsSync,
readdirSync, readdirSync,
statSync, statSync,
createWriteStream,
chmodSync,
readFileSync, readFileSync,
writeFileSync, writeFileSync,
unlinkSync,
} from 'fs'; } from 'fs';
import { rm } from 'fs/promises'; import { rm } from 'fs/promises';
import { dialog } from 'electron'; import { dialog } from 'electron';
@ -16,13 +13,11 @@ import { dialog } from 'electron';
import { execa } from 'execa'; import { execa } from 'execa';
import { terminateProcess } from '@/utils/process'; import { terminateProcess } from '@/utils/process';
import { getROCmDownload } from '@/utils/rocm'; import { getROCmDownload } from '@/utils/rocm';
import { got } from 'got';
import { pipeline } from 'stream/promises';
import { ConfigManager } from '@/main/managers/ConfigManager'; import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager'; import { LogManager } from '@/main/managers/LogManager';
import { WindowManager } from '@/main/managers/WindowManager'; import { WindowManager } from '@/main/managers/WindowManager';
import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants'; import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants';
import { stripAssetExtensions } from '@/utils/version'; import { downloadAndUnpackBinary } from '@/utils/server/download';
import type { import type {
GitHubAsset, GitHubAsset,
InstalledVersion, InstalledVersion,
@ -82,95 +77,34 @@ export class KoboldCppManager {
asset: GitHubAsset, asset: GitHubAsset,
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
): Promise<string> { ): Promise<string> {
const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`); const result = await downloadAndUnpackBinary(
const baseFilename = stripAssetExtensions(asset.name); {
const unpackedDirPath = join(this.installDir, baseFilename); name: asset.name,
url: asset.browser_download_url,
if (asset.isUpdate && existsSync(unpackedDirPath)) { size: asset.size,
try { type: 'asset' as const,
if (this.koboldProcess && !this.koboldProcess.killed) { },
this.windowManager.sendKoboldOutput( {
'Stopping KoboldCpp process before update...' 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,
}
); );
await this.cleanup(); if (!result.success) {
await new Promise((resolve) => setTimeout(resolve, 2000)); throw new Error(result.error || 'Download failed');
} }
await this.removeDirectoryWithRetry(unpackedDirPath); return result.path!;
} 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 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;
}
} }
private async unpackKoboldCpp( private async unpackKoboldCpp(
@ -562,91 +496,23 @@ export class KoboldCppManager {
}; };
} }
const tempPackedFilePath = join( return await downloadAndUnpackBinary(
this.installDir, {
`${rocmInfo.name}.packed` 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) { } catch (error) {
return { return {
success: false, success: false,

View file

@ -3,7 +3,7 @@ import { ROCM } from '@/constants';
export const formatDownloadSize = (size: number, url?: string): string => { export const formatDownloadSize = (size: number, url?: string): string => {
if (!size) return ''; if (!size) return '';
const isApproximateSize = url?.includes(ROCM.DOWNLOAD_URL); const isApproximateSize = url?.includes(ROCM.LINUX.DOWNLOAD_URL);
return isApproximateSize return isApproximateSize
? `~${formatFileSizeInMB(size)}` ? `~${formatFileSizeInMB(size)}`

View file

@ -1,13 +1,20 @@
import { GITHUB_API, ROCM } from '@/constants'; import { GITHUB_API, ROCM } from '@/constants';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
import type { GitHubAsset } from '@/types';
export async function getROCmDownload( export async function getROCmDownload(
platform: string platform: string
): Promise<DownloadItem | null> { ): Promise<DownloadItem | null> {
if (platform !== 'linux') { if (platform === 'linux') {
return null; return getLinuxROCmDownload();
} else if (platform === 'win32') {
return getWindowsROCmDownload();
} }
return null;
}
async function getLinuxROCmDownload(): Promise<DownloadItem | null> {
try { try {
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL); const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) { if (!response.ok) {
@ -18,19 +25,54 @@ export async function getROCmDownload(
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown'; const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
return { return {
name: ROCM.BINARY_NAME, name: ROCM.LINUX.BINARY_NAME,
url: ROCM.DOWNLOAD_URL, url: ROCM.LINUX.DOWNLOAD_URL,
size: ROCM.SIZE_BYTES_APPROX, size: ROCM.LINUX.SIZE_BYTES_APPROX,
version, version,
type: 'rocm', type: 'rocm',
}; };
} catch { } catch {
return { return {
name: ROCM.BINARY_NAME, name: ROCM.LINUX.BINARY_NAME,
url: ROCM.DOWNLOAD_URL, url: ROCM.LINUX.DOWNLOAD_URL,
size: ROCM.SIZE_BYTES_APPROX, size: ROCM.LINUX.SIZE_BYTES_APPROX,
version: 'unknown', version: 'unknown',
type: 'rocm', type: 'rocm',
}; };
} }
} }
async function getWindowsROCmDownload(): Promise<DownloadItem | null> {
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;
}
}

View file

@ -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<void>;
getLauncherPath: (unpackedDir: string) => string | null;
removeDirectoryWithRetry?: (dirPath: string) => Promise<void>;
cleanup?: () => Promise<void>;
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<void>) | undefined,
removeDirectoryWithRetry: ((dirPath: string) => Promise<void>) | 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<DownloadResult> {
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}`,
};
}
}