allow gerbil to work fully offline by allowing kcpp binary imports on the initial Download screen, more version -> backend renames

This commit is contained in:
Egor 2025-11-29 17:52:29 -08:00
parent 4b2e9b2ae9
commit 05cb3b8c05
16 changed files with 346 additions and 198 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.13.0", "version": "1.14.0",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",
@ -60,7 +60,7 @@
"eslint-plugin-sonarjs": "^3.0.5", "eslint-plugin-sonarjs": "^3.0.5",
"globals": "^16.5.0", "globals": "^16.5.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"prettier": "^3.7.2", "prettier": "^3.7.3",
"rollup-plugin-visualizer": "^6.0.5", "rollup-plugin-visualizer": "^6.0.5",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.4" "vite": "^7.2.4"
@ -75,7 +75,7 @@
"@mantine/hooks": "^8.3.9", "@mantine/hooks": "^8.3.9",
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"execa": "^9.6.0", "execa": "^9.6.1",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"react": "^19.2.0", "react": "^19.2.0",
@ -85,7 +85,7 @@
"winston": "^3.18.3", "winston": "^3.18.3",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
"yauzl": "^3.2.0", "yauzl": "^3.2.0",
"zustand": "^5.0.8" "zustand": "^5.0.9"
}, },
"build": { "build": {
"appId": "com.gerbil.app", "appId": "com.gerbil.app",

View file

@ -12,7 +12,7 @@ import { Download, X, ExternalLink } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
import type { BinaryUpdateInfo } from '@/hooks/useUpdateChecker'; import type { BinaryUpdateInfo } from '@/hooks/useUpdateChecker';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { pretifyBinName } from '@/utils/assets'; import { pretifyBinName } from '@/utils/assets';
import { formatDownloadSize } from '@/utils/format'; import { formatDownloadSize } from '@/utils/format';
import { GITHUB_API } from '@/constants'; import { GITHUB_API } from '@/constants';
@ -34,7 +34,7 @@ export const UpdateAvailableModal = ({
updateInfo, updateInfo,
onUpdate, onUpdate,
}: UpdateAvailableModalProps) => { }: UpdateAvailableModalProps) => {
const { downloading, downloadProgress } = useKoboldVersionsStore(); const { downloading, downloadProgress } = useKoboldBackendsStore();
const currentBackend = updateInfo?.currentBackend; const currentBackend = updateInfo?.currentBackend;
const availableUpdate = updateInfo?.availableUpdate; const availableUpdate = updateInfo?.availableUpdate;
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);

View file

@ -15,7 +15,7 @@ import { ErrorBoundary } from '@/components/App/ErrorBoundary';
import { AppRouter } from '@/components/App/Router'; import { AppRouter } from '@/components/App/Router';
import { NotepadContainer } from '@/components/Notepad/Container'; import { NotepadContainer } from '@/components/Notepad/Container';
import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants'; import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
@ -81,7 +81,7 @@ export const App = () => {
closeModal, closeModal,
} = useUpdateChecker(); } = useUpdateChecker();
const { handleDownload, loadingRemote } = useKoboldVersionsStore(); const { handleDownload, loadingRemote } = useKoboldBackendsStore();
const determineScreen = ( const determineScreen = (
currentVersion: unknown, currentVersion: unknown,
@ -142,7 +142,7 @@ export const App = () => {
item: download, item: download,
isUpdate: true, isUpdate: true,
wasCurrentBinary: true, wasCurrentBinary: true,
oldVersionPath: currentBackend?.path, oldBackendPath: currentBackend?.path,
}); });
closeModal(); closeModal();

View file

@ -13,11 +13,11 @@ import { Download, Trash2 } from 'lucide-react';
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { pretifyBinName, isWindowsROCmBuild } from '@/utils/assets'; import { pretifyBinName, isWindowsROCmBuild } from '@/utils/assets';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import type { VersionInfo } from '@/types'; import type { BackendInfo } from '@/types';
interface DownloadCardProps { interface DownloadCardProps {
version: VersionInfo; backend: BackendInfo;
size: string; size: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
@ -29,7 +29,7 @@ interface DownloadCardProps {
} }
export const DownloadCard = ({ export const DownloadCard = ({
version: versionInfo, backend,
size, size,
description, description,
disabled = false, disabled = false,
@ -40,23 +40,23 @@ export const DownloadCard = ({
onDelete, onDelete,
}: DownloadCardProps) => { }: DownloadCardProps) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore(); const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const { downloading, downloadProgress } = useKoboldVersionsStore(); const { downloading, downloadProgress } = useKoboldBackendsStore();
const isLoading = downloading === versionInfo.name; const isLoading = downloading === backend.name;
const currentProgress = isLoading const currentProgress = isLoading
? Math.min(downloadProgress[versionInfo.name], 100) || 0 ? Math.min(downloadProgress[backend.name], 100) || 0
: 0; : 0;
const hasVersionMismatch = Boolean( const hasVersionMismatch = Boolean(
versionInfo.version && backend.version &&
versionInfo.actualVersion && backend.actualVersion &&
versionInfo.version !== versionInfo.actualVersion backend.version !== backend.actualVersion
); );
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
const renderActionButtons = () => { const renderActionButtons = () => {
const buttons = []; const buttons = [];
if (!versionInfo.isInstalled) { if (!backend.isInstalled) {
return ( return (
<Button <Button
key="download" key="download"
@ -78,7 +78,7 @@ export const DownloadCard = ({
); );
} }
if (!versionInfo.isCurrent && onMakeCurrent) { if (!backend.isCurrent && onMakeCurrent) {
buttons.push( buttons.push(
<Button <Button
key="makeCurrent" key="makeCurrent"
@ -92,7 +92,7 @@ export const DownloadCard = ({
); );
} }
if (versionInfo.hasUpdate && onUpdate) { if (backend.hasUpdate && onUpdate) {
buttons.push( buttons.push(
<Button <Button
key="update" key="update"
@ -110,7 +110,7 @@ export const DownloadCard = ({
) )
} }
> >
{isLoading ? 'Updating...' : `Update to ${versionInfo.newerVersion}`} {isLoading ? 'Updating...' : `Update to ${backend.newerVersion}`}
</Button> </Button>
); );
} }
@ -138,7 +138,7 @@ export const DownloadCard = ({
); );
} }
if (onDelete && versionInfo.isInstalled && !versionInfo.isCurrent) { if (onDelete && backend.isInstalled && !backend.isCurrent) {
buttons.push( buttons.push(
<Button <Button
key="delete" key="delete"
@ -169,7 +169,7 @@ export const DownloadCard = ({
withBorder withBorder
radius="sm" radius="sm"
padding="sm" padding="sm"
{...(versionInfo.isCurrent && { {...(backend.isCurrent && {
bg: colorScheme === 'dark' ? 'dark.6' : 'gray.0', bg: colorScheme === 'dark' ? 'dark.6' : 'gray.0',
bd: `2px solid var(--mantine-color-${colorScheme === 'dark' ? 'blue-4' : 'blue-6'})`, bd: `2px solid var(--mantine-color-${colorScheme === 'dark' ? 'blue-4' : 'blue-6'})`,
})} })}
@ -178,19 +178,19 @@ export const DownloadCard = ({
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{pretifyBinName(versionInfo.name)} {pretifyBinName(backend.name)}
</Text> </Text>
{versionInfo.isCurrent && ( {backend.isCurrent && (
<Badge variant="light" color="blue" size="sm"> <Badge variant="light" color="blue" size="sm">
Current Current
</Badge> </Badge>
)} )}
{versionInfo.hasUpdate && ( {backend.hasUpdate && (
<Badge variant="light" color="orange" size="sm"> <Badge variant="light" color="orange" size="sm">
Update Available Update Available
</Badge> </Badge>
)} )}
{isWindowsROCmBuild(versionInfo.name) && ( {isWindowsROCmBuild(backend.name) && (
<Badge variant="light" color="yellow" size="sm"> <Badge variant="light" color="yellow" size="sm">
Experimental Experimental
</Badge> </Badge>
@ -202,13 +202,13 @@ export const DownloadCard = ({
</Text> </Text>
)} )}
<Group gap="xs" align="center"> <Group gap="xs" align="center">
{versionInfo.version && ( {backend.version && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Version {versionInfo.version} Version {backend.version}
{hasVersionMismatch && versionInfo.actualVersion && ( {hasVersionMismatch && backend.actualVersion && (
<span style={{ color: 'var(--mantine-color-red-6)' }}> <span style={{ color: 'var(--mantine-color-red-6)' }}>
{' '} {' '}
(actual: {versionInfo.actualVersion}) (actual: {backend.actualVersion})
</span> </span>
)} )}
</Text> </Text>

View file

@ -1,10 +1,18 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core'; import {
Card,
Text,
Title,
Loader,
Stack,
Container,
Anchor,
} from '@mantine/core';
import { DownloadCard } from '@/components/DownloadCard'; import { DownloadCard } from '@/components/DownloadCard';
import { getPlatformDisplayName } from '@/utils/platform'; import { getPlatformDisplayName } from '@/utils/platform';
import { formatDownloadSize } from '@/utils/format'; import { formatDownloadSize } from '@/utils/format';
import { getAssetDescription } from '@/utils/assets'; import { getAssetDescription } from '@/utils/assets';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
interface DownloadScreenProps { interface DownloadScreenProps {
@ -19,9 +27,11 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
loadingRemote, loadingRemote,
downloading, downloading,
handleDownload: handleDownloadFromStore, handleDownload: handleDownloadFromStore,
} = useKoboldVersionsStore(); } = useKoboldBackendsStore();
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null); const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const downloadingItemRef = useRef<HTMLDivElement>(null); const downloadingItemRef = useRef<HTMLDivElement>(null);
const loading = loadingPlatform || loadingRemote; const loading = loadingPlatform || loadingRemote;
@ -56,75 +66,101 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
return ( return (
<Container size="sm" mt="md"> <Container size="sm" mt="md">
<Stack gap="xl"> <Card withBorder radius="md" shadow="sm">
<Card withBorder radius="md" shadow="sm"> <Stack gap="lg">
<Stack gap="lg"> <Title order={3}>Select a Backend</Title>
<Title order={3}>Select a Backend</Title>
{loading ? ( {loading ? (
<Stack align="center" gap="md" py="xl"> <Stack align="center" gap="md" py="xl">
<Loader color="blue" /> <Loader color="blue" />
<Text c="dimmed">Preparing download options...</Text> <Text c="dimmed">Preparing download options...</Text>
</Stack> </Stack>
) : ( ) : (
<> <>
{availableDownloads.length > 0 ? ( {availableDownloads.length > 0 ? (
<Stack gap="sm"> <Stack gap="sm">
{availableDownloads.map((download) => { {availableDownloads.map((download) => {
const isDownloading = const isDownloading =
Boolean(downloading) && Boolean(downloading) &&
downloadingAsset === download.name; downloadingAsset === download.name;
return ( return (
<div <div
key={download.name} key={download.name}
ref={isDownloading ? downloadingItemRef : null} ref={isDownloading ? downloadingItemRef : null}
> >
<DownloadCard <DownloadCard
version={{ backend={{
name: download.name, name: download.name,
version: download.version || '', version: download.version || '',
size: download.size, size: download.size,
isInstalled: false, isInstalled: false,
isCurrent: false, isCurrent: false,
downloadUrl: download.url, downloadUrl: download.url,
hasUpdate: false, hasUpdate: false,
}} }}
size={formatDownloadSize( size={formatDownloadSize(download.size, download.url)}
download.size, description={getAssetDescription(download.name)}
download.url disabled={
)} importing ||
description={getAssetDescription(download.name)} (Boolean(downloading) &&
disabled={ downloadingAsset !== download.name)
Boolean(downloading) && }
downloadingAsset !== download.name onDownload={(e) => {
} e.stopPropagation();
onDownload={(e) => { handleDownload(download);
e.stopPropagation(); }}
handleDownload(download); />
}} </div>
/> );
</div> })}
); </Stack>
})} ) : (
</Stack> <Text size="sm" c="dimmed" ta="center">
) : ( Unable to fetch downloads for your platform (
<Card withBorder p="md" bg="red.0" c="red.9"> {getPlatformDisplayName(platform)}). Check your internet
<Stack gap="xs"> connection and try again.
<Text fw={500}>No downloads available</Text> </Text>
<Text size="sm"> )}
Unable to fetch downloads for your platform (
{getPlatformDisplayName(platform)}). Please check your {importError && (
internet connection and try again. <Text size="sm" c="red" ta="center">
</Text> {importError}
</Stack> </Text>
</Card> )}
)}
</> <Text size="sm" c="dimmed" ta="center">
)} Already have a backend downloaded?{' '}
</Stack> <Anchor
</Card> component="button"
</Stack> type="button"
size="sm"
disabled={importing || Boolean(downloading)}
onClick={async () => {
setImportError(null);
setImporting(true);
try {
const result =
await window.electronAPI.kobold.importLocalBackend();
if (result.success) {
onDownloadComplete();
} else if (result.error) {
setImportError(result.error);
}
} finally {
setImporting(false);
}
}}
>
{importing ? 'Importing...' : 'Select a local file'}
</Anchor>
</Text>
</>
)}
</Stack>
</Card>
</Container> </Container>
); );
}; };

View file

@ -18,9 +18,9 @@ import {
} from '@/utils/version'; } from '@/utils/version';
import { formatDownloadSize } from '@/utils/format'; import { formatDownloadSize } from '@/utils/format';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import type { InstalledBackend, ReleaseWithStatus } from '@/types/electron'; import type { InstalledBackend, ReleaseWithStatus } from '@/types/electron';
import type { VersionInfo } from '@/types'; import type { BackendInfo } from '@/types';
export const BackendsTab = () => { export const BackendsTab = () => {
const { const {
@ -31,7 +31,7 @@ export const BackendsTab = () => {
handleDownload: handleDownloadFromStore, handleDownload: handleDownloadFromStore,
getLatestReleaseWithDownloadStatus, getLatestReleaseWithDownloadStatus,
initialize, initialize,
} = useKoboldVersionsStore(); } = useKoboldBackendsStore();
const [installedBackends, setInstalledBackends] = useState< const [installedBackends, setInstalledBackends] = useState<
InstalledBackend[] InstalledBackend[]
@ -83,8 +83,8 @@ export const BackendsTab = () => {
initialize, initialize,
]); ]);
const allBackends = useMemo((): VersionInfo[] => { const allBackends = useMemo((): BackendInfo[] => {
const backends: VersionInfo[] = []; const backends: BackendInfo[] = [];
const processedInstalled = new Set<string>(); const processedInstalled = new Set<string>();
availableDownloads.forEach((download) => { availableDownloads.forEach((download) => {
@ -170,7 +170,7 @@ export const BackendsTab = () => {
} }
}, [downloading]); }, [downloading]);
const handleDownload = async (backend: VersionInfo) => { const handleDownload = async (backend: BackendInfo) => {
const download = availableDownloads.find((d) => d.name === backend.name); const download = availableDownloads.find((d) => d.name === backend.name);
if (!download) return; if (!download) return;
@ -183,7 +183,7 @@ export const BackendsTab = () => {
await loadInstalledBackends(); await loadInstalledBackends();
}; };
const handleUpdate = async (backend: VersionInfo) => { const handleUpdate = async (backend: BackendInfo) => {
const download = availableDownloads.find((d) => d.name === backend.name); const download = availableDownloads.find((d) => d.name === backend.name);
if (!download) return; if (!download) return;
@ -191,13 +191,13 @@ export const BackendsTab = () => {
item: download, item: download,
isUpdate: true, isUpdate: true,
wasCurrentBinary: backend.isCurrent, wasCurrentBinary: backend.isCurrent,
oldVersionPath: backend.installedPath, oldBackendPath: backend.installedPath,
}); });
await loadInstalledBackends(); await loadInstalledBackends();
}; };
const handleRedownload = async (backend: VersionInfo) => { const handleRedownload = async (backend: BackendInfo) => {
const download = availableDownloads.find((d) => d.name === backend.name); const download = availableDownloads.find((d) => d.name === backend.name);
if (!download) return; if (!download) return;
@ -205,13 +205,13 @@ export const BackendsTab = () => {
item: download, item: download,
isUpdate: true, isUpdate: true,
wasCurrentBinary: backend.isCurrent, wasCurrentBinary: backend.isCurrent,
oldVersionPath: backend.installedPath, oldBackendPath: backend.installedPath,
}); });
await loadInstalledBackends(); await loadInstalledBackends();
}; };
const handleDelete = async (backend: VersionInfo) => { const handleDelete = async (backend: BackendInfo) => {
if (!backend.installedPath || backend.isCurrent) return; if (!backend.installedPath || backend.isCurrent) return;
const result = await window.electronAPI.kobold.deleteRelease( const result = await window.electronAPI.kobold.deleteRelease(
@ -222,7 +222,7 @@ export const BackendsTab = () => {
} }
}; };
const makeCurrent = (backend: VersionInfo) => { const makeCurrent = (backend: BackendInfo) => {
if (!backend.installedPath) return; if (!backend.installedPath) return;
const targetBackend = installedBackends.find( const targetBackend = installedBackends.find(
@ -286,7 +286,7 @@ export const BackendsTab = () => {
ref={isDownloading ? downloadingItemRef : null} ref={isDownloading ? downloadingItemRef : null}
> >
<DownloadCard <DownloadCard
version={backend} backend={backend}
size={ size={
backend.size backend.size
? formatDownloadSize(backend.size, backend.downloadUrl) ? formatDownloadSize(backend.size, backend.downloadUrl)

View file

@ -4,7 +4,7 @@ import {
compareVersions, compareVersions,
stripAssetExtensions, stripAssetExtensions,
} from '@/utils/version'; } from '@/utils/version';
import { useKoboldVersionsStore } from '@/stores/koboldVersions'; import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { getROCmDownload } from '@/utils/rocm'; import { getROCmDownload } from '@/utils/rocm';
import type { InstalledBackend, DownloadItem } from '@/types/electron'; import type { InstalledBackend, DownloadItem } from '@/types/electron';
import type { DismissedUpdate } from '@/types'; import type { DismissedUpdate } from '@/types';
@ -23,7 +23,7 @@ export const useUpdateChecker = () => {
); );
const { availableDownloads: releases, loadingRemote } = const { availableDownloads: releases, loadingRemote } =
useKoboldVersionsStore(); useKoboldBackendsStore();
useEffect(() => { useEffect(() => {
const loadDismissedUpdates = async () => { const loadDismissedUpdates = async () => {
const dismissed = (await window.electronAPI.config.get( const dismissed = (await window.electronAPI.config.get(
@ -75,7 +75,7 @@ export const useUpdateChecker = () => {
if (hasUpdate) { if (hasUpdate) {
const isUpdateDismissed = dismissedUpdates.some( const isUpdateDismissed = dismissedUpdates.some(
(dismissedUpdate) => (dismissedUpdate) =>
dismissedUpdate.currentVersionPath === currentBackend.path && dismissedUpdate.currentBackendPath === currentBackend.path &&
dismissedUpdate.targetVersion === matchingDownload.version dismissedUpdate.targetVersion === matchingDownload.version
); );
@ -95,7 +95,7 @@ export const useUpdateChecker = () => {
const skipUpdate = useCallback(() => { const skipUpdate = useCallback(() => {
if (updateInfo && updateInfo.availableUpdate.version) { if (updateInfo && updateInfo.availableUpdate.version) {
const newDismissedUpdate: DismissedUpdate = { const newDismissedUpdate: DismissedUpdate = {
currentVersionPath: updateInfo.currentBackend.path, currentBackendPath: updateInfo.currentBackend.path,
targetVersion: updateInfo.availableUpdate.version, targetVersion: updateInfo.availableUpdate.version,
}; };

View file

@ -6,7 +6,10 @@ import {
stopKoboldCpp, stopKoboldCpp,
launchKoboldCppWithCustomFrontends, launchKoboldCppWithCustomFrontends,
} from '@/main/modules/koboldcpp/launcher'; } from '@/main/modules/koboldcpp/launcher';
import { downloadRelease } from '@/main/modules/koboldcpp/download'; import {
downloadRelease,
importLocalBackend,
} from '@/main/modules/koboldcpp/download';
import { import {
getInstalledBackends, getInstalledBackends,
getCurrentBackend, getCurrentBackend,
@ -160,6 +163,8 @@ export function setupIPCHandlers() {
selectModelFile(title) selectModelFile(title)
); );
ipcMain.handle('kobold:importLocalBackend', () => importLocalBackend());
ipcMain.handle('kobold:getLocalModels', (_, paramType: string) => ipcMain.handle('kobold:getLocalModels', (_, paramType: string) =>
getLocalModelsForType( getLocalModelsForType(
paramType as Parameters<typeof getLocalModelsForType>[0] paramType as Parameters<typeof getLocalModelsForType>[0]

View file

@ -13,16 +13,16 @@ import { logError } from '@/utils/node/logging';
import { getLauncherPath } from '@/utils/node/path'; import { getLauncherPath } from '@/utils/node/path';
import type { InstalledBackend } from '@/types/electron'; import type { InstalledBackend } from '@/types/electron';
const versionCache = new Map< const backendVersionCache = new Map<
string, string,
{ version: string; actualVersion?: string } | null { version: string; actualVersion?: string } | null
>(); >();
export function clearVersionCache(path?: string) { export function clearBackendVersionCache(path?: string) {
if (path) { if (path) {
versionCache.delete(path); backendVersionCache.delete(path);
} else { } else {
versionCache.clear(); backendVersionCache.clear();
} }
} }
@ -161,7 +161,7 @@ export async function deleteRelease(binaryPath: string) {
if (await pathExists(releaseDir)) { if (await pathExists(releaseDir)) {
await rm(releaseDir, { recursive: true, force: true }); await rm(releaseDir, { recursive: true, force: true });
clearVersionCache(binaryPath); clearBackendVersionCache(binaryPath);
sendToRenderer('versions-updated'); sendToRenderer('versions-updated');
return { success: true }; return { success: true };
@ -179,8 +179,8 @@ export async function getVersionFromBinary(launcherPath: string) {
return null; return null;
} }
if (versionCache.has(launcherPath)) { if (backendVersionCache.has(launcherPath)) {
return versionCache.get(launcherPath); return backendVersionCache.get(launcherPath);
} }
let folderVersion: string | null = null; let folderVersion: string | null = null;
@ -224,10 +224,10 @@ export async function getVersionFromBinary(launcherPath: string) {
: undefined, : undefined,
}; };
versionCache.set(launcherPath, result); backendVersionCache.set(launcherPath, result);
return result; return result;
} catch { } catch {
versionCache.set(launcherPath, null); backendVersionCache.set(launcherPath, null);
return null; return null;
} }
} }

View file

@ -1,8 +1,9 @@
import { createWriteStream } from 'fs'; import { createWriteStream } from 'fs';
import { join } from 'path'; import { join, basename } from 'path';
import { platform } from 'process'; import { platform } from 'process';
import { rm, unlink, rename, mkdir, chmod } from 'fs/promises'; import { rm, unlink, rename, mkdir, chmod, copyFile } from 'fs/promises';
import { execa } from 'execa'; import { execa } from 'execa';
import { dialog } from 'electron';
import { import {
getInstallDir, getInstallDir,
@ -15,7 +16,7 @@ import { pathExists } from '@/utils/node/fs';
import { stripAssetExtensions } from '@/utils/version'; import { stripAssetExtensions } from '@/utils/version';
import { getLauncherPath } from '@/utils/node/path'; import { getLauncherPath } from '@/utils/node/path';
import type { DownloadReleaseOptions, GitHubAsset } from '@/types/electron'; import type { DownloadReleaseOptions, GitHubAsset } from '@/types/electron';
import { clearVersionCache } from './backend'; import { clearBackendVersionCache, getVersionFromBinary } from './backend';
async function removeDirectoryWithRetry( async function removeDirectoryWithRetry(
dirPath: string, dirPath: string,
@ -150,6 +151,59 @@ async function unpackKoboldCpp(packedPath: string, unpackDir: string) {
} }
} }
interface InstallBackendOptions {
packedFilePath: string;
unpackedDirPath: string;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
oldBackendPath?: string;
skipUnpackError?: boolean;
skipCleanup?: boolean;
}
async function installBackend({
packedFilePath,
unpackedDirPath,
isUpdate = false,
wasCurrentBinary = false,
oldBackendPath,
skipUnpackError = false,
skipCleanup = false,
}: InstallBackendOptions) {
if (!skipCleanup && (await pathExists(unpackedDirPath))) {
await removeDirectoryWithRetry(unpackedDirPath);
}
await mkdir(unpackedDirPath, { recursive: true });
if (skipUnpackError) {
try {
await unpackKoboldCpp(packedFilePath, unpackedDirPath);
} catch {}
} else {
await unpackKoboldCpp(packedFilePath, unpackedDirPath);
}
const launcherPath = await setupLauncher(packedFilePath, unpackedDirPath);
clearBackendVersionCache(launcherPath);
if (oldBackendPath && isUpdate) {
const oldInstallDir = join(oldBackendPath, '..');
if (oldInstallDir !== unpackedDirPath) {
await removeDirectoryWithRetry(oldInstallDir);
}
}
if (!getCurrentKoboldBinary() || (isUpdate && wasCurrentBinary)) {
await setCurrentKoboldBinary(launcherPath);
}
sendToRenderer('versions-updated');
return launcherPath;
}
export async function downloadRelease( export async function downloadRelease(
asset: GitHubAsset, asset: GitHubAsset,
options: DownloadReleaseOptions options: DownloadReleaseOptions
@ -162,39 +216,73 @@ export async function downloadRelease(
const unpackedDirPath = join(getInstallDir(), folderName); const unpackedDirPath = join(getInstallDir(), folderName);
try { try {
if (await pathExists(unpackedDirPath)) {
await removeDirectoryWithRetry(unpackedDirPath);
}
await downloadFile(asset, tempPackedFilePath); await downloadFile(asset, tempPackedFilePath);
await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath); await installBackend({
packedFilePath: tempPackedFilePath,
const launcherPath = await setupLauncher( unpackedDirPath,
tempPackedFilePath, isUpdate: options.isUpdate,
unpackedDirPath wasCurrentBinary: options.wasCurrentBinary,
); oldBackendPath: options.oldBackendPath,
});
clearVersionCache(launcherPath);
if (options.oldVersionPath && options.isUpdate) {
const oldInstallDir = join(options.oldVersionPath, '..');
if (oldInstallDir !== unpackedDirPath) {
await removeDirectoryWithRetry(oldInstallDir);
}
}
if (
!getCurrentKoboldBinary() ||
(options.isUpdate && options.wasCurrentBinary)
) {
await setCurrentKoboldBinary(launcherPath);
}
sendToRenderer('versions-updated');
} catch (error) { } catch (error) {
logError('Failed to download or unpack binary:', error as Error); logError('Failed to download or unpack binary:', error as Error);
throw new Error('Failed to download or unpack binary'); throw new Error('Failed to download or unpack binary');
} }
} }
export async function importLocalBackend() {
const result = await dialog.showOpenDialog(getMainWindow(), {
title: 'Select Backend Executable',
filters:
platform === 'win32'
? [
{ name: 'Executable Files', extensions: ['exe'] },
{ name: 'All Files', extensions: ['*'] },
]
: [{ name: 'All Files', extensions: ['*'] }],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false };
}
const selectedPath = result.filePaths[0];
try {
if (platform !== 'win32') {
await chmod(selectedPath, 0o755);
}
const backendVersion = await getVersionFromBinary(selectedPath);
if (!backendVersion || backendVersion.version === 'unknown') {
return {
success: false,
error:
'Invalid backend executable. Could not determine version information.',
};
}
const version = backendVersion.actualVersion || backendVersion.version;
const filename = basename(selectedPath);
const baseFilename = stripAssetExtensions(filename);
const folderName = `${baseFilename}-${version}`;
const installDir = join(getInstallDir(), folderName);
const packedFilePath = join(getInstallDir(), `${filename}.packed`);
await copyFile(selectedPath, packedFilePath);
await installBackend({
packedFilePath,
unpackedDirPath: installDir,
skipUnpackError: true,
});
return { success: true };
} catch (error) {
logError('Failed to import local backend:', error as Error);
return { success: false, error: (error as Error).message };
}
}

View file

@ -18,8 +18,8 @@ import type {
const koboldAPI: KoboldAPI = { const koboldAPI: KoboldAPI = {
getInstalledBackends: () => ipcRenderer.invoke('kobold:getInstalledBackends'), getInstalledBackends: () => ipcRenderer.invoke('kobold:getInstalledBackends'),
getCurrentBackend: () => ipcRenderer.invoke('kobold:getCurrentBackend'), getCurrentBackend: () => ipcRenderer.invoke('kobold:getCurrentBackend'),
setCurrentBackend: (version) => setCurrentBackend: (backend) =>
ipcRenderer.invoke('kobold:setCurrentBackend', version), ipcRenderer.invoke('kobold:setCurrentBackend', backend),
getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'), getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'),
detectGPU: () => ipcRenderer.invoke('kobold:detectGPU'), detectGPU: () => ipcRenderer.invoke('kobold:detectGPU'),
detectCPU: () => ipcRenderer.invoke('kobold:detectCPU'), detectCPU: () => ipcRenderer.invoke('kobold:detectCPU'),
@ -53,6 +53,7 @@ const koboldAPI: KoboldAPI = {
ipcRenderer.invoke('kobold:parseConfigFile', filePath), ipcRenderer.invoke('kobold:parseConfigFile', filePath),
selectModelFile: (title) => selectModelFile: (title) =>
ipcRenderer.invoke('kobold:selectModelFile', title), ipcRenderer.invoke('kobold:selectModelFile', title),
importLocalBackend: () => ipcRenderer.invoke('kobold:importLocalBackend'),
getLocalModels: (paramType) => getLocalModels: (paramType) =>
ipcRenderer.invoke('kobold:getLocalModels', paramType), ipcRenderer.invoke('kobold:getLocalModels', paramType),
analyzeModel: (filePath) => analyzeModel: (filePath) =>

View file

@ -16,7 +16,7 @@ interface HandleDownloadParams {
item: DownloadItem; item: DownloadItem;
isUpdate?: boolean; isUpdate?: boolean;
wasCurrentBinary?: boolean; wasCurrentBinary?: boolean;
oldVersionPath?: string; oldBackendPath?: string;
} }
const transformReleaseToDownloadItems = ( const transformReleaseToDownloadItems = (
@ -48,7 +48,7 @@ const fetchLatestReleaseFromAPI = async (platform: string) => {
return transformReleaseToDownloadItems(release, platform); return transformReleaseToDownloadItems(release, platform);
}; };
interface KoboldVersionsState { interface KoboldBackendsState {
platform: string; platform: string;
availableDownloads: DownloadItem[]; availableDownloads: DownloadItem[];
loadingPlatform: boolean; loadingPlatform: boolean;
@ -61,7 +61,7 @@ interface KoboldVersionsState {
getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>; getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>;
} }
export const useKoboldVersionsStore = create<KoboldVersionsState>( export const useKoboldBackendsStore = create<KoboldBackendsState>(
(set, get) => ({ (set, get) => ({
platform: '', platform: '',
availableDownloads: [], availableDownloads: [],
@ -101,7 +101,7 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
item, item,
isUpdate = false, isUpdate = false,
wasCurrentBinary = false, wasCurrentBinary = false,
oldVersionPath, oldBackendPath,
} = params; } = params;
const { downloading } = get(); const { downloading } = get();
@ -127,15 +127,16 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
await window.electronAPI.kobold.downloadRelease(asset, { await window.electronAPI.kobold.downloadRelease(asset, {
isUpdate, isUpdate,
wasCurrentBinary, wasCurrentBinary,
oldVersionPath, oldBackendPath,
}); });
progressCleanup(); progressCleanup();
set({ downloading: null, downloadProgress: {} }); set({ downloading: null, downloadProgress: {} });
}, },
getLatestReleaseWithDownloadStatus: async () => getLatestReleaseWithDownloadStatus: async () =>
safeExecute(async () => { safeExecute(async () => {
const [response, installedVersions] = await Promise.all([ const [response, installedBackends] = await Promise.all([
fetch(GITHUB_API.LATEST_RELEASE_URL), fetch(GITHUB_API.LATEST_RELEASE_URL),
window.electronAPI.kobold.getInstalledBackends(), window.electronAPI.kobold.getInstalledBackends(),
]); ]);
@ -147,9 +148,9 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
const availableAssets = latestRelease.assets.map( const availableAssets = latestRelease.assets.map(
(asset: GitHubAsset) => { (asset: GitHubAsset) => {
const installedBackend = installedVersions.find( const installedBackend = installedBackends.find(
(v: InstalledBackend) => { (b: InstalledBackend) => {
const pathParts = v.path.split(/[/\\]/); const pathParts = b.path.split(/[/\\]/);
const launcherIndex = pathParts.findIndex( const launcherIndex = pathParts.findIndex(
(part: string) => (part: string) =>
part === 'koboldcpp-launcher' || part === 'koboldcpp-launcher' ||
@ -168,7 +169,7 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
return { return {
asset, asset,
isDownloaded: !!installedBackend, isDownloaded: !!installedBackend,
installedVersion: installedBackend?.version, installedBackendVersion: installedBackend?.version,
}; };
} }
); );
@ -181,4 +182,4 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
}) })
); );
useKoboldVersionsStore.getState().initialize(); useKoboldBackendsStore.getState().initialize();

View file

@ -29,7 +29,7 @@ export interface GitHubAsset {
export interface DownloadReleaseOptions { export interface DownloadReleaseOptions {
isUpdate?: boolean; isUpdate?: boolean;
wasCurrentBinary?: boolean; wasCurrentBinary?: boolean;
oldVersionPath?: string; oldBackendPath?: string;
} }
export interface GitHubRelease { export interface GitHubRelease {
@ -53,7 +53,7 @@ export interface ReleaseWithStatus {
availableAssets: { availableAssets: {
asset: GitHubAsset; asset: GitHubAsset;
isDownloaded: boolean; isDownloaded: boolean;
installedVersion?: string; installedBackendVersion?: string;
}[]; }[];
} }
@ -162,6 +162,7 @@ export interface KoboldAPI {
setSelectedConfig: (configName: string) => Promise<boolean>; setSelectedConfig: (configName: string) => Promise<boolean>;
parseConfigFile: (filePath: string) => Promise<KoboldConfig | null>; parseConfigFile: (filePath: string) => Promise<KoboldConfig | null>;
selectModelFile: (title?: string) => Promise<string | null>; selectModelFile: (title?: string) => Promise<string | null>;
importLocalBackend: () => Promise<{ success: boolean; error?: string }>;
getLocalModels: (paramType: string) => Promise<CachedModel[]>; getLocalModels: (paramType: string) => Promise<CachedModel[]>;
analyzeModel: (filePath: string) => Promise<ModelAnalysis>; analyzeModel: (filePath: string) => Promise<ModelAnalysis>;
calculateOptimalLayers: ( calculateOptimalLayers: (

View file

@ -66,7 +66,7 @@ export interface UpdateInfo {
hasUpdate: boolean; hasUpdate: boolean;
} }
export interface VersionInfo { export interface BackendInfo {
name: string; name: string;
version: string; version: string;
size?: number; size?: number;
@ -80,7 +80,7 @@ export interface VersionInfo {
} }
export interface DismissedUpdate { export interface DismissedUpdate {
currentVersionPath: string; currentBackendPath: string;
targetVersion: string; targetVersion: string;
} }

View file

@ -9,14 +9,22 @@ export const getAssetDescription = (assetName: string) => {
} }
if (name.endsWith(ASSET_SUFFIXES.OLDPC)) { if (name.endsWith(ASSET_SUFFIXES.OLDPC)) {
return 'Meant for old PCs with outdated CPUs that may not work with the standard build. Does not support modern AVX2 CPU architectures or modern CUDA.'; return 'Meant for old PCs with outdated CPUs that may not work with the standard backend. Does not support modern AVX2 CPU architectures or modern CUDA.';
} }
if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) { if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {
return 'Standard build with NVIDIA CUDA support removed for minimal file size.'; return 'Standard backend with NVIDIA CUDA support removed for minimal file size.';
} }
return "Standard build that's ideal for most cases."; if (
name === 'koboldcpp-linux-x64' ||
name === 'koboldcpp-mac-arm64' ||
name === 'koboldcpp'
) {
return "Standard backend that's ideal for most cases.";
}
return 'Custom backend.';
}; };
export const isWindowsROCmBuild = (assetName: string) => { export const isWindowsROCmBuild = (assetName: string) => {
@ -78,5 +86,13 @@ export const pretifyBinName = (binName: string) => {
return 'No CUDA'; return 'No CUDA';
} }
return 'Standard'; if (
cleanName === 'koboldcpp-linux-x64' ||
cleanName === 'koboldcpp-mac-arm64' ||
cleanName === 'koboldcpp'
) {
return 'Standard';
}
return cleanName;
}; };

View file

@ -3358,9 +3358,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"execa@npm:^9.6.0": "execa@npm:^9.6.1":
version: 9.6.0 version: 9.6.1
resolution: "execa@npm:9.6.0" resolution: "execa@npm:9.6.1"
dependencies: dependencies:
"@sindresorhus/merge-streams": "npm:^4.0.0" "@sindresorhus/merge-streams": "npm:^4.0.0"
cross-spawn: "npm:^7.0.6" cross-spawn: "npm:^7.0.6"
@ -3374,7 +3374,7 @@ __metadata:
signal-exit: "npm:^4.1.0" signal-exit: "npm:^4.1.0"
strip-final-newline: "npm:^4.0.0" strip-final-newline: "npm:^4.0.0"
yoctocolors: "npm:^2.1.1" yoctocolors: "npm:^2.1.1"
checksum: 10c0/2c44a33142f77d3a6a590a3b769b49b27029a76768593bac1f26fed4dd1330e9c189ee61eba6a8c990fb77e37286c68c7445472ebf24c22b31e9ff320e73d7ac checksum: 10c0/636b36585306a3c8bc3a9d7b25d2d915fb06d8c9b9b02a804280d62562de3b34535affc1b7702b039320e0953daa6545a073f3c4b63fe974c1fe11336c56b467
languageName: node languageName: node
linkType: hard linkType: hard
@ -3731,12 +3731,12 @@ __metadata:
eslint-plugin-react: "npm:^7.37.5" eslint-plugin-react: "npm:^7.37.5"
eslint-plugin-react-hooks: "npm:^7.0.1" eslint-plugin-react-hooks: "npm:^7.0.1"
eslint-plugin-sonarjs: "npm:^3.0.5" eslint-plugin-sonarjs: "npm:^3.0.5"
execa: "npm:^9.6.0" execa: "npm:^9.6.1"
globals: "npm:^16.5.0" globals: "npm:^16.5.0"
jiti: "npm:^2.6.1" jiti: "npm:^2.6.1"
lucide-react: "npm:^0.555.0" lucide-react: "npm:^0.555.0"
mime-types: "npm:^3.0.2" mime-types: "npm:^3.0.2"
prettier: "npm:^3.7.2" prettier: "npm:^3.7.3"
react: "npm:^19.2.0" react: "npm:^19.2.0"
react-dom: "npm:^19.2.0" react-dom: "npm:^19.2.0"
react-error-boundary: "npm:^6.0.0" react-error-boundary: "npm:^6.0.0"
@ -3747,7 +3747,7 @@ __metadata:
winston: "npm:^3.18.3" winston: "npm:^3.18.3"
winston-daily-rotate-file: "npm:^5.0.0" winston-daily-rotate-file: "npm:^5.0.0"
yauzl: "npm:^3.2.0" yauzl: "npm:^3.2.0"
zustand: "npm:^5.0.8" zustand: "npm:^5.0.9"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -5506,12 +5506,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier@npm:^3.7.2": "prettier@npm:^3.7.3":
version: 3.7.2 version: 3.7.3
resolution: "prettier@npm:3.7.2" resolution: "prettier@npm:3.7.3"
bin: bin:
prettier: bin/prettier.cjs prettier: bin/prettier.cjs
checksum: 10c0/df3d658df301face0918f8ecbd4354f32e1151d83a3a4720c7f252342baf631466568f708e0e57beea55bbc56415c40208adc76a91d5f1a88f3e743d0d775dc0 checksum: 10c0/ee86bb06121c74dadc54f30b6f99aff6288966d9b842ce501d6991e20d20c6ce2d45028651b3b0955ca6e5fa89c1bee1e72b6f810243a93cef8bc69737972ef7
languageName: node languageName: node
linkType: hard linkType: hard
@ -7308,9 +7308,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zustand@npm:^5.0.8": "zustand@npm:^5.0.9":
version: 5.0.8 version: 5.0.9
resolution: "zustand@npm:5.0.8" resolution: "zustand@npm:5.0.9"
peerDependencies: peerDependencies:
"@types/react": ">=18.0.0" "@types/react": ">=18.0.0"
immer: ">=9.0.6" immer: ">=9.0.6"
@ -7325,6 +7325,6 @@ __metadata:
optional: true optional: true
use-sync-external-store: use-sync-external-store:
optional: true optional: true
checksum: 10c0/e865a6f7f1c0e03571701db5904151aaa7acefd6f85541a117085e129bf16e8f60b11f8cc82f277472de5c7ad5dcb201e1b0a89035ea0b40c9d9aab3cd24c8ad checksum: 10c0/552849e4546c7760704d6509a5c412d57c62a1fa9e53169c939ba5e3d75f8cb3df50a64c3a22e6c3f1c8cc00de7543e4edd61ab5ae0c9169ba9a98e28303aba6
languageName: node languageName: node
linkType: hard linkType: hard