unpack the binary before working with it, bug fixes, 2 more advanced configuration items

This commit is contained in:
Egor 2025-08-15 02:29:34 -07:00
parent 01058a8d02
commit 21b6f87a53
21 changed files with 964 additions and 443 deletions

View file

@ -8,6 +8,7 @@ A koboldcpp manager.
- download and keep up-to-date your [koboldcpp](https://github.com/LostRuins/koboldcpp/releases) binary
- better surface the ROCm-specific builds of koboldcpp from YellowRoseCx and from [koboldai.org](https://koboldai.org/cpplinuxrocm)
- manage the koboldcpp binary to prevent it from running in the background indefinitely
- automatically unpack all downloaded koboldcpp binaries for significantly faster operations
### Prerequisites

View file

@ -22,10 +22,10 @@
"bundler",
"bundling",
"can",
"clblast",
"clinfo",
"classList",
"className",
"clblast",
"clinfo",
"cloneNode",
"codeinterface",
"componentDidCatch",
@ -60,11 +60,11 @@
"filenames",
"filepath",
"filepaths",
"flashattention",
"Flashattention",
"flexbox",
"flexdir",
"flexwrap",
"flashattention",
"Flashattention",
"fontsize",
"fontweight",
"forwardRef",
@ -118,11 +118,12 @@
"minified",
"minify",
"moz",
"multiuser",
"multiplayer",
"multiuser",
"namespace",
"namespaces",
"newpackagename",
"noavx",
"nocertify",
"nocuda",
"nodeIntegration",
@ -150,6 +151,8 @@
"preload",
"prettierrc",
"preventDefault",
"PYTHONDONTWRITEBYTECODE",
"PYTHONUNBUFFERED",
"querySelector",
"querySelectorAll",
"radeon",
@ -208,14 +211,14 @@
"url",
"urls",
"useCallback",
"useclblast",
"useContext",
"usecuda",
"useEffect",
"useMemo",
"useReducer",
"useRef",
"useState",
"useclblast",
"usecuda",
"usevulkan",
"util",
"utils",
@ -231,8 +234,8 @@
"webkit",
"webp",
"webpack",
"webSecurity",
"websearch",
"webSecurity",
"whitespace",
"wmic",
"woff",

8
package-lock.json generated
View file

@ -29,7 +29,7 @@
"@vitejs/plugin-react": "^5.0.0",
"cross-env": "^10.0.0",
"cspell": "^9.2.0",
"electron": "^37.2.6",
"electron": "^37.3.0",
"electron-builder": "^26.0.12",
"electron-vite": "^4.0.0",
"eslint": "^9.33.0",
@ -5607,9 +5607,9 @@
}
},
"node_modules/electron": {
"version": "37.2.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.2.6.tgz",
"integrity": "sha512-Ns6xyxE+hIK5UlujtRlw7w4e2Ju/ImCWXf1Q/PoOhc0N3/6SN6YW7+ujCarsHbxWnolbW+1RlkHtdklUJpjbPA==",
"version": "37.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.3.0.tgz",
"integrity": "sha512-cPOPUD26DwCh+PZ9q+gMyVBvdBN75SnekI6u5zcOeoLVIXQpzrCm1ewz9BcrkWkVW7oOtfQAEo1G1SffvXrSSw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",

View file

@ -57,7 +57,7 @@
"@vitejs/plugin-react": "^5.0.0",
"cross-env": "^10.0.0",
"cspell": "^9.2.0",
"electron": "^37.2.6",
"electron": "^37.3.0",
"electron-builder": "^26.0.12",
"electron-vite": "^4.0.0",
"eslint": "^9.33.0",

View file

@ -11,6 +11,7 @@ import {
Text,
Button,
Select,
Badge,
useMantineColorScheme,
} from '@mantine/core';
import { Settings, ArrowLeft } from 'lucide-react';
@ -20,7 +21,7 @@ import { InterfaceScreen } from '@/components/screens/InterfaceScreen';
import { UpdateDialog } from '@/components/UpdateDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { ScreenTransition } from '@/components/ScreenTransition';
import type { UpdateInfo } from '@/types';
import type { UpdateInfo, InstalledVersion } from '@/types';
type Screen = 'download' | 'launch' | 'interface';
@ -33,24 +34,64 @@ export const App = () => {
const [activeInterfaceTab, setActiveInterfaceTab] = useState<string | null>(
'terminal'
);
const [currentVersion, setCurrentVersion] = useState<InstalledVersion | null>(
null
);
const [installedVersions, setInstalledVersions] = useState<
InstalledVersion[]
>([]);
const { colorScheme } = useMantineColorScheme();
const getDisplayNameFromPath = (
installedVersion: InstalledVersion
): string => {
const pathParts = installedVersion.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex(
(part) =>
part === 'koboldcpp-launcher' ||
part === 'koboldcpp.exe' ||
part === 'koboldcpp'
);
if (launcherIndex > 0) {
return pathParts[launcherIndex - 1];
}
return installedVersion.filename;
};
useEffect(() => {
const checkInstallation = async () => {
try {
const startTime = Date.now();
const installedVersions =
await window.electronAPI.kobold.getInstalledVersions(false);
const [versions, currentBinaryPath] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get(
'currentKoboldBinary'
) as Promise<string>,
]);
const elapsed = Date.now() - startTime;
const minDelay = 500;
if (elapsed < minDelay) {
await new Promise((resolve) =>
setTimeout(resolve, minDelay - elapsed)
);
setInstalledVersions(versions);
if (versions.length > 0) {
let current = null;
if (currentBinaryPath) {
current = versions.find((v) => v.path === currentBinaryPath);
}
if (!current) {
current = versions[0];
if (current) {
await window.electronAPI.config.set(
'currentKoboldBinary',
current.path
);
}
}
setCurrentVersion(current);
setCurrentScreen('launch');
} else {
setCurrentScreen('download');
}
setCurrentScreen(installedVersions.length > 0 ? 'launch' : 'download');
setHasInitialized(true);
} catch (error) {
console.error('Error checking installation:', error);
@ -70,18 +111,73 @@ export const App = () => {
checkInstallation();
});
const cleanupVersionsListener = window.electronAPI.kobold.onVersionsUpdated(
() => {
checkInstallation();
}
);
return () => {
window.electronAPI.kobold.removeAllListeners('update-available');
cleanupInstallDirListener();
cleanupVersionsListener();
};
}, []);
const handleDownloadComplete = () => {
const handleDownloadComplete = async () => {
try {
const [versions, currentBinaryPath] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get('currentKoboldBinary') as Promise<string>,
]);
setInstalledVersions(versions);
if (versions.length > 0) {
let current = null;
if (currentBinaryPath) {
current = versions.find((v) => v.path === currentBinaryPath);
}
if (!current) {
current = versions[0];
if (current) {
await window.electronAPI.config.set(
'currentKoboldBinary',
current.path
);
}
}
setCurrentVersion(current);
}
} catch (error) {
console.error('Error refreshing versions after download:', error);
}
setTimeout(() => {
setCurrentScreen('launch');
}, 100);
};
const handleVersionChange = async (versionPath: string | null) => {
if (!versionPath) return;
try {
const success =
await window.electronAPI.kobold.setCurrentVersion(versionPath);
if (success) {
await window.electronAPI.config.set('currentKoboldBinary', versionPath);
const newCurrent = installedVersions.find(
(v) => v.path === versionPath
);
if (newCurrent) {
setCurrentVersion(newCurrent);
}
}
} catch (error) {
console.error('Failed to change version:', error);
}
};
const handleLaunch = () => {
setCurrentScreen('interface');
};
@ -170,6 +266,49 @@ export const App = () => {
/>
)}
{currentScreen === 'launch' && currentVersion && (
<Group gap="xs" align="center">
<Select
value={currentVersion.path}
onChange={handleVersionChange}
data={installedVersions.map((version) => ({
value: version.path,
label: getDisplayNameFromPath(version),
}))}
placeholder="Select version"
variant="unstyled"
styles={{
input: {
minWidth: '225px',
textAlign: 'center',
border: `1px solid var(--mantine-color-${colorScheme === 'dark' ? 'dark-4' : 'gray-4'})`,
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor:
colorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)',
fontWeight: 500,
fontSize: '14px',
padding: '6px 12px',
transition: 'all 200ms ease',
'&:hover': {
backgroundColor:
colorScheme === 'dark'
? 'var(--mantine-color-dark-5)'
: 'var(--mantine-color-gray-1)',
},
},
dropdown: {
minWidth: '200px',
},
}}
/>
<Badge variant="light" color="blue" size="sm">
v{currentVersion.version}
</Badge>
</Group>
)}
<div
style={{
minWidth: '100px',
@ -206,7 +345,7 @@ export const App = () => {
<Stack align="center" gap="lg">
<Loader size="xl" type="dots" />
<Text c="dimmed" size="lg">
Initializing...
Loading...
</Text>
</Stack>
</Center>
@ -249,6 +388,7 @@ export const App = () => {
<SettingsModal
opened={settingsOpened}
onClose={() => setSettingsOpened(false)}
currentScreen={currentScreen || undefined}
/>
</AppShell>
</>

View file

@ -0,0 +1,143 @@
import {
Card,
Stack,
Group,
Text,
Badge,
Button,
Loader,
Progress,
rem,
} from '@mantine/core';
import { Download } from 'lucide-react';
import { MouseEvent } from 'react';
interface DownloadCardProps {
name: string;
size: string;
version?: string;
description?: string;
isRecommended?: boolean;
isCurrent?: boolean;
isInstalled?: boolean;
isDownloading?: boolean;
downloadProgress?: number;
disabled?: boolean;
onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
onMakeCurrent?: () => void;
}
export const DownloadCard = ({
name,
size,
version,
description,
isRecommended = false,
isCurrent = false,
isInstalled = false,
isDownloading = false,
downloadProgress = 0,
disabled = false,
onDownload,
onMakeCurrent,
}: DownloadCardProps) => {
const renderActionButton = () => {
if (!isInstalled && onDownload) {
return (
<Button
variant="filled"
size="xs"
onClick={onDownload}
loading={isDownloading}
disabled={disabled}
leftSection={
isDownloading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
}
>
{isDownloading ? 'Downloading...' : 'Download'}
</Button>
);
}
if (isInstalled && !isCurrent && onMakeCurrent) {
return (
<Button variant="light" size="xs" onClick={onMakeCurrent}>
Make Current
</Button>
);
}
return null;
};
return (
<Card
withBorder
radius="sm"
padding="sm"
bd={isCurrent ? '2px solid var(--mantine-color-blue-filled)' : undefined}
bg={isCurrent ? 'var(--mantine-color-blue-light)' : undefined}
>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
<Group gap="xs" align="center" mb="xs">
<Text fw={500} size="sm">
{name}
</Text>
{isCurrent && (
<Badge variant="light" color="blue" size="sm">
Current
</Badge>
)}
{isRecommended && (
<Badge variant="light" color="blue" size="sm">
Recommended
</Badge>
)}
</Group>
{description && (
<Text size="xs" c="dimmed" mb="xs">
{description}
</Text>
)}
<Group gap="xs" align="center">
{version && (
<Text size="xs" c="dimmed">
Version {version}
</Text>
)}
{size && (
<>
<Text size="xs" c="dimmed">
</Text>
<Badge variant="light" color="gray" size="xs">
{size}
</Badge>
</>
)}
</Group>
</div>
{renderActionButton()}
</Group>
{isDownloading && downloadProgress !== undefined && (
<Stack gap="xs" mt="sm">
<Progress
value={Math.min(downloadProgress, 100)}
color="blue"
radius="xl"
/>
<Text size="xs" c="dimmed" ta="center">
{Math.min(downloadProgress, 100).toFixed(1)}% complete
</Text>
</Stack>
)}
</Card>
);
};

View file

@ -1,92 +0,0 @@
import {
Card,
Stack,
Group,
Text,
Badge,
Button,
Loader,
Progress,
} from '@mantine/core';
import { Download } from 'lucide-react';
import { MouseEvent } from 'react';
interface DownloadOptionCardProps {
name: string;
description: string;
size: string;
isSelected: boolean;
isRecommended: boolean;
isDownloading: boolean;
downloadProgress?: number;
onClick: () => void;
onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
}
export const DownloadOptionCard = ({
name,
description,
size,
isSelected,
isRecommended,
isDownloading,
downloadProgress = 0,
onClick,
onDownload,
}: DownloadOptionCardProps) => (
<Card
withBorder
radius="md"
style={{
cursor: 'pointer',
}}
bd={isSelected ? '2px solid var(--mantine-color-blue-filled)' : undefined}
bg={isSelected ? 'var(--mantine-color-blue-light)' : undefined}
onClick={onClick}
>
<Stack gap="xs" style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Group gap="xs">
<Text fw={500}>{name}</Text>
{isRecommended && (
<Badge variant="light" color="blue" size="sm">
Recommended
</Badge>
)}
</Group>
<Badge variant="light" color="gray" size="sm">
{size}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{description}
</Text>
{isSelected && (
<Stack gap="sm" pt="sm">
<Button
onClick={onDownload}
disabled={isDownloading}
leftSection={
isDownloading ? <Loader size="1rem" /> : <Download size="1rem" />
}
size="sm"
radius="md"
fullWidth
>
{isDownloading ? 'Downloading...' : 'Download'}
</Button>
{isDownloading && (
<Stack gap="xs">
<Progress value={downloadProgress} color="blue" radius="xl" />
<Text size="xs" c="dimmed" ta="center">
{downloadProgress.toFixed(1)}% complete
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
</Card>
);

View file

@ -8,11 +8,24 @@ import { AppearanceTab } from './settings/AppearanceTab';
interface SettingsModalProps {
opened: boolean;
onClose: () => void;
currentScreen?: 'download' | 'launch' | 'interface';
}
export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
export const SettingsModal = ({
opened,
onClose,
currentScreen,
}: SettingsModalProps) => {
const [activeTab, setActiveTab] = useState('general');
const showVersionsTab = currentScreen !== 'download';
useEffect(() => {
if (!showVersionsTab && activeTab === 'versions') {
setActiveTab('general');
}
}, [showVersionsTab, activeTab]);
useEffect(() => {
if (opened) {
const originalOverflow = document.body.style.overflow;
@ -39,7 +52,7 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<Text fw={500}>Settings</Text>
</Group>
}
size="lg"
size="xl"
centered
lockScroll={false}
styles={{
@ -81,14 +94,16 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
>
General
</Tabs.Tab>
<Tabs.Tab
value="versions"
leftSection={
<GitBranch style={{ width: rem(16), height: rem(16) }} />
}
>
Versions
</Tabs.Tab>
{showVersionsTab && (
<Tabs.Tab
value="versions"
leftSection={
<GitBranch style={{ width: rem(16), height: rem(16) }} />
}
>
Versions
</Tabs.Tab>
)}
<Tabs.Tab
value="appearance"
leftSection={
@ -103,9 +118,11 @@ export const SettingsModal = ({ opened, onClose }: SettingsModalProps) => {
<GeneralTab />
</Tabs.Panel>
<Tabs.Panel value="versions">
<VersionsTab />
</Tabs.Panel>
{showVersionsTab && (
<Tabs.Panel value="versions">
<VersionsTab />
</Tabs.Panel>
)}
<Tabs.Panel value="appearance">
<AppearanceTab />

View file

@ -6,10 +6,14 @@ interface AdvancedTabProps {
serverOnly: boolean;
noshift: boolean;
flashattention: boolean;
noavx2: boolean;
failsafe: boolean;
onAdditionalArgumentsChange: (args: string) => void;
onServerOnlyChange: (serverOnly: boolean) => void;
onNoshiftChange: (noshift: boolean) => void;
onFlashattentionChange: (flashattention: boolean) => void;
onNoavx2Change: (noavx2: boolean) => void;
onFailsafeChange: (failsafe: boolean) => void;
}
export const AdvancedTab = ({
@ -17,10 +21,14 @@ export const AdvancedTab = ({
serverOnly,
noshift,
flashattention,
noavx2,
failsafe,
onAdditionalArgumentsChange,
onServerOnlyChange,
onNoshiftChange,
onFlashattentionChange,
onNoavx2Change,
onFailsafeChange,
}: AdvancedTabProps) => (
<Stack gap="lg">
<div>
@ -40,38 +48,72 @@ export const AdvancedTab = ({
</div>
<div>
<Group gap="xs" align="center">
<Checkbox
checked={serverOnly}
onChange={(event) => onServerOnlyChange(event.currentTarget.checked)}
label="Server-only mode"
/>
<InfoTooltip label="In server-only mode, the KoboldAI Lite web UI won't be displayed. Use this if you'll be using your own frontend." />
</Group>
</div>
<Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={serverOnly}
onChange={(event) =>
onServerOnlyChange(event.currentTarget.checked)
}
label="Server-only mode"
/>
<InfoTooltip label="In server-only mode, the KoboldAI Lite web UI won't be displayed. Use this if you'll be using your own frontend." />
</Group>
</div>
<div>
<Group gap="xs" align="center">
<Checkbox
checked={!noshift}
onChange={(event) => onNoshiftChange(!event.currentTarget.checked)}
label="Use ContextShift"
/>
<InfoTooltip label="Use Context Shifting to reduce reprocessing. Recommended" />
</Group>
</div>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={!noshift}
onChange={(event) =>
onNoshiftChange(!event.currentTarget.checked)
}
label="Use ContextShift"
/>
<InfoTooltip label="Use Context Shifting to reduce reprocessing. Recommended" />
</Group>
</div>
</Group>
<div>
<Group gap="xs" align="center">
<Checkbox
checked={flashattention}
onChange={(event) =>
onFlashattentionChange(event.currentTarget.checked)
}
label="Use FlashAttention"
/>
<InfoTooltip label="Enable flash attention for GGUF models." />
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={flashattention}
onChange={(event) =>
onFlashattentionChange(event.currentTarget.checked)
}
label="Use FlashAttention"
/>
<InfoTooltip label="Enable flash attention for GGUF models." />
</Group>
</div>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={noavx2}
onChange={(event) =>
onNoavx2Change(event.currentTarget.checked)
}
label="Disable AVX2"
/>
<InfoTooltip label="Do not use AVX2 instructions, a slower compatibility mode for older devices." />
</Group>
</div>
</Group>
<Group gap="xs" align="center">
<Checkbox
checked={failsafe}
onChange={(event) => onFailsafeChange(event.currentTarget.checked)}
label="Failsafe"
/>
<InfoTooltip label="Use failsafe mode, extremely slow CPU only compatibility mode that should work on all devices. Can be combined with useclblast if your device supports OpenCL." />
</Group>
</Stack>
</div>
</Stack>
);

View file

@ -9,16 +9,13 @@ import {
Select,
Badge,
Card,
useMantineColorScheme,
} from '@mantine/core';
import { useState, useEffect } from 'react';
import { File, Search } from 'lucide-react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { getInputValidationState } from '@/utils/validation';
import {
isOldPcBinary,
isNoCudaBinary,
isRocmBinary,
} from '@/utils/binaryUtils';
import { isNoCudaBinary, isRocmBinary } from '@/utils/binaryUtils';
interface GeneralTabProps {
modelPath: string;
@ -47,6 +44,9 @@ export const GeneralTab = ({
onContextSizeChange,
onBackendChange,
}: GeneralTabProps) => {
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === 'dark';
const [availableBackends, setAvailableBackends] = useState<
Array<{ value: string; label: string; devices?: string[] }>
>([]);
@ -57,9 +57,9 @@ export const GeneralTab = ({
setIsLoadingBackends(true);
try {
const [currentVersion, cpuCapabilities, gpuCapabilities] =
const [currentBinaryInfo, _cpuCapabilities, gpuCapabilities] =
await Promise.all([
window.electronAPI.kobold.getCurrentVersion(),
window.electronAPI.kobold.getCurrentBinaryInfo(),
window.electronAPI.kobold.detectCPU(),
window.electronAPI.kobold.detectGPUCapabilities(),
]);
@ -70,12 +70,10 @@ export const GeneralTab = ({
devices?: string[];
}> = [];
if (currentVersion?.filename) {
const filename = currentVersion.filename;
if (currentBinaryInfo?.filename) {
const filename = currentBinaryInfo.filename;
if (!isOldPcBinary(filename) && cpuCapabilities.avx2) {
backends.push({ value: 'cpu', label: 'CPU' });
}
backends.push({ value: 'cpu', label: 'CPU' });
if (!isNoCudaBinary(filename) && gpuCapabilities.cuda.supported) {
backends.push({
@ -110,8 +108,6 @@ export const GeneralTab = ({
}
}
backends.push({ value: 'failsafe', label: 'Failsafe' });
setAvailableBackends(backends);
if (
@ -231,8 +227,8 @@ export const GeneralTab = ({
</Group>
)}
{backend === 'cpu' && (
<Card withBorder p="sm" mt="xs" bg="blue.0">
<Text size="sm" c="blue.8">
<Card withBorder p="sm" mt="xs" bg={isDark ? 'blue.9' : 'blue.0'}>
<Text size="sm" c={isDark ? 'blue.2' : 'blue.8'}>
<Text component="span" fw={600}>
Performance Note:
</Text>{' '}

View file

@ -75,7 +75,7 @@ export const NetworkTab = ({
<div>
<Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '250px' }}>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={multiuser}
@ -88,7 +88,7 @@ export const NetworkTab = ({
</Group>
</div>
<div style={{ minWidth: '250px' }}>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={multiplayer}
@ -103,7 +103,7 @@ export const NetworkTab = ({
</Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<div style={{ minWidth: '250px' }}>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={remotetunnel}
@ -116,7 +116,7 @@ export const NetworkTab = ({
</Group>
</div>
<div style={{ minWidth: '250px' }}>
<div style={{ minWidth: '200px' }}>
<Group gap="xs" align="center">
<Checkbox
checked={nocertify}

View file

@ -9,16 +9,16 @@ import {
Badge,
Tooltip,
} from '@mantine/core';
import { DownloadOptionCard } from '@/components/DownloadOptionCard';
import { DownloadCard } from '@/components/DownloadCard';
import {
getPlatformDisplayName,
filterAssetsByPlatform,
} from '@/utils/platform';
import { formatFileSize } from '@/utils/fileSize';
import {
getAssetDescription,
isAssetRecommended,
sortAssetsByRecommendation,
getAssetDescription,
} from '@/utils/assets';
import { ROCM } from '@/constants';
import type { GitHubAsset, GitHubRelease } from '@/types';
@ -32,8 +32,6 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
null
);
const [filteredAssets, setFilteredAssets] = useState<GitHubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GitHubAsset | null>(null);
const [selectedROCm, setSelectedROCm] = useState<boolean>(false);
const [userPlatform, setUserPlatform] = useState<string>('');
const [hasAMDGPU, setHasAMDGPU] = useState<boolean>(false);
const [hasROCm, setHasROCm] = useState<boolean>(false);
@ -43,6 +41,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
const [downloadingType, setDownloadingType] = useState<
'asset' | 'rocm' | null
>(null);
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
@ -112,54 +111,51 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDownload = async (type: 'asset' | 'rocm' = 'asset') => {
if (type === 'asset' && !selectedAsset) return;
const handleDownload = useCallback(
async (type: 'asset' | 'rocm', asset?: GitHubAsset) => {
if (type === 'asset' && !asset) return;
try {
setDownloading(true);
setDownloadProgress(0);
setDownloadingType(type);
const result =
type === 'rocm'
? await window.electronAPI.kobold.downloadROCm()
: await window.electronAPI.kobold.downloadRelease(selectedAsset!);
if (result.success) {
onDownloadComplete();
} else {
console.error(
'Download Failed',
result.error || `${type === 'rocm' ? 'ROCm' : ''} Download failed`
);
}
} catch (err) {
console.error(`${type === 'rocm' ? 'ROCm' : ''} Download error:`, err);
} finally {
setDownloading(false);
setDownloadingAsset(type === 'asset' ? asset!.name : null);
setDownloadProgress(0);
setDownloadingType(null);
}
};
try {
await (type === 'rocm'
? window.electronAPI.kobold.downloadROCm()
: await window.electronAPI.kobold.downloadRelease(asset!));
onDownloadComplete();
setTimeout(() => {
setDownloading(false);
setDownloadingType(null);
setDownloadingAsset(null);
setDownloadProgress(0);
}, 200);
} catch (error) {
console.error(`Failed to download ${type}:`, error);
setDownloading(false);
setDownloadingType(null);
setDownloadingAsset(null);
setDownloadProgress(0);
}
},
[onDownloadComplete]
);
const renderROCmCard = () => {
if (!rocmDownload) return null;
return (
<DownloadOptionCard
<DownloadCard
name={ROCM.BINARY_NAME}
description={getAssetDescription(ROCM.BINARY_NAME)}
size={formatFileSize(ROCM.SIZE_BYTES)}
isSelected={selectedROCm}
description={getAssetDescription(ROCM.BINARY_NAME)}
isRecommended={isAssetRecommended(ROCM.BINARY_NAME, hasAMDGPU)}
isDownloading={downloading && downloadingType === 'rocm'}
downloadProgress={downloadingType === 'rocm' ? downloadProgress : 0}
onClick={() => {
if (!selectedROCm) {
setSelectedROCm(true);
setSelectedAsset(null);
}
}}
disabled={downloading && downloadingType !== 'rocm'}
onDownload={(e) => {
e.stopPropagation();
handleDownload('rocm');
@ -178,7 +174,7 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
{loading ? (
<Stack align="center" gap="md" py="xl">
<Loader color="blue" />
<Text c="dimmed">Loading latest release...</Text>
<Text c="dimmed">Preparing download options...</Text>
</Stack>
) : (
<>
@ -253,12 +249,11 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
filteredAssets,
hasAMDGPU
).map((asset) => (
<DownloadOptionCard
<DownloadCard
key={asset.name}
name={asset.name}
description={getAssetDescription(asset.name)}
size={formatFileSize(asset.size)}
isSelected={selectedAsset?.name === asset.name}
description={getAssetDescription(asset.name)}
isRecommended={isAssetRecommended(
asset.name,
hasAMDGPU
@ -266,24 +261,21 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
isDownloading={
downloading &&
downloadingType === 'asset' &&
selectedAsset?.name === asset.name
downloadingAsset === asset.name
}
downloadProgress={
downloading &&
downloadingType === 'asset' &&
selectedAsset?.name === asset.name
downloadingAsset === asset.name
? downloadProgress
: 0
}
onClick={() => {
if (selectedAsset?.name !== asset.name) {
setSelectedAsset(asset);
setSelectedROCm(false);
}
}}
disabled={
downloading && downloadingAsset !== asset.name
}
onDownload={(e) => {
e.stopPropagation();
handleDownload();
handleDownload('asset', asset);
}}
/>
))}

View file

@ -46,6 +46,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
websearch,
noshift,
flashattention,
noavx2,
failsafe,
backend,
parseAndApplyConfigFile,
loadSavedSettings,
@ -66,6 +68,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
handleWebsearchChange,
handleNoshiftChange,
handleFlashattentionChange,
handleNoavx2Change,
handleFailsafeChange,
handleBackendChange,
} = useLaunchConfig();
@ -155,6 +159,16 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
setHasUnsavedChanges(true);
};
const handleNoavx2ChangeWithTracking = (noavx2: boolean) => {
handleNoavx2Change(noavx2);
setHasUnsavedChanges(true);
};
const handleFailsafeChangeWithTracking = (failsafe: boolean) => {
handleFailsafeChange(failsafe);
setHasUnsavedChanges(true);
};
const handleMultiuserChangeWithTracking = (multiuser: boolean) => {
handleMultiuserChange(multiuser);
setHasUnsavedChanges(true);
@ -199,7 +213,6 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
return cleanup;
}, [loadConfigFiles]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const handleLaunch = async () => {
if (isLaunching || !modelPath) {
return;
@ -271,8 +284,6 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
args.push('--usevulkan');
} else if (backend === 'clblast') {
args.push('--useclblast');
} else if (backend === 'failsafe') {
args.push('--failsafe');
}
}
@ -375,6 +386,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
serverOnly={serverOnly}
noshift={noshift}
flashattention={flashattention}
noavx2={noavx2}
failsafe={failsafe}
onAdditionalArgumentsChange={
handleAdditionalArgumentsChangeWithTracking
}
@ -383,6 +396,8 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
onFlashattentionChange={
handleFlashattentionChangeWithTracking
}
onNoavx2Change={handleNoavx2ChangeWithTracking}
onFailsafeChange={handleFailsafeChangeWithTracking}
/>
</Tabs.Panel>

View file

@ -5,14 +5,14 @@ import {
Group,
Button,
Card,
Badge,
Progress,
Loader,
rem,
Center,
} from '@mantine/core';
import { RotateCcw, Download } from 'lucide-react';
import { RotateCcw } from 'lucide-react';
import { DownloadCard } from '@/components/DownloadCard';
import { isAssetCompatibleWithPlatform } from '@/utils/platform';
import { getAssetDescription } from '@/utils/assets';
import type {
InstalledVersion,
GitHubAsset,
@ -27,6 +27,7 @@ interface VersionInfo {
isCurrent: boolean;
downloadUrl?: string;
installedPath?: string;
isROCm?: boolean;
}
export const VersionsTab = () => {
@ -37,6 +38,12 @@ export const VersionsTab = () => {
null
);
const [availableAssets, setAvailableAssets] = useState<GitHubAsset[]>([]);
const [rocmDownload, setRocmDownload] = useState<{
name: string;
url: string;
size: number;
version?: string;
} | null>(null);
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null
);
@ -60,14 +67,28 @@ export const VersionsTab = () => {
setInstalledVersions(versions);
if (currentBinaryPath) {
const current = versions.find(
(v) =>
v.path === currentBinaryPath || v.filename === currentBinaryPath
if (currentBinaryPath && versions.length > 0) {
const current = versions.find((v) => v.path === currentBinaryPath);
if (current) {
setCurrentVersion(current);
} else {
setCurrentVersion(versions[0]);
await window.electronAPI.config.set(
'currentKoboldBinary',
versions[0].path
);
}
} else if (versions.length > 0) {
setCurrentVersion(versions[0]);
await window.electronAPI.config.set(
'currentKoboldBinary',
versions[0].path
);
setCurrentVersion(current || null);
} else {
setCurrentVersion(versions[0] || null);
setCurrentVersion(null);
if (currentBinaryPath) {
await window.electronAPI.config.set('currentKoboldBinary', '');
}
}
} catch (error) {
console.error('Failed to load installed versions:', error);
@ -81,7 +102,10 @@ export const VersionsTab = () => {
setLoadingRemote(true);
try {
const release = await window.electronAPI.kobold.getLatestRelease();
const [release, rocm] = await Promise.all([
window.electronAPI.kobold.getLatestRelease(),
window.electronAPI.kobold.getROCmDownload(),
]);
if (release) {
setLatestRelease(release);
@ -90,6 +114,8 @@ export const VersionsTab = () => {
);
setAvailableAssets(compatibleAssets);
}
setRocmDownload(rocm);
} catch (error) {
console.error('Failed to load remote versions:', error);
} finally {
@ -134,13 +160,32 @@ export const VersionsTab = () => {
};
}, [downloading]);
const getDisplayNameFromPath = (
installedVersion: InstalledVersion
): string => {
const pathParts = installedVersion.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex(
(part) =>
part === 'koboldcpp-launcher' ||
part === 'koboldcpp.exe' ||
part === 'koboldcpp'
);
if (launcherIndex > 0) {
return pathParts[launcherIndex - 1];
}
return installedVersion.filename;
};
const getAllVersions = (): VersionInfo[] => {
const versions: VersionInfo[] = [];
availableAssets.forEach((asset) => {
const installedVersion = installedVersions.find(
(v) => v.filename === asset.name
);
const installedVersion = installedVersions.find((v) => {
const displayName = getDisplayNameFromPath(v);
return displayName === asset.name;
});
const isCurrent = Boolean(
installedVersion &&
@ -154,31 +199,63 @@ export const VersionsTab = () => {
installedVersion?.version ||
latestRelease?.tag_name.replace(/^v/, '') ||
'unknown',
size: installedVersion?.size || asset.size,
size: installedVersion ? undefined : asset.size,
isInstalled: Boolean(installedVersion),
isCurrent,
downloadUrl: asset.browser_download_url,
installedPath: installedVersion?.path,
isROCm: false,
});
});
installedVersions.forEach((installed) => {
const existsInRemote = versions.some(
(v) => v.name === installed.filename
if (rocmDownload) {
const installedVersion = installedVersions.find((v) => {
const displayName = getDisplayNameFromPath(v);
return displayName === rocmDownload.name;
});
const isCurrent = Boolean(
installedVersion &&
currentVersion &&
currentVersion.path === installedVersion.path
);
const existsInVersions = versions.some(
(v) => v.name === rocmDownload.name
);
if (!existsInVersions) {
versions.push({
name: rocmDownload.name,
version:
installedVersion?.version || rocmDownload.version || 'unknown',
size: installedVersion ? undefined : rocmDownload.size,
isInstalled: Boolean(installedVersion),
isCurrent,
downloadUrl: rocmDownload.url,
installedPath: installedVersion?.path,
isROCm: true,
});
}
}
installedVersions.forEach((installed) => {
const displayName = getDisplayNameFromPath(installed);
const existsInRemote = versions.some((v) => v.name === displayName);
if (!existsInRemote) {
const isCurrent = Boolean(
currentVersion && currentVersion.path === installed.path
);
versions.push({
name: installed.filename,
name: displayName,
version: installed.version,
size: installed.size,
size: undefined,
isInstalled: true,
isCurrent,
installedPath: installed.path,
isROCm: false,
});
}
});
@ -186,12 +263,22 @@ export const VersionsTab = () => {
return versions;
};
const handleDownload = async (asset: GitHubAsset) => {
setDownloading(asset.name);
setDownloadProgress((prev) => ({ ...prev, [asset.name]: 0 }));
const handleDownload = async (version: VersionInfo) => {
setDownloading(version.name);
setDownloadProgress((prev) => ({ ...prev, [version.name]: 0 }));
try {
const result = await window.electronAPI.kobold.downloadRelease(asset);
let result;
if (version.isROCm) {
result = await window.electronAPI.kobold.downloadROCm();
} else {
const asset = availableAssets.find((a) => a.name === version.name);
if (!asset) {
throw new Error('Asset not found');
}
result = await window.electronAPI.kobold.downloadRelease(asset);
}
if (result.success) {
await loadInstalledVersions();
@ -202,7 +289,7 @@ export const VersionsTab = () => {
setDownloading(null);
setDownloadProgress((prev) => {
const newProgress = { ...prev };
delete newProgress[asset.name];
delete newProgress[version.name];
return newProgress;
});
}
@ -275,92 +362,37 @@ export const VersionsTab = () => {
<Stack gap="xs">
{getAllVersions().map((version, index) => {
const isDownloading = downloading === version.name;
const asset = availableAssets.find((a) => a.name === version.name);
return (
<Card
<DownloadCard
key={`${version.name}-${version.version}-${index}`}
withBorder
radius="sm"
padding="sm"
bd={
version.isCurrent
? '2px solid var(--mantine-color-blue-filled)'
: undefined
name={version.name}
size={
version.size
? `${(version.size / 1024 / 1024).toFixed(1)} MB`
: ''
}
bg={
version.isCurrent
? 'var(--mantine-color-blue-light)'
: undefined
}
>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
<Group gap="xs" align="center" mb="xs">
<Text fw={500} size="sm">
{version.name}
</Text>
{version.isCurrent && (
<Badge variant="light" color="blue" size="sm">
Current
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
Version {version.version}
{version.size && (
<> {(version.size / 1024 / 1024).toFixed(1)} MB</>
)}
</Text>
</div>
{!version.isInstalled && asset && (
<Button
variant="filled"
size="xs"
onClick={(e) => {
version={version.version}
description={getAssetDescription(version.name)}
isCurrent={version.isCurrent}
isInstalled={version.isInstalled}
isDownloading={isDownloading}
downloadProgress={downloadProgress[version.name]}
disabled={downloading !== null}
onDownload={
!version.isInstalled
? (e) => {
e.stopPropagation();
handleDownload(asset);
}}
loading={isDownloading}
disabled={downloading !== null}
leftSection={
isDownloading ? (
<Loader size="1rem" />
) : (
<Download style={{ width: rem(14), height: rem(14) }} />
)
handleDownload(version);
}
>
{isDownloading ? 'Downloading...' : 'Download'}
</Button>
)}
{version.isInstalled && !version.isCurrent && (
<Button
variant="light"
size="xs"
onClick={() => makeCurrent(version)}
>
Make Current
</Button>
)}
</Group>
{isDownloading &&
downloadProgress[version.name] !== undefined && (
<Stack gap="xs" mt="sm">
<Progress
value={downloadProgress[version.name]}
color="blue"
radius="xl"
/>
<Text size="xs" c="dimmed" ta="center">
{downloadProgress[version.name].toFixed(1)}% complete
</Text>
</Stack>
)}
</Card>
: undefined
}
onMakeCurrent={
version.isInstalled && !version.isCurrent
? () => makeCurrent(version)
: undefined
}
/>
);
})}

View file

@ -17,7 +17,9 @@ export const useLaunchConfig = () => {
const [websearch, setWebsearch] = useState<boolean>(false);
const [noshift, setNoshift] = useState<boolean>(false);
const [flashattention, setFlashattention] = useState<boolean>(false);
const [backend, setBackend] = useState<string>('');
const [noavx2, setNoavx2] = useState<boolean>(false);
const [failsafe, setFailsafe] = useState<boolean>(false);
const [backend, setBackend] = useState<string>('cpu');
// eslint-disable-next-line sonarjs/cognitive-complexity
const parseAndApplyConfigFile = useCallback(async (configPath: string) => {
@ -94,10 +96,33 @@ export const useLaunchConfig = () => {
setFlashattention(false);
}
if (typeof configData.backend === 'string') {
setBackend(configData.backend);
if (typeof configData.noavx2 === 'boolean') {
setNoavx2(configData.noavx2);
} else {
setBackend('');
setNoavx2(false);
}
if (typeof configData.failsafe === 'boolean') {
setFailsafe(configData.failsafe);
} else {
setFailsafe(false);
}
if (configData.usecuda !== null && configData.usecuda !== undefined) {
const gpuInfo = await window.electronAPI.kobold.detectGPU();
setBackend(gpuInfo.hasNVIDIA ? 'cuda' : 'rocm');
} else if (
configData.usevulkan !== null &&
configData.usevulkan !== undefined
) {
setBackend('vulkan');
} else if (
configData.useclblast !== null &&
configData.useclblast !== undefined
) {
setBackend('clblast');
} else {
setBackend('cpu');
}
} else {
setGpuLayers(0);
@ -111,7 +136,9 @@ export const useLaunchConfig = () => {
setWebsearch(false);
setNoshift(false);
setFlashattention(false);
setBackend('');
setNoavx2(false);
setFailsafe(false);
setBackend('cpu');
}
}, []);
@ -131,7 +158,9 @@ export const useLaunchConfig = () => {
setWebsearch(false);
setNoshift(false);
setFlashattention(false);
setBackend('');
setNoavx2(false);
setFailsafe(false);
setBackend('cpu');
}, []);
const loadConfigFromFile = useCallback(
@ -237,6 +266,14 @@ export const useLaunchConfig = () => {
setFlashattention(checked);
}, []);
const handleNoavx2Change = useCallback((checked: boolean) => {
setNoavx2(checked);
}, []);
const handleFailsafeChange = useCallback((checked: boolean) => {
setFailsafe(checked);
}, []);
const handleBackendChange = useCallback((backend: string) => {
setBackend(backend);
}, []);
@ -257,6 +294,8 @@ export const useLaunchConfig = () => {
websearch,
noshift,
flashattention,
noavx2,
failsafe,
backend,
parseAndApplyConfigFile,
@ -278,6 +317,8 @@ export const useLaunchConfig = () => {
handleWebsearchChange,
handleNoshiftChange,
handleFlashattentionChange,
handleNoavx2Change,
handleFailsafeChange,
handleBackendChange,
};
};

View file

@ -95,13 +95,29 @@ class FriendlyKoboldApp {
app.on('before-quit', async (event) => {
event.preventDefault();
await this.koboldManager.cleanup();
try {
const cleanupPromise = this.koboldManager.cleanup();
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 10000);
});
await Promise.race([cleanupPromise, timeoutPromise]);
} catch (error) {
console.error('Error during KoboldCpp cleanup:', error);
}
this.windowManager.cleanup();
app.exit(0);
});
app.on('will-quit', async (event) => {
event.preventDefault();
app.exit(0);
});
app.on('activate', () => {
if (!this.windowManager.getMainWindow()) {
this.windowManager.createMainWindow();

View file

@ -7,6 +7,7 @@ import {
createWriteStream,
chmodSync,
readFileSync,
unlinkSync,
} from 'fs';
import { dialog } from 'electron';
import { GitHubService } from '@/main/services/GitHubService';
@ -74,7 +75,9 @@ export class KoboldCppManager {
asset: GitHubAsset,
onProgress?: (progress: number) => void
): Promise<string> {
const filePath = join(this.installDir, asset.name);
const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`);
const baseFilename = asset.name.replace(/\.exe$/, '');
const unpackedDirPath = join(this.installDir, baseFilename);
const response = await fetch(asset.browser_download_url);
@ -90,7 +93,7 @@ export class KoboldCppManager {
throw new Error('Failed to get response reader');
}
const writer = createWriteStream(filePath);
const writer = createWriteStream(tempPackedFilePath);
try {
while (true) {
@ -119,77 +122,151 @@ export class KoboldCppManager {
if (process.platform !== 'win32') {
try {
chmodSync(filePath, 0o755);
chmodSync(tempPackedFilePath, 0o755);
} catch (error) {
console.warn('Failed to make binary executable:', error);
console.error('Failed to make binary executable:', error);
}
}
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary) {
this.configManager.setCurrentKoboldBinary(filePath);
}
try {
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
return filePath;
try {
unlinkSync(tempPackedFilePath);
} catch (error) {
console.warn('Failed to cleanup packed file:', error);
}
const launcherPath = this.getLauncherPath(unpackedDirPath);
if (launcherPath && existsSync(launcherPath)) {
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary) {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
return launcherPath;
} else {
throw new Error('Failed to find koboldcpp-launcher after unpacking');
}
} catch (error) {
console.error('Failed to unpack KoboldCpp:', error);
throw error;
}
}
async getInstalledVersions(
includeVersions = true
): Promise<InstalledVersion[]> {
private async unpackKoboldCpp(
packedPath: string,
unpackDir: string
): Promise<void> {
return new Promise((resolve, reject) => {
const unpackProcess = spawn(packedPath, ['--unpack', unpackDir], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30000,
});
let output = '';
let errorOutput = '';
unpackProcess.stdout?.on('data', (data) => {
output += data.toString();
});
unpackProcess.stderr?.on('data', (data) => {
errorOutput += data.toString();
});
unpackProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`Unpack failed with code ${code}: ${errorOutput || output}`
)
);
}
});
unpackProcess.on('error', (error) => {
reject(error);
});
setTimeout(() => {
try {
unpackProcess.kill('SIGTERM');
} catch {
void 0;
}
reject(new Error('Unpack process timed out'));
}, 30000);
});
}
private getLauncherPath(unpackedDir: string): string | null {
const extensions =
process.platform === 'win32' ? ['.exe', ''] : ['', '.exe'];
for (const ext of extensions) {
const launcherPath = join(unpackedDir, `koboldcpp-launcher${ext}`);
if (existsSync(launcherPath)) {
return launcherPath;
}
}
return null;
}
async getInstalledVersions(): Promise<InstalledVersion[]> {
try {
if (!existsSync(this.installDir)) {
return [];
}
const files = readdirSync(this.installDir);
const koboldFiles = files.filter((file) => {
const filePath = join(this.installDir, file);
try {
const stats = statSync(filePath);
const items = readdirSync(this.installDir);
const launchers: Array<{ path: string; filename: string; size: number }> =
[];
if (stats.isFile() && file.startsWith('koboldcpp')) {
if (process.platform !== 'win32') {
const isExecutable = (stats.mode & parseInt('111', 8)) !== 0;
return isExecutable;
} else {
return true;
}
for (const item of items) {
const itemPath = join(this.installDir, item);
const stats = statSync(itemPath);
if (stats.isDirectory()) {
const launcherPath = this.getLauncherPath(itemPath);
if (launcherPath && existsSync(launcherPath)) {
const launcherStats = statSync(launcherPath);
const launcherFilename = launcherPath.split(/[/\\]/).pop() || '';
launchers.push({
path: launcherPath,
filename: launcherFilename,
size: launcherStats.size,
});
}
return false;
} catch {
return false;
}
});
if (!includeVersions) {
return koboldFiles.map((file) => {
const filePath = join(this.installDir, file);
const stats = statSync(filePath);
return {
version: 'unknown',
path: filePath,
filename: file,
size: stats.size,
};
});
}
const versionPromises = koboldFiles.map(async (file) => {
const filePath = join(this.installDir, file);
const versionPromises = launchers.map(async (launcher) => {
try {
const stats = statSync(filePath);
const detectedVersion = await this.getVersionFromBinary(filePath);
const detectedVersion = await this.getVersionFromBinary(
launcher.path
);
const version = detectedVersion || 'unknown';
return {
version,
path: filePath,
filename: file,
size: stats.size,
path: launcher.path,
filename: launcher.filename,
size: launcher.size,
} as InstalledVersion;
} catch (error) {
console.warn(`Could not detect version for ${file}:`, error);
console.error(
`Could not detect version for ${launcher.filename}:`,
error
);
return null;
}
});
@ -199,7 +276,7 @@ export class KoboldCppManager {
(version): version is InstalledVersion => version !== null
);
} catch (error) {
console.warn('Error scanning install directory:', error);
console.error('Error scanning install directory:', error);
return [];
}
}
@ -230,7 +307,7 @@ export class KoboldCppManager {
}
}
} catch (error) {
console.warn('Error scanning for config files:', error);
console.error('Error scanning for config files:', error);
}
return configFiles.sort((a, b) => a.name.localeCompare(b.name));
@ -252,7 +329,7 @@ export class KoboldCppManager {
return config;
} catch (error) {
console.warn('Error parsing config file:', error);
console.error('Error parsing config file:', error);
return null;
}
}
@ -279,45 +356,64 @@ export class KoboldCppManager {
return result.filePaths[0];
} catch (error) {
console.warn('Error selecting model file:', error);
console.error('Error selecting model file:', error);
return null;
}
}
async getCurrentVersion(): Promise<InstalledVersion | null> {
const currentBinaryPath = this.configManager.getCurrentKoboldBinary();
const versions = await this.getInstalledVersions();
if (currentBinaryPath && existsSync(currentBinaryPath)) {
try {
const filename = currentBinaryPath.split(/[/\\]/).pop() || '';
const version =
(await this.getVersionFromBinary(currentBinaryPath)) || 'unknown';
return {
version,
path: currentBinaryPath,
filename,
};
} catch (error) {
console.warn('Failed to get current version info:', error);
this.configManager.setCurrentKoboldBinary('');
const currentVersion = versions.find((v) => v.path === currentBinaryPath);
if (currentVersion) {
return currentVersion;
}
}
const versions = await this.getInstalledVersions();
const firstVersion = versions[0];
if (firstVersion) {
this.configManager.setCurrentKoboldBinary(firstVersion.path);
return firstVersion;
}
if (currentBinaryPath) {
this.configManager.setCurrentKoboldBinary('');
}
return null;
}
async getCurrentBinaryInfo(): Promise<{
path: string;
filename: string;
} | null> {
const currentVersion = await this.getCurrentVersion();
if (currentVersion) {
const pathParts = currentVersion.path.split(/[/\\]/);
const filename =
pathParts[pathParts.length - 2] || currentVersion.filename;
return {
path: currentVersion.path,
filename,
};
}
return null;
}
async setCurrentVersion(binaryPath: string): Promise<boolean> {
if (existsSync(binaryPath)) {
this.configManager.setCurrentKoboldBinary(binaryPath);
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
return true;
}
@ -335,6 +431,7 @@ export class KoboldCppManager {
const process = spawn(binaryPath, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
detached: false,
});
let output = '';
@ -519,7 +616,7 @@ export class KoboldCppManager {
};
}
} catch (error) {
console.warn('Failed to fetch Windows ROCm release:', error);
console.error('Failed to fetch Windows ROCm release:', error);
}
}
@ -540,6 +637,13 @@ export class KoboldCppManager {
};
}
const tempPackedFilePath = join(
this.installDir,
`${rocmInfo.name}.packed`
);
const baseFilename = rocmInfo.name.replace(/\.exe$/, '');
const unpackedDirPath = join(this.installDir, baseFilename);
const response = await fetch(rocmInfo.url);
if (!response.ok) {
return {
@ -559,8 +663,7 @@ export class KoboldCppManager {
};
}
const filePath = join(this.installDir, rocmInfo.name);
const writer = createWriteStream(filePath);
const writer = createWriteStream(tempPackedFilePath);
try {
while (true) {
@ -588,16 +691,50 @@ export class KoboldCppManager {
if (process.platform !== 'win32') {
try {
chmodSync(filePath, 0o755);
chmodSync(tempPackedFilePath, 0o755);
} catch (error) {
console.warn('Failed to make ROCm binary executable:', error);
console.error('Failed to make ROCm binary executable:', error);
}
}
return {
success: true,
path: filePath,
};
try {
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
try {
unlinkSync(tempPackedFilePath);
} catch (error) {
console.warn('Failed to cleanup packed ROCm file:', error);
}
const launcherPath = this.getLauncherPath(unpackedDirPath);
if (launcherPath && existsSync(launcherPath)) {
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary) {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('versions-updated');
}
return {
success: true,
path: launcherPath,
};
} else {
return {
success: false,
error: 'Failed to find koboldcpp-launcher after unpacking ROCm',
};
}
} catch (error) {
console.error('Failed to unpack ROCm:', error);
return {
success: false,
error: `Failed to unpack ROCm: ${(error as Error).message}`,
};
}
} catch (error) {
return {
success: false,
@ -663,8 +800,20 @@ export class KoboldCppManager {
const availableAssets = latestRelease.assets.map((asset: GitHubAsset) => {
const installedVersion = installedVersions.find((v) => {
const filename = v.filename || v.path.split(/[/\\]/).pop() || '';
return filename === asset.name;
const pathParts = v.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex(
(part) =>
part === 'koboldcpp-launcher' ||
part === 'koboldcpp.exe' ||
part === 'koboldcpp'
);
if (launcherIndex > 0) {
const directoryName = pathParts[launcherIndex - 1];
return directoryName === asset.name;
}
return false;
});
return {
@ -709,6 +858,11 @@ export class KoboldCppManager {
const child = spawn(currentVersion.path, finalArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false,
env: {
...process.env,
PYTHONDONTWRITEBYTECODE: '1',
PYTHONUNBUFFERED: '1',
},
});
this.koboldProcess = child;
@ -774,14 +928,14 @@ export class KoboldCppManager {
try {
this.koboldProcess.kill('SIGKILL');
} catch (error) {
console.warn('Error force-killing KoboldCpp process:', error);
console.error('Error force-killing KoboldCpp process:', error);
}
}
}, 5000);
this.koboldProcess = null;
} catch (error) {
console.warn('Error stopping KoboldCpp process:', error);
console.error('Error stopping KoboldCpp process:', error);
this.koboldProcess = null;
}
}
@ -789,7 +943,7 @@ export class KoboldCppManager {
async cleanup(): Promise<void> {
if (this.koboldProcess) {
return new Promise((resolve) => {
await new Promise<void>((resolve) => {
if (!this.koboldProcess) {
resolve();
return;
@ -817,7 +971,7 @@ export class KoboldCppManager {
cleanup();
}, 3000);
} catch (error) {
console.warn('Error during cleanup:', error);
console.error('Error during cleanup:', error);
cleanup();
}
});

View file

@ -203,14 +203,22 @@ export class HardwareService {
if (name) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let j = Math.max(0, i - 10);
j < Math.min(lines.length, i + 10);
j++
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[j].includes('Device Type:')) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[j].split('Device Type:')[1]?.trim() || '';
lines[searchIndex].split('Device Type:')[1]?.trim() ||
'';
break;
}
}

View file

@ -70,10 +70,8 @@ export class IPCHandlers {
}
});
ipcMain.handle(
'kobold:getInstalledVersions',
(_, includeVersions?: boolean) =>
this.koboldManager.getInstalledVersions(includeVersions)
ipcMain.handle('kobold:getInstalledVersions', () =>
this.koboldManager.getInstalledVersions()
);
ipcMain.handle('kobold:getConfigFiles', () =>
@ -92,6 +90,10 @@ export class IPCHandlers {
this.koboldManager.getCurrentVersion()
);
ipcMain.handle('kobold:getCurrentBinaryInfo', () =>
this.koboldManager.getCurrentBinaryInfo()
);
ipcMain.handle('kobold:setCurrentVersion', (_event, version) =>
this.koboldManager.setCurrentVersion(version)
);

View file

@ -8,9 +8,9 @@ import type {
const koboldAPI: KoboldAPI = {
getInstalledVersion: () => ipcRenderer.invoke('kobold:getInstalledVersion'),
getInstalledVersions: (includeVersions?: boolean) =>
ipcRenderer.invoke('kobold:getInstalledVersions', includeVersions),
getInstalledVersions: () => ipcRenderer.invoke('kobold:getInstalledVersions'),
getCurrentVersion: () => ipcRenderer.invoke('kobold:getCurrentVersion'),
getCurrentBinaryInfo: () => ipcRenderer.invoke('kobold:getCurrentBinaryInfo'),
setCurrentVersion: (version: string) =>
ipcRenderer.invoke('kobold:setCurrentVersion', version),
getVersionFromBinary: (binaryPath: string) =>
@ -68,6 +68,14 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.removeListener('install-dir-changed', handler);
};
},
onVersionsUpdated: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('versions-updated', handler);
return () => {
ipcRenderer.removeListener('versions-updated', handler);
};
},
onKoboldOutput: (callback: (data: string) => void) => {
const handler = (_: IpcRendererEvent, data: string) => callback(data);
ipcRenderer.on('kobold-output', handler);

View file

@ -55,10 +55,12 @@ interface ROCmDownload {
export interface KoboldAPI {
getInstalledVersion: () => Promise<string | undefined>;
getInstalledVersions: (
includeVersions?: boolean
) => Promise<InstalledVersion[]>;
getInstalledVersions: () => Promise<InstalledVersion[]>;
getCurrentVersion: () => Promise<InstalledVersion | null>;
getCurrentBinaryInfo: () => Promise<{
path: string;
filename: string;
} | null>;
setCurrentVersion: (version: string) => Promise<boolean>;
getVersionFromBinary: (binaryPath: string) => Promise<string | null>;
getLatestRelease: () => Promise<GitHubRelease>;
@ -105,6 +107,7 @@ export interface KoboldAPI {
onDownloadProgress: (callback: (progress: number) => void) => void;
onUpdateAvailable: (callback: (updateInfo: UpdateInfo) => void) => void;
onInstallDirChanged: (callback: (newPath: string) => void) => () => void;
onVersionsUpdated: (callback: () => void) => () => void;
onKoboldOutput: (callback: (data: string) => void) => () => void;
removeAllListeners: (channel: string) => void;
}