fighting rocm on windows, allow deleting kcpp binaries from versions tab

This commit is contained in:
Egor 2025-09-26 17:35:05 -07:00
parent b2533659c2
commit ec2410c0e4
14 changed files with 286 additions and 216 deletions

View file

@ -9,7 +9,7 @@ import {
Progress,
rem,
} from '@mantine/core';
import { Download } from 'lucide-react';
import { Download, Trash2 } from 'lucide-react';
import { MouseEvent } from 'react';
import { pretifyBinName, isWindowsROCmBuild } from '@/utils/assets';
import { usePreferencesStore } from '@/stores/preferences';
@ -19,26 +19,28 @@ interface DownloadCardProps {
version: VersionInfo;
size: string;
description?: string;
isDownloading?: boolean;
isLoading?: boolean;
downloadProgress?: number;
disabled?: boolean;
onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
onMakeCurrent?: () => void;
onUpdate?: (e: MouseEvent<HTMLButtonElement>) => void;
onRedownload?: (e: MouseEvent<HTMLButtonElement>) => void;
onDelete?: (e: MouseEvent<HTMLButtonElement>) => void;
}
export const DownloadCard = ({
version: versionInfo,
size,
description,
isDownloading = false,
isLoading = false,
downloadProgress = 0,
disabled = false,
onDownload,
onMakeCurrent,
onUpdate,
onRedownload,
onDelete,
}: DownloadCardProps) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const hasVersionMismatch = Boolean(
@ -46,6 +48,8 @@ export const DownloadCard = ({
versionInfo.actualVersion &&
versionInfo.version !== versionInfo.actualVersion
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const renderActionButtons = () => {
const buttons = [];
@ -56,17 +60,17 @@ export const DownloadCard = ({
variant="filled"
size="xs"
onClick={onDownload}
loading={isDownloading}
loading={isLoading}
disabled={disabled}
leftSection={
isDownloading ? (
isLoading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading ? 'Downloading...' : 'Download'}
{isLoading ? 'Downloading...' : 'Download'}
</Button>
);
}
@ -92,20 +96,18 @@ export const DownloadCard = ({
variant="filled"
size="xs"
onClick={onUpdate}
loading={isDownloading}
loading={isLoading}
disabled={disabled}
color="orange"
leftSection={
isDownloading ? (
isLoading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading
? 'Updating...'
: `Update to ${versionInfo.newerVersion}`}
{isLoading ? 'Updating...' : `Update to ${versionInfo.newerVersion}`}
</Button>
);
}
@ -117,18 +119,41 @@ export const DownloadCard = ({
variant="filled"
size="xs"
onClick={onRedownload}
loading={isDownloading}
loading={isLoading}
disabled={disabled}
color="red"
leftSection={
isDownloading ? (
isLoading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading ? 'Re-downloading...' : 'Re-download'}
{isLoading ? 'Re-downloading...' : 'Re-download'}
</Button>
);
}
if (onDelete && versionInfo.isInstalled && !versionInfo.isCurrent) {
buttons.push(
<Button
key="delete"
variant="light"
size="xs"
onClick={onDelete}
loading={isLoading}
disabled={disabled}
color="red"
leftSection={
isLoading ? (
<Loader size="1rem" />
) : (
<Trash2 style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isLoading ? 'Deleting...' : 'Delete'}
</Button>
);
}
@ -201,7 +226,7 @@ export const DownloadCard = ({
{renderActionButtons()}
</Group>
{isDownloading && downloadProgress !== undefined && (
{isLoading && downloadProgress !== undefined && (
<Stack gap="xs" mt="sm">
<Progress
value={Math.min(downloadProgress, 100)}

View file

@ -96,7 +96,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
download.url
)}
description={getAssetDescription(download.name)}
isDownloading={isDownloading}
isLoading={isDownloading}
downloadProgress={
isDownloading
? downloadProgress[download.name] || 0

View file

@ -26,8 +26,10 @@ export const BackendSelector = () => {
useEffect(() => {
const loadBackends = async () => {
setIsLoadingBackends(true);
const backends =
await window.electronAPI.kobold.getAvailableBackends(true);
setAvailableBackends(backends || []);
setIsLoadingBackends(false);
hasInitialized.current = true;

View file

@ -3,7 +3,6 @@ import {
createSoftwareItems,
createDriverItems,
createHardwareItems,
type HardwareInfo,
} from '@/utils/systemInfo';
import {
Text,
@ -18,9 +17,10 @@ import {
} from '@mantine/core';
import { Github, FolderOpen, FileText } from 'lucide-react';
import { useLogoClickSounds } from '@/hooks/useLogoClickSounds';
import type { SystemVersionInfo } from '@/types/electron';
import { PRODUCT_NAME, GITHUB_API } from '@/constants';
import { InfoCard } from '@/components/InfoCard';
import type { HardwareInfo } from '@/types/hardware';
import type { SystemVersionInfo } from '@/types/electron';
import icon from '/icon.png';

View file

@ -199,6 +199,17 @@ export const VersionsTab = () => {
await loadInstalledVersions();
};
const handleDelete = async (version: VersionInfo) => {
if (!version.installedPath || version.isCurrent) return;
const result = await window.electronAPI.kobold.deleteRelease(
version.installedPath
);
if (result.success) {
await loadInstalledVersions();
}
};
const makeCurrent = (version: VersionInfo) => {
if (!version.installedPath) return;
@ -270,7 +281,7 @@ export const VersionsTab = () => {
: ''
}
description={getAssetDescription(version.name)}
isDownloading={isDownloading}
isLoading={isDownloading}
downloadProgress={downloadProgress[version.name]}
disabled={downloading !== null}
onDownload={(e) => {
@ -285,6 +296,10 @@ export const VersionsTab = () => {
e.stopPropagation();
handleRedownload(version);
}}
onDelete={(e) => {
e.stopPropagation();
handleDelete(version);
}}
onMakeCurrent={() => makeCurrent(version)}
/>
</div>

View file

@ -11,6 +11,7 @@ import {
getInstalledVersions,
getCurrentVersion,
setCurrentVersion,
deleteRelease,
} from '@/main/modules/koboldcpp/version';
import {
getConfigFiles,
@ -138,6 +139,10 @@ export function setupIPCHandlers() {
launchKoboldCppWithCustomFrontends(args)
);
ipcMain.handle('kobold:deleteRelease', (_, binaryPath) =>
deleteRelease(binaryPath)
);
ipcMain.handle('kobold:stopKoboldCpp', () => {
stopKoboldCpp();
stopSillyTavernFrontend();

View file

@ -110,7 +110,7 @@ async function detectVulkan() {
const isIntegrated = gpu.isIntegrated;
devices.push({
name: isIntegrated ? gpu.deviceName : formatDeviceName(gpu.deviceName),
name: isIntegrated ? gpu.name : formatDeviceName(gpu.name),
isIntegrated,
});
}
@ -192,155 +192,30 @@ async function detectCUDA() {
export async function detectROCm() {
try {
const rocminfoCommand = platform === 'win32' ? 'hipInfo' : 'rocminfo';
const { stdout } = await execa(rocminfoCommand, [], COMMON_EXEC_OPTIONS);
const [rocminfoResult, vulkanInfo, hipccVersion] = await Promise.all([
execa(rocminfoCommand, [], COMMON_EXEC_OPTIONS),
getVulkanInfo(),
execa('hipcc', ['--version'], COMMON_EXEC_OPTIONS),
]);
const { stdout } = rocminfoResult;
const { stdout: hipccOutput } = hipccVersion;
let version: string | undefined;
let driverVersion: string | undefined;
try {
if (hipccOutput.trim()) {
const hipVersionMatch = hipccOutput.match(
/HIP version:\s*(\d+\.\d+(?:\.\d+)?)/i
);
if (hipVersionMatch) {
version = hipVersionMatch[1];
}
}
} catch {}
if (stdout.trim()) {
const devices: GPUDevice[] = [];
if (platform === 'win32') {
const lines = stdout.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) {
const name = line.split('Marketing Name:')[1]?.trim();
if (name && !name.toLowerCase().includes('cpu')) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() || '';
break;
}
}
if (deviceType !== 'CPU') {
let isIntegrated = true;
try {
const vulkanInfo = await getVulkanInfo();
const matchingGPU = vulkanInfo.allGPUs.find(
(gpu) =>
gpu.deviceName.includes(name) ||
name.includes(gpu.deviceName)
);
isIntegrated = matchingGPU ? matchingGPU.isIntegrated : false;
} catch {
isIntegrated = false;
}
devices.push({
name: isIntegrated ? name : formatDeviceName(name),
isIntegrated,
});
}
}
}
}
} else {
const lines = stdout.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) {
const name = line.split('Marketing Name:')[1]?.trim();
if (name) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() || '';
break;
}
}
if (deviceType !== 'CPU') {
// Check if integrated by cross-referencing with vulkan GPU list
let isIntegrated = true;
try {
const vulkanInfo = await getVulkanInfo();
const matchingGPU = vulkanInfo.allGPUs.find(
(gpu) =>
gpu.deviceName.includes(name) ||
name.includes(gpu.deviceName)
);
isIntegrated = matchingGPU ? matchingGPU.isIntegrated : false;
} catch {
isIntegrated = false;
}
devices.push({
name: isIntegrated ? name : formatDeviceName(name),
isIntegrated,
});
}
}
}
}
}
let version: string | undefined;
if (platform === 'linux' || platform === 'darwin') {
try {
const { stdout: amdSmiOutput } = await execa(
'amd-smi',
COMMON_EXEC_OPTIONS
);
if (amdSmiOutput.trim()) {
const match =
amdSmiOutput.match(/ROCm version:\s*(\d+\.\d+\.\d+)/i) ||
amdSmiOutput.match(/version\s*(\d+\.\d+\.\d+)/i) ||
amdSmiOutput.match(/(\d+\.\d+\.\d+)/);
if (match) {
version = match[1];
}
}
} catch {}
} else {
try {
const { stdout: hipccOutput } = await execa(
'hipcc',
['--version'],
COMMON_EXEC_OPTIONS
);
if (hipccOutput.trim()) {
const hipVersionMatch = hipccOutput.match(
/HIP version:\s*(\d+\.\d+(?:\.\d+)?)/i
);
if (hipVersionMatch) {
version = hipVersionMatch[1];
}
}
} catch {}
}
let driverVersion: string | undefined;
const devices = parseRocmOutput(stdout, vulkanInfo);
if (platform === 'win32') {
try {
@ -357,10 +232,8 @@ export async function detectROCm() {
driverVersion = driverOutput.trim();
}
} catch {}
} else if (platform === 'linux') {
} else {
try {
const vulkanInfo = await getVulkanInfo();
for (const gpu of vulkanInfo.allGPUs) {
if (gpu.driverInfo && !gpu.isIntegrated) {
driverVersion = gpu.driverInfo;
@ -467,6 +340,119 @@ function findComputeUnitsInClInfo(lines: string[], startIndex: number) {
const isDiscreteGPU = (computeUnits: number) => computeUnits > 12;
function parseRocmOutput(output: string, vulkanInfo: { allGPUs: GPUDevice[] }) {
const devices: GPUDevice[] = [];
const lines = output.split('\n');
let currentDevice: Partial<GPUDevice> | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// Handle hipInfo format
if (handleHipInfoLine(trimmedLine, currentDevice, devices)) {
currentDevice = trimmedLine.startsWith('device#') ? {} : currentDevice;
continue;
}
// Handle rocminfo format
if (line.includes('Marketing Name:')) {
const device = parseRocmInfoDevice(line, lines, i, vulkanInfo);
if (device) {
devices.push(device);
}
}
}
// Add the last device for hipInfo format
if (currentDevice?.name) {
devices.push(
createDevice(currentDevice.name, currentDevice.isIntegrated || false)
);
}
return devices;
}
function handleHipInfoLine(
trimmedLine: string,
currentDevice: Partial<GPUDevice> | null,
devices: GPUDevice[]
): boolean {
if (trimmedLine.startsWith('device#')) {
if (currentDevice?.name) {
devices.push(
createDevice(currentDevice.name, currentDevice.isIntegrated || false)
);
}
return true;
}
if (currentDevice) {
if (trimmedLine.startsWith('Name:')) {
currentDevice.name = trimmedLine.split('Name:')[1]?.trim();
} else if (trimmedLine.startsWith('isIntegrated:')) {
const value = trimmedLine.split('isIntegrated:')[1]?.trim();
currentDevice.isIntegrated = value === '1';
}
return true;
}
return false;
}
function parseRocmInfoDevice(
line: string,
lines: string[],
index: number,
vulkanInfo: { allGPUs: GPUDevice[] }
) {
const name = line.split('Marketing Name:')[1]?.trim();
if (!name) return null;
const deviceType = findDeviceType(lines, index);
if (deviceType === 'CPU') return null;
const isIntegrated = determineIfIntegrated(name, vulkanInfo);
return createDevice(name, isIntegrated);
}
const createDevice = (name: string, isIntegrated: boolean) => ({
name: isIntegrated ? name : formatDeviceName(name),
isIntegrated,
});
function findDeviceType(lines: string[], startIndex: number) {
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, startIndex - searchRangeLines);
const searchEndIndex = Math.min(lines.length, startIndex + searchRangeLines);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
return lines[searchIndex].split('Device Type:')[1]?.trim() || '';
}
}
return '';
}
function determineIfIntegrated(
name: string,
vulkanInfo: { allGPUs: GPUDevice[] }
): boolean {
try {
const matchingGPU = vulkanInfo.allGPUs.find(
(gpu) => gpu.name.includes(name) || name.includes(gpu.name)
);
return matchingGPU ? matchingGPU.isIntegrated : false;
} catch {
return false;
}
}
async function detectCLBlast() {
try {
const { stdout } = await execa('clinfo', [], COMMON_EXEC_OPTIONS);

View file

@ -15,6 +15,7 @@ import { pathExists } from '@/utils/node/fs';
import { stripAssetExtensions } from '@/utils/version';
import { getLauncherPath } from '@/utils/node/path';
import type { DownloadReleaseOptions, GitHubAsset } from '@/types/electron';
import { clearVersionCache } from './version';
async function removeDirectoryWithRetry(
dirPath: string,
@ -174,6 +175,8 @@ export async function downloadRelease(
unpackedDirPath
);
clearVersionCache(launcherPath);
if (options.oldVersionPath && options.isUpdate) {
const oldInstallDir = join(options.oldVersionPath, '..');

View file

@ -13,6 +13,19 @@ import { logError } from '@/utils/node/logging';
import { getLauncherPath } from '@/utils/node/path';
import type { InstalledVersion } from '@/types/electron';
const versionCache = new Map<
string,
{ version: string; actualVersion?: string } | null
>();
export function clearVersionCache(path?: string) {
if (path) {
versionCache.delete(path);
} else {
versionCache.clear();
}
}
export async function getInstalledVersions() {
try {
const installDir = getInstallDir();
@ -129,12 +142,48 @@ export async function setCurrentVersion(binaryPath: string) {
return false;
}
export async function deleteRelease(binaryPath: string) {
try {
if (!(await pathExists(binaryPath))) {
return { success: false, error: 'Release not found' };
}
const currentBinaryPath = getCurrentKoboldBinary();
if (currentBinaryPath === binaryPath) {
return {
success: false,
error: 'Cannot delete the currently active release',
};
}
const releaseDir = binaryPath.split(/[/\\]/).slice(0, -1).join('/');
if (await pathExists(releaseDir)) {
const { rm } = await import('fs/promises');
await rm(releaseDir, { recursive: true, force: true });
clearVersionCache(binaryPath);
sendToRenderer('versions-updated');
return { success: true };
}
return { success: false, error: 'Release directory not found' };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export async function getVersionFromBinary(launcherPath: string) {
try {
if (!(await pathExists(launcherPath))) {
return null;
}
if (versionCache.has(launcherPath)) {
return versionCache.get(launcherPath);
}
let folderVersion: string | null = null;
let actualVersion: string | null = null;
@ -155,37 +204,31 @@ export async function getVersionFromBinary(launcherPath: string) {
});
const allOutput = (result.stdout + result.stderr).trim();
const lines = allOutput.split('\n').filter((line) => line.trim());
if (/^\d+\.\d+/.test(allOutput)) {
const versionParts = allOutput.split(/\s+/)[0];
if (versionParts && /^\d+\.\d+/.test(versionParts)) {
actualVersion = versionParts;
}
}
if (!actualVersion) {
const lines = allOutput.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (/^\d+\.\d+/.test(trimmedLine)) {
const versionPart = trimmedLine.split(/\s+/)[0];
if (versionPart) {
actualVersion = versionPart;
break;
}
}
if (lines.length > 0) {
const lastLine = lines[lines.length - 1].trim();
const versionMatch = lastLine.match(
/^(\d+\.\d+(?:\.\d+)?(?:\.[a-zA-Z0-9]+)*(?:-[a-zA-Z0-9]+)*)$/
);
if (versionMatch) {
actualVersion = versionMatch[1];
}
}
} catch {}
return {
const result = {
version: folderVersion || actualVersion || 'unknown',
actualVersion:
folderVersion && actualVersion && folderVersion !== actualVersion
? actualVersion
: undefined,
};
versionCache.set(launcherPath, result);
return result;
} catch {
versionCache.set(launcherPath, null);
return null;
}
}

View file

@ -36,6 +36,8 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset, options) =>
ipcRenderer.invoke('kobold:downloadRelease', asset, options),
deleteRelease: (binaryPath) =>
ipcRenderer.invoke('kobold:deleteRelease', binaryPath),
launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
saveConfigFile: (configName, configData) =>

View file

@ -124,6 +124,9 @@ export interface KoboldAPI {
asset: GitHubAsset,
options: DownloadReleaseOptions
) => Promise<void>;
deleteRelease: (
binaryPath: string
) => Promise<{ success: boolean; error?: string }>;
launchKoboldCpp: (
args?: string[]
) => Promise<{ success: boolean; pid?: number; error?: string }>;

View file

@ -13,8 +13,8 @@ export interface SystemMemoryInfo {
}
export interface GPUDevice {
readonly name: string;
readonly isIntegrated: boolean;
name: string;
isIntegrated: boolean;
}
export interface GPUCapabilities {
@ -52,7 +52,7 @@ export interface HardwareDetectionResult {
export interface HardwareInfo {
cpu: CPUCapabilities;
gpu: BasicGPUInfo;
gpuCapabilities?: GPUCapabilities;
gpuMemory?: GPUMemoryInfo[];
systemMemory?: SystemMemoryInfo;
gpuCapabilities: GPUCapabilities;
gpuMemory: GPUMemoryInfo[];
systemMemory: SystemMemoryInfo;
}

View file

@ -4,7 +4,7 @@ import { formatDeviceName } from '@/utils/format';
let vulkanInfoCache: {
allGPUs: {
deviceName: string;
name: string;
driverInfo?: string;
apiVersion?: string;
hasAMD: boolean;
@ -27,7 +27,7 @@ export async function getVulkanInfo() {
});
const allGPUs: {
deviceName: string;
name: string;
driverInfo?: string;
apiVersion?: string;
hasAMD: boolean;
@ -62,7 +62,7 @@ export async function getVulkanInfo() {
'PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU'
);
currentGPU = {
deviceName: '',
name: '',
hasAMD: false,
hasNVIDIA: false,
isIntegrated,
@ -77,7 +77,7 @@ export async function getVulkanInfo() {
if (parts.length >= 2) {
const name = parts[1]?.trim();
if (name) {
currentGPU.deviceName = name;
currentGPU.name = name;
currentGPU.hasAMD =
name.toLowerCase().includes('amd') ||
name.toLowerCase().includes('radeon');
@ -107,7 +107,7 @@ export async function getVulkanInfo() {
}
}
} else if (foundGPU && currentGPU && line.includes('GPU')) {
if (currentGPU.deviceName) {
if (currentGPU.name) {
allGPUs.push(currentGPU);
}
foundGPU = false;
@ -115,7 +115,7 @@ export async function getVulkanInfo() {
}
}
if (foundGPU && currentGPU && currentGPU.deviceName) {
if (foundGPU && currentGPU && currentGPU.name) {
allGPUs.push(currentGPU);
}
}
@ -143,7 +143,7 @@ export async function detectGPUViaVulkan() {
const gpuInfo: string[] = [];
for (const gpu of vulkanInfo.allGPUs.filter((g) => !g.isIntegrated)) {
gpuInfo.push(formatDeviceName(gpu.deviceName));
gpuInfo.push(formatDeviceName(gpu.name));
if (gpu.hasAMD) {
hasAMD = true;

View file

@ -1,21 +1,7 @@
import type { SystemVersionInfo } from '@/types/electron';
import type {
CPUCapabilities,
GPUCapabilities,
BasicGPUInfo,
GPUMemoryInfo,
SystemMemoryInfo,
} from '@/types/hardware';
import { PRODUCT_NAME } from '@/constants';
import type { InfoItem } from '@/components/InfoCard';
export interface HardwareInfo {
cpu: CPUCapabilities;
gpu: BasicGPUInfo;
gpuCapabilities: GPUCapabilities;
gpuMemory: GPUMemoryInfo[];
systemMemory: SystemMemoryInfo;
}
import type { HardwareInfo } from '@/types/hardware';
export const createSoftwareItems = (versionInfo: SystemVersionInfo) => [
{